Js中函數式編程的理解


函數式編程的理解

函數式編程是一種編程范式,可以理解為是利用函數把運算過程封裝起來,通過組合各種函數來計算結果。函數式編程與命令式編程最大的不同其實在於,函數式編程關心數據的映射,命令式編程關心解決問題的步驟。

描述

到近些年,函數式以其優雅,簡單的特點開始重新風靡整個編程界,主流語言在設計的時候無一例外都會更多的參考函數式特性Lambda表達式、原生支持mapreduce...Java8開始支持函數式編程等等。
在前端領域,我們同樣能看到很多函數式編程的影子,ES6中加入了箭頭函數,Redux引入Elm思路降低Flux的復雜性,React16.6開始推出React.memo(),使得pure functional components成為可能,16.8開始主推Hook,建議使用pure function進行組件編寫等等。

實例

實際上這個概念還是比較抽象的,直接來舉一個例子說明,假設我們有一個需求,對數據結構進行更改。

["john-reese", "harold-finch", "sameen-shaw"] 
// 轉換成 
[{name: "John Reese"}, {name: "Harold Finch"}, {name: "Sameen Shaw"}]

按照傳統的命令式編程的思路,我們通常是使用循環將其進行循環拼接等操作,以得到最終的結果。

const arr = ["john-reese", "harold-finch", "sameen-shaw"];
const newArr = [];
for (let i = 0, n = arr.length; i < n ; i++) {
  let name = arr[i];
  let names = name.split("-");
  let newName = [];
  for (let j = 0, neamLen = names.length; j < neamLen; j++) {
    let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
    newName.push(nameItem);
  }
  newArr.push({ name : newName.join(" ") });
}
console.log(newArr);

/*
[
  { name: 'John Reese' },
  { name: 'Harold Finch' },
  { name: 'Sameen Shaw' }
]
*/

這樣當然能完成任務,但是產生了比較多的中間變量,另外變量名比較多,有比較復雜的邏輯,假如作為一個函數並返回值來處理的話就需要從頭到尾讀完才能知道整體的邏輯,而且一旦出問題很難定位。
如果我們換一個思路,采用函數式編程的思想來做,我們可以先忽略其中的currycompose以及map這些函數,之后當我們實現這兩個函數后會重現這個示例,當我們只是看這個編程思路,可以清晰看出,函數式編程的思維過程是完全不同的,它的着眼點是函數,而不是過程,它強調的是如何通過函數的組合變換去解決問題,而不是我通過寫什么樣的語句去解決問題,當你的代碼越來越多的時候,這種函數的拆分和組合就會產生出強大的力量。當然下面的例子可以直接在Ramda環境跑,需要將未定義的方法都加上R.作為R對象的方法調用。

const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();

const genObj = curry((key, x) => {
  let obj = {};
  obj[key] = x;
  return obj;
}) 

const capitalizeName = compose(join(" "), map(capitalize), split("-"));
const convert2Obj = compose(genObj("name"), capitalizeName)
const convertName = map(convert2Obj);

convertName(["john-reese", "harold-finch", "sameen-shaw"]);

函數式編程

根據學術上函數的定義,函數即是一種描述集合和集合之間的轉換關系,輸入通過函數都會返回有且只有一個輸出值。 所以,函數實際上是一個關系,或者說是一種映射,而這種映射關系是可以組合的,一旦我們知道一個函數的輸出類型可以匹配另一個函數的輸入,那他們就可以進行組合,就例如上邊的compose函數,它實際上就完成了映射關系的組合,把一個數據從String轉換成了另一個String然后再轉換成Object,實際上類似於數學上的復合運算g°f = g(f(x))

const convert2Obj = compose(genObj("name"), capitalizeName);

在編程世界中,我們需要處理的其實也只有數據和關系,而關系就是函數,我們所謂的編程工作也不過就是在找一種映射關系,一旦關系找到了,問題就解決了,剩下的事情,就是讓數據流過這種關系,然后轉換成另一個數據罷了。這其實就是一種類似於流水線的工作,把輸入當做原料,把輸出當做產品,數據可以不斷的從一個函數的輸出可以流入另一個函數輸入,最后再輸出結果。 所以通過這里就可以理解函數式編程其實就是強調在編程過程中把更多的關注點放在如何去構建關系,通過構建一條高效的建流水線,一次解決所有問題,而不是把精力分散在不同的加工廠中來回奔波傳遞數據。

相關特性

函數是一等公民

函數是一等公民First-Class Functions,這是函數式編程得以實現的前提,因為我們基本的操作都是在操作函數。這個特性意味着函數與其他數據類型一樣,處於平等地位,可以賦值給其他變量,也可以作為參數,傳入另一個函數,或者作為別的函數的返回值。

聲明式編程

聲明式編程Declarative Programming,函數式編程大多時候都是在聲明我需要做什么,而非怎么去做,這種編程風格稱為 聲明式編程,這樣有個好處是代碼的可讀性特別高,因為聲明式代碼大多都是接近自然語言的,同時它解放了大量的人力,因為它不關心具體的實現,因此它可以把優化能力交給具體的實現,這也方便我們進行分工協作。SQL語句就是聲明式的,你無需關心Select語句是如何實現的,不同的數據庫會去實現它自己的方法並且優化。React也是聲明式的,你只要描述你的UI,接下來狀態變化后UI如何更新,是React在運行時幫你處理的,而不是靠你自己去渲染和優化diff算法。

無狀態和數據不可變

無狀態和數據不可變Statelessness and Immutable data,是函數式編程的核心概念,為了實現這個目標,函數式編程提出函數應該具備的特性,沒有副作用和純函數。

  • 數據不可變: 它要求你所有的數據都是不可變的,這意味着如果你想修改一個對象,那你應該創建一個新的對象用來修改,而不是修改已有的對象。
  • 無狀態: 主要是強調對於一個函數,不管你何時運行,它都應該像第一次運行一樣,給定相同的輸入,給出相同的輸出,完全不依賴外部狀態的變化。

沒有副作用

沒有副作用No Side Effects,是指在完成函數主要功能之外完成的其他副要功能,在我們函數中最主要的功能當然是根據輸入返回結果,而在函數中我們最常見的副作用就是隨意操縱外部變量。由於Js中對象傳遞的是引用地址,哪怕我們用const關鍵詞聲明對象,它依舊是可以變的。保證函數沒有副作用,一來能保證數據的不可變性,二來能避免很多因為共享狀態帶來的問題。當你一個人維護代碼時候可能還不明顯,但隨着項目的迭代,項目參與人數增加,大家對同一變量的依賴和引用越來越多,這種問題會越來越嚴重,最終可能連維護者自己都不清楚變量到底是在哪里被改變而產生Bug。傳遞引用一時爽,代碼重構火葬場。

純函數

純函數pure functions,純函數算是在沒有副作用的要求上再進一步了。在Redux的三大原則中,我們看到它要求所有的修改必須使用純函數,純函數才是真正意義上的函數,它意味着相同的輸入,永遠會得到相同的輸出,其實純函數的概念很簡單就是兩點:

  • 不依賴外部狀態(無狀態):函數的的運行結果不依賴全局變量,this指針、IO操作等。
  • 沒有副作用(數據不變):不修改全局變量,不修改入參。

流水線的構建

如果說函數式編程中有兩種操作是必不可少的那無疑就是柯里化Currying和函數組合Compose,柯里化其實就是流水線上的加工站,函數組合就是我們的流水線,它由多個加工站組成。

柯里化

對於柯里化Currying,簡單來說就是將一個多元函數,轉換成一個依次調用的單元函數,也就是把一個多參數的函數轉化為單參數函數的方法,函數的柯里化是用於將一個操作分成多步進行,並且可以改變函數的行為,在我的理解中柯里化實際就是實現了一個狀態機,當達到指定參數時就從繼續接收參數的狀態轉換到執行函數的狀態。
簡單來說,通過柯里化可以把函數調用的形式改變。

f(a,b,c) → f(a)(b)(c)

與柯里化非常相似的概念有部分函數應用Partial Function Application,這兩者不是相同的,部分函數應用強調的是固定一定的參數,返回一個更小元的函數。

// 柯里化
f(a,b,c) → f(a)(b)(c)
// 部分函數調用
f(a,b,c) → f(a)(b,c) / f(a,b)(c)

柯里化強調的是生成單元函數,部分函數應用的強調的固定任意元參數,而我們平時生活中常用的其實是部分函數應用,這樣的好處是可以固定參數,降低函數通用性,提高函數的適合用性,在很多庫函數中curry函數都做了很多優化,已經不是純粹的柯里化函數了,可以將其稱作高級柯里化,這些版本實現可以根據你輸入的參數個數,返回一個柯里化函數/結果值,即如果你給的參數個數滿足了函數條件,則返回值。
實現一個簡單的柯里化的函數,可以通過閉包來實現。

var add = function(x) {
  return function(y) {
    return x + y;
  }; 
};
console.log(add(1)(2)); // 3

當有多個參數時,這樣顯然不夠優雅,於是封裝一個將普通函數轉變為柯里化函數的函數。

function convertToCurry(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return convertToCurry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

var funct = (x, y, z) => x + y + z;
var addCurry = convertToCurry(funct);
var result = addCurry(1)(2)(3);
console.log(result); // 6

舉一個需要正則匹配驗證手機與郵箱的例子來展示柯里化的應用。

function convertToCurry(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return convertToCurry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

var check = (regex, str) =>  regex.test(str);
var checkPhone = convertToCurry(check, /^1[34578]\d{9}$/);
var checkEmail = convertToCurry(check, /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);
console.log(checkPhone("13300000000")); // true
console.log(checkPhone("13311111111")); // true
console.log(checkPhone("13322222222")); // true
console.log(checkEmail("13300000000@a.com")); // true
console.log(checkEmail("13311111111@a.com")); // true
console.log(checkEmail("13322222222@a.com")); // true

高級柯里化有一個應用方面在於Thunk函數,Thunk函數是應用於編譯器的傳名調用實現,往往是將參數放到一個臨時函數之中,再將這個臨時函數傳入函數體,這個臨時函數就叫做Thunk 函數。Thunk函數將參數替換成單參數的版本,且只接受回調函數作為參數。

// 假設一個延時函數需要傳遞一些參數
// 通常使用的版本如下
var delayAsync = function(time, callback, ...args){
    setTimeout(() => callback(...args), time);
}

var callback = function(x, y, z){
    console.log(x, y, z);
}

delayAsync(1000, callback, 1, 2, 3);

// 使用Thunk函數

var thunk = function(time, ...args){
    return function(callback){
        setTimeout(() => callback(...args), time);
    }
}

var callback = function(x, y, z){
    console.log(x, y, z);
}

var delayAsyncThunk = thunk(1000, 1, 2, 3);
delayAsyncThunk(callback);

實現一個簡單的Thunk函數轉換器,對於任何函數,只要參數有回調函數,就能寫成Thunk函數的形式。

const convertToThunk = (funct) => (...args) => (callback) => funct.apply(null, args);

const callback = function(x, y, z){
    console.log(x, y, z);
}

const delayAsyncThunk = convertToThunk(function(time, ...args){
    setTimeout(() => callback(...args), time);
});

thunkFunct = delayAsyncThunk(1000, 1, 2, 3);
thunkFunct(callback);

Thunk函數在ES6之前可能應用比較少,但是在ES6之后,出現了Generator函數,通過使用Thunk函數就可以可以用於Generator函數的自動流程管理。首先是關於Generator函數的基本使用,調用一個生成器函數並不會馬上執行它里面的語句,而是返回一個這個生成器的迭代器iterator 對象,他是一個指向內部狀態對象的指針。當這個迭代器的next()方法被首次(后續)調用時,其內的語句會執行到第一個(后續)出現yield的位置為止,yield后緊跟迭代器要返回的值,也就是指針就會從函數頭部或者上一次停下來的地方開始執行到下一個yield。或者如果用的是yield*,則表示將執行權移交給另一個生成器函數(當前生成器暫停執行)。

function* f(x) {
    yield x + 10;
    yield x + 20;
    return x + 30;
}
var g = f(1);
console.log(g); // f {<suspended>}
console.log(g.next()); // {value: 11, done: false}
console.log(g.next()); // {value: 21, done: false}
console.log(g.next()); // {value: 31, done: true}
console.log(g.next()); // {value: undefined, done: true} // 可以無限next(),但是value總為undefined,done總為true

由於Generator函數能夠將函數的執行暫時掛起,那么他就完全可以操作一個異步任務,當上一個任務完成之后再繼續下一個任務,下面這個例子就是將一個異步任務同步化表達,當上一個延時定時器完成之后才會進行下一個定時器任務,可以通過這種方式解決一個異步嵌套的問題,例如利用回調的方式需要在一個網絡請求之后加入一次回調進行下一次請求,很容易造成回調地獄,而通過Generator函數就可以解決這個問題,事實上async/await就是利用的Generator函數以及Promise實現的異步解決方案。

var it = null;

function f(){
    var rand = Math.random() * 2;
    setTimeout(function(){
        if(it) it.next(rand);
    },1000)
}

function* g(){ 
    var r1 = yield f();
    console.log(r1);
    var r2 = yield f();
    console.log(r2);
    var r3 = yield f();
    console.log(r3);
}

it = g();
it.next();

雖然上邊的例子能夠自動執行,但是不夠方便,現在實現一個Thunk函數的自動流程管理,其自動幫我們進行回調函數的處理,只需要在Thunk函數中傳遞一些函數執行所需要的參數比如例子中的index,然后就可以編寫Generator函數的函數體,通過左邊的變量接收Thunk函數中funct執行的參數,在使用Thunk函數進行自動流程管理時,必須保證yield后是一個Thunk函數。
關於自動流程管理run函數,首先需要知道在調用next()方法時,如果傳入了參數,那么這個參數會傳給上一條執行的yield語句左邊的變量,在這個函數中,第一次執行next時並未傳遞參數,而且在第一個yield上邊也並不存在接收變量的語句,無需傳遞參數,接下來就是判斷是否執行完這個生成器函數,在這里並沒有執行完,那么將自定義的next函數傳入res.value中,這里需要注意res.value是一個函數,可以在下邊的例子中將注釋的那一行執行,然后就可以看到這個值是f(funct){...},此時我們將自定義的next函數傳遞后,就將next的執行權限交予了f這個函數,在這個函數執行完異步任務后,會執行回調函數,在這個回調函數中會觸發生成器的下一個next方法,並且這個next方法是傳遞了參數的,上文提到傳入參數后會將其傳遞給上一條執行的yield語句左邊的變量,那么在這一次執行中會將這個參數值傳遞給r1,然后在繼續執行next,不斷往復,直到生成器函數結束運行,這樣就實現了流程的自動管理。

function thunkFunct(index){
    return function f(funct){
        var rand = Math.random() * 2;
        setTimeout(() => funct({rand:rand, index: index}), 1000)
    }
}

function* g(){ 
    var r1 = yield thunkFunct(1);
    console.log(r1.index, r1.rand);
    var r2 = yield thunkFunct(2);
    console.log(r2.index, r2.rand);
    var r3 = yield thunkFunct(3);
    console.log(r3.index, r3.rand);
}

function run(generator){
    var g = generator();

    var next = function(data){
        var res = g.next(data);
        if(res.done) return ;
        // console.log(res.value);
        res.value(next);
    }

    next();
}

run(g);

函數組合

函數組合的目的是將多個函數組合成一個函數,寫一個簡單的示例。

const compose = (f, g) => x => f(g(x));

const f = x => x + 1;
const g = x => x * 2;
const fg = compose(f, g);
fg(1) //3

我們可以看到compose就實現了一個簡單的功能,形成了一個全新的函數,而這個函數就是一條從g -> f的流水線,同時我們可以很輕易的發現compose其實是滿足結合律的。

compose(f, compose(g, t)) <=> compose(compose(f, g), t)  <=> f(g(t(x)));

只要其順序一致,最后的結果是一致的,因此我們可以寫個更高級的compose,支持多個函數組合。

const compose = (...fns) => (...args) => fns.reduceRight((params, fn) => [fn.apply(null, [].concat(params))], args).pop();

const f = x => x + 1;
const g = x => x * 2;
const t = (x, y) => x + y;

let fgt = compose(f, g, t);
console.log(fgt(1, 2)); // 7 // 3 -> 6 -> 7

現在我們考慮一個小需求,將數組最后一個元素大寫,假設logheadreversetoUpperCase函數存在,之后以命令式的寫法是:

log(toUpperCase(head(reverse(arr))))

面向對象的寫法:

arr.reverse()
  .head()
  .toUpperCase()
  .log()

鏈式調用看起來順眼多了,通過函數組合組合的寫法:

const upperLastItem = compose(log, toUpperCase, head, reverse);

這其實就是類似於所謂管道pipe的概念,在Linux命令中常會用到,類似ps grep的組合,只是管道的執行方向和compose的(從右往左組合好像剛好相反,因此很多函數庫LodashRamda等中也提供了另一種組合方式pipe

const upperLastItem = R.pipe(reverse, head, toUppderCase, log);

那么最終,我們回到一開始的那個例子,將其完成為一個能跑通的示例。

const compose = (...fns) => (...args) => fns.reduceRight((params, fn) => [fn.apply(null, [].concat(params))], args).pop();

const curry = function(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return curry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

const join = curry((str, arr) => arr.join(str));

const map = curry((callback, arr) => arr.map(callback));

const split = curry((gap, str) => str.split(gap));

const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();

const genObj = curry((key, x) => {
    let obj = {};
    obj[key] = x;
    return obj;
})

const capitalizeName = compose(join(" "), map(capitalize), split("-"));
const convert2Obj = compose(genObj("name"), capitalizeName);
const convertName = map(convert2Obj);

const result = convertName(["john-reese", "harold-finch", "sameen-shaw"]);

console.log(result);
/*
[
  { name: 'John Reese' },
  { name: 'Harold Finch' },
  { name: 'Sameen Shaw' }
]
*/

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://zhuanlan.zhihu.com/p/67624686
https://juejin.cn/post/6844903936378273799
http://www.ruanyifeng.com/blog/2017/02/fp-tutorial.html
https://gist.github.com/riskers/637e23baeaa92c497efd52616ca83bdc
https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch1.html
https://blog.fundebug.com/2019/08/09/learn-javascript-functional-programming/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM