碼字辛苦昭伸,個人原創(chuàng),轉(zhuǎn)載請注明作者及出處澎迎。謝謝合作庐杨!
本文描述了 JavaScript 函數(shù)式編程的若干重要特征,以及一些好的實踐建議夹供。意在引導以前是非函數(shù)式編程的同學灵份,能快速切入到函數(shù)式編程的理念中來;而對于正在“函數(shù)式”的同學哮洽,也可鞏固認識填渠,同時也希望提出意見交流。
另外,本文略長氛什,只消了解 ES6
莺葫,就無閱讀困難,請讀者耐心閱讀枪眉。
背景介紹
關(guān)于函數(shù)式編程的起源捺檬,有一段這樣“不接地氣”的歷史。
在眾多光芒萬丈的一群人之中贸铜,有一位叫阿隆佐堡纬。他設(shè)計了一個名為 lambda 演算的形式系統(tǒng)。這個系統(tǒng)實質(zhì)上是為其中一個超級機器設(shè)計的編程語言蒿秦。在這種語言里面烤镐,函數(shù)的參數(shù)是函數(shù),返回值也是函數(shù)棍鳖。
除了阿隆佐·邱奇炮叶,艾倫·圖靈也在進行類似的研究。他設(shè)計了一種完全不同的系統(tǒng)(后來被稱為 圖靈機)鹊杖,并用這種系統(tǒng)得出了和阿隆佐相似的答案悴灵。到了后來人們證明了圖靈機和 lambda 演算的能力是一樣的扛芽。
由于二戰(zhàn)的推動骂蓖,1949 年,現(xiàn)實世界中率先誕生了第一臺圖靈機川尖,相比之下登下,運行阿隆佐的 lambda 演算硬件(Lisp 機)到了1973 年才得以實現(xiàn),這還得歸功于 MIT叮喳。
引用這段歷史說明說明什么呢被芳?說明函數(shù)式編程很有來頭。
一個簡單易懂的模型
我們的程序本質(zhì)上都可以描述為:輸入數(shù)據(jù) => 運算處理 => 輸出數(shù)據(jù)
I => {... f ...} => O
- 輸入數(shù)據(jù)并不復雜馍悟,只要給它一定的結(jié)構(gòu)就好了
- 輸出數(shù)據(jù)也不復雜畔濒,因為復雜的數(shù)據(jù)并不是我們想要的
所以兩端簡單,中間很復雜锣咒。所有這些復雜的過程都交給函數(shù) f
侵状。如果一個函數(shù) f
太過膨脹,或者無法勝任毅整,那就用 n
個函數(shù)來分擔解決趣兄。
I => {... f1 => f2 => f3 => ... => fn ...} => O
對于上述模型,請讀者只消專注于函數(shù)及函數(shù)彼此的關(guān)系悼嫉,并將數(shù)據(jù)屏蔽在視線之外艇潭。
其他編程風格
函數(shù)式編程和語言無關(guān),它只不過是一種編程風格(再直白點就是一種思維方式,一種代碼組織習慣)而已蹋凝。只要有函數(shù)的語言鲁纠,幾乎都能進行函數(shù)式編程。只不過有些語言鳍寂,天生就能做到更純粹而已房交。
作為編程風格,我們常見的還有以下這些伐割。
1候味、面向?qū)ο缶幊蹋∣OP)
面向?qū)ο髲娬{(diào)數(shù)據(jù)與行為綁定。
2隔心、命令式編程(CP)
數(shù)據(jù)與行為深度耦合白群。
3、聲明式編程(DP?)
我們常見的 SQL 數(shù)據(jù)庫操作語言硬霍,便是聲明式編程的典范帜慢。這篇 文章 已經(jīng)講的足夠清楚了。
4唯卖、函數(shù)式編程(FP)
數(shù)據(jù)和行為是解耦的粱玲。函數(shù)式編程屬于聲明式編程。
5拜轨、圖形化編程(GP?)
MIT 的 Scratch 是一款典型的圖形化編程語言抽减。
到此,我們?nèi)匀粺o需理會上面提到的種種概念橄碾。等 JSer 們刷完新聞卵沉,沖上了一杯咖啡,才開始言歸正傳法牲。
函數(shù)式編程的關(guān)鍵特征
首先史汗,函數(shù)式編程是不是 “燒腦” 編程?對我們普羅大眾來說拒垃,或許還輪不到 “燒腦”停撞,要燒也是那些可敬的布道師們幫我們頂替了。
也就是說函數(shù)式編程似難也不難悼瓮,那該如何學習函數(shù)式編程呢戈毒?
在筆者看來,仍然可以采用 “黑盒子” 學習方法谤牡,我們先從它的一些關(guān)鍵特征入手副硅,而有意的屏蔽一些底層而復雜的知識。
純函數(shù)
純函數(shù)是函數(shù)式編程的第一重要特征翅萤。它有兩條原則:
- 對于相同的輸入恐疲,一定有相同的輸出腊满;
- 對輸入的東西,不要改變它培己,對引用的東西碳蛋,也不要改變它。
第一條好說省咨,第二條就是所謂的無 “副作用”肃弟。
我們常常所寫的不純的函數(shù),基本上都是副作用滿天飛。比如下面的 “副作用” 的例子。
let arr = [1, 2, 3, 4, 5, 6];
// slice 是一個純函數(shù)
arr.slice(0, 3);
// =>[1, 2, 3]
arr.slice(0, 3);
// =>[1, 2, 3]
// splice 是一個不純的函數(shù)
arr.splice(0, 3);
// => [1, 2, 3]
arr.splice(0, 3);
// => [4, 5, 6]
上述示例淀弹,slice
函數(shù)只要輸入是 (0, 3)
無論執(zhí)行多少次,返回值恒為 [1, 2, 3]
;
但是 splice
函數(shù)相同的輸入箩兽,執(zhí)行 2 遍,返回的值就不同了章喉。原因是 splice
每次執(zhí)行汗贫,額外的改變了(破壞了)數(shù)組 arr
。這就是副作用秸脱。
再看一個副作用的例子:
let temperature = 35;
function check(t) {
// 副作用1
return t > temperature;
}
function monitor(day) {
// 副作用2
if(check(day.temperature)){
console.warn('High temperature warning!');
}
}
短短的幾行代碼落包,就有 2 處副作用。
副作用 1 因為依賴了外部的系統(tǒng)變量 temperature
, 一旦別處導致這個系統(tǒng)變量變化(這是難以說清的事)摊唇,那么這個 check
函數(shù)就不滿足相同輸入恒有相同輸出了咐蝇。
副作用 2 盡管 monitor
滿足相同輸入恒輸出 undefined
, 但它仍然依賴了外部變量 check
函數(shù),仍然可能有未知事情發(fā)生遏片。
副作用帶給我們的麻煩是很多的嘹害,除了每次得小心翼翼撮竿,更為麻煩的事是吮便,一旦系統(tǒng)變量改變,因為跨度太大幢踏,問題將很難定位髓需。
如何消除 “副作用”,其實非常容易:
const TEMPERATURE = 35;
function check(t) {
// 最好的做法是將變量 TEMPERATURE 收入函數(shù)體保護起來
return t > TEMPERATURE;
}
function monitor(check, day) {
if(check(day.temperature)){
console.warn('High temperature warning!');
}
}
減少副作用房蝉,其實不僅是函數(shù)式編程的要求僚匆,在我們?nèi)粘>幊讨幸矐撆囵B(yǎng)這樣的代碼習慣。很多優(yōu)秀的技術(shù)框架也在遵循著這一原則搭幻。
Redux 技術(shù)思想就提倡無副作用的純函數(shù)咧擂,這點從 reducer
的設(shè)計就體現(xiàn)出來了。當然檀蹋,React 本身也包含很多函數(shù)式編程思想松申,在此就不去展開了。
一些 I/O
是天生自帶副作用的,正如上文所提到的贸桶,這部分我們有一些特殊的處理辦法舅逸。JavaScript 天然存在而且還相當隱晦的副作用就是 this
,下文會介紹到它皇筛。除此之外琉历,JavaScript 很多的副作用都是可以避免的,關(guān)鍵是培養(yǎng)好避免副作用的習慣水醋。
柯里化 curry
柯里化的主要思路:
“函數(shù)接收多個參數(shù)旗笔,一次調(diào)用" 轉(zhuǎn)變成 "函數(shù)每次只接收一個參數(shù),分多次調(diào)用”拄踪。
簡言之换团,就是將多維變成一維。
curry:: f(x1, x2, ...xn) = f(x1)(x2)(...xn)
用具體函數(shù)舉例就很容易理解了宫蛆。
// 柯里化之前
let distance = function(x, y, z){
return Math.sqrt(x*x + y*y + z*z);
}
distance(1, 4, 8);
// => 9
// 柯里化之后
let distance_curried = function(x){
return function(y){
return function(z){
return Math.sqrt(x*x + y*y + z*z);
}
}
}
// 分多次調(diào)用
var xDistance = distance_curried(1);
var xyDistance = xDistance(4);
var myDistance = xyDistance(8);
// => 9
// 簡寫為
distance_curried(1)(4)(8)
// => 9
柯里化一個函數(shù)的結(jié)果艘包,就是新生成的函數(shù),每次傳一個參數(shù)耀盗,執(zhí)行后返回的仍是一個函數(shù)想虎,直至返回最后結(jié)果。
換言之叛拷,函數(shù)每次只接收一個參數(shù)舌厨,執(zhí)行后,就返回一個新函數(shù)處理剩余的參數(shù)忿薇。
至于柯里化算法怎么實現(xiàn)的裙椭,這里不去追究。正如前文介紹的署浩,函數(shù)式編程是一種聲明式編程揉燃,只管做什么,不管怎么做筋栋。因此炊汤,只需知道柯里化做的是分多次調(diào)用,但不管它是怎么做到的弊攘。
約定:函數(shù)在前抢腐,數(shù)據(jù)在后
這是一條重要約定。約定了作為參數(shù)時襟交,函數(shù)們在前迈倍,數(shù)據(jù)在最后。
首先捣域,它強調(diào)了函數(shù)的地位啼染,準確的說是我們編程習慣中的地位——函數(shù)應該站在前排醋界。
其次,數(shù)據(jù)是我們最后考慮的東西提完,我們始終關(guān)注 “映射邏輯” 本身的建設(shè)形纺。
再次,約定這樣的參數(shù)順序徒欣,某些函數(shù)經(jīng)柯里化之后逐样,不至于會搞不清楚本次調(diào)用是該傳函數(shù)還是該傳數(shù)據(jù)。
從下面的示例打肝,來看看我們?nèi)绾稳プ裱@條重要約定脂新。
// 1、將數(shù)組 filter 方法封裝一下
let arrFilter = function(f, arr) {
return arr.filter(f);
}
// 2粗梭、柯里化
let filter = curry(arrFilter);
//結(jié)束争便,就這么簡單
// 第一次調(diào)用
let filterSpaces = filter(hasSpaces);
//插一個問題:請問 hasSpaces 是個啥?
// 對断医,回答它是個函數(shù)滞乙,一定是沒錯的
// 因為函數(shù)式編程的世界全是函數(shù)嘛~
let hasSpaces = (val) => /\s+/g.test(val);
// 第二次調(diào)用
filterSpaces(['jeremy', 'jere my'])
// => ['jere my']
函數(shù)式編程的世界遍地都是函數(shù),尤其是一個函數(shù)柯里化后鉴嗤,幾乎絕大部分函數(shù)的執(zhí)行結(jié)果斩启,仍然是一個函數(shù)。
這仍然可以尋跡阿隆佐當時提出的 “在這種語言里面醉锅,函數(shù)的參數(shù)是函數(shù)兔簇,返回值也是函數(shù)”。
所以硬耍,忘掉煩惱吧垄琐,忘掉與副作用糾纏打斗的記憶吧,現(xiàn)在滿地都是白花花经柴、金燦燦的函數(shù)狸窘。
組合
兩個函數(shù)組合之后返回了一個新函數(shù)。就這么簡單口锭!
var fnC = compose(fnA, fnB);
組合 (compose
) 是函數(shù)式編程的一個重要概念朦前,有了它,就可以任意 “擺布” 函數(shù)了鹃操。
var first = (x) => x[0];
var reverse = reduce((acc, x) => [x].concat(acc), []);
// 組合后生成一個新函數(shù)
var last = compose(first, reverse);
// 新函數(shù)開始吃進數(shù)據(jù)
last(['jeremy', 'hello', 'world']);
// => 'world'
// 要是反過來組合
var reverse_one = compose(reverse, first);
// 新函數(shù)開始吃進相同數(shù)據(jù)
reverse_one(['jeremy', 'hello', 'world']);
// => Uncaught TypeError: reverse is not a function
可見,組合內(nèi)的參數(shù)順序不能隨意置換和顛倒春哨。
組合滿足結(jié)合律
組合中處理的全是函數(shù)荆隘,且 compose
中作為參數(shù)的函數(shù),是從右往左依次調(diào)用赴背,即最靠后的函數(shù)被優(yōu)先執(zhí)行(先進后出)椰拒。
compose(f, compose(g, h))
依次從右向左調(diào)用晶渠,即 h() -> g() -> f()
由此組合的結(jié)合律是:
compose(f, compose(g, h)) == compose(compose(f , g), h)
組合的結(jié)合律是相鄰參數(shù)兩兩組合,并沒有顛倒參數(shù)順序燃观。
注意褒脯,Ramda.js 的 R.pipe
則是從左往右執(zhí)行函數(shù)組合(先進先出),但這是另外一碼事缆毁。
組合也有好的實踐
讓組合可重用度高就是好的組合實踐番川。
結(jié)合律的一大好處是任何一個函數(shù)分組都可以被拆開來,然后再以它們自己的組合方式兩兩組合在一起脊框。
compose(addSymbol, toUpperCase, first, reverse)
拆解 & 組合 1:
var last = compose(first, reverse);
var symboledUpperLast = compose(addSymbol, toUpperCase, last);
拆解 & 組合 2:
var last = compose(first, reverse);
var upperLast = compose(toUpperCase, last);
var symboledUpperLast = compose(addSymbol, upperLast);
拆解 & 組合 3:
var last = compose(first, reverse);
var symboledUpper = compose(addSymbol, toUpperCase);
var symboledUpperLast = compose(symboledUpper, last);
誰的可重用性高颁督,感覺是第 3 種,也說不準浇雹,得有更多的實際需求沉御,才能判斷這事。
范疇學
范疇學 是組合的理論依據(jù)昭灵。它和集合論吠裆,函數(shù)理論都有很多相關(guān)概念。概念本身也不難理解烂完,此處不贅敘硫痰。
其它特征 - pointfree
函數(shù)無須提及將要操作的數(shù)據(jù)是什么樣的。
阮大大的文章講解得非常細致窜护。其實 pointfree 也不是什么復雜的概念效斑,運用一等公民函數(shù)、柯里化(curry)以及組合這些武器柱徙,就很容易實現(xiàn)這個目標缓屠。
敲黑板強調(diào) - 全部都是函數(shù)
如果每一個函數(shù)都是一個兵,那全城皆兵护侮。草木仍然是草木敌完,草木...呃,是數(shù)據(jù)羊初。
無論柯里化(curry
)滨溉,還是組合(compose
),都是面向于函數(shù)长赞,最后生成一個函數(shù)晦攒,任何時候,你見到的幾乎都是函數(shù)得哆,函數(shù)時刻待命脯颜。
let stylity = compose(map(addSymbol), reverse);
其實本條不算是特征,到算作一條反復洗腦的 “碎碎念”贩据。addSymbol
是一個函數(shù)栋操,map(addSymbol)
運算后是一個函數(shù)闸餐,最后的結(jié)果 stylity
仍然是一個函數(shù)。
函數(shù)式編程的一些好的實踐
這些好的實踐矾芙,并不是函數(shù)式編程所專有的舍沙,但是有助于加深對函數(shù)式編程風格的理解。同時剔宪,它們應該貫穿在我們設(shè)計拂铡、代碼之中。實踐得多了歼跟,我們也就更容易過渡到函數(shù)式編程和媳。
等價替換
var hi = function(name){ return "Hi " + name; };
var greeting = function(name) {
return hi(name);
};
// 等價
greeting = hi;
因為函數(shù)是純的,不會有副作用哈街。那么接收相同的輸入留瞳,返回相同的輸出,兩個函數(shù)就是等價的骚秦。
既然等價她倘,為啥還要多一層裹腳布?所以直接賦值相等即可作箍。
但在布滿地雷的非函數(shù)式編程中硬梁,不純的函數(shù),等價替換往往需要很慎重胞得。
“包裹它”不如“暴露它”
包裹一個函數(shù)荧止,不如直接把它暴露成參數(shù)。因為這符合強調(diào)函數(shù)地位的要求阶剑。
$.get('/path/fp', function(json){
return renderGet(json);
});
以上是一個常用 ajax
的運用跃巡。更為常見的要求是,如果有報錯牧愁,那得增加一個 error
參數(shù)素邪,我們繼續(xù)參考 nodejs
將錯誤參數(shù)放在第一個參數(shù)位置的約定,做出以下調(diào)整:
$.get('/path/fp', function(error, json){
return renderGet(error, json);
});
這是自然想到的修改方案猪半,但是也面臨著還得修改 renderGet
函數(shù)的麻煩兔朦,如果有多處這樣使用,那得多處修改磨确。
如果沽甥,僅僅遵循一條原則(養(yǎng)成思維習慣就好了)——突出函數(shù)的地位,增加函數(shù)的曝光度俐填,那就會有這樣的修改思路:
$.get('/path/fp', renderGet);
這樣的好處是安接,無論要求 renderGet
函數(shù)修改改成什么樣的參數(shù)形式,都只限制在這個函數(shù)本身了英融。
順便提一下的是盏檐,一些 API 設(shè)計中,在設(shè)計傳參數(shù)時驶悟,指明傳遞一般參數(shù)胡野,不如指明傳遞一個函數(shù)。
解耦函數(shù)痕鳍,函數(shù)名稱請通用化
寫業(yè)務(wù)邏輯時硫豆,有些中間函數(shù)或者輔助會被提取出來,此時的命名一般會和業(yè)務(wù)耦合笼呆。等到相關(guān)代碼都寫完后熊响,或者你在做 codeview 時,你會發(fā)現(xiàn)它和業(yè)務(wù)其實是可以解耦的诗赌。那么當時的那種基于業(yè)務(wù)上下文思考的函數(shù)命名汗茄,就完全可以改成一般化的命名,讓它從名字上看就顯得是通用的铭若。
在命名的時候洪碳,我們特別容易把自己限定在特定的數(shù)據(jù)上。這種現(xiàn)象很常見叼屠,也是重復造輪子的一大原因瞳腌。
函數(shù)式編程更多的專注在函數(shù)身上,它有著比較徹底的函數(shù)與數(shù)據(jù)解耦镜雨,所以壓根不會有這么強的數(shù)據(jù)耦合嫂侍。但這一條實踐,也值得我們一般式編程借鑒荚坞。
避免 this 的副作用
let Sound = {
_sound: 'miao',
play() {
console.log(this._sound);
}
}
上面是一個非常常見的示例挑宠,如果遵循了函數(shù)是一等公民、包裹它不如暴露它 等等這些理念或建議西剥,那么在需要的時候痹栖, play
方法就應該被當作另一個函數(shù)的參數(shù)。比如:
$.ajaxSuccess(Sound.paly);
因為 Sound.paly
函數(shù)中使用了 this
瞭空,而它指向了函數(shù)外部即調(diào)用上下文揪阿。從純函數(shù)定義的角度看,this
就是一塊最大的 “副作用"咆畏。
解決的辦法大家都知道南捂,就是將 this
鎖在籠子里,如同將權(quán)力之手鎖在籠子里一樣旧找。
$.ajaxSuccess(Sound.play.bind(this));
而事實上溺健,但在函數(shù)式編程中根本用不到它。
結(jié)語
說了這么多钮蛛,關(guān)于函數(shù)式編程鞭缭,以上最重要的兩點就是:
- 函數(shù)是一等公民剖膳,要時刻把函數(shù)放在參數(shù)位置
- 每一個函數(shù)盡量是無副作用的純函數(shù)
至于那些底層的、高級的岭辣、數(shù)學的邏輯吱晒,就把它們統(tǒng)統(tǒng)先關(guān)在 “黑盒子” 里吧。