在《Coders at Work》中,Simon Peyton Jones曾經提到:「純函數式領域中學到的觀念與想法,可能給主流領域帶來資訊,帶來啟發」,目前不少語言中實現的特性,確實都受到函數式的啟發,藉由探討Java 8中如何進行函式重用,這類啟發的樣貌,就能夠更具體地呈現。

從一級函式到高階函式

開發者對函式的傳統想法就是,封裝某個演算流程,以便後續重用該演算流程,在物件導向的世界中,操作與狀態結合在一起而成為物件,為了區別,此時,函式被稱為方法(Method),在JavaScript這類語言中,函式與方法這兩個名詞會混用,而在Java這類一開始沒有一級函式概念的語言中,通常只會使用方法這個名詞。這突顯了一個事實,無論是靜態方法或實例方法,在Java中定義好了,命運就是等待著被呼叫。

Java 8引入了Lambda特性,一級函式的概念因而進入Java之中,除了可將程式碼作為資料(Code as data)傳遞的Lambda語法之外,方法參考(Method reference)實際上也是一種函式重用的概念,這與JavaScript中定義了一個函式,就可以直接透過名稱引用該函式,彼此是類似的概念,也就是方法被定義之後,不再只是能被動地被呼叫,也能主動地進行傳遞。

Lambda語法與方法參考,可使得方法如同函式一樣地主動傳遞之後,衍生而出的就是高階函式的實現概念,也就是能接受函式作為引數,或者將函式當作傳回值,或者兩者皆有的函式,像是Comparator的comparing方法,實際上就是高階函式的概念,這是從既有函式產生新函式,也就是函式除了被定義呼叫、被傳遞之外,也可以在必要時用來產生新函式。

嚴格說來,在Java 8中實際上還是沒有函式,仍舊是只有物件與方法的世界,然而,Lambda特性的引入,讓方法的重用形式,有機會借鏡函數式設計中常見的函式重用模式,而使得方法與函式的界線模糊了。

既然如此,如果暫且直接將方法當成函式,進一步探討純函數式中的函式重用,如何能在Java中實現,就能發現更多的函式重用模式。

實現Curried函式與部份套用

在Haskell中,多參數函式其實是由單參數函式來組成的。

舉例來說,在Haskell中,要定義一個可給定三個邊長,判斷是否為直角三角形的函式,可以定義:isRightTri a b c = a ** 2 + b ** 2 == c ** 2。

實際上,這等同於使用Lambda語法定義為:isRightTri = \a -> \b -> \c -> a ** 2 + b ** 2 == c ** 2。

加上括號的話,成為:\a -> (\b -> (\c -> a ** 2 + b ** 2 == c ** 2))

如此一來,就可以看出,isRightTri是由三個單參數的Lambda函式組成,而isRightTri這樣的函式稱為Curried函式。

Curried函式可以進行部份套用,此時,isRightTri 3會傳回可繼續套用剩餘兩個引數的\b -> (\c -> 9 + b ** 2 == c ** 2),若是isRightTri 3 4,會傳回可繼續套用一個引數的\c -> 25 == c ** 2。

也就是說,在Haskell中,即使任何一個函式表面看來不接受函式,也沒傳回函式,事實上,也都是高階函式,可以隨時從既有函式產生新函式。

從另一個角度來看,如果已知一邊長為3,可以定義isRightTriWhenAThree b c = isRightTri 3 b c,此時兩邊參數同為b、c,由於函式可進行部份套用,不如直接定義isRightTriWhenAThree = isRightTri 3,形成Point free(或Pointless)風格,而greaterThanThree xs = filter (>3) xs,也可以定義為greaterThanThree = filter (>3)。

因此,上述這種風格突顯了函式的組合方式,而不是單純地根據引數來思考函式。

Java中確實也能實現出Curried函式的概念,可以使用Lambda語法定義a -> b -> c ->  a * a + b * b == c * c(雖然目標型態要用到醜陋的泛型),在必要時,也可以實現isRightTri(3)、isRightTri(3, 4)、isRightTri(3, 4, 5)的呼叫方式,實際上,這需要三個重載的isRightTri方法。

而如此的思考方式,也帶來一個可能性:如果確實經常需要filter(x -> x > 3)這樣的方式來產生Function實例,以便後續套用,那麼,可定義出:filter(Predicate<Integer> p),令其傳回lt -> lt.stream().filter(p).collect(toList())。

實現函式合成

在Haskell中,若想將[10, -20, -30]轉換為["10", "20", "30"],可以使用map (\x -> show (abs x)) [10, -20, -30],因為沒有現成函式可以使用,或以部份套用而得到可用的新函式,所以,這邊直接使用了Lambda,而經過仔細觀察,你會發現:abs x的運算結果,是直接作為show函式的輸入──在這種情況下,可以直接寫為map (show . abs) [10, -20, -30],獲得比較好的可讀性。

而show . abs會產生新函式,作用等同於\x -> show (abs x),這是Haskell的函式合成(Function composition)語法,因為它跟數學上的函數合成很類似。根據維基百科Function composition條目所示,若有函數f:X->Y與g:Y->Z,那麼合成函式g(f(x)),就可以將X中的x對應至Z,合成的函式可標示為g . f:X->Z,定義就是對X中所有x,(g . f)(x) = g(f(x))。

接下來,我們可進一步來看函式合成的運用。

如果有個需求,是要加總某個List,然後取得絕對值,並轉為字串,可定義showAbsSumOf xs = show (abs (sum xs)),括號代表著前一函式的輸出會作為後續函式的輸入,因此,可改為showAbsSumOf xs = show . abs . sum xs,此時,兩邊都是xs,由於可以進行部份套用,不如直接定義showAbsSumOf = show . abs . sum,以Point free風格,來突顯函式的組合方式。

Java 8中代表函式型態的Function函式介面,實際上,它已經就提供了compose預設方法(Default method),而且,這可以用Function<Integer, String> toString = Object::toString,而toString.<Integer>compose(Math::abs)來實現show . abs的概念;或者,使用andThen預設方法,以另一方向的Function<Integer, Integer> abs = Math::abs,還有abs.andThen(Object::toString)來實現。

函數式設計模式

從上面的舉例來看,你發現了什麼?在函數式程式設計世界中,重用總是圍繞著函式,像是程式碼作為資料傳遞、引用既有函式、從既有函式產生新函式、思考函式的組合方式,像是部份套用或函式合成,這些都是不斷出現的模式。

實際上,如果繼續探討下去,關於定義新的代數資料型態(Algebraic data type)、Monad等,也都會與函式有關,而這些圍繞著函式而出現的概念,就是函數式設計模式。

在物件導向世界中,談到重用,總是圍繞著物件,重視類別、介面,以及彼此之間的關係,因而有了物件導向設計模式。就像Scott Wlaschin則在〈Functional Programming Patterns〉的簡報中,就談到函數式設計模式是什麼?其中,第13頁他列出了FP pattern/principle,每一點都有Functions字眼。

如此的作法意謂著,在Java這類物件導向語言中,稍微將焦點從物件改變到函式,就有機會發現更多的可重用模式,獲得新的設計方向。

作者簡介


Advertisement

更多 iThome相關內容