2007年12月11日星期二

思考OO

作者:蔡學鏞

十多年前在讀大學時,我對於OO(Object-Orientation,物件導向)興致正濃,看了不少OO的書,有外文書(例如Grady Booch、Bertrand Meyer),也有中文書。其中,中文書為了幫助讀者理解,都會用現實生活中的物件做比擬,比方說:哺乳動物、交通工具,我記得我讀過的一個範例中提到:「斑馬」繼承自「馬」。

【學習OO的重點】
當時我在工研院當實習生,老闆要我報告OO,我於是拿了書上的例子當解說,當老闆聽到我宣稱「斑馬繼承自馬」時,他開玩笑地說:「那麼馬子(女朋友)應該也是繼承自馬」。我當時深受羞辱,感覺被IT中文書荼毒了。

OO的三大基礎是封裝、繼承、多型。用現實生活的物件做OO解說上的比擬,通常不會太恰當,因為只能解釋封裝和繼承,卻無法解釋多型。而多型卻是OO真正的重點,也是學習OO的門檻。沒有解釋多型,就等於小學而大遺。

對於OO,比較恰當的例子是「形狀」,一來容易理解,二來適合同時解說封裝、繼承、多型。

我認為OO的書不用看太多,只要看Bertrand Meyer的名著OOSC第二版就夠了,但這本書可不薄。

前面提到,物件導向的三大基礎是封裝、繼承、多型。你會在特定OO語言上看到一些其他機制,例如Template,RTTI(Run-Time Type Information),但這些都不是重點。學習OO的時候,焦點應該放在封裝、繼承、多型這三方面。

這三者是有次序性的,沒有封裝就不可能有繼承、沒有繼承就不可能有多型。只支援封裝的語言稱為Object-Based語言(例如傳統的Visual Basic),同時支援封裝、繼承、多型的語言才能稱為OO語言(例如.NET時代的Visual Basic)。

有沒有可能,存在某個語言只支援封裝和繼承,卻不支援多型?不會有語言這麼無聊,基本上繼承往往只是一個中間過程,真正的目的是多型。既然支援了繼承,卻不支援多型,這是沒有意義的。

【封裝】
封裝(encapsulation)的目的是要將程式碼切割成許多模組(module),每個模組之間的關連性降到最低,這麼一來比較不會產生「牽一髮而動全身」的狀況,降低相互依賴的程度,也等於是降低複雜度,可以讓開發與維護更容易。

事實上,沒有人用「模組」一詞來稱呼封裝的結果,而是稱為「類別」,把模組一詞做更高階的包裝用途。因此我們現在應該將「類別」視為封裝的結果,把「模組」視為整個程式切割出來的許多片段。而在OO的世界,一般來說,一個程式有多個模組,一個模組內包含多個類別。

模組的概念不是OO獨具的,許多非OO語言也具有模組,但是OO的語言幾乎都具備模組,例如Java的Package;D語言的模組;而.NET更是細分成組件(assembly)和模組,其實.NET的組件與模組都具備一般模組的概念,但程度有別(組件包含模組)。

封裝是以資料為核心,將相關的資料放在一起,將會用到這些資料的函式也放進來。封裝等於是將資料和函式放在一起。儘管有的語言還有其它的東西,例如event、property,但是從內部來看,這些都是函式的變形。

為了和非OO的世界做出區隔,OO也做了一些名詞上的改變,將Function(函式)改稱為Method(方法)、將Call(呼叫)改稱為Invoke(調用)。但是新舊詞彙基本上還是通用的。

【能見度】
封裝的目的既然是要「降低互相依賴的程度」,就牽涉到能見度的問題:這個「類別/方法/欄位」該不該暴露給別的模組、同一個模組的不同類別、自己的「次類別」、友伴類別(Friend Class)、內部類別(Inner Class)?這就是所謂的「能見度」(visibility)。

我們當然希望盡可能降低能見度,這才能「降低互相依賴的程度」。也就是,別人不需要知道的,就不要讓它知道,這就是所謂的「資訊隱藏」(information hiding)。

最該被隱藏的是資料。極致的封裝主義者,主張所有的資料一定都不可以直接被外部(包括次類別)存取。

上面提到,封裝將相關的資料和使用到這些資料的方法包成類別。最理想的狀況是,讓資料的能見度為最低,外面完全看不見。留下的對外介面(Interface)只剩下method。換句話說,每個物件的Interface是一些方法的集合,完全沒有資料。

設定能見度不是一件容易的事,往往需要深思熟慮。特別是對於設計「框架」(framework)的人來說,能見度設定得太寬,造成資訊隱藏效果不佳,可能會帶來相當多負面的效果(例如複雜度提高、程式容易出錯、非thread-safe…等);能見度設定得太緊,造成效率變差、擴充程度變差(有些設計因而做不出來)。

【繼承】

被繼承的對象稱為基底類別(base)或超類別(super)或親類別(parent),繼承者稱為衍生類別(derived)或次類別(sub-)或子類別(child)。

繼承的目的,是要達到「程式碼再用」(Code Reuse)或「介面再用」。而繼承的手段,就是「擴充」或「修改」。這是繼承的重點,請務必牢記。

繼承所導致的程式碼再用,是指次類別能自動沿襲超類別的所有程式碼,好讓你可以不用寫太多程式碼,只需要稍微擴充或修改,就能符合你的需求。「擴充」指的是定義新的方法(Method),修改指的是「針對超類別中的某方法重新定義其行為」。

請注意,繼承所產生的次類別,和其超類別之間,兩者在記憶體內是獨立的。繼承所做的擴充與修改,並不會影響到超類別。在Windows程式設計中,有所謂的SubClassing技巧,其實並不是繼承的概念,因為它會修改到原本類別的記憶體。

繼承所導致的介面再用,是在為OO的下一個階段(也就是多型)作準備。介面再用,搭配方法的修改,就形成了多型。

如果你不想再用程式碼,也不想再用介面,或者說你不進行擴充、也不進行修改,那麼透過繼承產生次類別,幾乎是沒有意義的。

唯一的一個小小的意義是,次類別和超類別兩者是不同的類別,你可以在程式中依據這一點做判斷,做不同的行為。但是這是一種瑣細的程式技巧,和OO無關,而且OO也不鼓勵你這麼做。對OO來說,透過多型的機制造成行為的差異,才是正確的作法。但即使是為了此目的,我們也會使用空介面當作特殊標籤(Mark),而不會使用類別當作標籤,因為介面當標籤的副作用小,成本低,且不是垂直的關係。

將許多類別之間的繼承關係,繪製出一張關係圖,如果繪製的時候依循「超類別在上,次類別在下」,或者「超類別在左,次類別在右」,就可以形成一個類別階層(Class Hierarchy)。由於大多數的類別階層設計都是採用單一繼承(Single Inheritance),而非多重繼承(Multiple Inheritance),所以階層圖往往是樹狀結構,符合樹狀結構的階層圖,也稱為繼承樹、類別樹。

【多重繼承與介面】
單一繼承指的是,只有一個超類別;多重繼承指的是,具有多個超類別。應用框架設計幾乎都是採用單一繼承(例如MFC、.NET Framework、Borland VCL、AIR),只有極少數以前的設計會採用多重繼承(例如Borland OWL)。

不只是如此,連語言本身的設計上,也往往禁止多重繼承(例如Java、Delphi、C#、VB.NET),只剩下極少數語言允許多重繼承(例如C++、Eiffel)。這個趨勢似乎會延續下去,主要是,多重繼承「可能」會造成「不知繼承的方法是來自那個超類別或祖先類別的困擾」。C++要求編程員要主動指明繼承的方法來自何處,但Eiffel的作法則更巧妙(請參考http://www.eiffel.com/)。

姑且不論多重繼承的缺點,多重繼承顯然表達能力比單一繼承更佳,至少,有不少原本在單一繼承時必須透過AOP(Aspect-Oriented Programming)解決的問題,在多重繼承之下可以輕易解決,不需要AOP。

從Java開始,多數的語言使用Interface來解決多重繼承的問題,它們號稱『利用介面可以享用多重繼承的優點,又沒有多重繼承的困擾』。但事實根本不是如此!

介面只能讓你繼承到介面,無法繼承到程式碼(介面不帶程式碼)。因此,如果你在Java中繼承多個介面,你必須親自定義所有介面的每個方法,也就是說,你必須寫許多程式碼。但如果是在C++/Eiffel中,你可以繼承許多類別,不需要再定義這些方法。

所以介面是在「捨棄多重繼承缺點的同時,也捨棄了多重繼承的優點」。也就是說,介面捨棄了「程式碼再用」,保留了「介面再用」。從這個角度來看,「介面再用」比「程式碼再用」更重要。這是因為多型的緣故,多型才是OO的終極目的。

【其他和繼承相關的問題】
繼承某些程度上破壞了一部份的封裝,造成次類別和超類別的相依程度提高。超類別如果改變,且次類別沒有跟著做出改變,可能會造成次類別出問題。類似DLL Hell的觀念。

法律上有所謂的「限定繼承」與「拋棄繼承」,目前的編程語言似乎都沒有這樣的概念,就算有,權力也是放在超類別上,由超類別所控制,而不是在次類別上。


設計繼承時,必須先考慮介面是否共享,再考慮程式碼是否共享,再考慮分類。但是經驗不足的編程員,反倒會先考慮分類和程式碼再用,而忽略了「介面再用」是其中最重要的事。

【多型與虛擬】
Polymorphism中文一般稱為「多型」,早期也有人稱為「同名異式」。我比叫喜歡前者,不喜歡後者。「多型」讓人覺得物件可以以「多種面貌」出現,同名異式則太強調「不同的函式」。其實,「型別的不同」是因,而「函式的不同」是果。當一個物件具有不同的型別,就有可能會引發多型機制。

一個物件為何為有不同的型別?這是因為繼承而來,物件可以扮演所有祖先類別的角色。例如當某物件的類別是Sub,當此物件被「轉型」成超類別Super之後,此物件就具有兩種不同的類別,「實際類別」是Sub,「形式類別」是Super,此時呼叫此物件的方法m,會執行到的是Super定義的方法m?還是Sub定義(修改)的方法m?

答案是實際類別的方法,也就是Sub定義的方法m。所以所謂的多型就是:不管形式類別是什麼,一定會執行到實際類別的方法。

你可能會覺得疑惑,為何當初要將物件轉型為祖先類別,導致「形式類別」(宣告類別)和「實際類別」(定義類別)不一樣?這個問題留待下一節時再回答。

如果你的程式中,大量使用switch/case語法,很有可能是你的設計不良,而沒有好好地使用多型。你最好能「重構」(Refactoring)你的程式。

類別的方法,可以分成虛擬(Virtual)與非虛擬兩種。只有虛擬方法才能搭配多型機制使用。如果是非虛擬方法,則會執行到形式類別(而非實際類別)的方法,因為多型沒有發揮作用。

關於虛擬,每個語言有不同的作法。Java強調動態,所以預定是虛擬;C++注重效率,所以預定不是虛擬。

【應用框架】
為了方便軟體的開發,許多軟體廠商都會提供應用框架(Application Framework),現今流行的框架相當多,例如:.NET Framework、Borland VCL、Java Class Library。1980年代OO開始興起,1990年代框架開始興起。有了框架,我們終於可以享受到OO的好處,重複利用別人寫好的程式碼,不用一切自己重頭寫。

框架廠商先將一大部分的程式先寫好,編程員只需要「利用繼承來做修改」,就能套用整個框架,為了要讓你修改的部分能夠確實被執行到(而不是執行到框架本身的方法),所以這些允許修改的方法都是定義成虛擬的。

因為編程員「利用繼承來做修改」所以產生了次類別和重新定義的方法。框架比這個次類別更早被定義,當然不認識這個次類別,所以框架內都是以此次類別的祖先類別為「形式上」的處理對象(處理介面)。當此次類別物件被傳入框架中,就會被自動轉型成為祖先類別,因此產生「形式類別」和「實際類別」的差異。正因為這樣的類別差異,加上次類別有重新定義方法,所以多型機制出現了。

單一繼承架構中,良好的框架設計(例如Java Swing)會將程式碼不需要被修改的部分,設計成類別。至於需要被繼承修改的部分,設計成介面(介面的方法全都是虛擬的),以及實踐這些介面的類別。框架內的類別盡量只使用到這些介面。

學習框架往往需要付出相當多心力,以Java Swing來說,就是一個相當複雜,不好學習的框架。

框架設計上,近年來比較比較不一樣的是,階層有變深的趨勢(例如AIR和WPF的框架);也就是說,繼承樹的葉節點到根節點之間的距離變大了。這樣的好處是程式碼重複利用度增加(所以框架檔案的體積變小),介面重複利用度增加(學習速度可以加快)。

【OO是生產力的最終解答?】
和物件導向程式設計關係緊密的是前一個階段「設計」和下一個階段「測試」。「設計模式」(Design Pattern)將許多好的設計整理出來,讓我們設計功力大增。如果既有的設計不太好,你可以利用「重構」(Refactoring)的技巧來重新整理你的程式。現在講求TDD(Test-Driven Development),對OO來說,「單元測試」(Unit Test)正是以類別為最小單元的。

千萬別忘了UML!設計OO系統的時候,UML可以整理你的想法,方便大家溝通,甚至當MDA(Model Driven Architecture)成熟之後,號稱可以用UML把架構設計圖畫出來,用OCL(Object Constraint Language)描述一些規範,然後就可以產生出程式碼了。

OO太美好了!OO是軟體開發的極致靈丹!OO真棒!我愛OO。… 你醒醒吧!… 儘管OO主宰現今的主流語言,OO對我們的開發效率似乎有一些提升,但我可沒看過什麼人用了OO之後就若有神助。更不用說OO還有學習門檻、各種OO框架的學習曲線、設計模式的學習曲線、過度工程化的問題。

最近,我覺得真正可以達到更高生產力的關鍵在於更高階的抽象,也就是DSL(Domain Specific Language)。儘管有的技術號稱有支援DSL,依然有程度上的差異。關於DSL,我將另闢專文介紹。

至於OO,在我認識到DSL的威力之後,已經被我打入冷宮了。因為當DSL發揮到極致的時候,OO似乎是派不上用場的。