前端開發(fā)函數(shù)式編程入門
函數(shù)式編程是一門古老的技術(shù),從上個世紀60年代Lisp語言誕生開始,各種方言層出不窮。各種方言帶來欣欣向榮的生態(tài)的同時,也給兼容性帶來很大麻煩。于是更種標準化工作也在不斷根據(jù)現(xiàn)有的實現(xiàn)去整理,比如Lisp就定義了Common Lisp規(guī)范,但是一大分支scheme是獨立的分支。另一種函數(shù)式語言ML,后來也標準化成Standard ML,但也攔不住另一門方言ocaml。后來的實踐干脆成立一個委員會,定義一個通用的函數(shù)式編程語言,這就是Haskell。后來Haskell被函數(shù)式原教旨主義者認為是純函數(shù)式語言,而Lisp, ML系都有不符合純函數(shù)式的地方。
不管純不純,函數(shù)式編程語言因為性能問題,一直影響其廣泛使用。直到單核性能在Pentium 4時代達到頂峰,單純靠提升單線程性能的免費午餐結(jié)束,函數(shù)式編程語言因為其多線程安全性再次火了起來,先有Erlang,后來還有Scala, Clojure等。
函數(shù)式編程的思想也不斷影響著傳統(tǒng)編程語言,比如Java 8開始支持lambda表達式,而函數(shù)式編程的大廈最初就是基于lambda計算構(gòu)建起來的。
不過比起后端用Java的同學(xué)對于函數(shù)式編程思想是可選的,對于前端同學(xué)變成了必選項。
前端同學(xué)為什么要學(xué)習函數(shù)式編程思想?
c
比如下面的類繼承的方式更符合大多數(shù)學(xué)過面向?qū)ο缶幊趟枷胪瑢W(xué)的心智:
- class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; }}
但是,完全可以寫成下面這樣的函數(shù)式的組件:
function Welcome(props) { return <h1>Hello, {props.name}</h1>;}
從React 16.8開始,React Hooks的出現(xiàn),使得函數(shù)式編程思想越來越變得不可或缺。
比如通過React Hooks,我們可以這樣為函數(shù)組件增加一個狀態(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來處理生命周期相關(guān)的操作,相當于是處理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之類的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ù)組件的渲染過程中。useEffect就是React純函數(shù)世界與命令式世界的通道。
當我們用React寫完了前端,現(xiàn)在想寫個BFF的功能,發(fā)現(xiàn)serverless也從原本框架套類的套娃模式變成了一個功能只需要一個函數(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) })};
雖然沒有需要關(guān)注副作用之類的要求,但是既然是用函數(shù)來寫了,用函數(shù)式思想總比命令式的要好。
學(xué)習函數(shù)式編程的方法和誤區(qū)
如果在網(wǎng)上搜“如何學(xué)習函數(shù)式編程”,十有八九會找到要學(xué)習函數(shù)式編程最好從學(xué)習Haskell開始的觀點。
然后很可能你就了解到那句著名的話”A monad is just a monoid in the category of endofunctors, what's the problem?“。
翻譯過來可能跟沒翻譯差不多:”一個單子(Monad)說白了不過就是自函子范疇上的一個幺半群而已“。
別被這些術(shù)語嚇到,就像React在純函數(shù)式世界外給我們提供了useState, useEffect這些Hooks,就是幫我們解決產(chǎn)生副作用操作的工具。而函子Functor,單子Monad也是這樣的工具,或者可以認為是設(shè)計模式。
Monad在Haskell中的重要性在于,對于IO這樣雖然基礎(chǔ)但是有副作用的操作,純函數(shù)的Haskell是無法用函數(shù)式方法來處理掉的,所以需要借助IO Monad。大部分其它語言沒有這么純,可以用非函數(shù)式的方法來處理IO之類的副作用操作,所以上面那句話被笑稱是Haskell用戶群的接頭暗號。
有范疇論和類型論等知識做為背景,當然會有助于從更高層次理解函數(shù)式編程。但是對于大部分前端開發(fā)同學(xué)來講,這筆技術(shù)債可以先欠著,先學(xué)會怎么寫代碼去使用可能是更好的辦法。前端開發(fā)的計劃比較短,較難有大塊時間學(xué)習,但是我們可以迭代式的進步,最終是會殊途同歸的。
先把架式練好,用于代碼中解決實際業(yè)務(wù)問題,比被困難嚇住還停留在命令式的思想上還是要強的。
函數(shù)式編程的精髓:無副作用
前端同學(xué)學(xué)習函數(shù)式編程的優(yōu)勢是React Hooks已經(jīng)將副作用擺在我們面前了,不用再解釋為什么要寫無副用的代碼了。
無副作用的函數(shù)應(yīng)該符合下面的特點:
要有輸入?yún)?shù)。如果沒有輸入?yún)?shù),這個函數(shù)拿不到任意外部信息,也就不用運行了。
要有返回值。如果有輸入沒有返回值,又沒有副作用,那么這個函數(shù)白調(diào)了。
對于確定的輸入,有確定的輸出
做到這一點,說簡單也簡單,只要保持功能足夠簡單就可以做到;說困難也困難,需要改變寫慣了命令行代碼的思路。
比如數(shù)學(xué)函數(shù)一般就是這樣的好例子,比如我們寫一個算平方的函數(shù):
let sqr2 = function(x){ return x * x; }console.log(sqr2(200));
無副作用函數(shù)擁有三個巨大的好處:
可以進行緩存。我們就可以采用動態(tài)規(guī)劃的方法保存中間值,用來代替實際函數(shù)的執(zhí)行結(jié)果,大大提升效率。
可以進行高并發(fā)。因為不依賴于環(huán)境,可以調(diào)度到另一個線程、worker甚至其它機器上,反正也沒有環(huán)境依賴。
容易測試,容易證明正確性。不容易產(chǎn)生偶現(xiàn)問題,也跟環(huán)境無關(guān),非常利于測試。
即使是跟有副作用的代碼一起工作,我們也可以在副作用代碼中緩存無副作用函數(shù)的值,可以將無副作用函數(shù)并發(fā)執(zhí)行。測試時也可以更重點關(guān)注有副作用的代碼以更有效地利用資源。
用函數(shù)的組合來代替命令的組合
會寫無副作用的函數(shù)之后,我們要學(xué)習的新問題就是如何將這些函數(shù)組合起來。
比如上面的sqr2函數(shù)有個問題,如果不是number類型,計算就會出錯。按照命令式的思路,我們可能就直接去修改sqr2的代碼,比如改成這樣:
- let sqr2 = function(x){ if (typeof x === 'number'){ return x * x; }else{ return 0; }}
但是,sqr2的代碼已經(jīng)測好了,我們能不能不改它,只在它外面進行判斷?
是的,我們可以這樣寫:
- let isNum = function(x){ if (typeof x === 'number'){ return x; }else{ return 0; }}console.log(sqr2(isNum("20")));
或者是我們在設(shè)計sqr2的時候就先預(yù)留出來一個預(yù)處理函數(shù)的位置,將來要升級就換這個預(yù)處理函數(shù),主體邏輯不變:
- let sqr2_v3 = function(fn, x){ let y = fn(x); return y * y; }console.log((sqr2_v3(isNum,1.1)));
嫌每次都寫isNum煩,可以定義個新函數(shù),把isNum給寫死進去:
- let sqr2_v4 = function(x){ return sqr2_v3(isNum,x);}console.log((sqr2_v4(2.2)));
用容器封裝函數(shù)能力
現(xiàn)在,我們想重用這個isNum的能力,不光是給sqr2用,我們想給其它數(shù)學(xué)函數(shù)也增加這個能力。
比如,如果給Math.sin計算undefined會得到一個NaN:
console.log(Math.sin(undefined));
這時候我們需要用面向?qū)ο蟮乃季S了,將isNum的能力封裝到一個類中:
- class MayBeNumber{ constructor(x){ this.x = x; } map(fn){ return new MayBeNumber(fn(isNum(this.x))); } getValue(){ return this.x; }}
這樣,我們不管拿到一個什么對象,用其構(gòu)造一個MayBeNumber對象出來,再調(diào)用這個對象的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.
封裝到對象中的另一個好處是我們可以用"."多次調(diào)用了,比如我們想調(diào)兩次算4次方,只要在.map(sqr2)之后再來一個.map(sqr2)
- let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();console.log(num3);
使用對象封裝之后的另一個好處是,函數(shù)嵌套調(diào)用跟命令式是相反的順序,而用map則與命令式一致。
如果不理解的話我們來舉個例子,比如我們想求sin(1)的平方,用函數(shù)調(diào)用應(yīng)該先寫后執(zhí)行的sqr2,后寫先執(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 來封裝 new
封裝到對象中,看起來還不錯,但是函數(shù)式編程還搞出來new對象再map,為什么不能構(gòu)造對象時也用個函數(shù)呢?
這好辦,我們給它定義個of方法吧:
MayBeNumber.of = function(x){ return new MayBeNumber(x);}
下面我們就可以用of來構(gòu)造MayBeNumber對象啦:
- 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ù)升升級。
之前的isNum有個問題,如果是非數(shù)字的話,其實沒必要賦給個0再去調(diào)用函數(shù),直接返回個0就好了。
之前我們一直沒寫過箭頭函數(shù),順手寫一寫:
isNum2 = x => typeof x === 'number';
map用isNum2和of改寫下:
- map(fn){ if (isNum2(this.x)){ return MayBeNumber.of(fn(this.x)); }else{ return MayBeNumber.of(0); } }
我們再來看下另一種情況,我們處理返回值的時候,如果有Error,就不處理Ok的返回值,可以這么寫:
- 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 }
我們來總結(jié)下前面這種容器的設(shè)計模式:
有一個用于存儲值的容器
這個容器提供一個map函數(shù),作用是map函數(shù)使其調(diào)用的函數(shù)可以跟容器中的值進行計算,最終返回的還是容器的對象
我們可以把這個設(shè)計模式叫做Functor函子。
如果這個容器還提供一個of函數(shù)將值轉(zhuǎn)換成容器,那么它叫做Pointed Functor.
比如我們看下js中的Array類型:
let aa1 = Array.of(1);console.log(aa1);console.log(aa1.map(Math.sin));
它支持of函數(shù),它還支持map函數(shù)調(diào)用Math.sin對Array中的值進行計算,map的結(jié)果仍然是一個Array。
那么我們可以說,Array是一個Pointed Functor.
簡化對象層級
有了上面的Result結(jié)構(gòu)了之后,我們的函數(shù)也跟著一起升級。如果是數(shù)值的話,Ok是數(shù)值,Err是undefined。如果非數(shù)值的話,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)用這個新的sqr2_Result函數(shù):
console.log(Result.of(4.3,undefined).map(sqr2_Result));
返回的是一個嵌套的結(jié)果:
Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }
我們需要給Result對象新加一個join函數(shù),用來獲取子Result的值給父Result:
- join(){ if (this.isOk()) { return this.Ok; }else{ return this.Err; } }
我們調(diào)用的時候最后加上調(diào)用這個join:
console.log(Result.of(4.5,undefined).map(sqr2_Result).join());
嵌套的結(jié)果變成了一層的:
Result { Ok: 20.25, Err: undefined }
每次調(diào)用map(fn).join()兩個寫起來麻煩,我們定義一個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 }
我們最后完整回顧下這個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);}
不嚴格地講,像Result這種實現(xiàn)了flatMap功能的Pointed Functor,就是傳說中的Monad.
偏函數(shù)和高階函數(shù)
在前面各種函數(shù)式編程模式中對函數(shù)的用法熟悉了之后,回來我們總結(jié)下函數(shù)式編程與命令行編程體感上的最大區(qū)別:
函數(shù)是一等公式,我們應(yīng)該熟悉變量中保存函數(shù)再對其進行調(diào)用
函數(shù)可以出現(xiàn)在返回值里,最重要的用法就是把輸入是n(n>2)個參數(shù)的函數(shù)轉(zhuǎn)換成n個1個參數(shù)的串聯(lián)調(diào)用,這就是傳說中的柯里化。這種減少了參數(shù)的新函數(shù),我們稱之為偏函數(shù)
函數(shù)可以用做函數(shù)的參數(shù),這樣的函數(shù)稱為高階函數(shù)
偏函數(shù)可以當作是更靈活的參數(shù)默認值。
比如我們有個結(jié)構(gòu)叫spm,由spm_a和spm_b組成。但是一個模塊中spm_a是固定的,大部分時候只需要指定spm_b就可以了,我們就可以寫一個偏函數(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ù)我們在前面的map和flatMap里面已經(jīng)用得很熟了。但是,其實高階函數(shù)值得學(xué)習的設(shè)計模式還不少。
比如給大家出一個思考題,如何用函數(shù)式方法實現(xiàn)一個只執(zhí)行一次有效的函數(shù)?
不要用全局變量啊,那不是函數(shù)式思維,我們要用閉包。
once是一個高階函數(shù),返回值是一個函數(shù),如果done是false,則將done設(shè)為true,然后執(zhí)行fn。done是在返回函數(shù)的同一層,所以會被閉包記憶獲取到:
- 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()沒有發(fā)生任何事情。
遞歸與記憶
前面介紹了這么多,但是函數(shù)編程其實還蠻復(fù)雜的,比如說涉及到遞歸。
遞歸中最簡單的就是階乘了吧:
- let factorial = (n) => { if (n===0){ return 1; } return n*factorial(n-1);}console.log(factorial(10));
但是我們都知道,這樣做效率很低,會重復(fù)計算好多次。應(yīng)該采用動態(tài)規(guī)劃的辦法。
那么如何在函數(shù)式編程中使用動態(tài)規(guī)劃,換句話說我們?nèi)绾伪4嬉呀?jīng)計算過的值?
想必經(jīng)過上一節(jié)學(xué)習,大家肯定想到要用閉包,沒錯,我們可以封裝一個叫memo的高階函數(shù)來實現(xiàn)這個功能:
- const memo = (fn) => { const cache = {}; return (arg) => cache[arg] || (cache[arg] = fn(arg));}
邏輯很簡單,返回值是lamdba表達式,它仍然支持閉包,所以我們在其同層定義一個cache,然后如果cache中的某項為空則計算并保存之,如果已經(jīng)有了就直接使用。
這個高階函數(shù)很好用,階乘的邏輯不用改,只要放到memo中就好了:
- let fastFact = memo( (n) => { if (n<=0){ return 1; }else{ return n * fastFact(n-1); } });
在本文即將結(jié)尾的時候,我們再回歸到前端,React Hooks里面提供的useMemo,就是這樣的記憶機制:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
小結(jié)
綜上,我們希望大家能記住幾點:
函數(shù)式編程的核心概念很簡單,就是將函數(shù)存到變量里,用在參數(shù)里,用在返回值里
在編程時要時刻記住將無副作用與有副作用代碼分開
函數(shù)式編程的原理雖然很簡單,但是因為大家習慣了命令式編程,剛開始學(xué)習時會有諸多不習慣,用多了就好了
函數(shù)式編程背后有其數(shù)學(xué)基礎(chǔ),在學(xué)習時可以先不要管它,當成設(shè)計模式學(xué)習。等將來熟悉之后,還是建議去了解下背后的真正原理