自從Java 17正式釋出之後,在面對一些類別設計時,我都會思考著:類別是否在本質上為資料載體,如果可以的話,我就將之重構為record類別;然而,有一次在面對具有繼承架構的一組類別之際,卻讓我思考了很久:Java的record類別不能實現繼承,可是,那一組類別很顯然是資料載體啊?

就函數式設計的觀點來看,record類別像是product型態,也就是,組合既有型態來建立新型態,如果不能實現繼承的話,可以改用組合啊?畢竟不是常有人說「組合優於繼承」嗎?

然而,試著使用record類別,並採用組合的方式來重構之後,整個程式碼會變得冗長而不易閱讀,因為此時不得不使用模式比對,確認物件的實際型態,才能取得物件的值域,而明明這些物件具有相同值域。

在重構之前,既有的類別因為有繼承關係,完全可以基於父類別型態來存取相同值域,這讓我有了一些疑問:這看來是個繼承優於組合的情況?在Java中,資料載體就不能同時享有組合與繼承的便利嗎?最重要的是,「組合優於繼承」真正的意義是什麼?

就思考後的結論而言,我所需要的是,基於父型態觀點來操作子型態實例,以便取得特定的資訊,既然如此,「取得特定資訊」這個行為,一開始就是父型態就需要的行為,那麼,就規範為介面,因為record類別雖然不能實現繼承,然而,卻可以實作介面。

也就是說,重構前既有的類別需要繼承關係,看似為了重用程式碼,將共同值域提升至父類別,其實真正之目的,是為了多型操作!

為了多型使用繼承

隨便在網路搜尋一下,就會知道繼承惡名昭彰,許多文件或書籍也會告誡:「不要濫用繼承」,也有著許多「多用組合少用繼承」之類的建議,於是,搞得好像用了繼承就是萬惡之事。只不過未經思考就接受某個口號式的觀念,從來就是最危險的事情。

別的不說,如果我們想定義equals、hashCode、toString,不就必須透過繼承Object來達成嗎?難道定義equals等方法也是萬惡之事?

我們應該思考的是,透過繼承Object來重新定義equals等方法之目的。

就根本來說,每個物件其實會有雜湊碼、字串描述等基礎狀態,而且,每個物件都會需要equals、toString等方法,既然Java每個物件都是Object的衍生,就直接定義在Object,以便於進行多型操作,也就是equals等方法,除了提供預設實作,也是在規範行為。

類似地,如果某種物件都具有某些基礎狀態,也需要有共同的行為,以便進行多型操作,使用繼承合情合理,例如Java的例外繼承架構,Throwable就規範了基礎狀態與共同行為,各種例外都是Throwable的衍生。

也就是說,為了多型而使用繼承可以是個出發點,以此來進行設計上的變化。

例如,想在父類別規範主要流程,真正的行為實現推給子類別實作,像是物件有一套生命週期,然而,在這個週期當中,我們可以讓使用者能安插一些自訂流程或註冊自訂服務,也就是想讓使用者能實現plugin;又或者想實現某種框架,其中實作了某些通用的商務流程,然而細節部份,可以讓使用者自訂。

就比較小的範疇而言,設計模式中行為分類樣版方法(Template method),就是這類使用繼承的思考方向之一;進一步地,如果不需要有共同的基礎狀態,或者也不需要有預設實作,或許可以透過定義介面來達成,Java的介面就是一種廣義的多重繼承,這就是方才談到,資料載體也可以透過介面來達成多型需求的原因。

有些語言完全沒有繼承,例如Go語言是基於結構來定義資料型態,透過結構與結構的組合來構造更多元的型態,然而還是有需要以相同行為看待資料的場合,Go語言也提供了介面概念的語法,目的就是為了實現多型。

誤用繼承的氣味

除了積極地思考,只為了多型而使用繼承,那麼,既有的程式碼,可否有什麼氣味,能嗅出誤用了繼承的可能性呢?

氣味之一是,僅僅因為流程重複,從數個原本不相關的類別,硬是提取出父類別來收納重複的程式碼,然而,這並不是在實施重構手法的提升方法(Pull Up Method),提升方法必須用於觀察到既有子類別有重複行為。視需求而定,採用裝飾(Decorator)模式或策略(Strategy)模式,可能會是較佳的重構方向。

如果子類別的方法中使用了super,有可能是誤用了繼承。例如,父類別希望子類先以super呼叫父類被重新定義的方法,沒有機制可以強制子類這麼做,而且,這限縮了子類的行為,採用樣版方法模式會是比較好的作法。

就算並非父類的期許,子類使用super呼叫了被重新定義的父類方法,本身就常被視為反模式,因為super意謂著父類實例,既然是個實例,表示也有基於組合的設計可能性。

然而,這也不能說使用了super就不對。如果類別之間真的有強烈的is a關係,例如,Throwable與Exception之間的關係,那麼,使用super還是有其必要性。

然而,誤用繼承時最難辨別的氣味就是is a關係,有時乍看是is a關係,其實並不是,例如,3D座標點是一種2D座標點嗎?正立方體是一種正方形嗎?表面看來,三維似乎是二維的延伸,一不小心就會誤用繼承從二維擴充為三維;一個簡單的判定方式是,子類若增加了值域,而且必須重新定義某些方法,將增加的值域納入運算考量,就有可能是誤用繼承。

例如,Point3D增加了z值域,因而必須重新定義equals,將z的比較納入考量,而這就是誤用繼承,如果有個compare方法是從父型態觀點Point2D來實現比較,此時,就有可能傳入了一個Point2D,以及一個Point3D,然而,如果從compare的觀點來看,不會知道有z的存在,也就是說,繼承其實是會隱藏狀態,而這也是record類別不讓開發者實現繼承的原因之一。

繼承提供了約束性

「組合優於繼承」真正的意義,並不是別使用繼承,也不是非到最後關頭才使用繼承(究竟什麼叫最後關頭?),而是在說,如果不需要繼承提供的約束性,請使用組合,必要時使用行為的多型,若進一步需要繼承(強烈的)約束性,才使用繼承來實現多型。

有時要解決的問題領域中,某些模型的型態架構已知,若能使用繼承施以約束性,問題就會比較容易解決,而Java 17提供了sealed類別,只是進一步讓這件事變得更加容易一些。

如果在需求上真的需要繼承的約束性,組合就不見得優於繼承,雖然沒有繼承,也可以解決問題,只是有時候會麻煩一些,甚至只能依賴一些約定或慣例。

因此,別太盲從「組合優於繼承」這類簡單的教條,那只是個思考的方向,並不是設計的終點!

專欄作者


熱門新聞

Advertisement