函數式設計的特徵之一,就是以代數資料型態(Algebraic data type)來定義值的型態,這在習慣抽象資料型態(Abstract data type)的開發者看來,似乎是格格不入的另一型態,因為,想要理解代數資料型態,似乎得用截然不同的思考模式。

實際上,我們可觀察處理資料的流程,將構造與拆解資料的重複模式重構出來,代數資料型態的樣貌與目的就自然呈現了。

從命令式看代數資料型態

在Wikipedia或HaskellWiki中,都有個〈Algebraic data type〉條目,解釋了此型態具有Algebraic名稱的原因,來自於型態實例可由兩類代數操作來構造:sum與product。sum是替代(alternation),例如P型態可由A型態或B型態實例構造;product是組合(combination),例如P型態是同時由A型態與B型態實例依序建立。光是這個定義,就可以嚇跑許多想入門函數式設計的開發者。

暫時回到命令式世界好了,來看看現今開發者應該不陌生的一個情況:null判斷與處理。實際上,null判斷與處理再延伸,就是使用if-else來進行判斷有某值或無值的處理,這個模式太常見,因而不少語言現今都有了Optional這類的型態。

因為此模式太常見,進一步地可為「有某值或無值」重構出一個型態,例如,Scala中有個Some代表某個值,None代表無值,而它們都是Option型態,反過來說就是,Option可以是「Some或None」構造而成,這就是代數資料型態中的sum操作。Option是代數資料型態的概念,像是Haskell中的Maybe型態,其定義基本上為data Maybe a = Just a | Nothing。

傳回具特定結構的物件,並從依序取得內含值進行處理,也是開發者在撰寫程式時常見的模式。

像是點座標,開發者可能定義包含整數座標x、y、z的Point類別,如果取得了Point實例,接下來經常就是依序取得x、y、z等值進行處理。Point是個容器,由三個整數組成,如果進一步要求三個整數的順序(也就是同時要求型態、順序),那就是代數資料型態的product概念。如果以Haskell來定義,那就是data Point = P Int Int Int。

代數資料型態的sum與product可以重複地結合,例如,若繪圖程式開發者經常要處理圓與方的問題,並發現流程中經常出現特定的值構造與拆解模式,就為此模式重構出形狀此一型態,此時,如果使用Haskell的話,就會定義出像是data Shape = Circle Float Float Float | Rectangle Float Float Float Float。|是sum操作(|代表"或"),而兩旁的Circle Float Float Float與Rectangle Float Float Float分別是product操作。

從手動構造拆解到模式比對

先前根據有某值或無值的構造與拆解模式,重構出Optional(Maybe)型態,或根據特定結構物件的構造與拆解模式,重構出Point或Circle之類的型態,都談到了「模式」兩字。如果是一般命令式物件導向語言中,可以將此類模式,重構入相關類別之中封裝為方法,從而使得客戶端只要呼叫該方法,就可避免撰寫重複的流程,像是Optional的map與flatMap就是實際案例,如此就可享有重構出代數資料型態的益處。

如果在Scala這類提供模式比對(Pattern Match)特性的語言中,原有的值拆解流程,就可以使用模式比對加以重構。以Scala為例,可以將原先if-else的有無值拆解流程,改為case Some(value) => process(value)與case None => processNone()這類比對Option實例的流程;或者是將形狀的拆解流程,改為case Shape(Circle(_, _, radius)) => process(radius)之類的模式比對處理。

如果值構造拆解時出現了某個模式,就以該值構造拆解時的型態、順序定義出代數資料型態,而後使用模式比對重構相關流程。來考慮另一情況,有時開發者想要臨時傳回一組資料,例如同時傳回名稱與年齡,使用List之類型態可解決傳回多值問題,不過,在靜態定型語言若不指定型態參數,雖可在List中放入不同型態實例,但會失去型態資訊,而且在後續取值處理時,會涉及多次索引指定操作,在Scala中,可以使用("Justin", 39)建立一個Tuple,如果val (id, name) = ("Justin", 39),那id就會是"Justin",而name會是39。

Tuple其實就是在開發者還沒打算為代數資料型態命名時,可臨時使用的型態。在Scala中,如果函式傳回("Justin", 39),那它就不可傳回("Justin", "Lin"),因為函式傳回型態已被定義為(String, Int)。當然,如果經常使用某種結構的Tuple,那麼也該為它重構出具名的代數資料型態,而不是繼續使用Tuple,如此在模式比對時,才能使用名稱來顯露比對處理時的意圖。

遞迴地定義資料結構

現在來看清單(List)處理,經常可見迴圈循序地走訪清單中各元素並加以處理的程式碼,在此情況下,索引實際上是個雜訊,如果語言提供foreach語法,就可重構掉使用索引的程式碼,接下來可以觀察到,每次foreach處理不過就是取得清單首個元素,清單剩餘元素會在下次迴圈加以處理,最後清單不會剩下任何元素(空清單),也就是迴圈結束的時機。

既然這個處理模式如此常見,可思考是否重構出代數資料型態,善用模式比對來重構首元素(Head)與剩餘清單(Tail)拆解的流程,然後重構為遞迴版本,因為純函數式沒有迴圈,然而在下次迴圈處理剩餘清單,與在下次遞迴處理剩餘清單是同樣意思,因為已經定義了清單的代數資料型態,將迴圈重構為遞迴就會是輕而易舉之事。結果與優點,就如我先前專欄〈List處理模式〉、〈抽象資料型態與代數資料型態〉中談到的內容,開發者可以發掘出更多的清單演算模式。

使用Haskell來自定義清單的話,會像是data List a = Empty | Cons a (List a),可看出型態也形成了遞迴定義。有些資料結構本身就是遞迴定義,例如二元樹,樹的每個節點會指向左右兩個子節點,擷取任一節點形成的子樹都是此結構。在各種二元樹的處理,都會有取得目前節點、左子節點、右子節點的拆解模式,因而可將這個模式重構出來,例如用Haskell來定義二元樹會是data Tree a = EmptyTree | Node a (Tree a) (Tree a),可看出型態也是遞迴地定義,之後就可搭配模式比對,進行各種二元樹的處理。

就如同開發者在循序走訪清單時,雖然會有各式演算,卻會有著構造與拆解清單的相同模式,開發者在走訪二元樹時雖會有各式演算,像是二元搜尋樹必須判斷左右子節點與目前節點大小,從而決定要繼續走訪左或右子節點,因此,有著構造與拆解二元樹的相同模式。將這種構造與拆解資料的模式重構出來,那麼實際取得資料之後,該如何進行演算就會很清楚。

資料構造與拆解處理模式的型態化

從命令式設計的角度來看,很容易會有型態就是型態,流程就是流程的看法,代數資料型態卻是一種將資料構造與拆解模式,進行型態化的結果,代數資料型態定義時的構造順序,就是資料拆解時的順序,若姑且忽略代數資料型態這神祕的名詞,這不過就是因為發覺程式碼中出現這類重複構造與拆解模式,將之重構為型態並加以命名罷了。

不過,代數資料型態的另一面,就是資料拆解後的後續演算會清楚許多,因而,開發者可以發掘出後續演算中更多的模式,即使在語言不支援,或程式未實作代數資料型態的情況下,也會因為發掘出更多後續演算的模式而獲益,像是JDK8的Stream等方法,就不是使用代數資料型態。然而,無論是哪方面,要觀察出這種重複性比單純地觀察出被複製的程式碼困難多了,或者應該說,這需要訓練與學習,而非一蹴可幾,多做觀察與思考,多以重構角度來訓練,才有可能擁有更高階的眼力與思維。

作者簡介


Advertisement

更多 iThome相關內容