讓我在這篇文章總結一下前面對物件導向設計的討論,我們討論了物件導向的四個特性:繼承、抽象、多型、封裝,分析了它們的問題,並跟函數式編程的思維做比較。我們引入了與之相對應的特性:泛型、特性系統、模組化,有些特性雖然跟那四個特性很像,但在一些細微的地方有不同的詮釋,使得整體思考方式很不一樣。
「繼承」是物件導向最重要的特性,它提供一種方法建立子類關係,並將父類別的成員、方法與介面的實作繼承下來。這種繼承機制讓物件成員與方法得以增加,並讓方法變得可以被改寫。並不是所有方法都適合被繼承,例如物件的複製方法應該要以實體類型為回傳類型,但這個方法會因為繼承變得不符合規則,實際上它的方法實作也不適合透過繼承被重用。允許子類的類型讓物件的實體類型可以和變數類型不一樣,因而造成類型資訊的丟失,因此物件導向很依賴動態類型檢查。這種子類關係是物件導向的抽象化的根基。物件導向的「抽象」是向外的抽象,它把相同的功能抽取出來成為父類,並把其他不同的部分當成它的延伸。它透過子類轉換將不重要的部分抹除,因而形成抽象的概念。這種基於抹除的抽象化讓類型資訊遺失,使得延伸的部分在一些情況變得難以掌握,尤其是定義容器類的資料結構時。相反的,函數式編程使用向內的抽象,它將不同的部分抽換成類型參數,因此可以保證未知類型的同調性,還可以透過類型約束限制延伸的方向。泛型可以取代大部分透過繼承能做到的事情,但在解決問題上會很不一樣,因為物件導向鼓勵開發者使用繼承為問題建模,而只使用泛型就必須以組合建構模型。物件導向的類別只專注於已知的部分,這很容易會讓我們忽略掉對子類做了哪些假設,使得未來做延伸時常常打破約束。而泛型的類型變數描述的是未知的部分,因此可以明確的知道你不知道什麼,透過了解知與未知的邊界更能有效控制抽象化的模型。
物件導向的「多型」指的是子類多型,它透過繼承類別/實作介面讓相同的方法根據物件的實體類型擁有不同的行為,從而實現類別的延展性。延展性讓函式或物件有辦法與未知的程式碼合作,只要未知的部分透過介面表明它符合某些特性。介面用來描述一個類別是否符合某些特性,它是物件導向多型的基礎,在這裡類別也可以看作一種帶有預設實作的介面。因為介面的方法是屬於物件的,一個變數的介面實作是由它的實體類型決定而非變數類型,因此我們很難保證兩個相同類型的變數是否具有相同的特性,例如比較兩個變數時使用哪個物件的比較方法會影響結果。特性本來就不應該被繼承,子類轉換會使特性的意義改變。rust沒有類似繼承的子類關係,物件的實體類型基本上就是變數的類型,因此一個變數的特性可以靜態地決定,使我們更容易控制未知的程式碼。trait的語法雖然看起來像是介面,但它實際上更像Haskell的typeclass,特性是獨立於資料類型的存在,這使得我們可以在某種程度上附加任意特性給已有的類型。
模組可藉由存取權控制依賴關係的範圍,這讓我們得以將危險的函式或複雜的實作細節隱藏起來,而不會受到外部引用。這種隱藏細節的概念稱作封裝。合適的依賴關係範圍非常重要,它決定了我們對這個函式、類型的看法,如果它只能被很小範圍的函式引用,那我們就不用太擔心它的情境。物件導向的「封裝」把這個概念放到類別上,讓我們可以把類別的實作細節封裝在類別的定義範圍內,還增加一種只讓子類存取的控制權限。這種存取權限讓類別具有感染性,使得很多方法只能放在類別內定義,因而破壞單一職責原則。
物件導向的類別結合了定義資料結構、定義抽象行為、封裝複雜情境與實作細節三種功能。物件導向的繼承做了三件事:增加資料的成員變數、繼承抽象行為的實作、延伸抽象行為。除了定義資料結構,抽象行為應交由介面定義描述,而封裝應是模組的職責。比起擴充資料結構成員變數,組合才是組成複雜結構的最好方法,要延伸抽象行為應使用帶有約束的介面建立依賴關係。類別的每個特性在大部分情況都有其他機制可以取代,甚至可以做的更好。物件導向看起來好用只是因為它把各種功能加到一個語法上,讓我們照著某種範式設計程式時比較方便。如果把這些看似新潮的功能從類別上拔掉,只留下最基礎的機制,大部分物件導向的問題都將不復存在,而golang就是這麼做的成果。從這個結果來看,物件導向的機制真的好用嗎,還是只是你沒有理解為何要這麼設計。
trait/typeclass等特性系統依賴於靜態的類型推論,因此只有存在語言上的支援才有辦法實現。而物件導向的介面是基於子類關係的特性,只要物件本身能帶有方法就能實現,因此它很適合應用在動態類型系統上。有人說「物件導向是一種態度」,只要我們有心就可以在任何程式語言使用物件導向編程,但事實上每個程式語言都有最合適的思考方式。我認為認識物件導向的機制與函數式編程的機制之間的不同非常重要,因為語言特性能形塑我們的思考方式。我相信程式語言有一個最好的典範模式,物件導向這種直覺的思考方式在某種程度上抓到了它的部分精髓,但在很多地方搞砸了。物件導向的程式語言把物件導向的概念作為基礎機制,讓我們必須以物件導向的方式思考,但也使得我們難以意識到它在哪裡搞砸了。這種以直覺想法作為根基的機制本來就不穩固,它的機制大多依賴於某種特定的使用方式,而非提供一種能力讓我們可以做到什麼事,例如繼承機制本質上是不需要的,我們可以用原型鏈達到一樣的效果。程式語言應該根據能做到什麼事而非想要怎麼做而加入機制,如此才能抓到程式語言的典範模式,而函數式編程就是它的實現。我把它稱呼為「函數式編程」而非「函數式導向」就是因為它不是因為想怎麼做而發明出來的(至少我是這麼認為的),而是透過最大化語言特性的用處所得到的編程範式。因此語言特性的邊界特別重要,這種邊界才是形塑核心概念的關鍵,前面的文章一直在討論這些細微的差異就是這個原因。