Js中函數語言程式設計的理解

WindrunnerMax發表於2021-01-16

函數語言程式設計的理解

函數語言程式設計是一種程式設計正規化,可以理解為是利用函式把運算過程封裝起來,通過組合各種函式來計算結果。函數語言程式設計與指令式程式設計最大的不同其實在於,函數語言程式設計關心資料的對映,指令式程式設計關心解決問題的步驟。

描述

到近些年,函式式以其優雅,簡單的特點開始重新風靡整個程式設計界,主流語言在設計的時候無一例外都會更多的參考函式式特性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/

相關文章