函數式編程思維第二章轉變思維
學習新的編程語言
把熟悉的概念用新的語法表達出來
可以通過套用自己已經在別的語言中掌握的知識來學習新的語言。
學習一種新的範式
為熟悉的問題找到新的解答方法。
換用函數式編程語言並不是寫出函數式代碼的必要條件,轉變看待問題的角度才是必不可少的。
普通的例子
演算法編寫上:
一方面程序員得以在更高的抽象層次上工作,
另一方面運行時也有了執行複雜優化的自由空間。
開發者從中獲得的好處體現在更低的複雜性和更高的性能,這點與垃圾收集相同,不過,函數式編程對個人的影響更直接,因為它改變的是你的解答思路。
普通的例子——命令式解法
命令式編程是按照「程序是一系列改變狀態的命令」來建模的一種編程風格。
傳統的for循環是命令式風格的絕好例子:先確立初始狀態,然後每次迭代都執行循環體中的一系列命令。
假設我們有一個名字列表,其中一些條目由單個字元構成。現在的任務是,將除去單字元條目之外的列表內容,放在一個逗號分隔的字元串里返回,且每個名字的首字母都要大寫。
由於必定要遍歷整個列表,那麼最方便下手操作的地方,自然就是在一個命令式循環的內部。每迭代一個名字,我們都檢查它的長度是否大於一個字元的保留門檻,然後調整其首字母為大寫後,連同作為分隔符的逗號一起,追加到。最後一個名字不應該有尾隨的逗號,所以從最後的返回值里去掉了這個多餘的分隔符。
命令式編程鼓勵程序員將操作安排在循環內部去執行:
filter,篩選列表,去除單字元條目;
transform,變換列表,使名字的首字母變成大寫;
convert,轉換列表,得到單個字元串。
在命令式語言里,這三種操作都必須依賴於相同的低層次機制(對列表進行迭代)。而函數式語言為這些操作提供了針對性的輔助手段。
普通的例子——函數式解法
函數式編程將程序描述為表達式和變換,以數學方程的形式建立模型,並且盡量避免可變的狀態。
函數式編程語言對問題的歸類不同於命令式語言。filter、transform、convert等每一種都作為一個邏輯分類由不同的函數所代表,這些函數實現了低層次的變換,但依賴於開發者定義的高階函數作為參數來調整其低層次運轉機構的運作。
函數式語言可以幫助我們輕鬆搭建出上面的概念性解答模型,同時又不必操心各種實現細節。
普通的例子——函數式解法——Scala版本
普通的例子——函數式解法——Java8版本
用方法取代了,原因是它操作Java的類的效率更高;是Java 8針對某些情形而提供的的特殊實現。
如果擔心某些列表元素可能為,那麼只要在後面多加一條檢查就可以了:
Java運行時會聰明地將檢查和針對長度的篩選合併成一次操作,這樣既不妨礙把意圖表達清楚,又不損失代碼的執行效率。
普通的例子——函數式解法——Groovy版本
Groovy語言命名上更接近Ruby等腳本語言。
Groovy的方法對集合中的元素執行參數里傳入的代碼塊,只留下結果為的元素。Groovy允許開發者簡寫只帶一個參數的代碼塊,它規定用關鍵字來代表這個唯一的參數,無需定義。Groovy的方法相當於前面的,負責對集合中的每個元素執行參數里傳入的代碼塊。函數的功能是用參數中指定的分隔符,把一個字元串集合串接起來,拼成單一的字元串。
普通的例子——函數式解法——Clojure版本
Clojure是一種函數式語言,它的函數命名上自然更傳統一些。
Lisp家族的Clojure是「由內向外」執行的,因此起點其實在最後一個參數值。Clojure的函數接受兩個參數:作為篩選條件的函數(例中為匿名函數)和將要被篩選的集合。第一個參數也可以寫成完整的函數定義,不過Clojure允許使用更簡短的匿名函數形式。
函數的第一個參數是變換函數,第二個參數是待變換的集合,也就是上一步(filter )操作的返回值。可以專門定製一個函數來作為(map )的第一個參數,不過既然任何單參數的函數都符合(map )的要求,所以直接用能夠滿足需求的Clojure內建函數即可。最後,操作的輸出成為下一步操作的集合參數。的第一個參數是負責拼合字元串的函數,作用於函數的返回值,而(interpose )負責在(map )返回集合的元素之間插入它的第一個參數指定的分隔符。
通過thread-last宏改善代碼的
Clojure的thread-last宏(即->>符號)針對的是非常常見的各種集合變換操作,它把典型的Lisp書寫順序顛倒了過來,重整為更自然的從左到右的閱讀順序。首先看到的是集合本身(list-of-emps),然後才是依次作用於前一個語法單元(form)的連串變換操作。Lisp靈活的語法正是它最強大的武器之一:什麼時候可讀性變差了,就調整語法去滿足可讀性。
函數式思維 好處
向函數式思維靠攏,意味著我們逐漸學會何時何地應該求助於這些更高層次的抽象,不要再一頭扎到實現細節里去。
學會用更高層次的抽象來思考有什麼好處?首先,會促使我們換一種角度去歸類問題,看到問題的共性。其次,讓運行時有更大的餘地去做智能的優化。有時候,在不改變最終輸出的前提下,調整一下作業的先後次序會更有效率(例如減少了需要處理的條目)。第三,讓埋頭於實現細節的開發者看到原本視野之外的一些解決方案。
普通的例子——函數式解法——多線程版本——Scala版本
命令式編程自己控制著低層次的迭代細節,那麼線程相關的代碼也就只好由自己動手穿插進去。可是換作Scala的實現,只要在stream上多調用一次方法就可以了。
普通的例子——函數式解法——多線程版本——Java版本
普通的例子——函數式解法——多線程版本——解讀
Clojure同樣只需簡單替換,就能夠將一般的集合變換操作不動聲色地並行化。在更高的抽象層次上做事情,運行時才好去優化低層次的細節。
編寫帶垃圾收集的工業級虛擬機實在是一項異常複雜的任務,開發者樂得交出這方面的職責。另一邊的JVM工程師則儘力封裝起垃圾收集,讓它從開發者的日常考慮事項中消失,大大減輕了開發者的負擔。
多從結果著眼,少糾結具體的步驟。
不要再讓那些迭代、變換、化約如何進行的低層次細節佔據你的思維,多想想哪些問題其實可以歸結為這幾樣基本操作的排列組合。
案例研究:完美數的分類問題
任意一個自然數都唯一地被歸類為過剩數(abundant)、完美數(perfect)或不足數(deficient)。一個完美數的真約數(即除了自身以外的所有正約數)之和,恰好等於它本身。例如6是一個完美數,因為它的約數是1、2、3,而6 = 1 + 2 + 3;28也是一個完美數,因為28 = 1 + 2 + 4 + 7 + 14。
案例研究:完美數的分類問題——自然數分類規則
完美數真約數之和 = 數本身
過剩數真約數之和 > 數本身
不足數真約數之和 < 數本身
案例研究:完美數的分類問題——實現不用「正約數和」
實現中用到一個數學概念,真約數和(aliquot sum),其定義就是除了數本身之外(一個數總是它本身的約數),其餘正約數的和。之所以不用「正約數和」來表述,是為了稍稍簡化判定完美數時的比較語句:要比易讀一些。
案例研究:完美數的分類問題——完美數分類的命令式解法
分類程序在使用中很可能需要對同一個數字進行多次分類,因此實現的時候有必要考慮這種情況。
類維持著兩個內部狀態。其中欄位的作用是為一系列函數省下一個參數。則通過一個結構來緩存每個數字的真約數和,以在後續針對同一個數字的調用中更快地返回結果(查錶速度與計算速度的差別)。
內部狀態在面向對象編程的世界裡是受到推崇的平常做法,因為封裝被OOP語言視為一項優勢。狀態的劃分往往為一些工程實踐提供了便利,比如單元測試的時候很容易注入各種取值。
代碼經過了精心的組織,劃分成很多個小方法。這是測試驅動開發的副產物,不過也正好把演算法的各個組成部分都表現了出來。其中一些部分會在後續的改造中逐漸被替換成更加函數式的寫法。
案例研究:完美數的分類問題——稍微向函數式靠攏的完美數分類解法——「最小化共享狀態」
可以去掉類的成員變數,改為通過參數來傳遞需要的值。
稍微向函數式風格靠攏的裡面,所有方法都是自足的、帶和作用域的純函數(即沒有副作用的函數)。而由於類裡面根本不存在任何內部狀態,也就沒有理由去「隱藏」任何一個方法。實際上,方法在很多其他應用中都有潛在的用途,比如用來尋找素數。
一般來說,面向對象系統里粒度最小的重用單元是類,開發者往往忘記了重用可以在更小的單元上發生。方法的參數類型沒有選擇某一種具體的列表類型,而是定為。一個兼容於所有數字集合的介面,在函數級別上發生重用的可能性自然更大一些。
這一版的實現沒有為求和結果設計緩存機制。緩存意味著持續存在的狀態,可是這一版的實現根本沒有可以放置狀態的地方。對比上一版,這一版的效率上要低一些。這是因為失去了存放求和結果的內部狀態,只好每次都重新計算。
案例研究:完美數的分類問題——完美數分類的Java 8實現
lambda塊其實就是高階函數。
比原來的命令式解法以及不完全的函數式版本短得多,也簡單得多。方法返回了一個,為後續串連其他操作,包括令產生數值輸出的終結操作提供了方便。換言之,沒有直接返回一個整數列表,而是給了一個尚未產生任何輸出的。方法很好寫,無非是對約數的列表求和,再減去數本身。不需要自行編寫求和用的方法,因為Java 8已經為準備了這樣一個產生輸出的終結操作。
物理上把機械能分成儲蓄起來的勢能和釋放出來的動能。在版本8以前的Java,以及它所代表的許多語言里,集合的行為可以比作動能:各種操作都立即求得結果,不存在中間狀態。函數式語言里的stream則更像勢能,它的操作可以引而不發。被stream儲蓄起來的有數據來源(例中的數據來源是方法),還有對數據設置的各種條件,如例中的篩選操作。只有當程序員通過、終結操作來向「要」求值結果的時候,才觸發從「勢能」到「動能」的轉換。在「動能」開始釋放之前,stream可以作為參數傳遞並後續附加更多的條件,繼續積蓄它的「勢能」。這裡關於「勢能」的比喻,用函數式編程的說法叫作緩求值(lazy evaluation)。
案例研究:完美數的分類問題——完美數分類的Functional Java實現
開源框架Functional Java針對1.5以上版本的Java運行時,以儘可能低的侵入性為代價引入了盡量多的函數式編程手法。Functional Java可以通過泛型和匿名內部類,在Java 1.5時代的JDK上模擬出它所缺少的高階函數特性。
Functional Java在其類中提供的方法為提供了很大的便利。「fold left」(即左摺疊操作)的含義是:
(1)用一個操作(或者叫運算)將初始值(例中為0)與列表中的第一個元素結合;
(2)繼續用同樣的操作將第1步的運算結果與下一個元素結合;
(3)反覆進行直到消耗完列表中的元素。
這幾個步驟正好就是對數字列表求和的一般做法:從0開始,先和第一個元素相加,結果再和第二個元素相加,以此類推直到列表結尾。Functional Java提供了運算所需的高階函數(函數),也由它負責施用。
Functional Java也無法在舊版本的Java里實現完整的高階函數功能,只是用匿名內部類來模擬高階函數的編程風格。
方法很好地體現了「多著眼結果,少糾結步驟」的格言。尋找一個數的約數,這個問題的實質是什麼?或者可以換一種方式來敘述:在從1到目標數字的整數列表裡,怎麼確定其中哪些數字是目標數的約數?這樣一來,篩選操作就呼之欲出了——可以逐一篩選列表中的元素,去除那些不滿足篩選條件的數字。
方法的作為基本上可以用一句話來描述:對於從1到目標數字的區間(不包含區間的右側端點,因此代碼中將區間上限寫成),以方法中的代碼來篩選區間內數字所構成的一個列表,類和方法是Functional Java留給我們「填空」數據類型和返回值的地方。
方法依次向左方,即向著第一個元素合併列表。對於滿足交換律的加法來說,摺疊的方向並不影響結果。萬一需要使用某些結果與摺疊次序相關的操作,還有方法可供選擇。
高階函數消除了摩擦。
不能認為Functional Java版本與Java 8版本的區別無非是一些語法糖衣(其實不止)。可是語法上的便利也是很重要的方面,畢竟想表達的意思都要由語法來承載。
語法承載著思維方式。在語法處處掣肘下塑造出來的抽象,很難配合思維過程而不產生無謂的摩擦。
不要增加無謂的摩擦。
Clojure安裝教程
在官網下載clojure-1.8.0,目錄結果如下:
運行如下命令:
這只是進入了Clojure的命令模式;


TAG:從頭開始自學java |