天天看點

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

作者|旭倫 Alibaba F2E  9月1日

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

函數式程式設計是一門古老的技術,從上個世紀60年代Lisp語言誕生開始,各種方言層出不窮。各種方言帶來欣欣向榮的生态的同時,也給相容性帶來很大麻煩。于是更種标準化工作也在不斷根據現有的實作去整理,比如Lisp就定義了Common Lisp規範,但是一大分支scheme是獨立的分支。另一種函數式語言ML,後來也标準化成Standard ML,但也攔不住另一門方言ocaml。後來的實踐幹脆成立一個委員會,定義一個通用的函數式程式設計語言,這就是Haskell。後來Haskell被函數式原教旨主義者認為是純函數式語言,而Lisp, ML系都有不符合純函數式的地方。

不管純不純,函數式程式設計語言因為性能問題,一直影響其廣泛使用。直到單核性能在Pentium 4時代達到頂峰,單純靠提升單線程性能的免費午餐結束,函數式程式設計語言因為其多線程安全性再次火了起來,先有Erlang,後來還有Scala, Clojure等。

函數式程式設計的思想也不斷影響着傳統程式設計語言,比如Java 8開始支援lambda表達式,而函數式程式設計的大廈最初就是基于lambda計算建構起來的。

不過比起後端用Java的同學對于函數式程式設計思想是可選的,對于前端同學變成了必選項。

前端同學為什麼要學習函數式程式設計思想?

React架構的元件從很早開始就是不僅支援類式元件,也支援函數式的元件。

比如下面的類繼承的方式更符合大多數學過面向對象程式設計思想同學的心智:

class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}      

但是,完全可以寫成下面這樣的函數式的元件:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}      
前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

從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使用者群的接頭暗号。

有範疇論和類型論等知識做為背景,當然會有助于從更高層次了解函數式程式設計。但是對于大部分前端開發同學來講,這筆技術債可以先欠着,先學會怎麼寫代碼去使用可能是更好的辦法。前端開發的計劃比較短,較難有大塊時間學習,但是我們可以疊代式的進步,最終是會殊途同歸的。

先把架式練好,用于代碼中解決實際業務問題,比被困難吓住還停留在指令式的思想上還是要強的。

函數式程式設計的精髓:無副作用

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

前端同學學習函數式程式設計的優勢是React Hooks已經将副作用擺在我們面前了,不用再解釋為什麼要寫無副用的代碼了。

無副作用的函數應該符合下面的特點:

  1. 要有輸入參數。如果沒有輸入參數,這個函數拿不到任意外部資訊,也就不用運作了。
  2. 要有傳回值。如果有輸入沒有傳回值,又沒有副作用,那麼這個函數白調了。
  3. 對于确定的輸入,有确定的輸出

做到這一點,說簡單也簡單,隻要保持功能足夠簡單就可以做到;說困難也困難,需要改變寫慣了指令行代碼的思路。

比如數學函數一般就是這樣的好例子,比如我們寫一個算平方的函數:

let sqr2 = function(x){
    return x * x; 
}
console.log(sqr2(200));      

無副作用函數擁有三個巨大的好處:

  1. 可以進行緩存。我們就可以采用動态規劃的方法儲存中間值,用來代替實際函數的執行結果,大大提升效率。
  2. 可以進行高并發。因為不依賴于環境,可以排程到另一個線程、worker甚至其它機器上,反正也沒有環境依賴。
  3. 容易測試,容易證明正确性。不容易産生偶現問題,也跟環境無關,非常利于測試。

即使是跟有副作用的代碼一起工作,我們也可以在副作用代碼中緩存無副作用函數的值,可以将無副作用函數并發執行。測試時也可以更重點關注有副作用的代碼以更有效地利用資源。

用函數的組合來代替指令的組合

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

會寫無副作用的函數之後,我們要學習的新問題就是如何将這些函數組合起來。

比如上面的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)));      

用容器封裝函數能力

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

現在,我們想重用這個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

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 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 }      

我們來總結下前面這種容器的設計模式:

  1. 有一個用于存儲值的容器
  2. 這個容器提供一個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.

簡化對象層級

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

有了上面的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.

偏函數和高階函數

前端開發函數式程式設計入門前端同學為什麼要學習函數式程式設計思想?學習函數式程式設計的方法和誤區函數式程式設計的精髓:無副作用用函數的組合來代替指令的組合用容器封裝函數能力用 of 來封裝 new簡化對象層級偏函數和高階函數遞歸與記憶

在前面各種函數式程式設計模式中對函數的用法熟悉了之後,回來我們總結下函數式程式設計與指令行程式設計體感上的最大差別:

  1. 函數是一等公式,我們應該熟悉變量中儲存函數再對其進行調用
  2. 函數可以出現在傳回值裡,最重要的用法就是把輸入是n(n>2)個參數的函數轉換成n個1個參數的串聯調用,這就是傳說中的柯裡化。這種減少了參數的新函數,我們稱之為偏函數
  3. 函數可以用做函數的參數,這樣的函數稱為高階函數

偏函數可以當作是更靈活的參數預設值。

比如我們有個結構叫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]);      

小結

綜上,我們希望大家能記住幾點:

  1. 函數式程式設計的核心概念很簡單,就是将函數存到變量裡,用在參數裡,用在傳回值裡
  2. 在程式設計時要時刻記住将無副作用與有副作用代碼分開
  3. 函數式程式設計的原理雖然很簡單,但是因為大家習慣了指令式程式設計,剛開始學習時會有諸多不習慣,用多了就好了
  4. 函數式程式設計背後有其數學基礎,在學習時可以先不要管它,當成設計模式學習。等将來熟悉之後,還是建議去了解下背後的真正原理