2011/03/30

翻譯:多型不行時(When Polymorphism Fails )by Steve Yegge

文章:When Polymorphism Fails(多型不行時)
日期:2004.08.25
作者:Steve Yegge
作者的部落格(2006至今):Stevey's Blog Rants
作者舊的文章(2004與2005):Stevey's Drunken Blog Rants

作者簡介:
目前任職於Google,之前曾任職於Geoworks與Amazon。程式語言生涯中有過兩次非常關鍵性的轉換,一次是組合語言,一次是Java 跟Perl,因為發現解決語言本身設計帶來的問題所花的時間,竟然比真正用在開發軟體系統的時間還多。其文章以長度聞名,長到應該稱為論文而非部落格,兼帶詼諧筆風,發表頻率大約一個月一到兩篇,作者總是說這些是在凌晨三罐啤酒下肚後的誇誇其談,但每一篇都是經過長時間醞釀,內容充實有見地的傑作。

獨立以Java/JPyton開發多人線上遊戲Wyvern,可讓玩家自行創建擴充遊戲內容。其工作團隊將Rails移植到Rhino上,Rhino是運作於JVM平台上的JavaScript引擎,Rails(Ruby on Rails)為一套受到廣泛喜愛使用的網站開發模組;為Emacs撰寫完整的JavaScript環境,期望在Emacs上可以有一套 JavaScript IDE,以及將來可用JavaScript而非elisp來開發Emacs extensions。


多型不行時(When Polymorphism Fails )


每個愛好OOP(object-oriented programming)的人順理成章地也會是多型(polymorphism)的狂熱份子,有很多本來可以更好的書籍(譬如Fowler那本講重構(refactoring)的),竭盡所能地傳達出一種信念:如果你用了執行期間型別檢查(runtime type checking),也就是Java的"instanceof"運算子,那麼你大概是個壞胚子,是那種會手拿switch恐嚇小孩子的邪惡角色。

總括來說,若是用上了"instanceof",這通常就表示程式的OO作的不好,這點我是同意的,那表示設計的技巧不足,兩相比較起來,應該要盡量使用多型,而不是執行期間型別檢查,寫出來的程式碼會比較乾淨比較容易維護,然而,我認為,至少存在一種狀況,這種狀況很常見到處都有,有資格稱為一個範式,在這種情況下你根本無法使用多型,如果你知道怎麼用,拜託告訴我,我很渴望知道,但我認為不太可能,至少在Java或C++這種靜態語言裡是不可能的。


多型的定義

若是你不是很熟悉OOP的術語,讓我解釋一下,多型是個矯揉造作的詞彙,它的概念其實就是延遲連結(late binding),而延遲連結這個詞彙也很矯揉造作(繼續往下看你會知道其中的奧妙),它的意思是,把找出哪個方法(method)要被呼叫的決定延遲到執行期間才作,到那時才判斷被呼叫的對象目標是不是能回應訊息。

以效能為優先考量的語言,譬如C++、Java、OCaml,方法(methods)會被賦予數字編號,每個類別都有一張表記錄著有哪些方法,在執行時會掃描這張表找出被呼叫的方法;而另外一方傾向於在執行期間時能夠擁有較大彈性的語言陣營,其找出方法的手段,通常是對方法的名字作雜湊(hashing)來判斷,除此之外,這兩種方式其實可看做是同等的。

光有虛擬方法(virtual methods)並不會帶來多型,多型冒出來的條件是:當你有好幾個子類別(subclass)繼承自某一個類別,每一個子類別都實作自己的多型方法,而且各自處理方式皆不相同。舉一個教科書上用到爛的例子,如果你去動物園,你會看到,動物們處理createBadSmell()訊息的方法都不一樣,嗯,或許從某些觀點來看會有共通極為類似之處,我猜啊,做那回事時的聲音大小算是個重點,我還是無法判斷出,到底是河馬還是長頸鹿的表現能力比較好,不過,有興趣的話你可以在過段時間後再來問問我。


多型大搖大擺地炫耀著

舉個實際一點的多型範例吧,讓我們來看看一個面試中會問的經典評估問題,就我所知,這是由Ron Braunstein帶進Amazon的,這問題的內涵相當豐富,企圖要探測出求職受試者是否擁有各種重要的技能:OOP設計、遞迴、二元樹、多型與執行期間型別檢查、一般撰碼技巧、以及(如果你要搞成這麼難的話)語法解析理論(parsing theory)。

進行在某個時間點,求職者應該能夠領悟到,可以把數學運算式以二元樹表示出來,在此假設只有二元運算子的情況,如"+"、"-"、"*"、"/",樹葉節點皆為數字,內部節點皆為運算子,想求出運算式的值就表示要把這棵樹走過一遍,如果受試者想不到這點,你可以適當地引導他們走到這一步,或是,若有必要的話,直接告訴他們也行。

即使你直接提示他們,這仍然會是個有趣的問題。

這問題的前半部分,某些人(為了人身安全我不敢點名,但他們的名字字首是Willie Lewis)覺得"如果你叫自己是個程式開發人員而且想在Amazon工作的話,這是一定要會的啦",但事實上是有些困難的,問題的前半部分是:你如何把一個數學運算式(例如在一個字串裡),例如是"2 + (2)",轉換成一棵運算樹,在我們的ADJ挑戰賽中應該有出現過這一題。

問題的後半部分是:假設這是個兩人專案,你的夥伴,讓我們叫他"Willie",負責把字串轉成樹,你負責簡單的部份:決定Willie應該用什麼類別把樹建構出來。你可以挑任何一種語言來用,但一定要確實挑出一種來,要不然Willie會用組合語言寫好交給你,要是不幸遇到他心情不好,那麼會更慘,他會用那種處理器已經停產的組合語言寫給你。

有很多面試求職者被這梗擊中爆笑不已喔,驚訝吧。

我不會給出解答,不過,標準的壞答案是,使用switch或case的語法(或是老派一點,一連串的if);稍微好一點點的答案是,使用儲存函式指標的表格;而大概稱得上最佳的解答是,使用多型。我鼓勵你做做看這題,很有趣喔!

諷刺的是(底下你就會看到),如果你想要打造一套可擴充有延伸性的系統,使用多型是相當理想的解答,如果你想要在系統內加入新的函式,而不想重新編譯整個系統──特別是,不想在那個有500個case的巨大分支指令裡加入更多的case,那麼,你就會想要使用多型。


用三種多型形式的歡呼來為多型喝采

所以,多型,講了這麼多,看起來還滿有用的。至今,多型最有用的地方是,呃,多型print。如果你用過Java、Python、Ruby或其他"真正"的OO語言,那麼多型print對你來說不是什麼新鮮事,你下命令叫物件把自己印出來,天呀,就印出來了,每個物件會印出內部狀態,恰恰好是你所需要知道的,在除錯、追蹤、記錄時非常有用,甚至在製作說明文件時也很有用。

如果你用的是有著扭曲OO外表的語言,例如C++或Perl這種強加裝上物件導向配件的語言,就好像是把一對價值美金2500的輪胎邊框裝在1978年的速霸陸老舊車款上,那麼,你能用的就是除錯器,或是Data::Dumper,或是類似的東西。哈,可憐喔,你啊!

(反問句:為什麼我們選擇使用C++與Perl呢?此兩者為世上最爛的語言是也!照同一標準的話,也可以用Pascal和Cobol啊,哭吧,大聲一點。)

順便說一下,最近我都沒提到OCaml,主要原因就是多型print,OCaml沒有提供多型print,原因我還沒完全搞懂,但大概落在"設計者的嚴重精神失常"那附近,因此,你沒辦法為了除錯而隨便叫一個物件在主控台上print出來;我希望他們這麼設計是有其必須性的,譬如為了能讓OCaml達到那擊敗C++效能表現的神奇傳說,若非如此,那就好像在使用便利性上打了一巴掌污辱人嘛,不過至少OCaml有支能夠穿越時空往回走的除錯器,你必定會需要它的。

總之!我們喜歡多型,多型就是微控管理的反面,你叫物件做事情,無需告訴他們要怎麼做,而他們就會忠實無誤地到網路看上整天的Strong Bad影片,嘿,那群笨笨的物件啊!愛死他們了。

可是,可是,多型啊,就好像所有的正義角色,擁有黑暗的一面,就多型這個案例來說,並不像,嗯,安納金天行者的黑暗面那麼黑,不過,不管怎麼說,黑暗面就是黑暗面。


多型的矛盾

要在程式碼裡使用多型,有個不明顯但非常要緊的條件:在之後你還能增加東西進去。至少,對靜態語言來說,例如Java與C++,如果你增加了一個多型方法,你必須重新編譯那些實作出方法的類別,也就是說,你需要擁有原始程式碼,並且能夠進行修改。

在我的能力範圍內,至少可以說出,的確有某一類系統是做不到這件事的,那就是:可擴充系統(extensible systems)。

讓我們假定,你正在打造一套嶄新的系統,允許使用者自行加進程式碼,這可不是件簡單的任務,有很多理由要考量,包括保密安全性、緒程安全、以及其他許許多多的地方,不過這種系統是存在的!譬如說,有些線上遊戲可以讓玩家放進他們自己寫的程式碼,毋須存取原先的遊戲原始碼程式,大部分的多人線上遊戲都朝著這個方向前進──管理階層意識到,使用者想要而且有能力創造出好的內容出來,所以讓遊戲把APIs公開出來,讓玩家能夠打造他們自己的怪物、法術等等,藉此擴充延伸遊戲系統。

嗯,我聽到某種聲音告訴我說,網站服務(web services)也走向類似的道路。

在任何時候你想打造出這種可讓使用者進行擴充的系統,就需要花費三倍多的心力,多出來的兩倍工作量在於,把你內部APIs與類別安排整理好,使得它們是可以被使用者所修改運用的。

Java的Swing就是個很好的範例,打造可擴充系統時,你就會跑進創造者矛盾(Inventor's Paradox)的弔詭中,你可以上網找找看,不過它的中心大意就是,你沒辦法事先預想出,你的使用者想要對系統的哪些部分進行修改。你可以把限制一直往外推──就算是你把系統裡的每一行程式碼都搞成虛擬函式並公開出去──即便如此,你的使用者最終仍然會跑到某個他們沒辦法客製修改的地方。這真不幸啊,我也不會假裝我知道答案。Swing的應對方式是:搞出一拖拉庫的掛勾(hooks),使得它變成一套龐大無比的API而很難搞懂。


怪物問題

為了讓說明更加具體,讓我們回到線上遊戲的案例,假設,你已經極盡所能地把APIs與類別都弄好公開出去,可以用來創造與操縱法術、怪物、以及其他遊戲中的物件,假設你已經寫好許許多多的怪物類別了,我想你一定可以想出不少。

現在,假想有一個玩家,想要參與並寫一隻叫做OpinionatedElf小怪物。這是個故意設計出來的假設,就好像自動機停機問題(halting problem)證明自己的論點那樣不自然,不過,在實際上的確會發生類似的狀況。

假設,這隻OpinionatedElf其唯一的使命,就是大聲嚷嚷著他喜歡還是不喜歡其他種類的怪物,他會坐在你肩膀上,無論何時當你碰見一隻,譬如說獸人(orc),他就會歇斯底里抓狂叫喊著:"我恨獸人!!!啊啊啊啊啊!!!"(附帶一提,這句話就是我對C++的感覺。)

這個問題的多型解法相當簡單:找出你那150個怪物類別,每一個都加進doesMrOpinionatedElfHateYou()方法。

天啊,聽起來白痴極了,但是,這真的就是使用多型的解法,沒錯吧?如果你有一堆類似的物件(此例就是一堆怪物),這些物件都要根據某種狀況做出不同的回應,所以,你加進一個虛擬方法,然後替每個物件實作出不同的程式碼,沒錯吧?

顯而易見,這該死的解法行不通,即使你硬來(你沒辦法硬來,因為撰寫這隻小elf的玩家並不持有原始碼),也是一種差勁的設計法,沒道理把這麼特殊的方法(method)放進遊戲裡每個怪物物件裡,這點很清楚,假如一段時間後,我們發現OpinionatedElf有版權問題而必須移除掉,怎麼辦?到時你就必須回頭找出那150個怪物類別,一個一個移除掉。

就我所知(我並沒有聲稱自己是個程式設計高手,僅是個想知道正確答案的人罷了),正確解法會是使用執行期間型別檢查(runtime typing),程式碼大概會長的像下面這樣:

    public boolean doesElfLikeIt ( Monster mon )
    {
        if ( mon instanceof Orc ) { return false; }
        if ( mon instanceof Elf ) { return true; }
        ...
    }


我知道我知道,你這個OO狂,你可以寫出150個附加類別來幫助OpinionatedElf,每一種怪物由一個類別負責,可是,那樣並沒有真正解決根本的問題喔,問題核心所在是,這些眾多紛紜的行為全部都落在呼叫者(caller)身上,而不是被呼叫者(callee),那就是這些行為的歸屬之地,在呼叫者裡,就是那裡。

較高階的程式語言,對這個問題擁有稍微好一點的優雅解法,注意,我強調只是"稍微好一點",譬如說Ruby,你可以為其他類別加進新的方法(methods),即使是內建的類別也行,即使是你沒有原始碼也行,舉例而言,你可以把下面程式碼放進OpinionatedElf檔案裡:

    class Orc
      def doesElfLikeMe; return false; end
    end

    class Troll
      def doesElfLikeMe; return false; end
    end

    class ElfMaiden
      def doesElfLikeMe; return true; end
    end

    ...

如果還沒載入的話,Ruby實際上真的會把你指定的類別通通都載入,並且把你加入的方法放進每個類別裡,一般而言,這是個相當不錯的特色。

不過呢,在這裡讓我來說說這種作法的優缺。對Ruby(以及其他大部分高階語言)來說,方法(methods)就是放在雜湊表(hashtable)裡的項目罷了,每個類別分別有一張表,上面所述的特色,實際上發生的動作就是,跑進每個怪物類別裡,把你的東西塞進雜湊表裡去。優點有:

    * 所有關於OpinionatedElf的程式碼都被封裝在他的檔案裡面
    * 在elf檔案被載入前這些程式碼並不會被載入
    * 系統裡的任何其他人都可以向某物件詢問,問elf喜不喜歡他

缺點是當加入了新怪物,而elf無法辨別時,此時你需要有預設的行為。如果某人加入怪物類別小精靈(gremlin),你的elf就會卡住,亂吼亂叫地說"啊呀,那是什麼啊?",直到你更新加入處理小精靈的程式碼為止。

我猜想,如果你能以某種手法列舉出系統裡的所有類別,然後檢查是不是繼承自怪物類別,然後就應該能以數行程式碼達到上面所說的。若是Ruby,我打賭你做得到...不過僅限於已經被載入的類別,而對那些還躺在磁碟裡的類別可沒用啊!你或許還是可以想辦法解決,但可別忘了網路的存在啊...

話雖如此,需要有預設的行為這點,還不算太糟,還有更慘的缺點,緒程安全(thread-safety)是其中之一,這點深深地困擾著我──以這個案例來說,我不認為Ruby對於緒程安全所下的語意是定義清楚的,有把類別鎖住(class lock)嗎?載入elf類別前所執行的物件,其執行所在緒程,鎖定又會做些什麼呢?我日文不夠好,無法得知證明的方法存在於標準規格文件中還是在實作程式碼裡。

但是,真正的問題,真正困擾我的問題是,你的程式碼會跑進系統裡並且散佈到所有的類別中,這感覺起來,就像是違反了封裝(encapsulation)原則。

事實上,比那還要更深層一點,這感覺起來像是個差勁的設計,我們的情況是,有個觀察者,做出判斷的呼叫,而我們把判斷的程式碼塞進被觀察者裡,這不就好像是,我走到所有員工面前,交給他們一塊牌子,說:"請拿著這張牌子,上面寫著我喜歡或是不喜歡你"。這可不是真實世界的運作方式,而OOP不是應該要模擬這個世界嗎。


再次審視多型

嘿!至此,我已經說的相當明白了,多型不再能被當作銀色子彈了,即使是一個無擴充性的系統,如果你需要根據目標對象的型別來決定做什麼動作,那麼,把判斷的程式碼放進目標類別裡是不對不好的。

一個更實際更樸實的例子是,身分認證(authentication),讓我問你:如果你要設計一套權限管理系統,你的設計裡會有一個虛擬的doYouHaveAccess()方法,然後讓所有來人自行實作該方法?換句話說,你會請一個保全警衛,讓他去問每個人是否有權限進入建築物嗎?

絕對不會,你會在程式碼裡做執行期間型別檢查:

    public boolean denyEntryToBuilding ( Person p )
    {
    return ( p.hasNoBadge() ||
         p.isSuspiciousLooking() ||
         p.hasMachineGun() );
    }

但是,等一下,這些檢查並不是公開檢查類別啊,譬如,我並沒有說"p instance of MachineGunBearer",這是怎麼回事?

嗯,所謂物件的"型別(type)",實際而言,指的是它的類別(class)(幾乎是寫死的,就像是基因)以及屬性(properties)總合起來的合集,在執行期間可能會也可能不會改變。

那將是另一篇部落格了,但,我認為這代表了,型別最好由屬性而非類別來代表,因為類別有著與生俱來的固定性,但是在"傳統"語言裡,像是C++與Java,這會讓程式碼更難以共享,因為沒有支援語法委託(syntactic delegation)的特色,如果你覺得我說的沒道理,it's OK:我正在喝第三杯酒,快要不省人事了,讓我們把這個題目留到另一篇文章罷。

在此,我希望我把論點說的夠清楚了,也就是,當多重行為是目標對象的行為時,使用多型才有意義;當行為屬於觀察者一方的話,你需要執行期間型別檢查。


總結

嗯,我希望你在今天的醉後部落格有學到一些東西,我知道我有,其一,我學到Google的搜尋引擎的確有足夠的智能可以把"anikin skywalker"修正為"你是說anakin skywalker嗎?"哎呀,那群厲害的渾球,這不像是他們擁有著作權呀。

我也學到了,一篇部落格的長度應該剛好是兩杯葡萄酒,如果超過,你就會開始半昏迷語無倫次,打字也會打到地獄去了。

下週見,接下來的主題是...

Stevey's Drunken Blog Rants(tm)
(發表於2004年8月25日)

No comments:

Post a Comment