學會像函數式編程者那樣思考
英文原文:Learning to think like a functional programmer——Functional thinking: Thinking functionally, Part 1
學會像函數式編程者那樣思考——函數式編程思想:以函數的方式來思考(第1部分)
讓我們先來扯一下這樣的一個話題,你是一個伐木工,在這森林中,你有著一把最好的斧頭,這使得你成為了營地中最具生產效率的樵夫。后來有一天,有個家伙出現了,極力吹捧一種新的砍樹工具的厲害之處,這種工具就是電鋸。這賣東西的家伙很有說服力,因此你買了一把電鋸,但你不知道它的工作方式。你試著舉起它,并使用很大的力氣在樹上來回拉動,這就是你使用另外一種砍樹工具的工作方式。你很快就斷定這種新奇的電鋸一類的玩意只不過是一種時髦的東西,于是你依舊使用斧頭來把樹砍倒。后來,有個家伙過來給你展示如何啟動電鋸。
你有可能就在這樣的一個故事中出現過,不過是使用函數式編程(functional programming)代替了電鋸。全新的編程范式的問題并不在于要學習一門新的語言,畢竟,語言的語法不過是細節,棘手的部分是要學會以一種不同的方式來思考。這就是我要開始的地方——電鋸開動者和函數式編程者。
歡迎開始閱讀函數式編程思想這一系列,該系列文章探討了函數式編程這一主題,但內容并非是完全關于編程語言的,而是正如我將要說明的那樣,以“函數”的方式來編寫代碼涉及了設計、權衡取舍、不同的可用構建塊,以及在其他方面的許多領悟。我會盡可能用Java(或是類Java語言)來展示函數式編程的概念,并會轉移到其他語言上以證明還未在Java語言中存在的功能。我不會倉促行事,馬上討論諸如monad(參見資源一節)一類時髦的東西(盡管我們到時會涉及這部分內容),相反,我們會慢慢給你展示一種新的思考問題的方式(其實你已經在某些地方用到了這一方式——只是你沒有意識到罷了)。
這一部分和接下來的三部分內容可看作是在與函數式編程相關的一些主題中的一個旋風之旅,主題中包括了一些核心的概念。隨著我在整個系列中構建出越來越多的上下文背景和細微差別,其中的一些概念會以更加細節化的方式再次出現。作為這一旅程的出發點,我會讓你看到問題兩種不同實現,一種是命令式的編寫方式,另一種帶有更多函數式的傾向。
數字分類器
要談論不同的編程風格的話,就需要比較代碼。我們的第一個例子是在我的書The Productive Programmer(參加資源一節)中, 以及在“Test-driven Design, Part 1”和“Test-driven design, Part 2“(我之前的developerWorks系列Evolutionary architecture and emergent design中的兩部分)這兩篇文章中給出的一個編碼問題的一個變體。我選擇這一代碼至少部分原因是因為這兩篇文章深入地描述了代碼的設計,這一文章所贊同的設計不存在什么問題,不過我在這里會提供一種不同的設計理念。
需求的陳述是這樣的,給定任何大于1的正數,你需要把它歸類為完美的(perfect)、富余的(abundant)或是欠缺的(deficient)。一個完美數字是一個這樣的數值,它的因子(數字本身不能作為因子)相加起來等于該數值。類似地,一個富余數字的因子之和大于該數字,而一個欠缺數字的因子之和小于該數字。
命令式的數字分類器
清單1給出一個滿足這些需求的命令式的類:
清單1. 數字分類器,問題的命令式解決方案
public class Classifier6 { private Set<Integer> _factors; private int _number; public Classifier6(int number) { if (number < 1) throw new InvalidNumberException( "Can't classify negative numbers"); _number = number; _factors = new HashSet<Integer>>(); _factors.add(1); _factors.add(_number); } private boolean isFactor(int factor) { return _number % factor == 0; } public Set<Integer> getFactors() { return _factors; } private void calculateFactors() { for (int i = 1; i <= sqrt(_number) + 1; i++) if (isFactor(i)) addFactor(i); } private void addFactor(int factor) { _factors.add(factor); _factors.add(_number / factor); } private int sumOfFactors() { calculateFactors(); int sum = 0; for (int i : _factors) sum += i; return sum; } public boolean isPerfect() { return sumOfFactors() - _number == _number; } public boolean isAbundant() { return sumOfFactors() - _number > _number; } public boolean isDeficient() { return sumOfFactors() - _number < _number; } public static boolean isPerfect(int number) { return new Classifier6(number).isPerfect(); } }
代碼中的幾件事情是值得關注一下的:
1. 它有許多的單元測試(部分原因是因為我寫這一代碼的目的是用于測試驅動的開發的討論)。
2. 該類包含了許多內聚的方法,這是在構建過程中使用測試驅動開發的一個邊際效應。
3. calculateFactors() 方法中內嵌了性能上的優化。該類的重要部分包括了因子的收集,這樣接下來我就可以合計它們,最終對它們分類。因子總是可以成對獲得,例如,如果問題中的數字是16,則當我取得因子2時,我也可以取得8,因為2x8=16。如果我是以成對方式獲得因子的話,那么我只需要以目標數字的平方根為上限值來檢查因子就可以了。而這正是calculateFactors()方法采用的做法。
(稍微)函數式的分類器
使用同樣的測試驅動的開發技術,我創建了分類器的另一個版本,清單2給出了該版本:
清單2. 稍微函數化一點的數字分類器
public class NumberClassifier { static public boolean isFactor(int number, int potential_factor) { return number % potential_factor == 0; } static public Set<Integer> factors(int number) { HashSet<Integer> factors = new HashSet<Integer>(); for (int i = 1; i <= sqrt(number); i++) if (isFactor(number, i)) { factors.add(i); factors.add(number / i); } return factors; } static public int sum(Set<Integer> factors) { Iterator it = factors.iterator(); int sum = 0; while (it.hasNext()) sum += (Integer) it.next(); return sum; } static public boolean isPerfect(int number) { return sum(factors(number)) - number == number; } static public boolean isAbundant(int number) { return sum(factors(number)) - number > number; } static public boolean isDeficient(int number) { return sum(factors(number)) - number < number; } }
這兩個分類器版本之間的差異很細微但很重要。主要的差別在于清單2中有針對性地省去了一些共享的狀態。在函數式編程中,共享狀態的消除(或至少是減少)是被青睞的抽象之一。不是使用跨方法的共享狀態來作為中間結果(參見清單1中的factors域),我直接調用方法,省去了狀態的使用。從設計的角度來看,這導致了factors()方法變長,但它防止了factors從方法中”泄漏出去“。還要注意的一點是,清單2可以全部由靜態方法構成。方法之間不存在要共享的知識,所以我不太需要通過劃定作用域來封裝。如果你給這些方法提供它們所預期的輸入參數的話,這些方法完全能工作得很好。(這是一個純函數(pure function)的例子,在后面的部分中,我會對這一概念做進一步研究。)
函數
函數式編程是計算科學中一個涉及廣泛、正四處擴展的領域,已經可見到最近對它的興趣正在爆炸式地增長。JVM上的新的函數式語言(比如說Scala和Lojure),以及框架(比如說Functional Java和Akka)都已出現(參見資源一節),隨之而來的通常是這樣的斷言:更少出錯、更具生產效率、更好的外觀、賺取更多的錢等等。我不打算把函數式編程的整個主題都拿出來分析處理,我會把重點放在幾個主要的概念上,并關注一些派生自這些概念的有趣的實現。
函數式編程的核心是函數(function),就像類是面向對象語言中的主要抽象一樣。函數形成了處理過程的構建塊,滿帶著一些傳統的命令式語言(imperative language)中沒有的功能特性。
高階函數
高階函數(Higher-order function)可以把其他函數當成參數,也可以把其他函數作為結果返回。我們在Java語言中沒有加入這一構造,最接近的做法是,你可以使用類(通常是匿名類)來作為你需要執行的方法的“持有者”。Java沒有獨立的函數(或方法),所以它們不能從函數中返回,或是作為參數傳遞。
在函數式語言中,這一功能很重要,原因至少有兩個。首先,有高階函數意味著你可以假設語言的各個部分以什么方式來互相配合。例如,你可以通過一種通用的機制來把某個類層次結構中的一些方法別類整個地去掉,該機制遍歷列表并在每個元素上應用一個(或多個)高階函數。(很快我就會給你展示一個這一構造的例子。)其次,通過啟用函數來作為返回值,你就有機會構建出高動態、可自適應的系統。
但是,適合用高階函數來解決的問題不僅只取決于函數式語言,在你以函數的方式來思考時,你解決問題的方法也會有所不同。考慮一下清單3中的例子(從一個較大的代碼庫中拿來的),一個對受保護的數據進行訪問的方法:
清單3. 潛在可重用的代碼模板
public void addOrderFrom(ShoppingCart cart, String userName, Order order) throws Exception { setupDataInfrastructure(); try { add(order, userKeyBasedOn(userName)); addLineItemsFrom(cart, order.getOrderKey()); completeTransaction(); } catch (Exception condition) { rollbackTransaction(); throw condition; } finally { cleanUp(); } }
清單3中的代碼完成初始化,執行某些工作,如果一切都順利的話就完成事務,否則回滾,最后清空資源。顯然,這段代碼的樣板部分可以重用,我們在面向對象的語言中通常是通過創建結構來實現這一點。在這一例子中,我結合了四人組設計模式(Gang of Four Design Patterns)(參見資源一節)中的兩種模式:模板方法(Template Method)模式和命令(Command)模式。按照模板方法模式的建議,我應該把共同的樣板代碼往繼承的層次結構的頂部移動,把算法的細節推遲到子類中實現。命令設計模式提供了一種使用公認的執行語義來把行為封裝在一個類中的方式。清單4給出了把這兩種模式應用到清單3中的代碼上的結果:
清單4. 重構的訂單代碼
public void wrapInTransaction(Command c) throws Exception { setupDataInfrastructure(); try { c.execute(); completeTransaction(); } catch (Exception condition) { rollbackTransaction(); throw condition; } finally { cleanUp(); } } public void addOrderFrom(final ShoppingCart cart, final String userName, final Order order) throws Exception { wrapInTransaction(new Command() { public void execute() { add(order, userKeyBasedOn(userName)); addLineItemsFrom(cart, order.getOrderKey()); } }); }
在清單4中,我把代碼中的通有部分提取出來放入wrapInTransaction()方法(其語義你應該認得——基本上是Spring的TransactionTemplate的一個簡易版)中,把Command對象作為工作的單元傳入。addOrderFrom()方法里折疊放置了一個匿名的內部類的定義,該類創建命令類,把兩項工作包裝了起來。
把所需的行為包裝在一個命令類中純粹是一種Java的設計工件,這其中不包含任何類型的獨立行為,Java中的所有行為都必須駐留在類的內部。即使是語言的設計者也很快看出了這一設計中的缺陷——事后再想,認為不會存在不與類相關的行為的這種想法是有點天真。JDK 1.1通過加入匿名的內部類來糾正了這一缺陷,這至少是提供了一種語法糖,用于創建許多小的類,這些類只是有著些一些純粹是功能性的而非結構性的方法。關于Java的這一方面,很有一些熱衷于搞笑而又不乏幽默的文章,可以看一看Steve Yegge的“Execution in the Kingdom of Nouns”(參見資源一節)。
Java強制我創建了一個Command類的實例,即使我真正想要的不過是類中的方法而已。類本身不提供什么好處:其沒有域、沒有構造函數(除了Java自動生成的那個之外),也沒有狀態。其純粹是充當方法內部的行為的包裝器而已。在函數式語言中,這會通過高階函數的處理加以代替。
如果愿意暫時把Java語言擱在一邊的話,則我可以使用閉包(closure),閉包是一種接近函數式編程想法的語義。清單5給出了同樣重構過的例子,不過使用的是Groovy(參見資源一節)而不是Java。
清單5. 使用Groovy閉包而不是命令類
def wrapInTransaction(command) { setupDataInfrastructure() try { command() completeTransaction() } catch (Exception ex) { rollbackTransaction() throw ex } finally { cleanUp() } } def addOrderFrom(cart, userName, order) { wrapInTransaction { add order, userKeyBasedOn(userName) addLineItemsFrom cart, order.getOrderKey() } }
在Groovy中,花括號{}中的任何東西都是一個代碼塊,代碼塊可被當作參數傳遞,模仿高階函數的功能。背地里,Groovy為你實現了命令設計模式。Groovy中的每個閉包塊實際上是Goovy閉包類型的一個實例,其中包含了一個call()方法,當你在持有閉包實例的變量后面放置一對空的括號時,該方法就會被自動調用。Groovy啟用了一些類函數式編程的行為,做法是其使用相應的語法糖來把適當的數據結構加入到語言自身中。正如我將要在接下來的各部分中展示的那樣,Groovy還包含了其他的一些超越了Java的函數式編程功能。在系列的后面某個部分中,我還會回頭再談閉包和高階函數之間的一些有趣的比較。
第一類函數
函數式語言中的函數被看作是第一類(first class)的,這意味著函數可以在出現在任何其他的語言構造(比如說變量)能夠出現的地方。第一類函數的出現允許我們以非預期的方式來使用函數,并迫使我們以不同的方式來思考解決方法,比如說在普通的數據結構上采用相對通用的操作(有著稍有差別的細節)。而這相應地又暴露出了函數式語言思想方面的一個基本轉變:注重結果而非步驟(focus on results, not steps)。
在命令式編程語言中,我必須考慮算法中的每一個原子步驟,清單1中的代碼說明了這一點。為了解決數字分類器的問題,我必須要明確如何收集因子,而這相應地又意味著我不得不編寫具體的代碼來循環遍歷數字來判斷因子。但是循環遍歷列表,在每個元素上進行操作,這聽起來確實是一種常見的事情。考慮一下清單6中給出的、使用了Functional Java框架來重新實現的數字分類代碼:
清單6. 函數式的數字分類器
public class FNumberClassifier { public boolean isFactor(int number, int potential_factor) { return number % potential_factor == 0; } public List<Integer> factors(final int number) { return range(1, number+1).filter(new F<Integer, Boolean>() { public Boolean f(final Integer i) { return number % i == 0; } }); } public int sum(List<Integer> factors) { return factors.foldLeft(fj.function.Integers.add, 0); } public boolean isPerfect(int number) { return sum(factors(number)) - number == number; } public boolean isAbundant(int number) { return sum(factors(number)) - number > number; } public boolean isDeficiend(int number) { return sum(factors(number)) - number < number; } }
清單6和清單2的主要區別在于兩個方法:sum()和factors()。sum()方法利用了Functional Java中的List類的一個方法foldLeft(),這是被稱作風化變質作用(catamorphism)的列表操縱概念的一種具體變化,這一概念指的是列表折疊的一種泛化。在這一例子中,“折疊剩余部分(fold left)”是指:
1. 獲得一個初始值,并且通過在列表中的第一個元素上的操作來合并該值。
2. 獲得結果,然后在下一個元素上采用相同的操作。
3. 繼續進行這一操作直到走完列表。
可以注意到,這正是你在合計列表中的數字時要做的事情:從零開始,加上第一個元素,獲得結果,接著把它和第二個元素相加,一直這樣做直到列表中元素被用完。Functional Java提供高階函數(在這一例子中是 Integers.add這一枚舉)并且幫你很好地應用它。(當然,Java并不是真的有高階函數,但是如果把它限定到某種具體的數據結構或是類型上的話,則你能夠寫一個很好的模擬體出來。)
清單6中另一個有些神秘的方法是factors(),該方法例證了我的“注重結果而非步驟”的建議。找出數字的因子的問題實質是什么?換一種表述方式,給定以一個目標數字為上限的所有可能數字的一個列表,如何確定哪些數字是該目標數字的因子?這暗示了一種過濾操作——我可以過濾整個列表中的數字,去掉不符合我的標準的那些。該方法讀起來就是這樣的一種描述:取得從1到我的數字的一個數字范圍(范圍是開區間的,因此+1);基于f()方法中的代碼來過濾列表,這是Functional Java允許你使用具體的數據類型來創建類的方式;然后返回值。
作為編程語言的一個大趨勢,這段代碼還說明了一個更大的概念。在過去,開發者需要處理各種各樣煩人的事情,比如說內存分配、垃圾收集以及指針等。隨著時間的過去,久而久之,語言負責起了更多這方面的責任。隨著計算機變得越來越強大,我們把越來越多的單調的(可自動化的)任務卸給了語言和運行時。作為一個Java開發者,我已經相當習慣于把所有的內存問題都丟給了語言。函數式語言擴充了這樣的授權,包攬起更多具體的細節。隨著時間的推移,我們會花費越來越少的時間來考慮需要用來解決問題的步驟,而會把越來越多地思考放在處理過程方面。隨著這一文章系列的進展,我會給出許多這方面的例子。
結論
函數式編程更多的是一種思維模式,而不僅是工具或是語言的一個特殊集合。在文章系列的這第一部分中,我先論及一些函數式編程中的主題,從簡單的設計決策到一些頗具挑戰性的問題反思都有涵蓋到。我重寫了一個簡單的Java類,以讓它變得更函數化一些,然后開始進入一些主題,通過使用傳統的命令式語言來突出函數式編程的不同。
這里先給出了兩個很重要的、有長遠影響的概念。第一個是,注重結果而非步驟。函數式編程嘗試以不同的方式來表現問題,因為你用的是一些不同的構建塊,這些構建塊培植出了一些解決方案。我在整個系列中要說明的第二個趨勢是,枯燥無味的的細節會被卸給編程語言和運行時,這就允許我們把重點放在編程問題的一些獨特方面上。在系列的下一部分中,我會繼續考慮函數式編程的一些常見方面的問題,以及研究如何把它應用到現時的開發上。
資源
學習資料
1. The Productive Programmer(Neal Ford,O'Reilly Media,2008):Neal Ford的最新著作進一步闡述了這一系列中的許多主題。
2. Monads:Monads是函數式編程語言中一個傳奇式的頗為恐怖的主題,在這一系列的后續部分中會提及。
3. Scala:Scala是一種現代的、位于JVM之上的函數式語言。
4. Clojure:Clojure是一種現代的、運行在JVM上的函數式Lisp語言。
5. Podcast: Stuart Halloway on Clojure:更多地了解Clojure,關于為什么它會被迅速采用以及在普及率方面快速飆升,在這里你可以找出兩個主要的原因。
6. Akka:Akka是一個Java框架,其允許復雜的基于參與者的并發。
7. Functional Java:Functional Java是一個框架,其為Java加入了許多的函數式語言構造。
8. “Execution in the Kingdom of Nouns”(Steve Yegge,March 2006):關于Java語言設計的某些方面的一些戲謔之言。
9. 瀏覽technology bookstore來查找一些關于這些和另外一些技術主題的書籍。
10. developerWorks Java technology zone:可以找到幾百篇關于Java編程的各個方面的文章。
討論
加入 developerWorks社區。