從函式重構到物件導向


iThome 網站首載:從函式重構到物件導向

物件導向與函數式並不衝突,實際上物件導向與函數式可以相輔相成。當面對職責混亂的物件,可試著以函數式概念對物件的函式進行重構,若是一開始就不知如何劃分物件職責的場合,可試著先以函式為單元進行設計,再看看函式是否可進一步重構出子函式。當問題被分解為子問題,函式被切得夠細小,回過頭來會發現數個函式間的關聯性,這時無論是使用類別組織資料、將函式搬運至適當類別之中,都會有較清楚的判斷界線,從而實現更高階的物件導向概念。

邏輯泥塊是劃分物件職責的阻礙

在《重構--改善既有程式的設計》書中第一章的影片出租店案例中,初看Movie、Customer與Rental類別的職責都十分簡單,然而Customer的statement函式過於冗長,這表示statement做的事情實在太多了,由於充斥了邏輯泥塊,看不出Movie與Rental可以如何分擔Customer的職責。當一個類別的方法內容過於冗長,充滿了邏輯泥塊,幾乎可以斷定擁有該函式的類別擔負了過多的職責。

分解邏輯泥塊是劃分職責前必要的動作,當單一任務的函式從邏輯泥塊中分解出來,才能清楚觀察出哪個物件促成了此任務的完成,進一步地更能為函式取個適當的名稱。舉例來說,當《重構》書中「金額計算」的職責從statement分解出來成為amountFor函式,就可以清楚看出amountFor僅使用了Rental物件來完成任務,而沒有用到任何Customer物件的資訊,amountFor函式顯然應該從Customer搬移至Rental類別中。在搬運到Rental之後,amountFor這個名稱沒有意義,因為職責改用Rental物件主動完成,將amountFor改為getCharge會是適當的選擇

如果因為函式中充滿邏輯泥塊,導致物件間職責劃分困難,往往代表著類別間的耦合程度就高。當《重構》書中「金額計算」的職責從statement分解出來,並搬移至Rental類別之後,就減少了statement中對Movie的多處引用,如果再將「常客積點計算」的職責從statement分離至Rental,並進一步將statement中each.getMovie().getTitle()改為each.getMovieTitle(),那麼Customer與Movie耦點就會被Rental完全切開

一旦物件的職責劃分清楚,並具有低耦合度,物件間的相對而言不易變動的公開協定就容易形成,此時想要獨立對某個物件或模組進行重構,就不易彼此牽動。就如同《重構》書中,對statement作了重構之後,才有辦法對Movie類別再進行重構,分離出Price類別,並進一步以多型取代條件判斷分支。如果不分解邏輯泥塊,不將冗長函式重構為子函式,不將問題劃分為子問題,這一切都不可能發生。

非物件導向語言的物件導向概念

有關於分解邏輯泥塊,亦可借鏡函數式概念來獲得啟發,這在我前一篇《用函數式重構程式碼與演算法》談過。有些語言是多重典範,同時提供支援物件導向與函數式的元素,以函數式分解問題,並進一步劃分物件職責就是很自然的過程。實際上在不支援物件導向的語言,甚至是純函數式語言中,即便語法不同,但架構上亦常有物件導向的概念。將函式重構為夠細小的函式,接著利用語言元素適當組織,最後形成物件導向概念的架構,此過程與《重構》書中提供的案例其實是十分類似的。

實際上就成品而言,有些非物件導向語言構築而成的程式庫,就有著物件導向的組織架構。以C語言開發的GTK為例,雖然C語言沒有類別,但以struct來組織相關聯的資料,事實上GTK也不拘泥名稱,直接稱由struct定義的資料為類別,在函式組織上則以名稱來辨別相關職責。以GtkWindow相關的函式為例,gtk_window_new用來封裝建立struct的細節,gtk_window_set_title用來設定標題名稱等;GTK在struct上使用鏈結(link),使得GtkWindow與GtkWidget等之間具有繼承概念,而許多gtk_window_開頭的函式,首個參數都接受GtkWidget指標,看來就像是GtkWindow專用的函式,這與物件導向中,將函式直接組織在類別中,概念其實是相同的。

即便是純函數式的Haskell而言,亦有元素可以展現物件導向精神。data關鍵字用以定義新型態,型態類(Typeclass)用來描述附屬於某型態類的型態應該實現的行為,而型態變數(Type variable)用來讓函式展現多型行為。在運用這些高階語法之前,必須以一組運作良好的函式作為基礎。由於Haskell是純函數式語言,分解問題是必然的出發點,因此很容易發覺應用data關鍵字、型態類與型態變數的時機,再搭配模組(module)適當開放可匯入(import)的函式,亦可達到封裝相關函式,隱藏內部實現機制之作用。

直接從函式出發再來辨識物件

既有程式碼中的邏輯泥塊既然是劃分職責的阻礙,將之分解就可促進職責劃分,而後獲得更高階或更抽象的物件導向架構;既然非物件導向語言亦可形成物件導向的概念,那麼對於同時具有函式及物件導向元素的語言來說,若初步無法清楚釐清物件應有職責時,直接從函式出發解決問題,而後對函式進行重構,將問題逐步分解,這個過程就不單只是為了獲得啟發,而是實際可進行的物件導向設計步驟。

直接從函式出發可以有兩個方向,一是先快速而隨興(Quick and dirty)將問題解決,而後分解函式中的邏輯泥塊,使之成為細小函式;另一個方向是直接分解問題,先用子函式解決子問題,而後組織子函式來解決整個問題。無論是哪個方向,最後可能會發現數個函式都使用了同一組參數,此時可將這組參數組合為選項物件(Option object),接著用該物件對數個函式進行重構,審視重構後的函式是否需要修改為適當名稱。

如果呼叫相關函式時,每次都要先進行選項物件的產生或初始化過程,這個過程可分解為初始函式;對於每次要取得物件字串描述的流程,可以分解而得到一個toString之類的函式...當所有函式重構完成之後,對於具有相同選項物件作為參數的函式,可以開始使用類別將之組織,選項物件上的特性(Property)就成了物件內部狀態,初始函式就成為建構式,函式上原本的選項物件參數也許不再需要或使用this或self取代,函式中對選項物件的參考成為直接取用物件內部狀態。當一切都就緒後,你也許會發現還有一些函式是獨立存在,這些函式或許就是公用函式(Utility function)的侯選對象。

職責清晰的最小單元是高階抽象基礎

無論是重構中的分解邏輯泥塊或是函數式地強制分解問題,都是為了獲得職責清晰的最小單元,才能進一步進行高階的抽象化。就大多數程式語言而言,即便是在物件導向為主要典範的語言中,組織職責的最小單位通常就是函式,類別只是用來組織相關資料及函式時的進一步抽象。如果函式的職責混亂,基於職責混亂的函式而建立的物件,其職責必然也是混亂的,而整個程式必然也是毫無架構可言。

就物件導向而言,只有職責清晰且單一的函式,才有辦法加以分類,因而有了類別封裝,有了類別才有辨法進一步談及繼承,實際上繼承是一種抽象化過程,將多個類別共用的程式碼基礎分解出來,以便進一步架構更複雜的多型行為。如果說遞迴是分解迴圈泥塊後的外在表現,那麼多型可說是分解多重條件判斷分支泥塊的抽象成果,這也是一種職責分解的過程,也就是將每個分支進行的工作分解至各個子類別中。一旦有了封裝、繼承、多型,更高度的抽象設計,如模式、架構等也才能因應而生。