曾幾何時,在習慣命令式風格的程式人眼中視為冷門知識的函數式設計,相關元素或多或少地都進入現代主流語言,就連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

在重構既有的程式碼時,請儘量為被抽取出來的方法取個明確的名稱,像是tracksOverOneMin、trackNames這類名稱,方法名稱上有複數名詞多半表示涉及迭代,如果使用了迴圈進行迭代,試著以JDK8提供的Stream相關API來取代。

而該使用哪個方法,可以從重構後抽取出來的方法名稱中,察覺到線索,例如tracksOverOneMin,就是「過濾」出長度超過一分鐘的Track,這表示你可以使用Stream的filter方法來取代tracksOverOneMin,將tracksOverOneMin中的過濾條件(長度超過一分鐘),化為Lambda表示式,而trackNames表示從Track清單取得名稱,這表示你可以使用map方法來取代它。

對於呼叫了tracksOverOneMin、trackNames的方法中,其實都進行了兩個動作,其中的第二個動作都是收集為另一個清單,因此使用filter來取代tracksOverOneMin時,你必須先將此次呼叫結果收集為List,也就是filter後,接著用collect方法。

而使用map來取代trackNames時,也是類似,因此你會得到兩條Stream管線化操作。如果第一條管線化操作的結果,直接又拿來進行另一條管線操作,實際上它們可以合併為一條Stream操作,也就是可使用filter、map、collect的順序,使得程式碼更為流暢,由於避免掉中介的操作結果,效率也會更好。

有時,在代換為Stream相關API的過程,你會需要從清單中取得單一結果,像是找出所有Track加總長度,這時可以簡單地先使用mapToInt將Track清單映射為長度清單,然後呼叫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有值或無值,你可以試著使用map或flatMap來取代那些程式碼,使得程式碼流暢而清晰。

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

你已經在做函數式設計了

從重構既有的程式碼開始,緊接著使用Stream相關API替代重構後被抽取出來的方法,試著操作Optional,或在適當的場合使用Optional來取代null,更進一步地,發現到可以用flatpMap重構巢狀或瀑布流程的程式碼。

這個過程中,你其實一直在做函數式設計,就算你不知道不可變動特性、不清楚何謂代數資料型態、沒有特別去做遞迴思考,不知道flapMap其實概念來自函數式中神秘的Monad(可參考我的〈神祕的Monad不神祕〉)。

更進一步,你也許開始嘗試parallelStream、groupingByConcurrent、toConcurrentMap等為平行處理而設計的API,平行做reduce、collect相關操作時,應留意個別資料間不相依,可隨意分解運算或合併結果時,你也在進行函數式設計。

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

作者簡介


Advertisement

更多 iThome相關內容