務實的函數式設計


iThome 網站首載:務實的函數式設計

曾幾何時,在習慣命令式風格的程式人眼中視為冷門知識的函數式設計,相關元素或多或少地都進入到現代主流語言之中,就連Apple方才發表的Swift語言,都有人開始為它寫了Functional Programming in Swift之類的文件甚至書籍,JDK8中提供的一系列搭配Lambda語法的API,亦是函數式風格以務實方式進入現代語言的一種表現,而不再僅僅是命令式語言世界中的空中樓閣。

重構:函數式的第一步

儘管函數式的元素,或多或少地進入到現代語言之中,在習慣命令式的程式人眼中,多半仍視其為附屬品,或者是訓練思考的一種工具,我之前在幾篇專欄中,像是〈List 處理模式〉、〈抽象資料型態與代數資料型態〉、〈不可變動性帶來的思維轉換〉等,多半也是從訓練思考的角度出發來討論函數式,有趣的是,JDK8中的Lambda等特性,無疑也是來自函數式,而且被視為Java最新版本發表時極為重大的特性,然而JDK8並沒有特別強調這些是函數式風格的元素,這表示Java開發者,要以務實的方式來看待相關的元素。

想要務實地使用JDK8的Lambda等元素,最好的方式並不是在新專案中使用它們,而是將它們導入既有的專案之中,當然,你必須先昇級至JDK8,昇級的經驗可以參考ingramchen的〈150,000 行到 Java 8〉,其中有句重要的話就是務實導入Lambda相關元素的起點:「以改最少的方式升級。先順利轉移到Java 8後,後續再進行程式碼的重構。」你不能馬上想要使用Lambda等元素,你要先重構既有的程式碼。

我在〈用函數式重構程式碼與演算法〉談過,如果你一開始不知從何開始重構,運用函數式的概念,對練習程式碼的重構非常有幫助,最終你可以函數式地思考,命令式地實作,然而反過來說的話,令人意外地,先重構既有的程式碼,對於導入函數式元素也有極大幫助,因為兩者的最大共同交集都是:將你的邏輯泥塊大卸八塊。

你需要的重構觀念與技巧不用多,只需要知道《重構--改善既有程式的設計》第一章的影片出租店範例是如何完成重構,也就是,每個迴圈只做一件事,讓你的方法實作行數儘量地少,程式碼縮排儘可能保持在一層或兩層之內,超過的縮排抽取出成為另一個方法,也許你已經在既有的程式碼中重構過了,然而導入Lambda相關元素之前,要再重構的更徹底一點;實際上,Richard Warburton在《Java 8 Lambdas》也談到,若想盡可能運用Lambda的好處,最好的方式就是將之導入既有的程式碼中,而在這之前的第一步也是對程式碼進行重構。

代換為Stream相關API

在重構既有的程式碼時,請儘量為被抽取出來的方法取個明確的名稱,像是tracksOverOneMintrackNames這類名稱,方法名稱上有複數名詞多半表示涉及迭代,如果使用了迴圈進行迭代,試著以JDK8提供的Stream相關API來取代,該使用哪個方法,可以從重構後抽取出來的方法名稱中察覺到線索,例如tracksOverOneMin,就是「過濾」出長度超過一分鐘的Track,這表示你可以使用Streamfilter方法來取代tracksOverOneMin,將tracksOverOneMin中的過濾條件(長度超過一分鐘)化為Lambda表示式,而trackNames表示從Track清單取得名稱,這表示你可以使用map方法來取代它。

對呼叫了tracksOverOneMintrackNames的方法中,其實都進行了兩個動作,第二個動作都是收集為另一個清單,因此使用filter來取代tracksOverOneMin時,你必須先將此次呼叫結果收集為List,也就是filter之後緊接著使用collect方法,而使用map來取代trackNames時也是類似,因此你會得到兩條Stream管線化操作,如果第一條管線化操作的結果直接又拿來進行另一條管線操作,實際上它們可以合併為一條Stream操作,也就是可使用filtermapcollect的順序,使得程式碼更為流暢,由於避免掉中介的操作結果,效率也會更好。

有時在代換為Stream相關API的過程,你會需要從清單中取得單一結果,像是找出所有Track加總長度,這時可以簡單地先使用mapToIntTrack清單映射為長度清單,然後呼叫sum方法,實際上sum是一種reduce操作,reduce實際上就是循序走訪清單並從事運算的概念罷了,只要是使用迴圈從清單中求出單一值,都會是reduce的概念,reduce這名稱不易令人瞭解,因此對於加總這類動作,有sum這個簡便方法,對於找出第一個這類動作,有findFirst方法,這表示,如果你使用reduce作較複雜運算,單從reduce的Lambda表示式不容易看出意圖時,將這段Stream管線操作抽取出來,獨立為一個名稱明確的方法,會是比較好的做法。

實際上,collect也是reduce的一種,因為僅使用reduce從某個清單中取得較短長度的清單時,程式碼撰寫與閱讀上都不是那麼容易,因而設計了collect方法,這個方法除了名稱明確之外,最重要的,是將收集的職責分出來給Collector,這使得你可以設計各種複雜但可重用的Collector,透過適當的命名,使得複雜的收集動作也能清楚易讀,實際上JDK8提供了幾個標準Collector實作,像是collect(groupingBy(Person::getGender))的結果,會是一個Map<Gender, List<Person>>,也就是男性為一個清單,女性為一個清單。

試試flatMap

在使用findFirst或者是reduce方法這類從清單取得某個結果的方法時,你得到的其實是個Optional,道理很簡單,運算可能沒有結果,最簡單的例子就是,萬一清單根本是空的呢?該傳回什麼?null?不選擇傳回null的原因,我在〈補救null的策略〉中談過,如果可以在適當的場合使用Optional取代null,你就有可能發現另一個重構程式碼的機會,一但發現有巢狀或瀑布式程式碼不斷地在確認每個Optional有值或無值,你可以試著使用mapflatMap來取代那些程式碼,使得程式碼流暢而清晰。

實際上,Stream也有flatMap方法,如果你發現有巢狀或瀑布式程式碼不斷地在取得下一份清單,就可以試著使用flatMap來取代,不過,StreamflatMap不容易理解,可以將Stream想像為一個盒子,flatMap方法會取得盒子中的值給你,並讓你使用Lambda表示式指定這個值與下個盒子間的關係,在撰寫與閱讀程式碼時,忽略掉flatMap這個名稱,就能比較清楚程式碼的主要意圖。

你已經在做函數式設計了

從重構既有的程式碼開始,緊接著使用Stream相關API替代重構後被抽取出來的方法,試著操作Optional,或在適當的場合使用Optional來取代null,更進一步地,發現到可以用flatpMap重構巢狀或瀑布流程的程式碼,這個過程中,你其實一直在做函數式設計,就算你不知道不可變動特性、不清楚何謂代數資料型態、沒有特別去做遞迴思考、不知道flapMap其實概念來自函數式中神秘的Monad(可參考我的〈神秘的Monad不神秘〉)。

更進一步地,你也許開始嘗試parallelStreamgroupingByConcurrenttoConcurrentMap等為平行而設計的API,注意到平行做reducecollect相關操作時,應留意個別資料之間應當不相依,可以隨意地分解運算或合併結果時,你也已經在進行函數式設計。

儘管這邊使用了JDK8來舉例,是函數式風格在Java上的務實方式,然而,也可以是其他語言環境中的務實方式,當你習慣這類務實方式時,可以回顧一下你看過的重構相關書籍,像是《重構--改善既有程式的設計》這本書第一章,你可以進一步用Stream等相關API來繼續重構它的成果嗎?像是statementgetTotalChargegetTotalFrequentRenterPoints那些方法嗎?