JavaScript 函數語言程式設計

zhaiyy發表於2018-10-26

應該很多童鞋都聽過函數語言程式設計(Functional programming)的概念吧,可能有的童鞋有聽說過函數語言程式設計但並不是特別瞭解,但其實在我們的開發過程中,或多或少都已經應用了函數語言程式設計的思想。

相對於物件導向程式設計(Object-oriented programming)關注的是資料而言,函數語言程式設計關注的則是動作,其是一種過程抽象的思維,就是對當前的動作去進行抽象。

比如說我要計算一個數 加上 4 再乘以 4 的值,按照正常寫程式碼的邏輯,我們可能會這麼去實現

function calculate(x){
    return (x + 4) * 4;
}

console.log(calculate(1))  // 20
複製程式碼

這是沒有任何問題的,我們在平時開發的過程中會經常將需要重複的操作封裝成函式以便在不同的地方能夠呼叫。但從函數語言程式設計的思維來看的話,我們關注的則是這一系列操作的動作,先「加上 4」再「乘以 4」。

如何封裝函式才是最佳實踐呢?如何封裝才能使函式更加通用,使用起來讓人感覺更加舒服呢?函數語言程式設計或許能給我們一些啟發。

函數語言程式設計具有兩個基本特徵。

  • 函式是第一等公民
  • 函式是純函式

函式是第一等公民

第一等公民是指函式跟其它的資料型別一樣處於平等地位,可以賦值給其他變數,可以作為引數傳入另一個函式,也可以作為別的函式的返回值。

// 賦值
var a = function fn1() {  }
// 函式作為引數
function fn2(fn) {
    fn()
}   
// 函式作為返回值
function fn3() {
    return function() {}
}
複製程式碼

函式是純函式

純函式是指相同的輸入總會得到相同的輸出,並且不會產生副作用的函式。

從純函式的概念我們可以知道純函式具有兩個特點:

  • 同輸入同輸出
  • 無副作用

無副作用指的是函式內部的操作不會對外部產生影響(如修改全域性變數的值、修改 dom 節點等)。

// 是純函式
function add(x,y){
    return x + y
}
// 輸出不確定,不是純函式
function random(x){
    return Math.random() * x
}
// 有副作用,不是純函式
function setColor(el,color){
    el.style.color = color ;
}
// 輸出不確定、有副作用,不是純函式
var count = 0;
function addCount(x){
    count+=x;
    return count;
}
複製程式碼

函數語言程式設計具有兩個最基本的運算:合成(compose)和柯里化(Currying)。

函式合成(compose)

函式合成指的是將代表各個動作的多個函式合併成一個函式。

上面講到,函數語言程式設計是對過程的抽象,關注的是動作。以上面計算的例子為例,我們關注的是它的動作,先「加上 4」再「乘以 4」。那麼我們的程式碼實現如下

function add4(x) {
    return x + 4
}
function multiply4(x) {
    return x * 4
}

console.log(multiply4(add4(1)))  // 20
複製程式碼

根據函式合成的定義,我們能夠將上述代表兩個動作的兩個函式的合成一個函式。我們將合成的動作抽象為一個函式 compose,這裡可以比較容易地知道,函式 compose 的程式碼如下

function compose(f,g) {
    return function(x) {
        return f(g(x));
    };
}
複製程式碼

所以我們可以通過如下的方式得到合成函式

var calculate=compose(multiply4,add4);  //執行動作的順序是從右往左

console.log(calculate(1))  // 20
複製程式碼

可以看到,只要往 compose 函式中傳入代表各個動作的函式,我們便能得到最終的合成函式。但上述 compose 函式的侷限性是隻能夠合成兩個函式,如果需要合成的函式不止兩個呢,所以我們需要一個通用的 compose 函式。

這裡我直接給出通用 compose 函式的程式碼

function compose() {
  var args = arguments;
  var start = args.length - 1;
  return function () {
    var i = start - 1;
    var result = args[start].apply(this, arguments);
    while (i >= 0){
      result = args[i].call(this, result);
      i--;
    }
    return result;
  };
}
複製程式碼

讓我們來實踐下上述通用的 compose 函式~

function addHello(str){
    return 'hello '+str;
}
function toUpperCase(str) {
    return str.toUpperCase();
}
function reverse(str){
    return str.split('').reverse().join('');
}

var composeFn=compose(reverse,toUpperCase,addHello);

console.log(composeFn('ttsy'));  // YSTT OLLEH
複製程式碼

上述過程有三個動作,「hello」、「轉換大寫」、「反轉」,可以看到通過 compose 將上述三個動作代表的函式合併成了一個,最終輸出了正確的結果。

函式柯里化(Currying)

在維基百科中對柯里化的定義是:在電腦科學中,柯里化,又譯為卡瑞化或加里化,是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。

柯里化函式則是將函式柯里化之後得到的一個新函式。由上述定義可知,柯里化函式有如下兩個特性:

  • 接受一個單一引數
  • 返回接受餘下的引數而且返回結果的新函式

舉個例子~

function add(a, b) {
    return a + b;
}

console.log(add(1, 2)) // 3
複製程式碼

假設函式 add 的柯里化函式是 addCurry,那麼從上述定義可知,addCurry(1)(2) 應該實現與上述程式碼相同的效果,輸出 3 。這裡我們可以比較容易的知道,addCurry 的程式碼如下

// addCurry 是 add 的柯里化函式
function addCurry(a) {
    return function(b) {
        return a + b;
    }
}

console.log(addCurry(1)(2));  // 3
複製程式碼

但假設如果有一個函式 createCurry 能夠實現柯里化,那麼我們便可以通過下述的方式來得出相同的結果

// createCurry 返回一個柯里化函式
var addCurry=createCurry(add);

console.log(addCurry(1)(2));  // 3
複製程式碼

可以看到,函式 createCurry 傳入一個函式 add 作為引數,返回一個柯里化函式 addCurry,函式 addCurry 能夠處理 add 中的剩餘引數。這個過程稱為函式柯里化,我們稱 addCurry 是 add 的柯里化函式。

那麼,怎麼得到實現柯里化的函式 createCurry 呢?這裡我直接給出 createCurry 的程式碼

// 引數只能從左到右傳遞
function createCurry(func, arrArgs) {
    var args=arguments;
    var funcLength = func.length;
    var arrArgs = arrArgs || [];

    return function(param) {
        var allArrArgs=arrArgs.concat([param])

        // 如果引數個數小於最初的func.length,則遞迴呼叫,繼續收集引數
        if (allArrArgs.length < funcLength) {
            return args.callee.call(this, func, allArrArgs);
        }

        // 引數收集完畢,則執行func
        return func.apply(this, allArrArgs);
    }
}
複製程式碼

我們可以通過如下方式去呼叫

// createCurry 返回一個柯里化函式
var addCurry=createCurry(function(a, b, c) {
    return a + b + c;
});

console.log(addCurry(1)(2)(3));  // 6
複製程式碼

上述 createCurry 函式已經能夠實現柯里化的過程,但是其並沒有那麼完美,如果我希望以 addCurry(1, 2)(3) 的方式來呼叫呢?則上述程式碼並不能給出我們想要的結果,所以我們要對 createCurry 做一個優化,優化後的 createCurry 程式碼如下

// 引數只能從左到右傳遞
function createCurry(func, arrArgs) {
    var args=arguments;
    var funcLength = func.length;
    var arrArgs = arrArgs || [];

    return function() {
        var _arrArgs = Array.prototype.slice.call(arguments);
        var allArrArgs=arrArgs.concat(_arrArgs)

        // 如果引數個數小於最初的func.length,則遞迴呼叫,繼續收集引數
        if (allArrArgs.length < funcLength) {
            return args.callee.call(this, func, allArrArgs);
        }

        // 引數收集完畢,則執行func
        return func.apply(this, allArrArgs);
    }
}
複製程式碼

優化之後的 createCurry 函式則顯得更加強大

// createCurry 返回一個柯里化函式
var addCurry=createCurry(function(a, b, c) {
    return a + b + c;
});

console.log(addCurry(1)(2)(3));  // 6
console.log(addCurry(1, 2, 3));  // 6
console.log(addCurry(1, 2)(3));  // 6
console.log(addCurry(1)(2, 3));  // 6
複製程式碼

柯里化實際上是把簡答的問題複雜化了,但是複雜化的同時,我們在使用函式時擁有了更加多的自由度。

那麼,柯里化有什麼用途呢?舉個例子~

現在我們需要實現一個功能,將一個全是數字的陣列中的數字轉換成百分數的形式。按照正常的邏輯,我們可以按如下程式碼實現

function getNewArray(array) {
    return array.map(function(item) {
        return item * 100 + '%'
    })
}

console.log(getNewArray([1, 0.2, 3, 0.4]));   // ['100%', '20%', '300%', '40%']
複製程式碼

而如果通過柯里化的方式來實現

function map(func, array) {
    return array.map(func);
}
var mapCurry = createCurry(map);
var getNewArray = mapCurry(function(item) {
    return item * 100 + '%'
})

console.log(getNewArray([1, 0.2, 3, 0.4]));   // ['100%', '20%', '300%', '40%']
複製程式碼

上述例子可能太簡單以致不能表現出柯里化的強大,具體柯里化的使用還需要結合具體的場景,個人覺得沒有必要為了柯里化而柯里化,我們最終的目的是為了更好地解決問題,不是麼?

在函數語言程式設計中,還有一個很重要的概念是函子。

函子

在前面函式合成的例子中,執行了先「加上 4」再「乘以 4」的動作,我們可以看到程式碼中是通過 multiply4(add4(1)) 這種形式來實現的,如果通過 compose 函式,則是類似於 compose(multiply4,add4)(1) 這種形式來實現程式碼。

而在函數語言程式設計的思維中,除了將動作抽象出來外,還希望動作執行的順序更加清晰,所以對於上面的例子來說,更希望是通過如下的形式來執行我們的動作

fn(1).add4().multiply4()
複製程式碼

這時我們需要用到函子的概念。

function Functor(val){
    this.val = val;
}
Functor.prototype.map=function(f){
    return new Functor(f(this.val));
}
複製程式碼

函子可以簡單地理解為有用到 map 方法的資料結構。如上 Functor 的例項就是一個函子。

在函子的 map 方法中接受一個函式引數,然後返回一個新的函子,新的函子中包含的值是被函式引數處理過後返回的值。該方法將函子裡面的每一個值,對映到另一個函子。

通過 Functor 函子,我們可以通過如下的方式呼叫

console.log((new Functor(1)).map(add4).map(multiply4))  // Functor { val: 20 }
複製程式碼

上述呼叫的方式是 (new Calculate(1)).map(add4).map(multiply4) ,跟我們想要的效果已經差不多了,但是我們不希望有 new 的存在,所以我們在 Functor 函子掛載上 of 方法

function Functor(val){
    this.val = val;
}
Functor.prototype.map=function(f){
    return new Functor(f(this.val));
}
Functor.of = function(val) {
    return new Functor(val);
}
複製程式碼

最終我們可以通過如下方式呼叫

console.log(Functor.of(1).map(add4).map(multiply4))  // Functor { val: 20 }
複製程式碼

接下來介紹各種常見的函子。

Maybe 函子

Maybe 函子是指在 map 方法中增加了對空值的判斷的函子。

由於函子中的 map 方法中的函式引數會對函子內部的值進行處理,所以當傳入函子中的值為空(如 null)時,則可能會產生錯誤。

function toUpperCase(str) {
    return str.toUpperCase();
}

console.log(Functor.of(null).map(toUpperCase));  // TypeError
複製程式碼

Maybe 函子則在 map 方法中增加了對空值的判斷,若是函子內部的值為空,則直接返回一個內部值為空的函子。

function Maybe(val){
    this.val = val;
}
Maybe.prototype.map=function(f){
    return this.val ? Maybe.of(f(this.val)) : Maybe.of(null);
}
Maybe.of = function(val) {
    return new Maybe(val);
}
複製程式碼

當使用 Maybe 函子時傳入空值則不會報錯

console.log(Maybe.of(null).map(toUpperCase));  // Maybe { val: null }
複製程式碼

Either 函子

Either 函子是指內部有分別有左值(left)和右值(right),正常情況下會使用右值,而當右值不存在的時候會使用左值的函子。

function Either(left,right){
    this.left = left;
    this.right = right;
}
Either.prototype.map=function(f){
    return this.right ? Either.of(this.left, f(this.right)) : Either.of(f(this.left), this.right);
}
Either.of = function(left,right) {
    return new Either(left,right);
}
複製程式碼

如下當左右值都存在的時候則以右值為函子的預設值,當右值不存在是則以左值為函子的預設值。

function addOne(x) {
    return x+1;
}

console.log(Either.of(1,2).map(addOne));  // Either { left: 1, right: 3 }
console.log(Either.of(3,null).map(addOne));  // Either { left: 4, right: null }
複製程式碼

Monad 函子

Monad 函子是指能夠將函子多層巢狀解除的函子。

我們往函子傳入的值不僅僅可以是普通的資料型別,也可以是其它函子,當往函子內部傳其它函子的時候,則會出現函子的多層巢狀。如下

var functor = Functor.of(Functor.of(Functor.of('ttsy')))

console.log(functor);  // Functor { val: Functor { val: Functor { val: 'ttsy' } } }
console.log(functor.val);  // Functor { val: Functor { val: 'ttsy' } }
console.log(functor.val.val);  // Functor { val: 'ttsy' }
複製程式碼

Monad 函子中新增了 join 和 flatMap 方法,通過 flatMap 我們能夠在每一次傳入函子的時候都將巢狀解除。

Monad.prototype.map=function(f){
    return Monad.of(f(this.val))
}
Monad.prototype.join=function(){
    return this.val;
}
Monad.prototype.flatMap=function(f){
    return this.map(f).join();
}
Monad.of = function(val) {
    return new Monad(val);
}
複製程式碼

通過 Monad 函子,我們最終得到的都是隻有一層的函子。

console.log(Monad.of('ttsy').flatMap(Monad.of).flatMap(Monad.of));  // Monad { val: 'TTSY' }
複製程式碼

在我們平時的開發過程中,要根據不同的場景去實現不同功能的函式,而函數語言程式設計則讓我們從不同的角度去讓我們能夠以最佳的方式去實現函式功能,但函數語言程式設計不是非此即彼的,而是要根據不同的應用場景去選擇不同的實現方式。

覺得還不錯的小夥伴,可以關注一波公眾號哦。

JavaScript 函數語言程式設計

相關文章