前端開(kāi)發(fā)函數(shù)式編程入門(mén)
函數(shù)式編程是一門(mén)古老的技術(shù),從上個(gè)世紀(jì)60年代Lisp語(yǔ)言誕生開(kāi)始,各種方言層出不窮。各種方言帶來(lái)欣欣向榮的生態(tài)的同時(shí),也給兼容性帶來(lái)很大麻煩。于是更種標(biāo)準(zhǔn)化工作也在不斷根據(jù)現(xiàn)有的實(shí)現(xiàn)去整理,比如Lisp就定義了Common Lisp規(guī)范,但是一大分支scheme是獨(dú)立的分支。另一種函數(shù)式語(yǔ)言ML,后來(lái)也標(biāo)準(zhǔn)化成Standard ML,但也攔不住另一門(mén)方言ocaml。后來(lái)的實(shí)踐干脆成立一個(gè)委員會(huì),定義一個(gè)通用的函數(shù)式編程語(yǔ)言,這就是Haskell。后來(lái)Haskell被函數(shù)式原教旨主義者認(rèn)為是純函數(shù)式語(yǔ)言,而Lisp, ML系都有不符合純函數(shù)式的地方。
不管純不純,函數(shù)式編程語(yǔ)言因?yàn)樾阅軉?wèn)題,一直影響其廣泛使用。直到單核性能在Pentium 4時(shí)代達(dá)到頂峰,單純靠提升單線(xiàn)程性能的免費(fèi)午餐結(jié)束,函數(shù)式編程語(yǔ)言因?yàn)槠涠嗑€(xiàn)程安全性再次火了起來(lái),先有Erlang,后來(lái)還有Scala, Clojure等。
函數(shù)式編程的思想也不斷影響著傳統(tǒng)編程語(yǔ)言,比如Java 8開(kāi)始支持lambda表達(dá)式,而函數(shù)式編程的大廈最初就是基于lambda計(jì)算構(gòu)建起來(lái)的。
不過(guò)比起后端用Java的同學(xué)對(duì)于函數(shù)式編程思想是可選的,對(duì)于前端同學(xué)變成了必選項(xiàng)。
前端同學(xué)為什么要學(xué)習(xí)函數(shù)式編程思想?
c
比如下面的類(lèi)繼承的方式更符合大多數(shù)學(xué)過(guò)面向?qū)ο缶幊趟枷胪瑢W(xué)的心智:
- class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; }}
但是,完全可以寫(xiě)成下面這樣的函數(shù)式的組件:
function Welcome(props) { return <h1>Hello, {props.name}</h1>;}
從React 16.8開(kāi)始,React Hooks的出現(xiàn),使得函數(shù)式編程思想越來(lái)越變得不可或缺。
比如通過(guò)React Hooks,我們可以這樣為函數(shù)組件增加一個(gè)狀態(tài):
- import React, { useState } from 'react';function Example() { // Declare a new state variable, which we'll call "count" const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );}
同樣我們可以使用useEffect來(lái)處理生命周期相關(guān)的操作,相當(dāng)于是處理ComponentDidMount:
- import React, { useState, useEffect } from 'react';function Example() { const [count, setCount] = useState(0); // Similar to componentDidMount and componentDidUpdate: useEffect(() => { // Update the document title using the browser API document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );}
那么,useState, useEffect之類(lèi)的API跟函數(shù)式編程有什么關(guān)系呢?
我們可以看下useEffect的API文檔:
Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.
Instead, use useEffect. The function passed to useEffect will run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.
所有的可變性、消息訂閱、定時(shí)器、日志等副作用不能使用在函數(shù)組件的渲染過(guò)程中。useEffect就是React純函數(shù)世界與命令式世界的通道。
當(dāng)我們用React寫(xiě)完了前端,現(xiàn)在想寫(xiě)個(gè)BFF的功能,發(fā)現(xiàn)serverless也從原本框架套類(lèi)的套娃模式變成了一個(gè)功能只需要一個(gè)函數(shù)了。下面是阿里云serverless HTTP函數(shù)的官方例子:
- var getRawBody = require('raw-body')module.exports.handler = var getRawBody = require('raw-body')module.exports.handler = function (request, response, context) { // get requset header var reqHeader = request.headers var headerStr = ' ' for (var key in reqHeader) { headerStr += key + ':' + reqHeader[key] + ' ' }; // get request info var url = request.url var path = request.path var queries = request.queries var queryStr = '' for (var param in queries) { queryStr += param + "=" + queries[param] + ' ' }; var method = request.method var clientIP = request.clientIP // get request body getRawBody(request, function (err, data) { var body = data // you can deal with your own logic here // set response var respBody = new Buffer('requestHeader:' + headerStr + '\n' + 'url: ' + url + '\n' + 'path: ' + path + '\n' + 'queries: ' + queryStr + '\n' + 'method: ' + method + '\n' + 'clientIP: ' + clientIP + '\n' + 'body: ' + body + '\n') response.setStatusCode(200) response.setHeader('content-type', 'application/json') response.send(respBody) })};
雖然沒(méi)有需要關(guān)注副作用之類(lèi)的要求,但是既然是用函數(shù)來(lái)寫(xiě)了,用函數(shù)式思想總比命令式的要好。
學(xué)習(xí)函數(shù)式編程的方法和誤區(qū)
如果在網(wǎng)上搜“如何學(xué)習(xí)函數(shù)式編程”,十有八九會(huì)找到要學(xué)習(xí)函數(shù)式編程最好從學(xué)習(xí)Haskell開(kāi)始的觀(guān)點(diǎn)。
然后很可能你就了解到那句著名的話(huà)”A monad is just a monoid in the category of endofunctors, what's the problem?“。
翻譯過(guò)來(lái)可能跟沒(méi)翻譯差不多:”一個(gè)單子(Monad)說(shuō)白了不過(guò)就是自函子范疇上的一個(gè)幺半群而已“。
別被這些術(shù)語(yǔ)嚇到,就像React在純函數(shù)式世界外給我們提供了useState, useEffect這些Hooks,就是幫我們解決產(chǎn)生副作用操作的工具。而函子Functor,單子Monad也是這樣的工具,或者可以認(rèn)為是設(shè)計(jì)模式。
Monad在Haskell中的重要性在于,對(duì)于IO這樣雖然基礎(chǔ)但是有副作用的操作,純函數(shù)的Haskell是無(wú)法用函數(shù)式方法來(lái)處理掉的,所以需要借助IO Monad。大部分其它語(yǔ)言沒(méi)有這么純,可以用非函數(shù)式的方法來(lái)處理IO之類(lèi)的副作用操作,所以上面那句話(huà)被笑稱(chēng)是Haskell用戶(hù)群的接頭暗號(hào)。
有范疇論和類(lèi)型論等知識(shí)做為背景,當(dāng)然會(huì)有助于從更高層次理解函數(shù)式編程。但是對(duì)于大部分前端開(kāi)發(fā)同學(xué)來(lái)講,這筆技術(shù)債可以先欠著,先學(xué)會(huì)怎么寫(xiě)代碼去使用可能是更好的辦法。前端開(kāi)發(fā)的計(jì)劃比較短,較難有大塊時(shí)間學(xué)習(xí),但是我們可以迭代式的進(jìn)步,最終是會(huì)殊途同歸的。
先把架式練好,用于代碼中解決實(shí)際業(yè)務(wù)問(wèn)題,比被困難嚇住還停留在命令式的思想上還是要強(qiáng)的。
函數(shù)式編程的精髓:無(wú)副作用
前端同學(xué)學(xué)習(xí)函數(shù)式編程的優(yōu)勢(shì)是React Hooks已經(jīng)將副作用擺在我們面前了,不用再解釋為什么要寫(xiě)無(wú)副用的代碼了。
無(wú)副作用的函數(shù)應(yīng)該符合下面的特點(diǎn):
要有輸入?yún)?shù)。如果沒(méi)有輸入?yún)?shù),這個(gè)函數(shù)拿不到任意外部信息,也就不用運(yùn)行了。
要有返回值。如果有輸入沒(méi)有返回值,又沒(méi)有副作用,那么這個(gè)函數(shù)白調(diào)了。
對(duì)于確定的輸入,有確定的輸出
做到這一點(diǎn),說(shuō)簡(jiǎn)單也簡(jiǎn)單,只要保持功能足夠簡(jiǎn)單就可以做到;說(shuō)困難也困難,需要改變寫(xiě)慣了命令行代碼的思路。
比如數(shù)學(xué)函數(shù)一般就是這樣的好例子,比如我們寫(xiě)一個(gè)算平方的函數(shù):
let sqr2 = function(x){ return x * x; }console.log(sqr2(200));
無(wú)副作用函數(shù)擁有三個(gè)巨大的好處:
可以進(jìn)行緩存。我們就可以采用動(dòng)態(tài)規(guī)劃的方法保存中間值,用來(lái)代替實(shí)際函數(shù)的執(zhí)行結(jié)果,大大提升效率。
可以進(jìn)行高并發(fā)。因?yàn)椴灰蕾?lài)于環(huán)境,可以調(diào)度到另一個(gè)線(xiàn)程、worker甚至其它機(jī)器上,反正也沒(méi)有環(huán)境依賴(lài)。
容易測(cè)試,容易證明正確性。不容易產(chǎn)生偶現(xiàn)問(wèn)題,也跟環(huán)境無(wú)關(guān),非常利于測(cè)試。
即使是跟有副作用的代碼一起工作,我們也可以在副作用代碼中緩存無(wú)副作用函數(shù)的值,可以將無(wú)副作用函數(shù)并發(fā)執(zhí)行。測(cè)試時(shí)也可以更重點(diǎn)關(guān)注有副作用的代碼以更有效地利用資源。
用函數(shù)的組合來(lái)代替命令的組合
會(huì)寫(xiě)無(wú)副作用的函數(shù)之后,我們要學(xué)習(xí)的新問(wèn)題就是如何將這些函數(shù)組合起來(lái)。
比如上面的sqr2函數(shù)有個(gè)問(wèn)題,如果不是number類(lèi)型,計(jì)算就會(huì)出錯(cuò)。按照命令式的思路,我們可能就直接去修改sqr2的代碼,比如改成這樣:
- let sqr2 = function(x){ if (typeof x === 'number'){ return x * x; }else{ return 0; }}
但是,sqr2的代碼已經(jīng)測(cè)好了,我們能不能不改它,只在它外面進(jìn)行判斷?
是的,我們可以這樣寫(xiě):
- let isNum = function(x){ if (typeof x === 'number'){ return x; }else{ return 0; }}console.log(sqr2(isNum("20")));
或者是我們?cè)谠O(shè)計(jì)sqr2的時(shí)候就先預(yù)留出來(lái)一個(gè)預(yù)處理函數(shù)的位置,將來(lái)要升級(jí)就換這個(gè)預(yù)處理函數(shù),主體邏輯不變:
- let sqr2_v3 = function(fn, x){ let y = fn(x); return y * y; }console.log((sqr2_v3(isNum,1.1)));
嫌每次都寫(xiě)isNum煩,可以定義個(gè)新函數(shù),把isNum給寫(xiě)死進(jìn)去:
- let sqr2_v4 = function(x){ return sqr2_v3(isNum,x);}console.log((sqr2_v4(2.2)));
用容器封裝函數(shù)能力
現(xiàn)在,我們想重用這個(gè)isNum的能力,不光是給sqr2用,我們想給其它數(shù)學(xué)函數(shù)也增加這個(gè)能力。
比如,如果給Math.sin計(jì)算undefined會(huì)得到一個(gè)NaN:
console.log(Math.sin(undefined));
這時(shí)候我們需要用面向?qū)ο蟮乃季S了,將isNum的能力封裝到一個(gè)類(lèi)中:
- class MayBeNumber{ constructor(x){ this.x = x; } map(fn){ return new MayBeNumber(fn(isNum(this.x))); } getValue(){ return this.x; }}
這樣,我們不管拿到一個(gè)什么對(duì)象,用其構(gòu)造一個(gè)MayBeNumber對(duì)象出來(lái),再調(diào)用這個(gè)對(duì)象的map方法去調(diào)用數(shù)學(xué)函數(shù),就自帶了isNum的能力。
我們先看調(diào)用sqr2的例子:
- let num1 = new MayBeNumber(3.3).map(sqr2).getValue();console.log(num1);let notnum1 = new MayBeNumber(undefined).map(sqr2).getValue();console.log(notnum1);
我們可以將sqr2換成Math.sin:
- let notnum2 = new MayBeNumber(undefined).map(Math.sin).getValue();console.log(notnum2);
可以發(fā)現(xiàn),輸出值從NaN變成了0.
封裝到對(duì)象中的另一個(gè)好處是我們可以用"."多次調(diào)用了,比如我們想調(diào)兩次算4次方,只要在.map(sqr2)之后再來(lái)一個(gè).map(sqr2)
- let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();console.log(num3);
使用對(duì)象封裝之后的另一個(gè)好處是,函數(shù)嵌套調(diào)用跟命令式是相反的順序,而用map則與命令式一致。
如果不理解的話(huà)我們來(lái)舉個(gè)例子,比如我們想求sin(1)的平方,用函數(shù)調(diào)用應(yīng)該先寫(xiě)后執(zhí)行的sqr2,后寫(xiě)先執(zhí)行的Math.sin:
console.log(sqr2(Math.sin(1)));
而調(diào)用map就跟命令式一樣了:
- let num4 = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();console.log(num4);
用 of 來(lái)封裝 new
封裝到對(duì)象中,看起來(lái)還不錯(cuò),但是函數(shù)式編程還搞出來(lái)new對(duì)象再map,為什么不能構(gòu)造對(duì)象時(shí)也用個(gè)函數(shù)呢?
這好辦,我們給它定義個(gè)of方法吧:
MayBeNumber.of = function(x){ return new MayBeNumber(x);}
下面我們就可以用of來(lái)構(gòu)造MayBeNumber對(duì)象啦:
- let num5 = MayBeNumber.of(1).map(Math.cos).getValue();console.log(num5);let num6 = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();console.log(num6);
有了of之后,我們也可以給map函數(shù)升升級(jí)。
之前的isNum有個(gè)問(wèn)題,如果是非數(shù)字的話(huà),其實(shí)沒(méi)必要賦給個(gè)0再去調(diào)用函數(shù),直接返回個(gè)0就好了。
之前我們一直沒(méi)寫(xiě)過(guò)箭頭函數(shù),順手寫(xiě)一寫(xiě):
isNum2 = x => typeof x === 'number';
map用isNum2和of改寫(xiě)下:
- map(fn){ if (isNum2(this.x)){ return MayBeNumber.of(fn(this.x)); }else{ return MayBeNumber.of(0); } }
我們?cè)賮?lái)看下另一種情況,我們處理返回值的時(shí)候,如果有Error,就不處理Ok的返回值,可以這么寫(xiě):
- class Result{ constructor(Ok, Err){ this.Ok = Ok; this.Err = Err; } isOk(){ return this.Err === null || this.Err === undefined; } map(fn){ return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err)); }}Result.of = function(Ok, Err){ return new Result(Ok, Err);}console.log(Result.of(1.2,undefined).map(sqr2));
輸出結(jié)果為:
Result { Ok: 1.44, Err: undefined }
我們來(lái)總結(jié)下前面這種容器的設(shè)計(jì)模式:
有一個(gè)用于存儲(chǔ)值的容器
這個(gè)容器提供一個(gè)map函數(shù),作用是map函數(shù)使其調(diào)用的函數(shù)可以跟容器中的值進(jìn)行計(jì)算,最終返回的還是容器的對(duì)象
我們可以把這個(gè)設(shè)計(jì)模式叫做Functor函子。
如果這個(gè)容器還提供一個(gè)of函數(shù)將值轉(zhuǎn)換成容器,那么它叫做Pointed Functor.
比如我們看下js中的Array類(lèi)型:
let aa1 = Array.of(1);console.log(aa1);console.log(aa1.map(Math.sin));
它支持of函數(shù),它還支持map函數(shù)調(diào)用Math.sin對(duì)Array中的值進(jìn)行計(jì)算,map的結(jié)果仍然是一個(gè)Array。
那么我們可以說(shuō),Array是一個(gè)Pointed Functor.
簡(jiǎn)化對(duì)象層級(jí)
有了上面的Result結(jié)構(gòu)了之后,我們的函數(shù)也跟著一起升級(jí)。如果是數(shù)值的話(huà),Ok是數(shù)值,Err是undefined。如果非數(shù)值的話(huà),Ok是undefined,Err是0:
- let sqr2_Result = function(x){ if (isNum2(x)){ return Result.of(x*x, undefined); }else{ return Result.of(undefined,0); }}
我們調(diào)用這個(gè)新的sqr2_Result函數(shù):
console.log(Result.of(4.3,undefined).map(sqr2_Result));
返回的是一個(gè)嵌套的結(jié)果:
Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }
我們需要給Result對(duì)象新加一個(gè)join函數(shù),用來(lái)獲取子Result的值給父Result:
- join(){ if (this.isOk()) { return this.Ok; }else{ return this.Err; } }
我們調(diào)用的時(shí)候最后加上調(diào)用這個(gè)join:
console.log(Result.of(4.5,undefined).map(sqr2_Result).join());
嵌套的結(jié)果變成了一層的:
Result { Ok: 20.25, Err: undefined }
每次調(diào)用map(fn).join()兩個(gè)寫(xiě)起來(lái)麻煩,我們定義一個(gè)flatMap函數(shù)一次性處理掉:
flatMap(fn){ return this.map(fn).join(); }
調(diào)用方法如下:
console.log(Result.of(4.7,undefined).flatMap(sqr2_Result));
結(jié)果如下:
Result { Ok: 22.090000000000003, Err: undefined }
我們最后完整回顧下這個(gè)Result:
- class Result{ constructor(Ok, Err){ this.Ok = Ok; this.Err = Err; } isOk(){ return this.Err === null || this.Err === undefined; } map(fn){ return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err)); } join(){ if (this.isOk()) { return this.Ok; }else{ return this.Err; } } flatMap(fn){ return this.map(fn).join(); }}Result.of = function(Ok, Err){ return new Result(Ok, Err);}
不嚴(yán)格地講,像Result這種實(shí)現(xiàn)了flatMap功能的Pointed Functor,就是傳說(shuō)中的Monad.
偏函數(shù)和高階函數(shù)
在前面各種函數(shù)式編程模式中對(duì)函數(shù)的用法熟悉了之后,回來(lái)我們總結(jié)下函數(shù)式編程與命令行編程體感上的最大區(qū)別:
函數(shù)是一等公式,我們應(yīng)該熟悉變量中保存函數(shù)再對(duì)其進(jìn)行調(diào)用
函數(shù)可以出現(xiàn)在返回值里,最重要的用法就是把輸入是n(n>2)個(gè)參數(shù)的函數(shù)轉(zhuǎn)換成n個(gè)1個(gè)參數(shù)的串聯(lián)調(diào)用,這就是傳說(shuō)中的柯里化。這種減少了參數(shù)的新函數(shù),我們稱(chēng)之為偏函數(shù)
函數(shù)可以用做函數(shù)的參數(shù),這樣的函數(shù)稱(chēng)為高階函數(shù)
偏函數(shù)可以當(dāng)作是更靈活的參數(shù)默認(rèn)值。
比如我們有個(gè)結(jié)構(gòu)叫spm,由spm_a和spm_b組成。但是一個(gè)模塊中spm_a是固定的,大部分時(shí)候只需要指定spm_b就可以了,我們就可以寫(xiě)一個(gè)偏函數(shù):
- const getSpm = function(spm_a, spm_b){ return [spm_a, spm_b];}const getSpmb = function(spm_b){ return getSpm(1000, spm_b);}console.log(getSpmb(1007));
高階函數(shù)我們?cè)谇懊娴膍ap和flatMap里面已經(jīng)用得很熟了。但是,其實(shí)高階函數(shù)值得學(xué)習(xí)的設(shè)計(jì)模式還不少。
比如給大家出一個(gè)思考題,如何用函數(shù)式方法實(shí)現(xiàn)一個(gè)只執(zhí)行一次有效的函數(shù)?
不要用全局變量啊,那不是函數(shù)式思維,我們要用閉包。
once是一個(gè)高階函數(shù),返回值是一個(gè)函數(shù),如果done是false,則將done設(shè)為true,然后執(zhí)行fn。done是在返回函數(shù)的同一層,所以會(huì)被閉包記憶獲取到:
- const once = (fn) => { let done = false; return function() { return done ? undefined : ((done=true), fn.apply(this,arguments)); }}let init_data = once( () => { console.log("Initialize data"); });init_data();init_data();
我們可以看到,第二次調(diào)用init_data()沒(méi)有發(fā)生任何事情。
遞歸與記憶
前面介紹了這么多,但是函數(shù)編程其實(shí)還蠻復(fù)雜的,比如說(shuō)涉及到遞歸。
遞歸中最簡(jiǎn)單的就是階乘了吧:
- let factorial = (n) => { if (n===0){ return 1; } return n*factorial(n-1);}console.log(factorial(10));
但是我們都知道,這樣做效率很低,會(huì)重復(fù)計(jì)算好多次。應(yīng)該采用動(dòng)態(tài)規(guī)劃的辦法。
那么如何在函數(shù)式編程中使用動(dòng)態(tài)規(guī)劃,換句話(huà)說(shuō)我們?nèi)绾伪4嬉呀?jīng)計(jì)算過(guò)的值?
想必經(jīng)過(guò)上一節(jié)學(xué)習(xí),大家肯定想到要用閉包,沒(méi)錯(cuò),我們可以封裝一個(gè)叫memo的高階函數(shù)來(lái)實(shí)現(xiàn)這個(gè)功能:
- const memo = (fn) => { const cache = {}; return (arg) => cache[arg] || (cache[arg] = fn(arg));}
邏輯很簡(jiǎn)單,返回值是lamdba表達(dá)式,它仍然支持閉包,所以我們?cè)谄渫瑢佣x一個(gè)cache,然后如果cache中的某項(xiàng)為空則計(jì)算并保存之,如果已經(jīng)有了就直接使用。
這個(gè)高階函數(shù)很好用,階乘的邏輯不用改,只要放到memo中就好了:
- let fastFact = memo( (n) => { if (n<=0){ return 1; }else{ return n * fastFact(n-1); } });
在本文即將結(jié)尾的時(shí)候,我們?cè)倩貧w到前端,React Hooks里面提供的useMemo,就是這樣的記憶機(jī)制:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
小結(jié)
綜上,我們希望大家能記住幾點(diǎn):
函數(shù)式編程的核心概念很簡(jiǎn)單,就是將函數(shù)存到變量里,用在參數(shù)里,用在返回值里
在編程時(shí)要時(shí)刻記住將無(wú)副作用與有副作用代碼分開(kāi)
函數(shù)式編程的原理雖然很簡(jiǎn)單,但是因?yàn)榇蠹伊?xí)慣了命令式編程,剛開(kāi)始學(xué)習(xí)時(shí)會(huì)有諸多不習(xí)慣,用多了就好了
函數(shù)式編程背后有其數(shù)學(xué)基礎(chǔ),在學(xué)習(xí)時(shí)可以先不要管它,當(dāng)成設(shè)計(jì)模式學(xué)習(xí)。等將來(lái)熟悉之后,還是建議去了解下背后的真正原理