從歷史來看,物件導向觀念起於Simula語言,歷經Smalltalk-80等融入語言之中,在Java話題性高的年代風行草偃,但隨之而來的就是各種的批評,有開發者甚至棄之如敝屣,在新生代的語言中,Go語言乍看令人迷惑,表面看來有物件導向的概念,實際上似乎又不是這麼一回事。

不愛物件導向的千百理由?

如果在網路上搜尋,可以找到無數種不愛物件導向的理由。

有人認為,它創造了太多的名詞與產物,封裝、繼承、多型、模式、框架、UML與各種方法論等,有人是單純討厭別的開發者,老是OO不離口,老對著你說「你這一點也不OO」。也有人是在為了擁抱另一典範而唾棄曾經擁護過的物件導向,常見的就是要你去擁抱函數式程式設計,就像是〈Goodbye, Object Oriented Programming〉這類的文章。

愛或不愛這件事,本身就是個二分法,就我個人來說,我喜歡物件與多型的概念,然而很少使用繼承,事實上無論喜歡或不喜歡,現在許多語言,多多少少都有物件與多型的概念存在,開發者多半也無法否認的是,如果一組方法總是操作某幾個資料,將它們放在一起,總是比較方便,想要設計具彈性的程式碼時,多半都得從行為的角度出發,而不是思考特定型態(就傳統物件導向來說,就是定義類別)。

在抨擊物件導向的相關文件中,儘管理由族繁不及備載,然而其中必定會提到的一點就是繼承這個大坑,特別是只為了重用程式碼而使用了繼承的場合,繼承的階層性帶來了許多問題,像是鑽石繼承,或者是同時繼承了許多不必要的特性。

對此,Erlang的創建者Joe Armstrong有個傳神的比喻:「你只是要根香蕉,結果卻得到一隻拿著香蕉的大猩猩,還有整座叢林」。

為了解決繼承帶來的問題,有著許多的折衷與設計方式,像是限制多重繼承、透過介面定義行為,以組合的方式重用程式碼,或者是Mixins,甚至是我先前專欄〈從Mixins到HOCs〉中提過的High Order Components設計。然而,像Go這門語言,就乾脆不支援繼承。

接著,許多接觸過物件導向的開發者,都問了類似的問題「在Go中要怎麼實現繼承呢?」

型態內嵌模擬繼承?

想要回答這個問題,必須先得釐清一件事「Go是物件導向語言嗎?」官方的FAQ回答為「Yes and no.」就連「物件」的概念,在Go中都很模糊,如果一組資料總是會在某些運算時同時出現,為了便於使用,在Go中可以使用struct來定義,如果某個函式總是在處理某個struct,在Go中可以定義成員函式(member function)。

如果從物件導向的世界來到Go,直覺上會認為,struct就像是類別,而成員函式就像是方法,只不過實作方式類似於C++,將成員函式的程式碼寫在類別之外,下一步地,也許會想到該怎麼實作繼承。

而許多文件都會告訴你,用型態內嵌(type embedding)來實作。舉例來說,如果Account是個struct,擁有id、name與balance值域,那麼底下的CheckingAccount,就使用形式上來說,就會有id、name、balance、 overdraftlimit四個值域:

type CheckingAccount struct {
    Account
    overdraftlimit float64
}

不過官方FAQ明確談到,Go中沒有繼承,正確的說法是CheckingAccount包含(Contains)了Account。雖然Account中定義的成員函式,透過CheckingAccount實例也可以呼叫,不過實際上是使用內含的Account之成員函式,如果為CheckingAccount定義同名的成員函式,看似像物件導向中的重新定義(Override),不過其實只是針對CheckingAccount型態定義了專屬的成員函式。

這樣的好處是,與型態相關的商務邏輯,只會在屬於該型態的範疇之中,儘管使用上看來像是繼承,但實際上沒有型態上的繼承關係,一個簡單的事實就是,將CheckingAccount實例指定給一個Account型態變數,就會產生編譯錯誤。

沒有行為,沒有多型

對物件導向有一定認識的開發者,接著必然會發問,如果沒有型態上的階層關係,要如何實現多型的概念?

繼承帶來的型態階層關係,在過去實際上有兩種意義,一是程式碼的重用,二是為了能實現多型。前者的作用早已被認為不適當,而提倡組合優於繼承的概念,實際上,Go的型態內嵌,就是將組合的概念實現為程式語法的一部份;對於多型,Go中必須使用interface,將行為定義出來。

舉例來說,如果Account有Deposit成員函式,而CheckingAccount也定義了Deposit,就傳統物件導向的觀點來看,CheckingAccount重新定義了Account的Deposit方法;然而,就Go的觀點來看,其實是CheckingAccount擁有了自己的Deposit方法,跟Account的Deposit沒有關係。

如果有個函式想要能操作Account的Deposit,也要能操作CheckingAccount的Deposit,那麼表示這個函式思考的是Deposit這個行為,而不是Account與CheckingAccount在型態上有無階層關係,只要使用interface定義Deposit行為,並宣告在函式上,就可以解決這個問題。因此,多型不應與繼承混為一談,多型實際思考的就是行為,因此,沒有思考行為,就沒有多型。

也正因為Go中沒有繼承,也就不會有抽象方法模式的類似實作,舉例來說,如果Account有個成員函式realClose,而CheckingAccount也定義了成員函式realClose,但是沒有定義close,那麼透過CheckingAccount呼叫底下的close成員函式時,實際上執行的會是Account的realClose,而不是CheckingAccount的realClose:

func (acct *Account) close() {
    acct.realClose()
}

忘了物件導向外在形式吧!

如果是物件導向的忠實信徒,看到以上的結果一定會極度不適應!如果真要能模擬抽象方法的概念,雖然也是做得到,只是可能也因需求不同而會有許多做法,一直從物件導向在封裝、繼承、多型上的形式來思考Go語言,只會走入死胡同,這有點類似在沒有類別、基於原型的JavaScript中,硬是要模擬出基於類別的繼承機制一樣,做法也是因人、因場合而有許多不同方式。

Go語言是不是物件導向?官方FAQ的回答為「Yes and no.」也許代表的是另一個意義,Yes或no沒有那麼重要!

Go中有的是struct、成員函式、內嵌型態、介面與多型,而沒有傳統物件導件的那些形式。如果真的非得從物件導向的觀點來看待一門語言,那麼就得認真思考過,封裝的目的是什麼?繼承與多型的目的是什麼?以及自己的需求實際上是什麼,然後用語言的特性,以不同的方式來實現它,而不是只從語法層面上來討論它支不支援某些特性,並試圖將一門語言上既有的習慣帶到另一門語言上,如果能做到這一點,有機會再回到物件導向的世界中,也會帶來許多的啟發。

專欄作者

熱門新聞

Advertisement