函數式編程思想概論


原文地址

前言

在討論函數式編程(Functional Programming)的具體內容之前,我們首先看一下函數式編程的含義。在維基百科上,函數式編程的定義如下:“函數式編程是一種編程範式。它把計算當成是數學函數的求值,從而避免改變狀態和使用可變數據。它是一種聲明式的編程範式,通過表達式和聲明而不是語句來編程。” (見 Functional Programming

函數式編程的思想在軟件開發領域由來已久。在衆多的編程範式中,函數式編程雖然出現的時間很長,但是在編程範式領域和整個開發社區中的流行度一直不溫不火。函數式編程有一部分狂熱的支持者,在他們眼中,函數式編程思想是解決各種軟件開發問題的終極方案;而另外的一部分人,則覺得函數式編程的思想並不容易理解,學習曲線較陡,上手起來也有一定的難度。大多數人更傾向於接受面向對象或是面向過程這樣的編程範式。這也是造成函數式編程範式一直停留在小衆階段的原因。

這樣兩極化的反應,與函數式編程本身的特性是分不開的。函數式編程的思想脫胎於數學理論,也就是我們通常所說的λ演算( λ-calculus)。一聽到數學理論,可能很多人就感覺頭都大了。這的確是造成函數式編程的學習曲線較陡的一個原因。如同數學中的函數一樣,函數式編程範式中的函數有獨特的特性,也就是通常說的無狀態或引用透明性(referential transparency)。一個函數的輸出由且僅由其輸入決定,同樣的輸入永遠會產生同樣的輸出。這使得函數式編程在處理很多與狀態相關的問題時,有着天然的優勢。函數式編程的代碼通常更加簡潔,但是不一定易懂。函數式編程的解決方案中透露出優雅的美。

函數式編程所涵蓋的內容非常廣泛,從其背後的數學理論,到其中包含的基本概念,再到諸如 Haskell 這樣的函數式編程語言,以及主流編程語言中對函數式編程方式的支持,相關的專有第三方庫等。通過本系列的學習,你可以瞭解到很多函數式編程相關的概念。你會發現很多概念都可以在日常的開發中找到相應的映射。比如做前端的開發人員一定聽說過高階組件(high-order component),它就與函數式編程中的高階函數有着異曲同工之妙。流行的前端狀態管理方案 Redux 的核心是 reduce 函數。庫 reselect 則是記憶化( memoization)的精妙應用。很多 Java 開發人員已經切實的體會到了 Java 8 中的 Lambda 表達式如何讓對流(Stream)的操作變得簡潔又自然。

近年來,隨着多核平臺和併發計算的發展,函數式編程的無狀態特性,在處理這些問題時有着其他編程範式不可比擬的天然優勢。不管是前端還是後端開發人員,學習一些函數式編程的思想和概念,對於手頭的開發工作和以後的職業發展,都是大有裨益的。本系列雖然側重的是 Java 平臺上的函數式編程,但是對於其他背景的開發人員同樣有借鑑意義。

下面介紹函數的基本概念。

函數

我們先從數學中的函數開始談起。數學中的函數是輸入元素的集合到可能的輸出元素的集合之間的映射關係,並且每個輸入元素只能映射到一個輸出元素。比如典型的函數 f(x)=xx 把所有實數的集合映射到其平方值的集合,如 f(2)=4 和 f(-2)=4。函數允許不同的輸入元素映射到同一個輸出元素,但是每個輸入元素只能映射到一個輸出元素。比如上述函數 f(x)=xx 中,2 和-2 都映射到同一個輸出元素 4。這也限定了每個輸入元素所對應的輸出元素是固定的。每個輸入元素都必須被映射到某個輸出元素,也就是說函數可以應用到輸入元素集合中的每個元素。

用專業的術語來說,輸入元素稱爲函數的參數(argument)。輸出元素稱爲函數的值(value)。輸入元素的集合稱爲函數的定義域(domain)。輸出元素和其他附加元素的集合稱爲函數的到達域(codomain)。存在映射關係的輸入和輸出元素對的集合,稱爲函數的圖形(graph)。輸出元素的集合稱爲像(image)。這裏需要注意像和到達域的區別。到達域還可能包含除了像中元素之外的其他元素,也就是沒有輸入元素與之對應的元素。

圖 1 表示了一個函數對應的映射關係(圖片來源於維基百科上的 Function 條目)。輸入集合 X 中的每個元素都映射到了輸出集合 Y 中某個元素,即 f(1)=D、f(2)=C 和 f(3)=C。X 中的元素 2 和 3 都映射到了 Y 中的 C,這是合法的。Y 中的元素 B 和 A 沒有被映射到,這也是合法的。該函數的定義域是 X,到達域是 Y,圖形是 (1, D)、(2, C) 和 (3, C) 的集合,像是 C 和 D 的集合。

圖 1. 函數的映射關係
在這裏插入圖片描述
我們通常可以把函數看成是一個黑盒子,對其內部的實現一無所知。只需要清楚其映射關係,就可以正確的使用它。函數的圖形是描述函數的一種方式,也就是列出來函數對應的映射中所有可能的元素對。這種描述方式用到了集合相關的理論,對於圖 1 中這樣的簡單函數比較容易進行描述。對於包含輸入變量的函數,如 f(x)=x+1,需要用到更加複雜的集合邏輯。因爲輸入元素 x 可以是任何數,定義域是一個無窮集合,對應的輸出元素集合也是無窮的。要描述這樣的函數,用我們下面介紹的 λ 演算會更加直觀。

λ 演算

λ 演算是數理邏輯中的一個形式系統,在函數抽象和應用的基礎上,使用變量綁定和替換來表達計算。討論 λ 演算離不開形式化的表達。在本文中,我們儘量集中在與編程相關的基本概念上,而不拘泥於數學上的形式化表示。λ 演算實際上是對前面提到的函數概念的簡化,方便以系統的方式來研究函數。λ 演算的函數有兩個重要特徵:

  • λ 演算中的函數都是匿名的,沒有顯式的名稱。比如函數 sum(x, y) = x + y 可以寫成 (x, y)|-> x + y。由於函數本身僅由其映射關係來確定,函數名稱實際上並沒有意義。因此使用匿名函數是合理的。
  • λ演算中的函數都只有一個輸入。有多個輸入的函數可以轉換成多個只包含一個輸入的函數的嵌套調用。這個過程就是通常所說的柯里化(currying)。如 (x, y)|-> x + y 可以轉換成 x |-> (y |-> x + y)。右邊的函數的返回值是另外一個函數。這一限定簡化了λ演算的定義。

對函數簡化之後,就可以開始定義 λ 演算。λ 演算是基於 λ 項(λ-term)的語言。λ 項是 λ 演算的基本單元。λ 演算在 λ 項上定義了各種轉換規則。

λ項

λ 項由下面 3 個規則來定義:

  • 一個變量 x 本身就是一個 λ 項。
  • 如果 M 是 λ 項,x 是一個變量,那麼 (λx.M) 也是一個 λ 項。這樣的 λ 項稱爲 λ 抽象(abstraction)。x 和 M 中間的點(.)用來分隔函數參數和內容。
  • 如果 M 和 N 都是 λ 項,那麼 (MN) 也是一個 λ 項。這樣的λ項稱爲應用(application)。

所有的合法 λ 項,都只能通過重複應用上面的 3 個規則得來。需要注意的是,λ 項最外圍的括號是可以省略的,也就是可以直接寫爲 λx.M 和 MN。當多個 λ 項連接在一起時,需要用括號來進行分隔,以確定 λ 項的解析順序。默認的順序是左向關聯的。所以 MNO 相當於 ((MN)O)。在不出現歧義的情況下,可以省略括號。

重複應用上述 3 個規則就可以得到所有的λ項。把變量作爲λ項是重複應用規則的起點。λ 項 λx.M 定義的是匿名函數,把輸入變量 x 的值替換到表達式 M 中。比如,λx.x+1 就是函數 f(x)=x+1 的 λ 抽象,其中 x 是變量,M 是 x+1。λ 項 MN 表示的是把表達式 N 應用到函數 M 上,也就是調用函數。N 可以是類似 x 這樣的簡單變量,也可以是 λ 抽象表示的項。當使用λ抽象時,就是我們通常所說的高階函數的概念。

綁定變量和自由變量

在 λ 抽象中,如果變量 x 出現在表達式中,那麼該變量被綁定。表達式中綁定變量之外的其他變量稱爲自由變量。我們可以用函數的方式來分別定義綁定變量(bound variable,BV)和自由變量(free variable,FV)。

對綁定變量來說:

  • 對變量 x 來說,BV(x) = ∅。也就是說,一個單獨的變量是自由的。
  • 對 λ 項 M 和變量 x 來說,BV(λx.M) = BV(M) ∪ { x }。也就是說,λ 抽象在 M 中已有的綁定變量的基礎上,額外綁定了變量 x。
  • 對 λ 項 M 和λ項 N 來說,BV(MN) = BV(M) ∪ BV(N)。也就是說,λ 項的應用結果中的綁定變量的集合是各自 λ 項的綁定變量集合的並集。

對自由變量來說,相應的定義和綁定變量是相反的:

  • 對變量 x 來說,FV(x) = { x }。
  • 對 λ M 和變量 x 來說,FV(λx.M) = FV(M) − { x }。
  • 對 λ 項 M 和 λ 項 N 來說,FV(MN) = FV(M) ∪ FV(N)。

在 λ 項 λx.x+1 中,x 是綁定變量,沒有自由變量。在 λ 項 λx.x+y 中,x 是綁定變量,y 是自由變量。

在λ抽象中,綁定變量的名稱在某些情況下是無關緊要的。如 λx.x+1 和 λy.y+1 實際上表示的是同樣的函數,都是把輸入值加 1。變量名稱 x 或 y,並不影響函數的語義。類似 λx.x+1 和 λy.y+1 這樣的 λ 項在λ演算中被認爲是相等的,稱爲 α 等價(alpha equivalence)。

約簡

在 λ 項上可以進行不同的約簡(reduction)操作,主要有如下 3 種。

α 變換

α 變換(α-conversion)的目的是改變綁定變量的名稱,避免名稱衝突。比如,我們可以通過 α 變換把 λx.x+1 轉換成 λy.y+1。如果兩個λ項可以通過α變換來進行轉換,則這兩個 λ 項是 α 等價的。這也是我們上一節中提到的 α 等價的形式化定義。

對 λ 抽象進行 α 變換時,只能替換那些綁定到當前 λ 抽象上的變量。如 λ 抽象 λx.λx.x 可以 α 變換爲 λx.λy.y 或 λy.λx.x,但是不能變換爲 λy.λx.y,因爲兩者的語義是不同的。λx.x 表示的是恆等函數。λx.λx.x 和 λy.λx.x 都是表示返回恆等函數的 λ 抽象,因此它們是 α 等價的。而 λx.y 表示的不再是恆等函數,因此 λy.λx.y 與 λx.λy.y 和 λy.λx.x 都不是 α 等價的。

β 約簡

β 約簡(β-reduction)與函數應用相關。在討論 β 約簡之前,需要先介紹替換的概念。對於 λ 項 M 來說,M[x := N] 表示把 λ 項 M 中變量 x 的自由出現替換成 N。具體的替換規則如下所示。A、B 和 M 是 λ 項,而 x 和 y 是變量。A ≡ B 表示兩個 λ 項是相等的。

  • x[x := M] ≡ M:直接替換一個變量 x 的結果是用來進行替換的 λ 項 M。
  • y[x := M] ≡ y(x ≠ y):y 是與 x 不同的變量,因此替換 x 並不會影響 y,替換結果仍然爲 y。
  • (AB)[x := M] ≡ (A[x := M]B[x := M]):A 和 B 都是 λ 項,(AB) 是 λ 項的應用。對 λ 項的應用進行替換,相當於替換之後再進行應用。
  • (λx.A)[x := M] ≡ λx.A:這條規則針對 λ 抽象。如果 x 是 λ 抽象的綁定變量,那麼不需要對 x 進行替換,得到的結果與之前的 λ 抽象相同。這是因爲替換隻是針對 M 中 x 的自由出現,如果 x 在 M 中是不自由的,那麼替換就不需要進行。
  • (λy.A)[x := M] ≡ λy.A[x := M](x ≠ y 並且 y ∉ FV(M)):這條規則也是針對λ抽象。λ 項 A 的綁定變量是 y,不同於要替換的 x,因此可以在 A 中進行替換動作。
    在進行替換之前,可能需要先使用 α 變換來改變綁定變量的名稱。比如,在進行替換 (λx.y)[y := x] 時,不能直接把出現的 y 替換成 x。這樣就改變了之前的 λ 抽象的語義。正確的做法是先進行 α 變換,把 λx.y 替換成 λz.y,再進行替換,得到的結果是 λz.x。

替換的基本原則是要求在替換完成之後,原來的自由變量仍然是自由的。如果替換變量可能導致一個變量從自由變成綁定,需要首先進行 α 變換。在之前的例子中,λx.y 中的 x 是自由變量,而直接替換的結果 λx.x 把 x 變成了綁定變量,因此 α 變換是必須的。在正確的替換結果 λz.x 中,z 仍然是自由的。

β 約簡用替換來表示函數應用。對 ((λV.E) E′) 進行 β 約簡的結果就是 E[V := E′]。如 ((λx.x+1)y) 進行 β 約簡的結果是 (x+1)[x := y],也就是 y+1。

η 變換

η 變換(η-conversion)描述函數的外延性(extensionality)。外延性指的是如果兩個函數當且僅當對所有參數的結果相同時,才被認爲是相等的。比如一個函數 F,當參數爲 x 時,它的返回值是 Fx。那麼考慮聲明爲 λy.Fy 的函數 G。函數 G 對於輸入參數 x,同樣返回結果 Fx。F 和 G 可能由不同的 λ 項組成,但是隻要 Fx=Gx 對所有的 x 都成立,那麼 F 和 G 是相等的。

以 F=λx.x 和 G=λx.(λy.y)x 來說,F 是恆等函數,而 G 則是在輸入參數 x 上應用恆等函數。F 和 G 雖然由不同的 λ 項組成,但是它們的行爲是一樣,本質上都是恆等函數。我們稱之爲 F 和 G 是 η 等價的,F 是 G 的 η 約簡,而 G 是 F 的 η 擴展。F 和 G 互爲對方的 η 變換。

純函數、副作用和引用透明性

瞭解函數式編程的人可能聽說過純函數和副作用等名稱。這兩個概念與引用透明性緊密相關。純函數需要具備兩個特徵:

  • 對於相同的輸入參數,總是返回相同的值。
  • 求值過程中不產生副作用,也就是不會對運行環境產生影響。

對於第一個特徵,如果是從數學概念上抽象出來的函數,則很容易理解。比如 f(x)=x+1 和 g(x)=x*x 這樣的函數,都是典型的純函數。如果考慮到一般編程語言中出現的方法,則函數中不能使用靜態局部變量、非局部變量,可變對象的引用或 I/O 流。這是因爲這些變量的值可能在不同的函數執行中發生變化,導致產生不一樣的輸出。第二個特徵,要求函數體中不能對靜態局部變量、非局部變量,可變對象的引用或 I/O 流進行修改。這就保證了函數的執行過程中不會對外部環境造成影響。純函數的這兩個特徵缺一不可。下面通過幾個 Java 方法來具體說明純函數。

在清單 1 中,方法 f1 是純函數;方法 f2 不是純函數,因爲引用了外部變量 y;方法 f3 不是純函數,因爲使用了調用了產生副作用的 Counter 對象的 inc 方法;方法 f4 不是純函數,因爲調用 writeFile 方法會寫入文件,從而對外部環境造成影響。

清單 1. 純函數和非純函數示例

int f1(int x) {
  return x + 1;
}
 
int f2(int x) {
  return x + y;
}
 
int f3(Counter c) {
  c.inc();
  return 0;
}
 
int f4(int x) {
  writeFile();
  return 1;
}

如果一個表達式是純的,那麼它在代碼中的所有出現,都可以用它的值來代替。對於一個函數調用來說,這就意味着,對於同一個函數的輸入參數相同的調用,都可以用其值來代替。這就是函數的引用透明性。引用透明性的重要性在於使得編譯器可以用各種措施來對代碼進行自動優化。

函數式編程與併發編程

隨着計算機硬件多核的普及,爲了儘可能地利用硬件平臺的能力,併發編程顯得尤爲重要。與傳統的命令式編程範式相比,函數式編程範式由於其天然的無狀態特性,在併發編程中有着獨特的優勢。以 Java 平臺來說,相信很多開發人員都對 Java 的多線程和併發編程有所瞭解。可能最直觀的感受是,Java 平臺的多線程和併發編程並不容易掌握。這主要是因爲其中所涉及的概念太多,從 Java 內存模型,到底層原語 synchronized 和 wait/notify,再到 java.util.concurrent 包中的高級同步對象。由於併發編程的複雜性,即使是經驗豐富的開發人員,也很難保證多線程代碼不出現錯誤。很多錯誤只在運行時的特定情況下出現,很難排錯和修復。在學習如何更好的進行併發編程的同時,我們可以從另外一個角度來看待這個問題。多線程編程的問題根源在於對共享變量的併發訪問。如果這樣的訪問並不需要存在,那麼自然就不存在多線程相關的問題。在函數式編程範式中,函數中並不存在可變的狀態,也就不需要對它們的訪問進行控制。這就從根本上避免了多線程的問題。

總結

作爲 Java 函數式編程系列的第一篇文章,本文對函數式編程做了簡要的概述。由於函數式編程與數學中的函數密不可分,本文首先介紹了函數的基本概念。接着對作爲函數式編程理論基礎的λ演算進行了詳細的介紹,包括λ項、自由變量和綁定變量、α變換、β約簡和η變換等重要概念。最後介紹了編程中可能會遇到的純函數、副作用和引用透明性等概念。本系列的下一篇文章將對函數式編程中的重要概念進行介紹,包括高階函數、閉包、遞歸、記憶化和柯里化等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章