我們最好從最難的問題開始:“到底什麼是函數編程 (FP)?”一個答案可能會說 FP 就是您在使用例如 Lisp、Scheme、Haskell、ML、OCAML、Clean、Mercury、Erlang(或其它一些)語言進行編程時所做的。這是一個穩妥的答案,但不能很確切地闡明問題。不幸的是,即使是函數程式員他們自己也很難對 FP 究竟是什麼有個一致的認識。“盲人摸象”的故事用來形容這一情況似乎很合適。還可以放心地將 FP 與“命令編程”(使用例如 C、Pascal、C++、Java、Perl、Awk、TCL 以及其它大多數語言所執行的操作,至少是在很大程度上)進行對比。
從個人角度來說,我會將函數編程粗略地描繪為至少具有以下幾個特徵。稱得上函數性的語言使這些事情變得簡單,而使其它事情變得困難或不可能:
- 函數是第一類(對象)。即,可以對“資料”進行的每樣操作都可以使用函數本身做到(例如將一個函數傳遞給另一個函數)。
- 將遞迴用作主要的控制結構。在某些語言中,不存在其它“迴圈”構造。
- 重點集中在列表 LISt 處理(例如,名稱 Lisp )。列表經常和子列表的遞迴一起使用以替代迴圈。
- “純”函數語言能夠避免副作用。這不包括在命令語言中最普遍的模式,即指定第一個,然後將另一個值指定給同一個變數來跟蹤程式狀態。
- FP 不鼓勵或根本不允許出現 語句,取而代之是使用運算式求值(換句話說,即函數加上自變數)。在很純粹的情況下,一個程式就是一個運算式(加上支援的定義)。
- FP 關心的是計算 什麼而不是 如何計算。
- 許多 FP 利用了“更高等級”函數(換句話說,就是函數對一些函數操作,而這些函數又對其它函數操作)。
函數編程的提倡者認為所有這些特徵都導致更快速的開發更短以及錯誤更少的代碼。而且,電腦科學、邏輯和數學領域的進階理論學家發現證明函數語言和程式的正式效能比命令語言和程式容易得多。
固有的 Python 函數能力
自從 Python 1.0 以來,Python 具有上面列出的大多數 FP 特徵。但對於大多數 Python 特性,它們以一種非常混合的語言呈現。很大程度上是因為 Python 的 OOP 特性,您可以使用希望使用的部分而忽略其餘部分(直到在稍後需要它為止)。使用 Python 2.0, 列表內涵添加了一些 非常棒的“句法上的粉飾”。雖然列表內涵沒有添加什麼新的能力,但它們使許多舊的能力看起來好了 許多。
Python 中 FP 的基本元素是函數 map() 、 reduce() 和 filter() ,以及運算子 lambda 。在 Python 1.x 中, apply() 函數對於將一個函數的列表傳回值直接應用於另一個函數也很方便。Python 2.0 為這一目的提供了改進的文法。可能讓人吃驚,但很少的這幾個函數(以及基本運算子)幾乎足以編寫任何 Python程式;特別是,所有的流量控制語句( if 、 elif 、 else 、 assert 、 try 、 except 、 finally 、 for 、 break 、 continue 、 while 、 def )可以只使用 FP 函數和運算子以函數風格處理。雖然實際上消除程式中的所有流量控制命令可能只對加入“混亂的 Python”競爭(與看上去非常象 Lisp 的代碼)有用,但是理解 FP 是如何使用函數和遞迴來表示流量控制是值得的。
消除流量控制語句
在我們執行消除聯絡時要考慮的第一件事是 Python “短路”了布林運算式的求值這一事實。這樣就提供了運算式版本的 if / elif / else 塊(假設每塊都調用一個函數,通常總有可能這樣安排)。下面是具體方法:
清單 1. Python 中的“短路”條件調用
# Normal statement-based flow control if : func1() elif : func2() else : func3() # Equivalent "short circuit" expression ( and func1()) or ( and func2()) or (func3()) # Example "short circuit" expression >>> x = 3 >>> defpr (s): return s >>> (x==1 and pr( 'one')) or (x==2 and pr( 'two')) or (pr( 'other')) 'other' >>> x = 2 >>> (x==1 and pr( 'one')) or (x==2 and pr( 'two')) or (pr( 'other')) 'two'
運算式版本的條件性調用似乎不過是個位置訣竅;不過,如果我們注意到 Lambda 運算子必須返回運算式時,就更有趣了。因為 -- 如前所示 -- 運算式可以通過短路來包含條件塊,所以 lambda 運算式在表達條件傳回值中非常普通。在我們的樣本上構建:
清單 2. Python 中 Lambda 短路
>>> pr = lambda s:s >>> namenum = lambda x: (x==1 and pr( "one")) \ .... or (x==2 and pr( "two")) \ .... or (pr( "other")) >>> namenum(1) 'one' >>> namenum(2) 'two' >>> namenum(3) 'other'
函數作為第一類對象
上面的樣本已經顯示出函數在 Python 中所處的第一類的地位,但以很微妙的方式。在使用 lambda 操作建立 函數對象 時,我們有一些完全常規的事物。正是因為這樣,我們可以將對象與名稱 "pr" 和 "namenum" 綁定,使用的方法和將數字 23 或字串 "spam" 與這些名稱綁定的方法完全相同。但正如我們可以使用數字 23 而無需將它與任何名稱綁定一樣(換句話說,象函數自變數一樣),我們可以使用用 lambda 建立的函數對象而不用將它與任何名稱綁定。一個函數只是我們在 Python 中對其執行某些操作的另一個值。
我們對第一類對象所執行的主要操作是將它們傳遞給 FP 內建函數 map() 、 reduce() 和 filter() 。這些函數中的每一個都接受函數對象作為其第一個自變數。
map() 對指定列表中每個對應的項執行傳遞的函數,並返回結果清單。
reduce() 對每個後續項執行傳遞的函數,返回的是最終結果的內部累加;例如 reduce(lambda n,m:n*m, range(1,10)) 意味著“10 的階乘”(換句話說,用每一項乘上前一次相乘的乘積)。
filter() 使用傳遞的函數對列表中的每一項“求值”,然後返回經過甄別的,通過了傳遞函數測試的項的列表。
我們還經常將函數對象傳遞給自己的定製函數,但它們通常等同於上述內建函數的組合。
通過將這三種 FP 內建函數進行組合,可以執行驚人的一系列“流”操作(都不使用語句,而只使用運算式)。
Python 中的函數迴圈
替換迴圈與替換條件塊一樣簡單。 for 可以直接轉換成 map() 。對於我們的條件執行,我們需要將語句塊簡化成單一函數調用(我們正逐步接近通常的做法):
清單 3. Python 中的函數 'for' 迴圈
for e in lst: func(e) # statement-based loopmap(func,lst) # map()-based loop
另外,對於連續程式流的函數方法有類似的技術。即,命令編程通常包含接近於“做這樣,然後做那樣,然後做其它事。”這樣的語句。 map() 讓我們正好做到這一點:
清單 4. Python 中的函數連續操作
# let's create an execution utility functiondo_it = lambda f: f() # let f1, f2, f3 (etc) be functions that perform actionsmap(do_it, [f1,f2,f3]) # map()-based action sequence
通常,我們的整個 main 程式可以是 map() 運算式和一系列完成程式所需要執行的函數。第一類函數的另一個方便的特性就是可以將它們放在一個列表中。
while 的轉換稍微複雜了一些,但仍然可以直接進行:
清單 5. Python 中的函數 'while' 迴圈
# statement-based while loop while : if : break else : # FP-style recursive while loopp defwhile_block (): if : return 1 else : return 0while_FP = lambda : ( and while_block()) or while_FP()while_FP()
while 的轉換仍需要 while_block() 函數,它本身包含語句而不僅僅是運算式。但我們需要對該函數做進一步的消除(例如對模板中的 if/else 進行短路)。另外,因為迴圈主體(按設計)無法更改任何變數值,所以 很難用在一般的測試中,例如 while myvar==7 (那麼,將在 while_block() 中修改全部內容)。添加更有用條件的一個方法是讓 while_block() 返回一個更有趣的值,然後將這個傳回值與終止條件進行比較。有必要看一下這些消除語句的具體樣本:
清單 6. Python 中的函數 'echo' 迴圈
# imperative version of "echo()" defecho_IMP (): while 1: x = raw_input( "IMP -- ") if x == 'quit': break else print xecho_IMP() # utility function for "identity with side-effect" defmonadic_print (x): print x return x # FP version of "echo()"echo_FP = lambda : monadic_print(raw_input( "FP -- "))== 'quit' or echo_FP()echo_FP()
我們所完成的是設法將涉及 I/O、迴圈和條件陳述式的小程式表示成一個帶有遞迴的純運算式(實際上,如果需要,可以表示成能傳遞到任何其它地方的函數對象)。我們 的確 仍然利用了公用程式函數 monadic_print() ,但這個函數是完全一般性的,可以在我們以後建立的每個函數程式運算式中重用(它是一次性成本)。請注意,任何包含 monadic_print(x) 的運算式所 求值 的結果都是相同的,就象它只包含 x 一樣。FP(特別是 Haskell)對於“不執行任何操作,在進程中有副作用”的函數具有“單一體”意思。
消除副作用
在除去完美的、有意義的語句不用而代之以晦澀的、嵌套的運算式的工作後,一個很自然的問題是:“為什嗎?!”我對 FP 的所有描述都是使用 Python 做到的。但最重要的特性 -- 可能也是具體情況中最有用的特性 -- 是它消除了副作用(或者至少對一些特殊領域,例如單一體,有一些牽製作用)。絕大部分程式錯誤 -- 和促使程式員求助於調試來解決的問題 -- 之所以會發生,是因為在程式執行過程期間,變數包含了意外的值。函數程式只不過根本就不為變數分配值,從而避免了這一特殊問題。
讓我們看一段相當普通的命令代碼。它的目的是列印出乘積大於 25 的幾對數位列表。組成各對的數字本身是從另外兩個列表中挑選出的。這種操作與程式員在他們程式段中實際執行的操作差不多。實現這一目的的命令方法如下:
清單 7. “列印大乘積”的命令 Python 代碼
# Nested loop procedural style for finding big productsxs = (1,2,3,4)ys = (10,15,3,22)bigmuls = [] # ...more stuff... for x in xs: for y in ys: # ...more stuff... if x*y > 25: bigmuls.append((x,y)) # ...more stuff...# ...more stuff... print bigmuls
這個項目太小,以至於沒有什麼可能出錯。但我們的目的可能嵌在要同時實現許多其它目的的代碼中。用 "more stuff" 注釋的那些部分是副作用可能導致錯誤發生的地方。在這些地方中的任何一處,變數 xs 、 ys 、 bigmuls 、 x 、 y 有可能獲得假設節略代碼中的意外值。而且,在執行完這一段代碼後,所有變數都可能具有稍後代碼可能需要也可能不需要的一些值。很明顯,可以使用函數/執行個體形式的封裝和有關範圍的考慮來防止出現這種類型的錯誤。而且,您總是可以在執行完變數後 del 它們。但在實際中,這些指出類型的錯誤非常普遍。
目標的函數方法完全消除了這些副作用錯誤。以下是可能的一段代碼:
清單 8. “列印大乘積”的函數 Python 代碼
bigmuls = lambda xs,ys: filter( lambda (x,y):x*y > 25, combine(xs,ys))combine = lambda xs,ys: map(None, xs*len(ys), dupelms(ys,len(xs)))dupelms = lambda lst,n: reduce( lambda s,t:s+t, map( lambda l,n=n: [l]*n, lst)) print bigmuls((1,2,3,4),(10,15,3,22))
在樣本中,我們將匿名 ( lambda ) 函數對象與名稱進行綁定,但這不是一定必要的。我們可以只嵌套定義。這樣做是出於可讀性目的;但也是因為 combine() 是一種隨處可得的很好公用程式函數(從兩個輸入列表中產生所有元素對的列表)。隨後的 dupelms() 主要只是協助 combine() 發揮作用的一種方法。即使這一函數樣本比命令樣本更冗長,但一旦考慮到公用程式函數可以重用,那麼 bigmuls() 中的新代碼本身可能比命令版本中的代碼數量還要少一些。
這種函數樣本真正的優勢在於絕對不會有變數更改其中的任何值。稍後的代碼中沒有 可能的不曾預料到的副作用(較早的代碼中也不會有)。很明顯,它本身沒有副作用並不能保證代碼 正確,但即使這樣,這也是個優點。不過請注意,Python(與許多函數語言不同) 不能 防止名稱 bigmuls 、 combine 和 dupelms 的重新綁定。如果 combine() 在程式的稍後部分中開始有其它意義,則所有努力都前功盡棄。您可以逐步建立一個 Singleton 類來包含這種類型的不可變綁定(例如 s.bigmuls 等);但本專欄並不涉及這一內容。
特別值得注意的一個問題是我們的特定目的是對 Python 2 中的新特性進行定製。最好的(也是函數的)技術既不是上面提供的命令樣本,也不是函數執行個體,而是:
清單 9. "bigmuls" 的列表內涵 Python 代碼
print [(x,y) for x in (1,2,3,4) for y in (10,15,3,22) if x*y > 25]
結束語
我已介紹了使用函數等價物替換每個 Python 流量控制構造所使用的方法(在過程中消除了副作用)。對特定程式進行有效轉換將帶來一些額外的考慮,但我們已經知道內建函數是常規而完整的。在稍後的專欄中,我們將考慮一些更進階的函數編程技術;希望能夠探索函數風格的更多利弊。