談到純函數式典範,開發者腦中第一個想到是不可變(Immutable)特性,名稱一旦綁定了值,就沒有方式能改變,從這出發點開始而建立的各種資料結構,也是不可變。然而,就整個系統的狀態來看,是會變的。

為了在不變中求變,函數式的世界有著許多的高階抽象,乍看其型態定義,容易令人精神恍惚,只有透過反覆觀察、重構,才能明白其真正的意涵。

求變的基本模式

純函數式名稱一旦綁定了值,就沒有方式能改變這個事實。從命令列語言的角度來看,就像是變數只能指定一次值,之後不能再重新賦值,接觸過函數式稍有時日的開發者,多半能應付這個情況了,對於單純的算式變動,可以定義許多小函式來避免變數的重新賦值,對於命令式風格的迴圈,就是讓迴圈的任務單一化,然後再使用遞迴來完成任務。

然而,在需求漸趨複雜之後,面對的就不單只是變數不可變的問題,開發者必須定義各種資料結構,也就必須去改變各種資料結構的狀態。說是改變,實際上是將既有的資料狀態與變動的部份結合,重建出新的資料,例如,改變清單(List)中某個索引處之值時,會使用該索引前後不變的清單與新值,重新建構出一份清單。

當然,每次都要重新構造一份資料,勢必有嚴峻的效能考驗,幸而在不可變特性下,底層實作有許多共用既有資料的機會,而為了有效率地利用這類實作,開發者也須思考,資料結構本身是否可分而治之,定義出代數資料型態(Algebraic data type),然後在型態的結構特性下,發展出許多模式,就像論及開發者入門函數式典範時,最常見的就是清單的filter、map、reduce等模式,就是常見的例子。

實際上,一些簡單的資料型態,在需求變得複雜之後,也可能遇上麻煩。比方說,在Haskell中,開發者定義了點data Point = Point {x::Double, y::Double},對於一個Point實例,想要改變其x座標,可以定義setX v pt = pt {x = v},這會使用pt的y與指定的v作為x值,來重構出一個Point實例。

類似地,開發者可以定義data Line = Line {start::Point, end::Point}來代表線段,若想改變起始點,可以定義setStart pt line = line {start = pt},這看來是個很平常的需求,然而,若想改變線段起始點的x座標值為4呢?有幾個方式,像是line2 = line1 {start = (start line1) {x = 4})}或者是line2 = setStart (setX 4 (start line1)) line1等,而問題馬上就浮現了,可讀性迅速降低,在更複雜的資料結構下情況會更糟糕。

透鏡模式

Haskell有個透鏡模式,就結論而言,實作了此模式之後,對於以上的需求,可以定義一個lens函式,並指定getter、setter來建立透鏡。

例如,xLens = lens x setX與startLens = lens start setStart,之後定義一個set函式,可以以set (startLens . xLens) 4 line1的方式,改變起始點x座標為4,並傳回新的Line實例,重點在於透鏡是可組合的,像是進一步地組合出(triSide1Lens . startLens . xLens),以便透過set函式來改變三角形第一個邊的x座標,並傳回新的Triangle實例。

然而,透鏡的模式該怎麼實作?若直接看看Haskell的Lens模組,我們可能會被Lens型態的抽象定義給驚嚇到。

其實,在理解這類高階抽象時,最好的方式是從簡單的案例開始,嘗試找出通往抽象的路線,例如,startLens的出發點就是方才定義的setStart函式,而pt會是被轉換後(例如設定新的x座標)的新Point實例,假設轉換方式可以定義為f函式,那麼,新的Line實例可以是setStart (f (start line)) line的結果,其中setStart就是setter,而start是Haskell的Record自動產生,相當於getter的角色,因此可以定義:

startLen f line = setter (f (getter line)) line
where getter = start
setter = setStart

startLen寫死了start與setStart函式,如果它們是傳遞進來的函式呢?這就有了lens getter setter f line = setter (f (getter line)) line定義。由於Haskell的函式可以部份套用,因此,可以取得startLens = lens start setStart,類似地,也可取得xLens = lens x setX,也就是說,有個較通用的lens函式,能用來建立透鏡了。

那麼,這透鏡怎麼使用?如果有個pt = Point 10 20,想要將設定x座標為5,可以寫為xLens (\_ -> 5) pt得到Point 5 20,進一步地,也可以使用xLens (\x -> x + 1) pt得到Point 11 20,也就是說,藉由xLens透鏡可以取得更多轉換的可能性,而不僅僅是setter。

從透鏡找出Setter、Getter

在建立透鏡時,必須提供Setter函式,然而,對於line2 = setStart (setX 4 (start line1)) line1這類需求,必須在不清楚實際透鏡的組成方式下,自動產生Setter函式,以便進行set (startLens . xLens) 4 line1這樣的行為,一樣地,從最簡單的開始,剛剛的xLens (\_ -> 5) pt實際上就是在設定x座標,如果5實際來自參數呢?這就可以定義出set lens v pt = lens (\_ -> v) pt。

由於最後一個都是pt,可改寫為Point-free風格set lens v = lens (\_ -> v),現在,想要更新pt的x座標為5,可以寫成set xLens 5 pt了,就目前的實作來說,也可以完成set (startLens . xLens) 4 line1的行為了。

回頭看看透鏡的實作,以xLens來說,型態是(Double -> Double) -> Point -> Point,可以應付(Double -> Double)這類簡單的轉換,然而,轉換實際上有許多可能性,而Haskell已經有許多既有的Functor實作,若想要利用既有Functor的fmap實作,可以嘗試用fmap來重新定義lens函式,讓xLens最終擁有Functor f => (Double -> f Double) -> Point -> f Point形態。

好處之一?來想想xLens (\x -> [x * 2, x * 3, x * 4]) (Point 10 20)會是什麼呢?這會取得一個清單,其中包含了Point 20 20、Point 30 20與Point 40 20,List實際上是個Functor,也就是可以利用List的fmap實作,轉換出指定的多個清單。

進一步地,還可以定義出set與view函式,view函式的作用,能在不清楚實際透鏡的組成方式下,自動產生一個Getter函式,從事像是view (startLens . xLens) line1的行為,如果line1的起點x座標為5,最後就是得到5的結果,也就是說對於巢狀的資料結構,可以透過透鏡的組合,來檢視相關欄位。

如果對方才的set、view(還有一個基礎的over函式)定義的細節有興趣,可以看看《Haskell的魔力》中,有關透鏡組章節的內容(難能可貴地,有出版社引進翻譯為正體中文)。

通往抽象化的道路

在學習Haskell的過程中,許多先進都會建議,事先慎重思考型態定義,因為當型態確定,實作也就確定了;不過,在面對許多已經是高度抽象化的模式,我始終無法單從型態定義,就能直接梳理出通往抽象化的道路。

我一向偏愛的方式,是透過觀察、重構來嘗試理解,思考這個議題在抽象化之前的狀態,以及過程中有哪些考量,而逐步提高抽象的程度。在剛看到Lens的定義之後,我試過幾個方向,試圖找出通往抽象的路線,不過都沒有成功,這表示我不能理解抽象化之下涵蓋的具體情境。

Haskell是純函數式語言,為了在不變中求變,發展出許多高度的抽象化模式,代數資料型態、List處理模式、Functor、Applicative、Monad等,Lens模式也是其中之一,而我的經驗總是告訴我,不要試圖去直接理解抽象化的結果,真正重要的是理解通往抽象化的道路上,會有哪些考量與提高抽象化的手段。

 

專欄作者

熱門新聞

Advertisement