函數(shù)式編程是一種歷史悠久的編程范式。作為演算法,它的歷史可以追溯到現(xiàn)代計(jì)算機(jī)誕生之前的λ演算,本文希望帶大家快速了解函數(shù)式編程的歷史、基礎(chǔ)技術(shù)、重要特性和實(shí)踐法則。
在內(nèi)容層面,主要使用JavaScript語言來描述函數(shù)式編程的特性,并以演算規(guī)則、語言特性、范式特性、副作用處理等方面作為切入點(diǎn),通過大量演示示例來講解這種編程范式。同時(shí),文末列舉比較一些此范式的優(yōu)缺點(diǎn),供讀者參考。
本文分為上下兩篇,上篇講述函數(shù)式編程的基礎(chǔ)概念和特性,下篇講述函數(shù)式編程的進(jìn)階概念、應(yīng)用及優(yōu)缺點(diǎn)。函數(shù)式編程既不是簡單的堆砌函數(shù),也不是語言范式的終極之道。我們將深入淺出地討論它的特性,以期在日常工作中能在對應(yīng)場景中進(jìn)行靈活應(yīng)用。
1. 先覽:代碼組合和復(fù)用
在前端代碼中,我們現(xiàn)有一些可行的模塊復(fù)用方式,比如:
圖 1
除了上面提到的組件和功能級別的代碼復(fù)用,我們也可以在軟件架構(gòu)層面上,通過選擇一些合理的架構(gòu)設(shè)計(jì)來減少重復(fù)開發(fā)的工作量,比如說很多公司在中后臺場景中大量使用的低代碼平臺。
可以說,在大部分軟件項(xiàng)目中,我們都要去探索代碼組合和復(fù)用。
函數(shù)式編程,曾經(jīng)有過一段黃金時(shí)代,后來又因面向?qū)ο蠓妒降尼绕鸲鸩阶優(yōu)樾”姺妒?。但是,函?shù)式編程目前又開始在不同的語言中流行起來了,像Java 8、JS、Rust等語言都有對函數(shù)式編程的支持。
今天我們就來探討JavaScript的函數(shù),并進(jìn)一步探討JavaScript中的函數(shù)式編程(關(guān)于函數(shù)式編程風(fēng)格軟件的組織、組合和復(fù)用)。
圖 2
2. 什么是函數(shù)式編程?
2.1 定義
函數(shù)式編程是一種風(fēng)格范式,沒有一個(gè)標(biāo)準(zhǔn)的教條式定義。我們來看一下維基百科的定義:
函數(shù)式編程是一種編程范式,它將電腦運(yùn)算視為函數(shù)運(yùn)算,并且避免使用程序狀態(tài)以及易變對象。其中,λ演算是該語言最重要的基礎(chǔ)。而且λ演算的函數(shù)可以接受函數(shù)作為輸入的參數(shù)和輸出的返回值。
我們可以直接讀出以下信息:
避免狀態(tài)變更
函數(shù)作為輸入輸出
和λ演算有關(guān)
那什么是λ演算呢?
2.2 函數(shù)式編程起源:λ演算
λ演算(讀作lambda演算)由數(shù)學(xué)家阿隆佐·邱奇在20世紀(jì)30年代首次發(fā)表,它從數(shù)理邏輯(Mathematical logic)中發(fā)展而來,使用變量綁定(binding)和代換規(guī)則(substitution)來研究函數(shù)如何抽象化定義(define)、函數(shù)如何被應(yīng)用(apply)以及遞歸(recursion)的形式系統(tǒng)。
λ演算和圖靈機(jī)等價(jià)(圖靈完備,作為一種研究語言又很方便)。
通常用這個(gè)定義形式來表示一個(gè)λ演算。
圖 3
所以λ演算式就三個(gè)要點(diǎn):
綁定關(guān)系。變量任意性,x、y和z都行,它僅僅是具體數(shù)據(jù)的代稱。
遞歸定義。λ項(xiàng)遞歸定義,M可以是一個(gè)λ項(xiàng)。
替換歸約。λ項(xiàng)可應(yīng)用,空格分隔表示對M應(yīng)用N,N可以是一個(gè)λ項(xiàng)。
比如這樣的演算式:
圖 4
通過變量代換(substitution)和歸約(reduction),我們可以像化簡方程一樣處理我們的演算。
λ演算有很多方式進(jìn)行,數(shù)學(xué)家們也總結(jié)了許多和它相關(guān)的規(guī)律和定律(可查看維基百科)。
舉個(gè)例子,小時(shí)候我們學(xué)習(xí)整數(shù)就是學(xué)會幾個(gè)數(shù)字,然后用加法/減法來推演其他數(shù)字。在函數(shù)式編程中,我們可以用函數(shù)來定義自然數(shù),有很多定義方式,這里我們講一種實(shí)現(xiàn)方式:
圖 5
上面的演算式表示有一個(gè)函數(shù)f和一個(gè)參數(shù)x。令0為x,1為f x,2為f f x...
什么意思呢?這是不是很像我們數(shù)學(xué)中的冪:a^x(a的x次冪表示a對自身乘x次)。相應(yīng)的,我們理解上面的演算式就是數(shù)字n就是f對x作用的次數(shù)。有了這個(gè)數(shù)字的定義之后,我們就可以在這個(gè)基礎(chǔ)上定義運(yùn)算。
圖 6 其中SUCC表示后繼函數(shù)(+1操作),PLUS表示加法?,F(xiàn)在我們來推導(dǎo)這個(gè)正確性。 ?
圖 7
這樣,進(jìn)行λ演算就像是方程的代換和化簡,在一個(gè)已知前提(公理,比如0/1,加法)下,進(jìn)行規(guī)則推演。
2.2.1 演算:變量的含義
在λ演算中我們的表達(dá)式只有一個(gè)參數(shù),那它怎么實(shí)現(xiàn)兩個(gè)數(shù)字的二元操作呢?比如加法a + b,需要兩個(gè)參數(shù)。
這時(shí),我們要把函數(shù)本身也視為值,可以通過把一個(gè)變量綁定到上下文,然后返回一個(gè)新的函數(shù),來實(shí)現(xiàn)數(shù)據(jù)(或者說是狀態(tài))的保存和傳遞,被綁定的變量可以在需要實(shí)際使用的時(shí)候從上下文中引用到。
比如下面這個(gè)簡單的演算式:
圖 8
第一次函數(shù)調(diào)用傳入m=5,返回一個(gè)新函數(shù),這個(gè)新函數(shù)接收一個(gè)參數(shù)n,并返回m + n的結(jié)果。像這種情況產(chǎn)生的上下文,就是Closure(閉包,函數(shù)式編程常用的狀態(tài)保存和引用手段),我們稱變量m是被綁定(binding)到了第二個(gè)函數(shù)的上下文。
除了綁定的變量,λ演算也支持自由的變量,比如下面這個(gè)y:
圖 9
這里的y是一個(gè)沒有綁定到參數(shù)位置的變量,被稱為一個(gè)自由變量。
綁定變量和自由變量是函數(shù)的兩種狀態(tài)來源,一個(gè)可以被代換,另一個(gè)不能。實(shí)際程序中,通常把綁定變量實(shí)現(xiàn)為局部變量或者參數(shù),自由變量實(shí)現(xiàn)為全局變量或者環(huán)境變量。
2.2.2 演算:代換和歸約
演算分為Alpha代換和Beta歸約。前面章節(jié)我們實(shí)際上已經(jīng)涉及這兩個(gè)概念,下面來介紹一下它們。
Alpha代換指的是變量的名稱是不重要的,你可以寫λm.λn.m + n,也可以寫λx.λy.x + y,在演算過程中它們表示同一個(gè)函數(shù)。也就是說我們只關(guān)心計(jì)算的形式,而不關(guān)心細(xì)節(jié)用什么變量去實(shí)現(xiàn)。這方便我們不改變運(yùn)算結(jié)果的前提下去修改變量命名,以方便在函數(shù)比較復(fù)雜的情況下進(jìn)行化簡操作。實(shí)際上,連整個(gè)lambda演算式的名字也是不重要的,我們只需要這種形式的計(jì)算,而不在乎這個(gè)形式的命名。
Beta歸約指的是如果你有一個(gè)函數(shù)應(yīng)用(函數(shù)調(diào)用),那么你可以對這個(gè)函數(shù)體中與標(biāo)識符對應(yīng)的部分做代換(substitution),方式為使用參數(shù)(可能是另一個(gè)演算式)去替換標(biāo)識符。聽起來有點(diǎn)繞口,但是它實(shí)際上就是函數(shù)調(diào)用的參數(shù)替換。比如:
圖 10
可以使用1替換m,3替換n,那么整個(gè)表達(dá)式可以化簡為4。這也是函數(shù)式編程里面的引用透明性的由來。需要注意的是,這里的1和3表示表達(dá)式運(yùn)算值,可以替換為其他表達(dá)式。比如把1替換為(λm.λn.m + n 1 3),這里就需要做兩次歸約來得到下面的最終結(jié)果:
圖 11
2.3 JavaScript中的λ表達(dá)式:箭頭函數(shù)
ECMAScript 2015規(guī)范引入了箭頭函數(shù),它沒有this,沒有arguments。只能作為一個(gè)表達(dá)式(expression)而不能作為一個(gè)聲明式(statement),表達(dá)式產(chǎn)生一個(gè)箭頭函數(shù)引用,該箭頭函數(shù)引用仍然有name和length屬性,分別表示箭頭函數(shù)的名字、形參(parameters)長度。一個(gè)箭頭函數(shù)就是一個(gè)單純的運(yùn)算式,箭頭函數(shù)我們也可以稱為lambda函數(shù),它在書寫形式上就像是一個(gè)λ演算式。
圖 12
可以利用箭頭函數(shù)做一些簡單的運(yùn)算,下例比較了四種箭頭函數(shù)的使用方式:
圖 13
這是直接針對數(shù)字(基本數(shù)據(jù)類型)的情況,如果是針對函數(shù)做運(yùn)算(引用數(shù)據(jù)類型),事情就變得有趣起來了。我們看一下下面的示例:
圖 14
fn_x類型,表明我們可以利用函數(shù)內(nèi)的函數(shù),當(dāng)函數(shù)被當(dāng)作數(shù)據(jù)傳遞的時(shí)候,就可以對函數(shù)進(jìn)行應(yīng)用(apply),生成更高階的操作。并且x => y => x(y)可以有兩種理解,一種是x => y函數(shù)傳入X => x(y),另一種是x傳入y => x(y)。
add_x類型表明,一個(gè)運(yùn)算式可以有很多不同的路徑來實(shí)現(xiàn)。
上面的add_1/add_2/add_3我們用到了JavaScript的立即運(yùn)算表達(dá)式IIFE。
λ演算是一種抽象的數(shù)學(xué)表達(dá)方式,我們不關(guān)心真實(shí)的運(yùn)算情況,我們只關(guān)心這種運(yùn)算形式。因此上一節(jié)的演算可以用JavaScript來模擬。下面我們來實(shí)現(xiàn)λ演算的JavaScript表示。
圖 15
我們把λ演算中的f和x分別取為countTime和x,代入運(yùn)算就得到了我們的自然數(shù)。
這也說明了不管你使用符號系統(tǒng)還是JavaScript語言,你想要表達(dá)的自然數(shù)是等價(jià)的。這也側(cè)面說明λ演算是一種形式上的抽象(和具體語言表述無關(guān)的抽象表達(dá))。
2.4 函數(shù)式編程基礎(chǔ):函數(shù)的元、柯里化和Point-Free
回到JavaScript本身,我們要探究函數(shù)本身能不能帶給我們更多的東西?我們在JavaScript中有很多創(chuàng)建函數(shù)的方式:
圖 16
雖然函數(shù)有這么多定義,但function關(guān)鍵字聲明的函數(shù)帶有arguments和this關(guān)鍵字,這讓他們看起來更像是對象方法(method),而不是函數(shù)(function) 。
況且function定義的函數(shù)大多數(shù)還能被構(gòu)造(比如new Array)。
接下來我們將只研究箭頭函數(shù),因?yàn)樗袷菙?shù)學(xué)意義上的函數(shù)(僅執(zhí)行計(jì)算過程)。
沒有arguments和this。
不可以被構(gòu)造new。
2.4.1 函數(shù)的元:完全調(diào)用和不完全調(diào)用
不論使用何種方式去構(gòu)造一個(gè)函數(shù),這個(gè)函數(shù)都有兩個(gè)固定的信息可以獲取:
name 表示當(dāng)前標(biāo)識符指向的函數(shù)的名字。
length 表示當(dāng)前標(biāo)識符指向的函數(shù)定義時(shí)的參數(shù)列表長度。
在數(shù)學(xué)上,我們定義f(x) = x是一個(gè)一元函數(shù),而f(x, y) = x + y是一個(gè)二元函數(shù)。在JavaScript中我們可以使用函數(shù)定義時(shí)的length來定義它的元。
圖 17
定義函數(shù)的元的意義在于,我們可以對函數(shù)進(jìn)行歸類,并且可以明確一個(gè)函數(shù)需要的確切參數(shù)個(gè)數(shù)。函數(shù)的元在編譯期(類型檢查、重載)和運(yùn)行時(shí)(異常處理、動態(tài)生成代碼)都有重要作用。
如果我給你一個(gè)二元函數(shù),你就知道需要傳遞兩個(gè)參數(shù)。比如+就可以看成是一個(gè)二元函數(shù),它的左邊接受一個(gè)參數(shù),右邊接受一個(gè)參數(shù),返回它們的和(或字符串連接)。
在一些其他語言中,+確實(shí)也是由抽象類來定義實(shí)現(xiàn)的,比如Rust語言的trait Add。
但我們上面看到的λ演算,每個(gè)函數(shù)都只有一個(gè)元。為什么呢?
只有一個(gè)元的函數(shù)方便我們進(jìn)行代數(shù)運(yùn)算。λ演算的參數(shù)列表使用λx.λy.λz的格式進(jìn)行分割,返回值一般都是函數(shù),如果一個(gè)二元函數(shù),調(diào)用時(shí)只使用了一個(gè)參數(shù),則返回一個(gè)“不完全調(diào)用函數(shù)”。這里用三個(gè)例子解釋“不完全調(diào)用”。
第一個(gè),不完全調(diào)用,代換參數(shù)后產(chǎn)生了一個(gè)不完全調(diào)用函數(shù) λz.3 + z。
圖 18
第二個(gè),Haskell代碼,調(diào)用一個(gè)函數(shù)add(類型為a -> a -> a),得到另一個(gè)函數(shù)add 1(類型為a -> a)。
圖 19
第三個(gè),上一個(gè)例子的JavaScript版本。
圖 20
“不完全調(diào)用”在JavaScript中也成立。實(shí)際上它就是JavaScript中閉包(Closure,上面我們已經(jīng)提到過)產(chǎn)生的原因,一個(gè)函數(shù)還沒有被銷毀(調(diào)用沒有完全結(jié)束),你可以在子環(huán)境內(nèi)使用父環(huán)境的變量。
注意,上面我們使用到的是一元函數(shù),如果使用三元函數(shù)來表示addThree,那么函數(shù)一次性就調(diào)用完畢了,不會產(chǎn)生不完全調(diào)用。
函數(shù)的元還有一個(gè)著名的例子(面試題):
圖 21
造成上述結(jié)果的原因就是,Number是一元的,接受map第一個(gè)參數(shù)就轉(zhuǎn)換得到返回值;而parseInt是二元的,第二個(gè)參數(shù)拿到進(jìn)制為1(map函數(shù)為三元函數(shù),第二個(gè)參數(shù)返回元素索引),無法輸出正確的轉(zhuǎn)換,只能返回NaN。這個(gè)例子里面涉及到了一元、二元、三元函數(shù),多一個(gè)元,函數(shù)體就多一個(gè)狀態(tài)。如果世界上只有一元函數(shù)就好了!我們可以全通過一元函數(shù)和不完全調(diào)用來生成新的函數(shù)處理新的問題。
認(rèn)識到函數(shù)是有元的,這是函數(shù)式編程的重要一步,多一個(gè)元就多一種復(fù)雜度。
2.4.2 柯里化函數(shù):函數(shù)元降維技術(shù)
柯里化(curry)函數(shù)是一種把函數(shù)的元降維的技術(shù),這個(gè)名詞是為了紀(jì)念我們上文提到的數(shù)學(xué)家阿隆佐·邱奇。
首先,函數(shù)的幾種寫法是等價(jià)的(最終計(jì)算結(jié)果一致)。
圖 22
這里列一個(gè)簡單的把普通函數(shù)變?yōu)榭吕锘瘮?shù)的方式(柯里化函數(shù)Curry)。
圖 23
柯里化函數(shù)幫助我們把一個(gè)多元函數(shù)變成一個(gè)不完全調(diào)用,利用Closure的魔法,把函數(shù)調(diào)用變成延遲的偏函數(shù)(不完全調(diào)用函數(shù))調(diào)用。這在函數(shù)組合、復(fù)用等場景非常有用。比如:
圖 24 雖然你可以用其他閉包方式來實(shí)現(xiàn)函數(shù)的延遲調(diào)用,但Curry函數(shù)絕對是其中形式最美的幾種方式之一。
2.4.3 Point-Free|無參風(fēng)格:函數(shù)的高階組合
函數(shù)式編程中有一種Point-Free風(fēng)格,中文語境大概可以把point認(rèn)為是參數(shù)點(diǎn),對應(yīng)λ演算中的函數(shù)應(yīng)用(Function Apply),或者JavaScript中的函數(shù)調(diào)用(Function Call),所以可以理解Point-Free就指的是無參調(diào)用。
來看一個(gè)日常的例子,把二進(jìn)制數(shù)據(jù)轉(zhuǎn)換為八進(jìn)制數(shù)據(jù):
圖 25
這段代碼運(yùn)行起來沒有問題,但我們?yōu)榱颂幚磉@個(gè)轉(zhuǎn)換,需要了解 parseInt(x, 2) 和 toString(8) 這兩個(gè)函數(shù)(為什么有魔法數(shù)字2和魔法數(shù)字8),并且要關(guān)心數(shù)據(jù)(函數(shù)類型a -> b)在每個(gè)節(jié)點(diǎn)的形態(tài)(關(guān)心數(shù)據(jù)的流向)。有沒有一種方式,可以讓我們只關(guān)心入?yún)⒑统鰠?,不關(guān)心數(shù)據(jù)流動過程呢?
圖 26
上面的方法假設(shè)我們已經(jīng)有了一些基礎(chǔ)函數(shù)toBinary(語義化,消除魔法數(shù)字2)和toStringOx(語義化,消除魔法數(shù)字8),并且有一種方式(pipe)把基礎(chǔ)函數(shù)組合(Composition)起來。如果用Typescript表示我們的數(shù)據(jù)流動就是:
圖 27
用Haskell表示更簡潔,后面都用Haskell類型表示方式,作為我們的符號語言。
圖 28
值得注意的是,這里的x-> [int] ->y我們不用關(guān)心,因?yàn)閜ipe(..)函數(shù)幫我們處理了它們。pipe就像一個(gè)盒子。
圖 29
BOX內(nèi)容不需要我們理解。而為了達(dá)成這個(gè)目的,我們需要做這些事:
utils 一些特定的工具函數(shù)。
curry 柯里化并使得函數(shù)可以被復(fù)用。
composition 一個(gè)組合函數(shù),像膠水一樣粘合所有的操作。
name 給每個(gè)函數(shù)定義一個(gè)見名知意的名字。
綜上,Point-Free風(fēng)格是粘合一些基礎(chǔ)函數(shù),最終讓我們的數(shù)據(jù)操作不再關(guān)心中間態(tài)的方式。這是函數(shù)式編程,或者說函數(shù)式編程語言中我們會一直遇到的風(fēng)格,表明我們的基礎(chǔ)函數(shù)是值得信賴的,我們僅關(guān)心數(shù)據(jù)轉(zhuǎn)換這種形式,而不是過程。JavaScript中有很多實(shí)現(xiàn)這種基礎(chǔ)函數(shù)工具的庫,最出名的是Lodash。
可以說函數(shù)式編程范式就是在不停地組合函數(shù)。
2.5 函數(shù)式編程特性
說了這么久,都是在講函數(shù),那么究竟什么是函數(shù)式編程呢?在網(wǎng)上你可以看到很多定義,但大都離不開這些特性。
First Class 函數(shù):函數(shù)可以被應(yīng)用,也可以被當(dāng)作數(shù)據(jù)。
Pure 純函數(shù),無副作用:任意時(shí)刻以相同參數(shù)調(diào)用函數(shù)任意次數(shù)得到的結(jié)果都一樣。
Referential Transparency 引用透明:可以被表達(dá)式替代。
Expression 基于表達(dá)式:表達(dá)式可以被計(jì)算,促進(jìn)數(shù)據(jù)流動,狀態(tài)聲明就像是一個(gè)暫停,好像數(shù)據(jù)到這里就會停滯了一下。
Immutable 不可變性:參數(shù)不可被修改、變量不可被修改---寧可犧牲性能,也要產(chǎn)生新的數(shù)據(jù)(Rust內(nèi)存模型例外)。
High Order Function 大量使用高階函數(shù):變量存儲、閉包應(yīng)用、函數(shù)高度可組合。
Curry 柯里化:對函數(shù)進(jìn)行降維,方便進(jìn)行組合。
Composition 函數(shù)組合:將多個(gè)單函數(shù)進(jìn)行組合,像流水線一樣工作。
另外還有一些特性,有的會提到,有的一筆帶過,但實(shí)際也是一個(gè)特性(以Haskell為例)。
Type Inference 類型推導(dǎo):如果無法確定數(shù)據(jù)的類型,那函數(shù)怎么去組合?(常見,但非必需)
Lazy Evaluation 惰性求值:函數(shù)天然就是一個(gè)執(zhí)行環(huán)境,惰性求值是很自然的選擇。
Side Effect IO:一種類型,用于處理副作用。一個(gè)不能執(zhí)行打印文字、修改文件等操作的程序,是沒有意義的,總要有位置處理副作用。(邊緣)
數(shù)學(xué)上,我們定義函數(shù)為集合A到集合B的映射。在函數(shù)式編程中,我們也是這么認(rèn)為的。函數(shù)就是把數(shù)據(jù)從某種形態(tài)映射到另一種形態(tài)。注意理解“映射”,后面我們還會講到。
圖 30
2.5.1 First Class:函數(shù)也是一種數(shù)據(jù)
函數(shù)本身也是數(shù)據(jù)的一種,可以是參數(shù),也可以是返回值。
圖 31 通過這種方式,我們可以讓函數(shù)作為一種可以保存狀態(tài)的值進(jìn)行流動,也可以充分利用不完全調(diào)用函數(shù)來進(jìn)行函數(shù)組合。把函數(shù)作為數(shù)據(jù),實(shí)際上就讓我們有能力獲取函數(shù)內(nèi)部的狀態(tài),這樣也產(chǎn)生了閉包。但函數(shù)式編程不強(qiáng)調(diào)狀態(tài),大部分情況下,我們的“狀態(tài)”就是一個(gè)函數(shù)的元(我們從元獲取外部狀態(tài))。
2.5.2 純函數(shù):無狀態(tài)的世界
通常我們定義輸入輸出(IO)是不純的,因?yàn)镮O操作不僅操作了數(shù)據(jù),還操作了這個(gè)數(shù)據(jù)范疇外部的世界,比如打印、播放聲音、修改變量狀態(tài)、網(wǎng)絡(luò)請求等。這些操作并不是說對程序造成了破壞,相反,一個(gè)完整的程序一定是需要它們的,不然我們的所有計(jì)算都將毫無意義。
但純函數(shù)是可預(yù)測的,引用透明的,我們希望代碼中更多地出現(xiàn)純函數(shù)式的代碼,這樣的代碼可以被預(yù)測,可以被表達(dá)式替換,而更多地把IO操作放到一個(gè)統(tǒng)一的位置做處理。
圖 32
這個(gè)add函數(shù)是不純的,但我們把副作用都放到它里面了。任意使用這個(gè)add函數(shù)的位置,都將變成不純的(就像是async/await的傳遞性一樣)。需要說明的是拋開實(shí)際應(yīng)用來談?wù)摵瘮?shù)的純粹性是毫無意義的,我們的程序需要和終端、網(wǎng)絡(luò)等設(shè)備進(jìn)行交互,不然一個(gè)計(jì)算的運(yùn)行結(jié)果將毫無意義。但對于函數(shù)的元來說,這種純粹性就很有意義,比如:
圖 33
當(dāng)函數(shù)的元像上面那樣是一個(gè)引用值,如果一個(gè)函數(shù)的調(diào)用不去控制自己的純粹性,對別人來說,可能會造成毀滅性打擊。因此我們需要對引用值特別小心。
圖 34
上面這種解構(gòu)賦值的方式僅解決了第一層的引用值,很多其他情況下,我們要處理一個(gè)引用樹、或者返回一個(gè)引用樹,這需要更深層次的解引用操作。請小心對待你的引用。
函數(shù)的純粹性要求我們時(shí)刻提醒自己降低狀態(tài)數(shù)量,把變化留在函數(shù)外部。
2.5.3 引用透明:代換
通過表達(dá)式替代(也就是λ演算中講的歸約),可以得到最終數(shù)據(jù)形態(tài)。
圖 35
也就是說,調(diào)用一個(gè)函數(shù)的位置,我們可以使用函數(shù)的調(diào)用結(jié)果來替代此函數(shù)調(diào)用,產(chǎn)生的結(jié)果不變。
一個(gè)引用透明的函數(shù)調(diào)用鏈永遠(yuǎn)是可以被合并式代換的。
2.5.4 不可變性:把簡單留給自己
一個(gè)函數(shù)不應(yīng)該去改變原有的引用值,避免對運(yùn)算的其他部分造成影響。
圖 36
一個(gè)充滿變化的世界是混沌的,在函數(shù)式編程世界,我們需要強(qiáng)調(diào)參數(shù)和值的不可變性,甚至在很多時(shí)候我們可以為了不改變原來的引用值,犧牲性能以產(chǎn)生新的對象來進(jìn)行運(yùn)算。犧牲一部分性能來保證我們程序的每個(gè)部分都是可預(yù)測的,任意一個(gè)對象從創(chuàng)建到消失,它的值應(yīng)該是固定的。
一個(gè)元如果是引用值,請使用一個(gè)副本(克隆、復(fù)制、替代等方式)來得到狀態(tài)變更。
2.5.5 高階:函數(shù)抽象和組合
JS中用的最多的就是Array相關(guān)的高階函數(shù)。實(shí)際上Array是一種Monad(后面講解)。
圖 37
通過高階函數(shù)傳遞和修改變量:
圖 38
高階函數(shù)實(shí)際上為我們提供了注入環(huán)境變量(或者說綁定環(huán)境變量)提供了更多可能。React的高階組件就從這個(gè)思想上借用而來。一個(gè)高階函數(shù)就是使用或者產(chǎn)生另一個(gè)函數(shù)的函數(shù)。高階函數(shù)是函數(shù)組合(Composition)的一種方式。
高階函數(shù)讓我們可以更好地組合業(yè)務(wù)。常見的高階函數(shù)有:
map
compose
fold
pipe
curry
....
2.5.6 惰性計(jì)算:降低運(yùn)行時(shí)開銷
惰性計(jì)算的含義就是在真正調(diào)用到的時(shí)候才執(zhí)行,中間步驟不真實(shí)執(zhí)行程序。這樣可以讓我們在運(yùn)行時(shí)創(chuàng)建很多基礎(chǔ)函數(shù),但并不影響實(shí)際業(yè)務(wù)運(yùn)行速度,唯有業(yè)務(wù)代碼真實(shí)調(diào)用時(shí)才產(chǎn)生開銷。
圖 39
map(addOne)并不會真實(shí)執(zhí)行+1,只有真實(shí)調(diào)用exec才執(zhí)行。當(dāng)然這個(gè)只是一個(gè)簡單的例子,強(qiáng)大的惰性計(jì)算在函數(shù)式編程語言中還有很多其他例子。比如:
圖 40
“無窮”只是一個(gè)字面定義,我們知道計(jì)算機(jī)是無法定義無窮的數(shù)據(jù)的,因此數(shù)據(jù)在take的時(shí)候才真實(shí)產(chǎn)生。
惰性計(jì)算讓我們可以無限使用函數(shù)組合,在寫這些函數(shù)組合的過程中并不產(chǎn)生調(diào)用。但這種形式,可能會有一個(gè)嚴(yán)重的問題,那就是產(chǎn)生一個(gè)非常長的調(diào)用棧,而虛擬機(jī)或者解釋器的函數(shù)調(diào)用棧一般都是有上限的,比如2000個(gè),超過這個(gè)限制,函數(shù)調(diào)用就會棧溢出。雖然函數(shù)調(diào)用棧過長會引起這個(gè)嚴(yán)重的問題,但這個(gè)問題其實(shí)不是函數(shù)式語言設(shè)計(jì)的邏輯問題,因?yàn)檎{(diào)用棧溢出的問題在任何設(shè)計(jì)不良的程序中都有可能出現(xiàn),惰性計(jì)算只是利用了函數(shù)調(diào)用棧的特性,而不是一種缺陷。
記住,任何時(shí)候我們都可以重構(gòu)代碼,通過良好的設(shè)計(jì)來解決棧溢出的問題。
2.5.7 類型推導(dǎo)
當(dāng)前的JS有TypeScript的加持,也可以算是有類型推導(dǎo)了。
圖 41
沒有類型推導(dǎo)的函數(shù)式編程,在使用的時(shí)候會很不方便,所有的工具函數(shù)都要查表查示例,開發(fā)中效率會比較低下,也容易造成錯(cuò)誤。
但并不是說一門函數(shù)式語言必須要類型推導(dǎo),這不是強(qiáng)制的。像Lisp就沒有強(qiáng)制類型推導(dǎo),JavaScript也沒有強(qiáng)制的類型推導(dǎo),這不影響他們的成功。只是說,有了類型推導(dǎo),我們的編譯器可以在編譯器期間提前捕獲錯(cuò)誤,甚至在編譯之前,寫代碼的時(shí)候就可以發(fā)現(xiàn)錯(cuò)誤。類型推導(dǎo)是一些語言強(qiáng)調(diào)的優(yōu)秀特性,它確實(shí)可以幫助我們提前發(fā)現(xiàn)更多的代碼問題。像Rust、Haskell等。
2.5.8 其他
你現(xiàn)在也可以總結(jié)一些其他的風(fēng)格或者定義。比如強(qiáng)調(diào)函數(shù)的組合、強(qiáng)調(diào)Point-Free的風(fēng)格等等。
圖 42
3. 小結(jié)
函數(shù)式有很多基礎(chǔ)的特性,熟練地使用這些特性,并加以巧妙地組合,就形成了我們的“函數(shù)式編程范式”。這些基礎(chǔ)特性讓我們對待一個(gè)function,更多地看作函數(shù),而不是一個(gè)方法。在許多場景下,使用這種范式進(jìn)行編程,就像是在做數(shù)學(xué)推導(dǎo)(或者說是類型推導(dǎo)),它讓我們像學(xué)習(xí)數(shù)學(xué)一樣,把一個(gè)個(gè)復(fù)雜的問題簡單化,再進(jìn)行累加/累積,從而得到結(jié)果。
同時(shí),函數(shù)式編程還有一塊大的領(lǐng)域需要進(jìn)入,即副作用處理。不處理副作用的程序是毫無意義的(僅在內(nèi)存中進(jìn)行計(jì)算),下篇我們將深入函數(shù)式編程的應(yīng)用,為我們的工程應(yīng)用發(fā)掘出更多的特性。
4. 作者簡介
俊杰,美團(tuán)到家研發(fā)平臺/醫(yī)藥技術(shù)部前端工程師。
編輯:黃飛
?
評論
查看更多