英文原文:Charming Python: Functional programming in Python, Part 1,翻譯:開源中國
摘要:雖然人們總把Python當作過程化的,物件導向的語言,但是他實際上包含了函式化程式設計中,你需要的任何東西。這篇文章主要討論函式化程式設計的一般概念,並說明用Python來函式化程式設計的技術。
我們最好從艱難的問題開始出發:“到底什麼是函式化程式設計呢?”其中一個答案可能是這樣的,函式化程式設計就是你在使用Lisp這樣的語言時所做的(還有Scheme,Haskell,ML,OCAML,Mercury,Erlang和其他一些語言)。這是一個保險的回答,但是它解釋得並不清晰。不幸的是對於什麼是函式化程式設計,很難能有一個協調一致的定義,即使是從函式化變成本身出發,也很難說明。這點倒很像盲人摸象。不過,把它拿來和指令式程式設計(imperative programming)做比較也不錯(指令式程式設計就像你在用C,Pascal,C++,Java,Perl,Awk,TCL和很多其他類似語言時所做的,至少大部分一樣 )。
讓我們回想一下功能模組的繫結類。使用該類的特性,我們可以確認在一個給定的範圍塊內,一個特定的名字僅僅代表了一個唯一的事物。
我個人粗略總結了一下,認為函數語言程式設計至少應該具有下列幾點中的多個特點。在謂之為函式式的語言中,要做到這些就比較容易,但要做到其它一些事情不是很難就是完全不可能:
- 函式具有首要地位 (物件)。也就是說,能對“資料”做什麼事,就要能對函式本身做到那些事(比如將函式作為引數傳遞給另外一個函式)。
- 將遞迴作為主要的控制結構。在有些函式式語言中,都不存在其它的“迴圈”結構。
- 列表處理作為一個重點(例如,Lisp語言的名字)。列表往往是通過對子列表進行遞迴取代了迴圈。
- “純”函式式語言會完全避免副作用。這麼做就完全棄絕了命令式語言中幾乎無處不在的這種做法:將第一個值賦給一個變數之後為了跟蹤程式的執行狀態,接著又將另外一個值賦給同一個變數。
- 函數語言程式設計不是不鼓勵就是完全禁止使用語句,而是通過對錶達式(換句話說,就是函式加上引數)求值(evaluation of expressions)完成任務. 在最純粹的情形下,一個程式就是一個表示式(再加上輔助性的定義)
- 函數語言程式設計中最關心的是要對什麼進行計算,而不是要怎麼來進行計算。
- 在很多函數語言程式設計語言中都會用到“高階”(higher order)函式 (換句話說,高階函式就是對對函式進行運算的函式進行運算的函式)。
函數語言程式設計的倡導者們認為,所有這些特性都有助於更快地編寫出更多更簡潔並且更不容易出Bug的程式碼。而且,電腦科學、邏輯學和數學這三個領域中的高階理論家發現,函數語言程式設計語言和程式的形式化特性在證明起來比指令式程式設計語言和程式要簡單很多。
Python內在的函式式功能
自Python 1.0起,Python就已具有了以上所列中的絕大多數特點。但是就象Python所具有的大多數特性一樣,這些特點出現在了一種混合了各種特性的語言 中。 和Python的OOP(物件導向程式設計) 特性非常象,你想用多少就用多少,剩下的都可以不管(直到你隨後需要用到它們為止)。在Python 2.0中,加入了列表解析(list comprehensions)這個非常好用的”語法糖“。 儘管列表解析沒有新增什麼新功能,但它讓很多舊功能看起來好了不少。
Python中函數語言程式設計的基本要素包括functionsmap()、reduce()、filter()和lambda運算元(operator)。 在Python 1.x中,apply()函式也可以非常方便地拿來將一個函式的列表返回值直接用於另外一個函式。Python 2.0為此提供了一個改進後的語法。可能有點讓人驚奇,使用如此之少的函式(以及基本的運算元)幾乎就足以寫出任何Python程式了;更加特別的是,幾乎 用不著什麼執行流程控制語句。
所有(if,elif,else,assert,try,except,finally,for,break,continue,while,def)這 些都都能通過僅僅使用函數語言程式設計中的函式和運算元就能以函數語言程式設計的風格處理好。儘管真正地在程式中完全排除使用所有流程控制命令可能只在想參 加”Python混亂程式設計“大賽(可將Python程式碼寫得跟Lisp程式碼非常象)時才有意義,但這對理解函數語言程式設計如何通過函式和遞迴表達流程控制很有 價值。
剔除流程控制語句
剔除練習首先要考慮的第一件事是,實際上,Python會對布林表示式求值進行“短路”處理。這就為我們提供了一個if/elif/else分支語句的表示式版(假設每個分支只呼叫一個函式,不是這種情況時也很容易組織成重新安排成這種情況)。 這裡給出怎麼做:
對Python中的條件呼叫進行短路處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# Normal statement-based flow control if <cond1>: func1() elif <cond2>: func2() else: func3() # Equivalent "short circuit" expression (<cond1> and func1()) or (<cond2> and func2()) or (func3()) # Example "short circuit" expression >>> x = 3 >>> def pr(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表示式就是個完全通用的表達條件判斷返回值的手段了。我們來一個例子:
Python中短路的Lambda
1 2 3 4 5 6 7 8 9 10 |
>>> 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中,函式就是另外一種我們能夠就像某種處理的值。
我們對具有首要地位的物件做的比較多的事情就是,將它們作為引數傳遞給函數語言程式設計固有的函式map()、reduce()和filter()。這三個函式接受的第一個引數都是一個函式物件。
- map()針對指定給它的一個或多個列表中每一項對應的內容,執行一次作為引數傳遞給它的那個函式 ,最後返回一個結果列表。
- reduce()針對每個後繼項以及最後結果的累積結果,執行一次作為引數傳遞給它的那個函式;例如,reduce(lambda n,m:n*m, range(1,10))是求”10的階乘”的意思(換言之,將每一項和前面所得的乘積進行相乘)
- filter()使用那個作為引數傳遞給它的函式,對一個列表中的所有項進行”求值“,返回一個由所有能夠通過那個函式測試的項組成的經過遴選後的列表。
我們經常也會把函式物件傳遞給我們自己定義的函式,不過一般情況下這些自定義的函式就是前文提及的內建函式的某種形式的組合。
通過組合使用這三種函數語言程式設計內建的函式, 能夠實現範圍驚人的“執行流程”操作(全都不用語句,僅僅使用表示式實現)。
Python中的函式式迴圈
替換迴圈語言和條件狀態語言塊同樣簡單。for可以直接翻譯成map()函式。正如我們的條件執行,我們會需要簡化語句塊成簡單的函式呼叫(我們正在接近通常能做的):
替換迴圈
1 2 |
for e in lst: func(e) # statement-based loop map(func,lst) # map()-based loop |
通過這種方法,對有序程式流將有一個相似的函式式方式。那就是,指令式程式設計幾乎是由大量“做這,然後做那,之後做其它的”語句組成。map()讓我們只要做這樣:
Map-based 動作序列
1 2 3 4 5 6 |
# let's create an execution utility function do_it = lambda f: f() # let f1, f2, f3 (etc) be functions that perform actions map(do_it, [f1,f2,f3]) # map()-based action sequence |
通常,我們的整個主要的程式都可以使用一個map表示式加上一些函式列表的執行來完成這個程式。最高階別的函式的另一個方便的特性是你可以把它們放在一個列表裡。
翻譯while會稍稍複雜一些,但仍然可以直接地完成:
Python中的函式式”while”迴圈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# statement-based while loop while <cond>: <pre-suite> if <break_condition>: break else: <suite> # FP-style recursive while loop def while_block(): <pre-suite> if <break_condition>: return 1 else: <suite> return 0 while_FP = lambda: (<cond> and while_block()) or while_FP() while_FP() |
在翻譯while迴圈時,我們仍然需要使用while_block()函式,這個函式本身裡面可以包含語句而不是僅僅包含表示式。但我們可能還能夠對這個 函式再進行更進一步的剔除過程(就像前面模版中的對if/else進行短路處理一樣)。 還有,<cond>很難對普通的測試有什麼用,比如while myvar==7,既然迴圈體(在設計上)不能對任何變數的值進行修改(當然,在while_block()中可以修改全域性變數)。有一種方法可以用來為 while_block()新增更有用的條件判斷,讓 while_block()返回一個有意義的值,然後將這個返回值同迴圈結束條件進行比較。現在應該來看一個剔除其中語句的具體例子了:
Python中’echo’迴圈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# imperative version of "echo()" def echo_IMP(): while 1: x = raw_input("IMP -- ") if x == 'quit': break else print x echo_IMP() # utility function for "identity with side-effect" def monadic_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而已。函數語言程式設計中(特別是在Haskell中)的函式有一種叫做”monad”(一元)的概念,這種一元函式“實際什麼都不做,只是在執行過程中產生一個副作用”。
避免副作用
在做完這些沒有非常明智的理由陳述,並把晦澀的巢狀表示式代替他們之後,一個很自然的問題是“為什麼要這樣做?!” 我描述的函數語言程式設計在Python中 都實現了。但是最重要的特性和一個有具體用處——就是避免副作用(或至少它們阻止如monads的特殊區域)。程式錯誤的大部分——並且這些問題驅使程式 員去debug——出現是因為在程式的執行中變數獲取了非期望的值。函數語言程式設計簡單地通過從不給變數賦值而繞過了這個問題。
現在讓我們看一段非常普通的命令式程式碼。這段程式碼的目的是列印出乘積大於25的一對一對數字所組成的一個列表。組成每對數字的每一個數字都是取自另外的兩個列表。這種事情和很多程式設計師在他們的程式設計中經常做的一些事情比較相似。命令式的解決方式有可能就象下面這樣:
命令式的”列印大乘積”的Python程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Nested loop procedural style for finding big products xs = (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”(其它程式碼)註釋掉的部分,就是有可能存在導致出現bug的副作用的地方。在那三部分的任何一點上,變數sxs、ys、bigmuls、x、 y都有可能在這段按照理想情況簡化後的程式碼中取得一個出人意料的值。還有,這段程式碼執行完後,後繼程式碼有可能需要也有可能不需要對所有這些變數中的值有所 預期。顯而易見,將這段程式碼封裝到函式/例項中,小心處理變數的作用範圍,就能夠避免這種型別的錯誤。你也可以總是將使用完畢的變數del掉。但在實踐 中,這裡指出的這種型別的錯誤很常見。
以一種函式式的途徑一舉消除這些副作用所產生的錯誤,這樣就達到了我們的目的。一種可能的程式碼如下:
以函式式途徑達到我們的目的
1 2 3 4 |
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()函式在任何地方都是一個非常好的功能函式(函式從兩個輸入的列表讀入資料生成一個相應的pair列 表)。函式dupelms()只是用來輔助函式combine()的。即使這個函式式的例子跟命令式的例子顯得要累贅些,不過一旦你考慮到功能函式的重 用,則新的bigmuls()中程式碼就會比命令式的那個要稍少些。
這個函式式例子的真正優點在於:在函式中絕對沒有改變變數的值。這樣就不可能在之後的程式碼(或者從之前的程式碼)中產生不可預期的副作用。顯然,在函式中沒有副作用,並不能保證程式碼的正確性,但它仍然是一個優點。無論如何請注意,Python(不像很多其它的函式式語言)不會阻止名字bigmuls,combine和dupelms的再次繫結。如果combine()執行在之後的程式中意味著有所不同時,所有的預測都會失效。你可能會需要新建一個單例類來包含這個不變的繫結(也就是說,s.bigmuls之類的);但是這一例並沒有空間來做這些。
一個明顯值得注意的是,我們特定的目標是定製Python 2的一些特性。而不是命令式的或函數語言程式設計的例子,最好的(也是函式式的)方法是:
1 |
print [(x,y) for x in (1,2,3,4) for y in (10,15,3,22) if x*y > 25] |
結束語
我已經列出了把每一個Python控制流替換成一個相等的函式式程式碼的方法(在程式中減少副作用)。高效翻譯一個特定的程式需要一些額外的思考,但我們已 經看出內建的函式式功能是全面且完善的。在接下來的文章裡,我們會看到更多函數語言程式設計的高階技巧;並且希望我們接下來能夠摸索到函數語言程式設計風格的更多優點 和缺點。
可愛的 Python : Python中函數語言程式設計,第二部分
可愛的 Python : Python中的函數語言程式設計,第三部分