給 Python程式設計師的函數語言程式設計實踐經驗

Justin(李加慶)發表於2016-06-27

這篇文章會讓讀者瞭解到在 python 背景下的函數語言程式設計。除了作為學術課程的一部分,大部分程式設計師很少會接觸到函數語言程式設計語言,比如 Lisp 或者 Haskell。由於 Python 是一門應用廣泛的語言,並且支援大部分函數語言程式設計的結構,所以這篇文章會盡可能展示它們的用途和優點。對於 Python 這門語言,函數語言程式設計可能不是最好或者最 Pythonic 的方式,但是在一些應用中,函數語言程式設計有它的優點,這也是本文接下來要講的東西。

1. 什麼是函數語言程式設計?

函數語言程式設計是一種以純函式為中心的程式設計正規化。如果你曾經寫過程式,你可能會將函式與子程式聯絡在一起,這是典型的‘CS’視角。這裡,我們更傾向於其數學定義:

函式的數學定義純函式是指:任何函式基本上都可以用一個數學表示式來表示。這就意味著沒有副作用:沒有 IO 操作、沒有全域性狀態的變化、沒有資料庫的互動(你確實無法用一個數學表示式來實現這些,對嗎?)。純函式的輸出只依賴其輸入。所以,如果你使用相同的輸入多次呼叫純函式,每次得到的結果都一樣。當然,對於實際的函數語言程式設計語言如 Haskell,也能夠實現 IO 操作等功能,但是重點仍然是純函式。

2. lambda結構

Python 中,初始化一個純函式最簡單的方式就是使用 lambda 關鍵字:

lambda 關鍵字可以讓你在一行內定義一個函式,如果僅僅是一個數學表示式,這種定義方法就非常方便。事實上,lambda 關鍵字在函數語言程式設計(不只針對 Python )中非常重要,其根源於 Lambda Calculus — 函數語言程式設計的“鼻祖”之一。

使用 lambda 初始化的函式也被稱作匿名函式。看一下上面程式碼的第五行,你把一個用 lambda 初始化的函式傳給排序方法。但並未給它命名,只是動態地定義並將它作為引數傳遞,因此稱它是“匿名的”。當然, 你也可以將匿名函式賦值給一個變數(見第一行),然後像普通函式一樣呼叫它們(見第二行)。

3. 函式是“第一等”公民

在函數語言程式設計中,函式是“第一等公民”。這基本上就意味著你可以像使用其他物件那樣使用函式 — 賦值給變數、作為引數傳遞,甚至作為函式返回值。之前已經在程式碼中見過這種用法,下面是一些示例:

square_func 本身是一個函式。另一方面,function_product 是一個高階函式,它有兩個輸入 — 函式F(x) 和乘數 mfunction_product 返回 F'(x), 等同於 m * F(x)。因此,對於上面第五行程式碼,function_product(square_func, 3)被呼叫時帶有引數2,返回 2² * 3 = 12。

4. 資料和資料流的不變性

物件的不變性意味著一旦物件被初始化,你就絕不能再修改其資料的值。在函數語言程式設計中,無論何時你針對某些資料呼叫函式,你總會得到一個新的例項 — 你從不會“更新”任何變數的值。從程式設計角度來說,這就意味著,一旦你初始化一個變數如 x=3,變數x將不再出現在宣告的左邊。

所以,任何函式式的程式碼都可以被看作是前饋資料流,你絕不會“回來”改變任何變數的值。因此,資料總是向前移動,從輸入到最終的輸出 — 從一個函式到另一個函式。

資料移動

這種資料的不變性引出了另外一個屬性,稱之為引用透明性。這就意味著,只要所需的變數定義好,一個表示式的值在程式中任何地方都是一樣的。由於你不會更新任何變數或物件(包括函式)的值,所以在任何上下文中,一旦被定義,它們的意義就相同。基於此,函式式程式碼非常容易分析和除錯。你無需追蹤變數狀態的值或者記住任何更新。

資料不變性可以讓我們利用製表法 — 基本上,只要你“記住“一些代價高昂的函式輸出以及一種查表的某些常見引數即可。這就是犧牲記憶體來減少了計算的複雜度。

5. 遞迴

函數語言程式設計並未通過 while 或者 for 宣告提供迭代功能,同樣也未提供狀態更新。所以,遞迴在函數語言程式設計中就顯得尤為重要。值得記住的是,任何可迭代的程式碼都可以轉換為可遞迴的程式碼

下面是計算第n個 Fibonacci 數的函式式版本:

第二行定義了計算的基本條件,第三行實現了遞迴呼叫。思考一下,xx_1 以及 x_2 是必要的狀態變數,它們更新後的版本被傳到每一次遞迴中,這就是在函式式程式碼處理狀態的機制。

編寫函式式程式碼時,最好實現尾遞迴,特別是對純函式式的語言如 Schema 。這樣做的好處是:尾遞迴程式碼很容易通過編譯器優化成可迭代的程式碼(儘管這不適用於 Python),使得編譯後的程式碼更加高效。

6. 惰性求值

這是一個 Python 沒有采用函數語言程式設計的一個方面。在很多純函式式語言中,如Haskell,無需求值的物件是不會被求值的。求值意味著要計算函式表示式的值。考慮下面的程式碼:

在例如Python這樣的語言中,出現 1/0 將會立刻丟擲異常。但是,如果我們實現惰性求值,函式就會返回3。因為列表中有三個物件,計數時無需計算它們的值。這會在資料流中造成一些圖規約,導致更少的函式呼叫(這會有忽略錯誤的風險)。

Python 3.x 確實支援不同種類的惰性求值,比如 some_dict.keys(),呼叫時返回迭代器,這可以防止載入所有資料到記憶體中,從程式設計角度來說,這樣更高效。

7. 序列中沒有迭代器

雖然這是一點不太重要,但是由於迭代器下一個元素的值依賴於它的狀態(這違反了引用透明性),而在純函式式程式碼中沒有迭代器。如果我們編寫純函式式程式碼,替代序列,我們只處理明確且不變的tuple,在 Python 中可以使用 tuple() 從迭代器中生成 tuple。

8. map, reduce and filter

mapreduce 以及 filter是三個高階函式,在所有純函式式語言中都包含這三個函式,當然 Python 中也有。它們的普遍性證明了,為了使函式式程式碼更加優雅,它們是多麼頻繁地被使用。

map 通過對 list 或者 array 中所有元素呼叫函式,基本上提供了一種並行性。下面是一個示例:

注意 map 為何是並行的,由於你針對陣列中的是所有元素呼叫了同一個函式,並且沒有做任何修改,所以你可以以任何既定順序呼叫函式。

filter 針對序列提供另外一種並行。輸入一個 bool 值和函式,返回一個序列,保留序列中呼叫函式後返回 True 的值,示例如下:

上面的程式碼過濾出列表中所有的偶數。

reduce 是一個結構體,它在序列上執行一系列迭代。其第一個引數是函式 F(a, x)F 接收兩個引數 — 一個累加器 a 和當前輸入 xF 計算並返回一個新值 a。reduce中第二個引數是序列本身 — [x1, x2, ..., x_n],第三個引數初始的累加器 a,內部實現是:

i. a_1 = F(a, x1)
ii. a_2 = F(a_1, x2)
iii. a_3 = F(a_2, x3)

最後得到的是 an。 一個示例:

作為列表中的元素,累加器不必是相同型別,也可以是列表。下面是使用 reduce 翻轉序列的例子:

注意上面的程式碼中未改變列表(提醒一下,reduce 需要從 Python 3.x 中的 functools 中匯入)

此外,需要理解的是,因為每一次 reduce 對一個元素呼叫 F 函式,都依賴於上個元素產生的值,所以 reduce 不是並行的。

尾註 1:為了更好地讓您理解如何在 Python 中使用上述關鍵字或操作進行函數語言程式設計,本文提供了非常簡潔的例子和程式碼,同時也引入了一些先進的技術,如管道。一定要看一看!

尾註 2:對於為什麼函數語言程式設計是一些專門業務更喜愛的方式,本文提供了一些觀點,並對一些函式式程式碼提供了易懂的註解(寫的不好的地方請見諒)。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

給 Python程式設計師的函數語言程式設計實踐經驗

相關文章