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