深入理解函數語言程式設計(上)

語言: CN / TW / HK

函數語言程式設計是一種歷史悠久的程式設計正規化。作為演演算法,它的歷史可以追溯到現代計算機誕生之前的λ演算,本文希望帶大家快速瞭解函數語言程式設計的歷史、基礎技術、重要特性和實踐法則。在內容層面,主要使用JavaScript語言來描述函數語言程式設計的特性,並以演算規則、語言特性、正規化特性、副作用處理等方面作為切入點,通過大量演示示例來講解這種程式設計正規化。同時,文末列舉比較一些此正規化的優缺點,供讀者參考。因為文章涵蓋一些範疇論知識,可能需要其他參考資料一起輔助閱讀。

前言

本文分為上下兩篇,上篇講述函數語言程式設計的基礎概念和特性,下篇講述函數語言程式設計的進階概念、應用及優缺點。函數語言程式設計既不是簡單的堆砌函式,也不是語言正規化的終極之道。我們將深入淺出地討論它的特性,以期在日常工作中能在對應場景中進行靈活應用。

1. 先覽:程式碼組合和複用

在前端程式碼中,我們現有一些可行的模組複用方式,比如:

圖 1

除了上面提到的元件和功能級別的程式碼複用,我們也可以在軟體架構層面上,通過選擇一些合理的架構設計來減少重複開發的工作量,比如說很多公司在中後臺場景中大量使用的低程式碼平臺

可以說,在大部分軟體專案中,我們都要去探索程式碼組合和複用

函數語言程式設計,曾經有過一段黃金時代,後來又因面向物件正規化的崛起而逐步變為小眾正規化。但是,函數語言程式設計目前又開始在不同的語言中流行起來了,像Java 8、JS、Rust等語言都有對函數語言程式設計的支援。

今天我們就來探討JavaScript的函式,並進一步探討JavaScript中的函數語言程式設計(關於函數語言程式設計風格軟體的組織、組合和複用)。

圖 2

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

2.1 定義

函數語言程式設計是一種風格範式,沒有一個標準的教條式定義。我們來看一下維基百科的定義:

函數語言程式設計是一種程式設計正規化,它將電腦運算視為函式運算,並且避免使用程式狀態以及易變物件。其中,λ演算是該語言最重要的基礎。而且λ演算的函式可以接受函式作為輸入的引數和輸出的返回值。

我們可以直接讀出以下資訊:

  1. 避免狀態變更
  2. 函式作為輸入輸出
  3. λ演算有關

那什麼是λ演算呢?

2.2 函數語言程式設計起源:λ演算

λ演算(讀作lambda演算)由數學家阿隆佐·邱奇在20世紀30年代首次發表,它從數理邏輯(Mathematical logic)中發展而來,使用變數繫結(binding)和代換規則(substitution)來研究函式如何抽象化定義(define)、函式如何被應用(apply)以及遞迴(recursion)的形式系統。

λ演算和圖靈機等價(圖靈完備,作為一種研究語言又很方便)。

通常用這個定義形式來表示一個λ演算

圖 3

所以λ演算式就三個要點:

  1. 繫結關係。變數任意性,x、y和z都行,它僅僅是具體資料的代稱。
  2. 遞迴定義。λ項遞迴定義,M可以是一個λ項。
  3. 替換歸約。λ項可應用,空格分隔表示對M應用NN可以是一個λ項。

比如這樣的演算式:

圖 4

通過變數代換(substitution)歸約(reduction),我們可以像化簡方程一樣處理我們的演算。

λ演算有很多方式進行,數學家們也總結了許多和它相關的規律和定律(可檢視維基百科)。

舉個例子,小時候我們學習整數就是學會幾個數字,然後用加法/減法來推演其他數字。在函數語言程式設計中,我們可以用函式來定義自然數,有很多定義方式,這裡我們講一種實現方式:

圖 5

上面的演算式表示有一個函式f和一個引數x。令0x1f x2f f x...

什麼意思呢?這是不是很像我們數學中的冪:a^x(a的x次冪表示a對自身乘x次)。相應的,我們理解上面的演算式就是數字n就是f對x作用的次數。有了這個數字的定義之後,我們就可以在這個基礎上定義運算。

圖 6

其中SUCC表示後繼函式(+1操作),PLUS表示加法。現在我們來推導這個正確性。

圖 7

這樣,進行λ演算就像是方程的代換和化簡,在一個已知前提(公理,比如0/1,加法)下,進行規則推演。

2.2.1 演算:變數的含義

λ演算中我們的表示式只有一個引數,那它怎麼實現兩個數字的二元操作呢?比如加法a + b,需要兩個引數。

這時,我們要把函式本身也視為值,可以通過把一個變數繫結到上下文,然後返回一個新的函式,來實現資料(或者說是狀態)的儲存和傳遞,被繫結的變數可以在需要實際使用的時候從上下文中引用到。

比如下面這個簡單的演算式:

圖 8

第一次函式呼叫傳入m=5,返回一個新函式,這個新函式接收一個引數n,並返回m + n的結果。像這種情況產生的上下文,就是Closure(閉包,函數語言程式設計常用的狀態儲存和引用手段),我們稱變數m是被繫結(binding)到了第二個函式的上下文。

除了繫結的變數,λ演算也支援自由的變數,比如下面這個y

圖 9

這裡的y是一個沒有繫結到引數位置的變數,被稱為一個自由變數

繫結變數自由變數是函式的兩種狀態來源,一個可以被代換,另一個不能。實際程式中,通常把繫結變數實現為區域性變數或者引數,自由變數實現為全域性變數或者環境變數。

2.2.2 演算:代換和歸約

演算分為Alpha代換和Beta歸約。 前面章節我們實際上已經涉及這兩個概念,下面來介紹一下它們。

Alpha代換指的是變數的名稱是不重要的,你可以寫λm.λn.m + n,也可以寫λx.λy.x + y,在演算過程中它們表示同一個函式。也就是說我們只關心計算的形式,而不關心細節用什麼變數去實現。這方便我們不改變運算結果的前提下去修改變數命名,以方便在函式比較複雜的情況下進行化簡操作。實際上,連整個lambda演算式的名字也是不重要的,我們只需要這種形式的計算,而不在乎這個形式的命名。

Beta歸約指的是如果你有一個函式應用(函式呼叫),那麼你可以對這個函式體中與識別符號對應的部分做代換(substitution),方式為使用引數(可能是另一個演算式)去替換識別符號。聽起來有點繞口,但是它實際上就是函式呼叫的引數替換。比如:

圖 10

可以使用1替換m3替換n,那麼整個表示式可以化簡為4。這也是函數語言程式設計裡面的引用透明性的由來。需要注意的是,這裡的13表示表示式運算值,可以替換為其他表示式。比如把1替換為(λm.λn.m + n 1 3),這裡就需要做兩次歸約來得到下面的最終結果:

圖 11

2.3 JavaScript中的λ表示式:箭頭函式

ECMAScript 2015規範引入了箭頭函式,它沒有this,沒有arguments。只能作為一個表示式(expression)而不能作為一個宣告式(statement),表示式產生一個箭頭函式引用,該箭頭函式引用仍然有namelength屬性,分別表示箭頭函式的名字、形參(parameters)長度。一個箭頭函式就是一個單純的運算式,箭頭函式我們也可以稱為lambda函式,它在書寫形式上就像是一個λ演算式

圖 12

可以利用箭頭函式做一些簡單的運算,下例比較了四種箭頭函式的使用方式:

圖 13

這是直接針對數字(基本資料型別)的情況,如果是針對函式做運算(引用資料型別),事情就變得有趣起來了。我們看一下下面的示例:

圖 14

fn_x型別,表明我們可以利用函式內的函式,當函式被當作資料傳遞的時候,就可以對函式進行應用(apply),生成更高階的操作。 並且x => y => x(y)可以有兩種理解,一種是x => y函式傳入X => x(y),另一種是x傳入y => x(y)

add_x型別表明,一個運算式可以有很多不同的路徑來實現。

上面的add_1/add_2/add_3我們用到了JavaScript的立即運算表示式IIFE

λ演算是一種抽象的數學表達方式,我們不關心真實的運算情況,我們只關心這種運算形式。因此上一節的演算可以用JavaScript來模擬。下面我們來實現λ演算的JavaScript表示

圖 15

我們把λ演算中fx分別取為countTimex,代入運算就得到了我們的自然數。

這也說明了不管你使用符號系統還是JavaScript語言,你想要表達的自然數是等價的。這也側面說明λ演算是一種形式上的抽象(和具體語言表述無關的抽象表達)

2.4 函數語言程式設計基礎:函式的元、柯里化和Point-Free

回到JavaScript本身,我們要探究函式本身能不能帶給我們更多的東西?我們在JavaScript中有很多建立函式的方式:

圖 16

雖然函式有這麼多定義,但function關鍵字宣告的函式帶有arguments和this關鍵字,這讓他們看起來更像是物件方法(method),而不是函式(function) 。

況且function定義的函式大多數還能被構造(比如new Array)。

接下來我們將只研究箭頭函式,因為它更像是數學意義上的函式(僅執行計算過程)。

  • 沒有arguments和this。
  • 不可以被構造new。

2.4.1 函式的元:完全呼叫和不完全呼叫

不論使用何種方式去構造一個函式,這個函式都有兩個固定的資訊可以獲取:

  • name 表示當前識別符號指向的函式的名字。
  • length 表示當前識別符號指向的函式定義時的引數列表長度。

在數學上,我們定義f(x) = x是一個一元函式,而f(x, y) = x + y是一個二元函式。在JavaScript中我們可以使用函式定義時的length來定義它的元。

圖 17

定義函式的元的意義在於,我們可以對函式進行歸類,並且可以明確一個函式需要的確切引數個數。函式的元在編譯期(型別檢查、過載)和執行時(異常處理、動態生成程式碼)都有重要作用。

如果我給你一個二元函式,你就知道需要傳遞兩個引數。比如+就可以看成是一個二元函式,它的左邊接受一個引數,右邊接受一個引數,返回它們的和(或字串連線)。

在一些其他語言中,+確實也是由抽象類來定義實現的,比如Rust語言的trait Add

但我們上面看到的λ演算,每個函式都只有一個元。為什麼呢?

只有一個元的函式方便我們進行代數運算。λ演算的引數列表使用λx.λy.λz的格式進行分割,返回值一般都是函式,如果一個二元函式,呼叫時只使用了一個引數,則返回一個「不完全呼叫函式」。這裡用三個例子解釋“不完全呼叫”。

第一個,不完全呼叫,代換引數後產生了一個不完全呼叫函式 λz.3 + z

圖 18

第二個,Haskell程式碼,呼叫一個函式add(型別為a -> a -> a),得到另一個函式add 1(型別為a -> a)。

圖 19

第三個,上一個例子的JavaScript版本。

圖 20

“不完全呼叫”在JavaScript中也成立。實際上它就是JavaScript中閉包(Closure,上面我們已經提到過)產生的原因,一個函式還沒有被銷燬(呼叫沒有完全結束),你可以在子環境內使用父環境的變數。

注意,上面我們使用到的是一元函式,如果使用三元函式來表示addThree,那麼函式一次性就呼叫完畢了,不會產生不完全呼叫。

函式的元還有一個著名的例子(面試題):

圖 21

造成上述結果的原因就是,Number是一元的,接受map第一個引數就轉換得到返回值;而parseInt是二元的,第二個引數拿到進製為1map函式為三元函式,第二個引數返回元素索引),無法輸出正確的轉換,只能返回NaN。這個例子裡面涉及到了一元、二元、三元函式,多一個元,函式體就多一個狀態。如果世界上只有一元函式就好了!我們可以全通過一元函式和不完全呼叫來生成新的函式處理新的問題。

認識到函式是有元的,這是函數語言程式設計的重要一步,多一個元就多一種複雜度。

2.4.2 柯里化函式:函式元降維技術

柯里化(curry)函式是一種把函式的元降維的技術,這個名詞是為了紀念我們上文提到的數學家阿隆佐·邱奇

首先,函式的幾種寫法是等價的(最終計算結果一致)。

圖 22

這裡列一個簡單的把普通函式變為柯里化函式的方式(柯里化函式Curry)。

圖 23

柯里化函式幫助我們把一個多元函式變成一個不完全呼叫,利用Closure的魔法,把函式呼叫變成延遲的偏函式(不完全呼叫函式)呼叫。這在函式組合、複用等場景非常有用。比如:

圖 24

雖然你可以用其他閉包方式來實現函式的延遲呼叫,但Curry函式絕對是其中形式最美的幾種方式之一。

2.4.3 Point-Free|無參風格:函式的高階組合

函數語言程式設計中有一種Point-Free風格,中文語境大概可以把point認為是引數點,對應λ演算中的函式應用(Function Apply),或者JavaScript中的函式呼叫(Function Call),所以可以理解Point-Free就指的是無參呼叫

來看一個日常的例子,把二進位制資料轉換為八進位制資料:

圖 25

這段程式碼執行起來沒有問題,但我們為了處理這個轉換,需要了解 parseInt(x, 2)toString(8) 這兩個函式(為什麼有魔法數字2和魔法數字8),並且要關心資料(函式型別a -> b)在每個節點的形態(關心資料的流向)。有沒有一種方式,可以讓我們只關心入參和出參,不關心資料流動過程呢?

圖 26

上面的方法假設我們已經有了一些基礎函式toBinary(語義化,消除魔法數字2)和toStringOx(語義化,消除魔法數字8),並且有一種方式(pipe)把基礎函式組合(Composition)起來。如果用Typescript表示我們的資料流動就是:

圖 27

用Haskell表示更簡潔,後面都用Haskell型別表示方式,作為我們的符號語言。

圖 28

值得注意的是,這裡的x-> [int] ->y我們不用關心,因為pipe(..)函式幫我們處理了它們。pipe就像一個盒子。

圖 29

BOX內容不需要我們理解。而為了達成這個目的,我們需要做這些事:

  • utils 一些特定的工具函式。
  • curry 柯里化並使得函式可以被複用。
  • composition 一個組合函式,像膠水一樣粘合所有的操作。
  • name 給每個函式定義一個見名知意的名字。

綜上,Point-Free風格是粘合一些基礎函式,最終讓我們的資料操作不再關心中間態的方式。這是函數語言程式設計,或者說函數語言程式設計語言中我們會一直遇到的風格,表明我們的基礎函式是值得信賴的,我們僅關心資料轉換這種形式,而不是過程。JavaScript中有很多實現這種基礎函式工具的庫,最出名的是Lodash。

可以說函數語言程式設計正規化就是在不停地組合函式。

2.5 函數語言程式設計特性

說了這麼久,都是在講函式,那麼究竟什麼是函數語言程式設計呢?在網上你可以看到很多定義,但大都離不開這些特性。

  • First Class 函式:函式可以被應用,也可以被當作資料。
  • Pure 純函式,無副作用:任意時刻以相同引數呼叫函式任意次數得到的結果都一樣。
  • Referential Transparency 引用透明:可以被表示式替代。
  • Expression 基於表示式:表示式可以被計算,促進資料流動,狀態宣告就像是一個暫停,好像資料到這裡就會停滯了一下。
  • Immutable 不可變性:引數不可被修改、變數不可被修改---寧可犧牲效能,也要產生新的資料(Rust記憶體模型例外)。
  • High Order Function 大量使用高階函式:變數儲存、閉包應用、函式高度可組合。
  • Curry 柯里化:對函式進行降維,方便進行組合。
  • Composition 函式組合:將多個單函式進行組合,像流水線一樣工作。

另外還有一些特性,有的會提到,有的一筆帶過,但實際也是一個特性(以Haskell為例)。

  • Type Inference 型別推導:如果無法確定資料的型別,那函式怎麼去組合?(常見,但非必需)
  • Lazy Evaluation 惰性求值:函式天然就是一個執行環境,惰性求值是很自然的選擇。
  • Side Effect IO:一種型別,用於處理副作用。一個不能執行列印文字、修改檔案等操作的程式,是沒有意義的,總要有位置處理副作用。(邊緣)

數學上,我們定義函式為集合A到集合B的對映。在函數語言程式設計中,我們也是這麼認為的。函式就是把資料從某種形態對映到另一種形態。注意理解“對映”,後面我們還會講到。

圖 30

2.5.1 First Class:函式也是一種資料

函式本身也是資料的一種,可以是引數,也可以是返回值。

圖 31

通過這種方式,我們可以讓函式作為一種可以儲存狀態的值進行流動,也可以充分利用不完全呼叫函式來進行函式組合。把函式作為資料,實際上就讓我們有能力獲取函式內部的狀態,這樣也產生了閉包。但函數語言程式設計不強調狀態,大部分情況下,我們的“狀態”就是一個函式的元(我們從元獲取外部狀態)。

2.5.2 純函式:無狀態的世界

通常我們定義輸入輸出(IO)是不純的,因為IO操作不僅操作了資料,還操作了這個資料範疇外部的世界,比如列印、播放聲音、修改變數狀態、網路請求等。這些操作並不是說對程式造成了破壞,相反,一個完整的程式一定是需要它們的,不然我們的所有計算都將毫無意義。

但純函式是可預測的,引用透明的,我們希望程式碼中更多地出現純函式式的程式碼,這樣的程式碼可以被預測,可以被表示式替換,而更多地把IO操作放到一個統一的位置做處理。

圖 32

這個add函式是不純的,但我們把副作用都放到它裡面了。任意使用這個add函式的位置,都將變成不純的(就像是async/await的傳遞性一樣)。需要說明的是拋開實際應用來談論函式的純粹性是毫無意義的,我們的程式需要和終端、網路等裝置進行互動,不然一個計算的執行結果將毫無意義。但對於函式的元來說,這種純粹性就很有意義,比如:

圖 33

當函式的元像上面那樣是一個引用值,如果一個函式的呼叫不去控制自己的純粹性,對別人來說,可能會造成毀滅性打擊。因此我們需要對引用值特別小心。

圖 34

上面這種解構賦值的方式僅解決了第一層的引用值,很多其他情況下,我們要處理一個引用樹、或者返回一個引用樹,這需要更深層次的解引用操作。請小心對待你的引用。

函式的純粹性要求我們時刻提醒自己降低狀態數量,把變化留在函式外部。

2.5.3 引用透明:代換

通過表示式替代(也就是λ演算中講的歸約),可以得到最終資料形態。

圖 35

也就是說,呼叫一個函式的位置,我們可以使用函式的呼叫結果來替代此函式呼叫,產生的結果不變。

一個引用透明的函式呼叫鏈永遠是可以被合併式代換的。

2.5.4 不可變性:把簡單留給自己

一個函式不應該去改變原有的引用值,避免對運算的其他部分造成影響。

圖 36

一個充滿變化的世界是混沌的,在函數語言程式設計世界,我們需要強調引數和值的不可變性,甚至在很多時候我們可以為了不改變原來的引用值,犧牲效能以產生新的物件來進行運算。犧牲一部分效能來保證我們程式的每個部分都是可預測的,任意一個物件從建立到消失,它的值應該是固定的。

一個元如果是引用值,請使用一個副本(克隆、複製、替代等方式)來得到狀態變更。

2.5.5 高階:函式抽象和組合

JS中用的最多的就是Array相關的高階函式。實際上Array是一種Monad(後面講解)。

圖 37

通過高階函式傳遞和修改變數:

圖 38

高階函式實際上為我們提供了注入環境變數(或者說繫結環境變數)提供了更多可能。React的高階元件就從這個思想上借用而來。一個高階函式就是使用或者產生另一個函式的函式。高階函式是函式組合(Composition)的一種方式。

高階函式讓我們可以更好地組合業務。常見的高階函式有:

  • map
  • compose
  • fold
  • pipe
  • curry
  • ....

2.5.6 惰性計算:降低執行時開銷

惰性計算的含義就是在真正呼叫到的時候才執行,中間步驟不真實執行程式。這樣可以讓我們在執行時建立很多基礎函式,但並不影響實際業務執行速度,唯有業務程式碼真實呼叫時才產生開銷。

圖 39

map(addOne)並不會真實執行+1,只有真實呼叫exec才執行。當然這個只是一個簡單的例子,強大的惰性計算在函數語言程式設計語言中還有很多其他例子。比如:

圖 40

“無窮”只是一個字面定義,我們知道計算機是無法定義無窮的資料的,因此資料在take的時候才真實產生。

惰性計算讓我們可以無限使用函式組合,在寫這些函式組合的過程中並不產生呼叫。但這種形式,可能會有一個嚴重的問題,那就是產生一個非常長的呼叫棧,而虛擬機器或者直譯器的函式呼叫棧一般都是有上限的,比如2000個,超過這個限制,函式呼叫就會棧溢位。雖然函式呼叫棧過長會引起這個嚴重的問題,但這個問題其實不是函式式語言設計的邏輯問題,因為呼叫棧溢位的問題在任何設計不良的程式中都有可能出現,惰性計算只是利用了函式呼叫棧的特性,而不是一種缺陷。

記住,任何時候我們都可以重構程式碼,通過良好的設計來解決棧溢位的問題。

2.5.7 型別推導

當前的JS有TypeScript的加持,也可以算是有型別推導了。

圖 41

沒有型別推導的函數語言程式設計,在使用的時候會很不方便,所有的工具函式都要查表查示例,開發中效率會比較低下,也容易造成錯誤。

但並不是說一門函式式語言必須要型別推導,這不是強制的。像Lisp就沒有強制型別推導,JavaScript也沒有強制的型別推導,這不影響他們的成功。只是說,有了型別推導,我們的編譯器可以在編譯器期間提前捕獲錯誤,甚至在編譯之前,寫程式碼的時候就可以發現錯誤。型別推導是一些語言強調的優秀特性,它確實可以幫助我們提前發現更多的程式碼問題。像Rust、Haskell等。

2.5.8 其他

你現在也可以總結一些其他的風格或者定義。比如強調函式的組合、強調Point-Free的風格等等。

圖 42

3. 小結

函式式有很多基礎的特性,熟練地使用這些特性,並加以巧妙地組合,就形成了我們的“函數語言程式設計正規化”。這些基礎特性讓我們對待一個function,更多地看作函式,而不是一個方法。在許多場景下,使用這種正規化進行程式設計,就像是在做數學推導(或者說是型別推導),它讓我們像學習數學一樣,把一個個複雜的問題簡單化,再進行累加/累積,從而得到結果。

同時,函數語言程式設計還有一塊大的領域需要進入,即副作用處理。不處理副作用的程式是毫無意義的(僅在記憶體中進行計算),下篇我們將深入函數語言程式設計的應用,為我們的工程應用發掘出更多的特性。

4. 作者簡介

俊傑,美團到家研發平臺/醫藥技術部前端工程師。

閱讀美團技術團隊更多技術文章合集

前端 | 演算法 | 後端 | 資料 | 安全 | 運維 | iOS | Android | 測試

| 在公眾號選單欄對話方塊回覆【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至[email protected]申請授權。