[譯] JavaScript中的函數柯里化

原文

函數柯里化

函數柯里化以Haskell Brooks Curry命名,柯里化是指將一個函數分解爲一系列函數的過程,每一個函數都只接收一個參數。(譯註:這些函數不會當即求值,而是經過閉包的方式把傳入的參數保存起來,直到真正須要的時候纔會求值)javascript


柯里化例子

如下是一個簡單的柯里化例子。咱們寫一個接收三個數字並返回它們總和的函數sum3html

function sum3(x, y, z) {
  return x + y + z;
}

console.log(sum3(1, 2, 3))  // 6
複製代碼

sum3的柯里化版本的結構不同。它接收一個參數並返回一個函數。返回的函數。返回的函數中又接收一個餐你輸,返回另外一個仍然只接收一個參數的函數...(以此往復)java

直到返回的函數接收到最後一個參數時,這個循環才結束。這個最後的函數將會返回數字的總和,以下所示。git

function sum(x) {
  return (y) => {
    return (z) => {
      return x + y + z
    }
  } 
}

console.log(sum(1)(2)(3)) // 6
複製代碼

以上的代碼能跑起來,是由於JavaScript支持閉包github

一個閉包是由函數和聲明這個函數的詞法環境組成的 -- MDN編程

注意函數鏈中的最後一個函數只接收一個z,但它同時也對外層的變量進行操做,在這個例子中,這些外層的變量對於最後一個函數來講相似於全局變量。實際上只是至關於不一樣函數下的局部變量數組

// 至關於全局變量
let x = ...?
let y = ...?

// 只接收一個參數 z 但也操做 x 和 y
return function(z) {
  return x + y + z;
}
複製代碼

通用的柯里化

寫一個柯里化函數還好,但若是要編寫多個函數時,這就不夠用了,所以咱們須要一種更加通用的編寫方式。安全

在大多數函數式編程語言中,好比haskell,咱們所要作的就是定義函數,它會自動地進行柯里化。bash

let sum3 x y z = x + y + z

sum3 1 2 3
-- 6

:t sum3 -- print the type of sum3()
-- sum3 :: Int -> Int -> Int -> Int

(sum3) :: Int -> Int -> Int -> Int -- 函數名 括號中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定義柯里化函數 括號中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最後返回 括號中的部分
複製代碼

咱們不能JS引擎重寫爲curry-ify全部函數,可是咱們可使用一個策略來實現。閉包


柯里化策略

經過上述兩種sum3的形式發現,實際上處理加法邏輯的函數被移動到閉包鏈的最後一個函數中。在到達最後一級以前,咱們不會在執行環境中得到全部須要的參數。

這意味着咱們能夠建立一個包裝哈數來收集這些參數,而後把它們傳遞給實際要執行的函數 (sum3)。全部中間嵌套的函數都稱爲累加器函數 - 至少咱們能夠這樣稱呼它們。

function _sum3(x, y, z) {
  return x + y + z;
}

function sum3(x) {
  return (y) => {
    return (z) => {
      return _sum3(x, y, z);  // 把參數都傳給這個最終執行的函數
    }
  }
}

sum3(1)(2)(3)  // 6
複製代碼

用柯里化包裹之

因爲咱們要使用一個包裝後的函數來替代實際的函數,所以咱們能夠建立另外一個函數來包裹。咱們將這個新生成的函數稱之爲curry —— 一個更高階的函數,它的做用是返回一系列嵌套的累加器函數,最後調用回調函數fn

function curry(fn) {     // 定義一個包裹它們的柯里化函數
  return (x) => { 
    return (y) => { 
      return (z) => { 
        return fn(x, y, z);  // 調用回調函數
      };
    };
  };
}

const sum = curry((x, y, z) => {   // 傳入回調函數
  return x + y + z;
});

sum3(1)(2)(3) // 6
複製代碼

如今咱們須要知足有不一樣參數的柯里化函數:它可能有0個參數,1個參數,2個參數等等....


遞歸的柯里化

實際上咱們並非真的要編寫多個知足不一樣參數的柯里化函數,而是應當編寫一個適用於多個參數的柯里化函數。

若是咱們真的寫多個curry函數,那將會以下所示...:

function curry0(fn) {
  return fn();
}
function curry1(fn) {
  return (a1) => {
    return fn(a1);
  };
}
function curry2(fn) {
  return (a1) => {
    return (a2) => {
      return fn(a1, a2);
    };
  };
}
function curry3(fn) {
  return (a1) => {
    return (a2) => {
      return (a3) => {
        return fn(a1, a2, a3);
      };
    };
  };
}
...
function curryN(fn){
  return (a1) => {
    return (a2) => {
      ...
      return (aN) => {
        // N 個嵌套函數
        return fn(a1, a2, ... aN);
      };
    };
  };
}
複製代碼

以上函數有如下特徵:

  1. i 個累加器返回另外一個函數(也就是第(i+1)個累加器),也能夠稱它爲第j個累加器。
  2. i個累加器接收i個參數,同時把以前的i-1個參數都保存其閉包環境中。
  3. 將會有N個嵌套函數,其中N是函數fn
  4. N個函數老是會調用fn函數

根據以上的特徵,咱們能夠看到柯里化函數返回一個擁有多個類似的累加器的嵌套函數。所以咱們可使用遞歸輕鬆生成這樣的結構。

function nest(fn) {
  return (x) => {
    // accumulator function
    return nest(fn);
  };
}

function curry(fn) {
  return nest(fn);
}
複製代碼

爲了不無限嵌套下去,須要一個讓嵌套中斷的狀況。咱們將當前嵌套深度存儲在變量i中,那麼此條件是i === N

function nest(fn, i) {
  return (x) => {
    if (i === fn.length) {    // 當執行到第 i 個時返回 fn
      return fn(...);
    }
    return nest(fn, i + 1);
  };
}
function curry(fn) {
  return nest(fn, 1);
}
複製代碼

接下來,咱們須要存儲全部參數,並把它們傳遞給fn()。最簡單的解決方案就是在curry中年建立一個數組args並將其傳遞給nest

function nest(fn, i, args) {
  return (x) => {
    args.push(x);      // 存儲每個參數
    if (i === fn.length) {
      return fn(...args);      // 最後把參數都傳遞給 fn()
    }
    return nest(fn, i + 1, args);
  };
}
function curry(fn) {
  const args = [];      // 須要傳入的參數列表

  return nest(fn, 1, args);
}
複製代碼

而後再添加一個沒有參數時的臨界處理:

function curry(fn) {
  if (fn.length === 0) {  // 當沒有參數時直接返回
    return fn;
  }
  const args = [];

  return nest(fn, 1, args);
}
複製代碼

此時來測試一下咱們的代碼:

const log1 = curry((x) => console.log(x));
log1(10); // 10
const log2 = curry((x, y) => console.log(x, y));
log2(10)(20); // 10 20
複製代碼

你能夠在codepen上運行測試


優化

對於初學者,咱們能夠在把nest放到curry中,從而能夠經過在閉包中讀取fnargs來,以此減小傳給nest的參數數量。

function curry(fn) {
  if (fn.length === 0) {
    return fn;
  }
  const args = [];
  function nest(i) {        // 相比於以前,不用傳遞 fn 和 args
    return (x) => {
      args.push(x);
      if (i === fn.length) {
        return fn(...args);
      }
      return nest(i + 1);
    };
  }
  return nest(1);
}
複製代碼

讓咱們把這個新的curry變得更加函數式,而不是依賴於閉包變量。咱們經過提供argsfn.length做爲參數嵌套來實現。此外,咱們把剩餘的遞歸深度(譯註:也就是除最後一層的函數),而不是傳遞目標深度(fn.length)進行比較。

function curry(fn) {
  if (fn.length === 0) {
    return fn;
  }
  function nest(N, args) {
    return (x) => {
      if (N - 1 === 0) {
        return fn(...args, x);
      }
      return nest(N - 1, [...args, x]);    // 根據fn.length - 1 遞歸那些嵌套的中間函數
    };
  }
  return nest(fn.length, []);  // 傳入 fn 的參數個數
}
複製代碼

可變的柯里化

讓咱們來比較sum3sum5

const sum3 = curry((x, y, z) => {
  return x + y + z;
});
const sum5 = curry((a, b, c, d, e) => {
  return a + b + c + d + e;
});
sum3(1)(2)(3)       // 6 <-- It works!
sum5(1)(2)(3)(4)(5) // 15 <-- It works!
複製代碼

毫無心外,它是正確的,但這個代碼是有點噁心。

在haskell和許多其餘函數式語言中,它們的設計更爲簡潔,和上面噁心的相比,咱們來看看haskell是如何處理它的:

let sum3 x y z = x + y + z
let sum5 a b c d e = a + b + c + d + e
sum3 1 2 3
> 6
sum5 1 2 3 4 5
> 15
sum5 1 2 3 (sum3 1 2 3) 5
> 17
複製代碼

若是你問我,JavaScript如下面的使用方式來調用會更好:

sum5(1, 2, 3, 4, 5) // 15
複製代碼

但這並不意味着咱們不得不放棄currying。咱們能作到的是找到一個一箭雙鵰的方式。一個便是「柯里化」又不是「柯里化」的調用方式。

sum3(1, 2, 3) // 清晰的
sum3(1, 2)(3)
sum3(1)(2, 3)
sum3(1)(2)(3) // 柯里化的
複製代碼

所以咱們須要作一個簡單的修改——用可變函數替換累加器函數。

當第i個累加器接收k個參數時,下一個累加器將不是N-1的深度,而是N-k``的深度。使用N-1```是因爲全部的累加器都只接收一個參數,這也意味着咱們再也不須要判斷參數爲0的狀況(Why?)。

因爲咱們如今每一個層級都收集多個參數,咱們須要檢查參數的數量來判斷是否超過fn的參數個數,而後再調用它。

function curry(fn) {
  function nest(N, args) {
    return (...xs) => {
      if (N - xs.length <= 0) {
        return fn(...args, ...xs);
      }
      return nest(N - xs.length, [...args, ...xs]);
    };
  }
  return nest(fn.length, []);
}
複製代碼

接下來是測試時間,你能夠在codepen上運行測試。

function curry(){...}
const sum3 = curry((x, y, z) => x + y + z);
console.log(
  sum3(1, 2, 3),
  sum3(1, 2)(3),
  sum3(1)(2, 3),
  sum3(1)(2)(3),
);
// 6 6 6 6
複製代碼

調用空的累加器

當使用可變參數的柯里化時,咱們能夠不向它傳遞任何參數來調用累加器函數。這將返回另外一個與前一個累加器相同的累加器。

const sum3 = curry((x, y, z) => x + y + z);
sum3(1,2,3) // 6
sum3()()()(1,2,3) // 6
sum3(1)(2,3) // 6
sum3()()()(1)()()(2,3) // 6
複製代碼

這種調用十分噁心,有一系列的空括號。雖然技術上沒有問題,但這個寫法是很糟糕的,所以須要有一個避免這種糟糕寫法的方式。

function curry(fn) {
  function nest(N, args) {
    return (...xs) => {
      if (xs.length === 0) {    // 避免空括號
        throw Error('EMPTY INVOCATION');
      }
      // ...
    };
  }
  return nest(fn.length, []);
}
複製代碼

另外一種柯里化的方式

咱們成功了!咱們創造了一個curry函數,它接收多個函數參數並返回帶有可變參數的柯里化函數。但我想展現JavaScript中的另外一種柯里化方法

在JavaScript中,咱們能夠將參數bind(綁定)到函數並建立綁定副本。返回的函數是隻是「部分應用」,由於函數已經擁有它所需的一些參數,但在調用以前須要更多。

到目前爲止,curry將返回一個函數,該函數在收到全部參數以前在不停地累積參數,而後使用這些參數來調用fn。經過將參數綁(譯註:bind方法)定到函數,咱們能夠消除對多個嵌套累加器函數。

所以能夠獲得:

function curry(fn) {
  return (...xs) => {
    if (xs.length === 0) {
      throw Error('EMPTY INVOCATION');
    }
    if (xs.length >= fn.length) {
      return fn(...xs);
    }
    return curry(fn.bind(null, ...xs));
  };
}
複製代碼

以上是它的工做原理。curry採用多個參數的函數並返回累加器函數。當用k個參數調用累加器時,咱們檢查k>=N,即判斷是否知足函數所需的參數個數。

若是知足,咱們傳入參數並調用fn,若是沒知足,則建立一個fn的副本,它具備綁定調用fn前的那些累加器的k個參數,並將其做爲下一個fn傳遞給curry,以達到減小N-k的目的。


最後

咱們經過累加器的方式編寫了通用的柯里化方法。這種方法適用於函數是一等公民的語言。咱們看到了嚴格的柯里化和可變參數的柯里化之間的區別。感謝JavaScript中提供了bind方法,用bind方法實現柯里化是很是容易的。

若是您對源代碼感興趣,請戳codepen


後記

給柯里化添加靜態類型檢查

在2018年,人們喜歡JavaScript中的靜態類型。並且我認爲如今是時候添加一些類型約束以保證類型安全了。

讓咱們從基礎開始:curry()接收一個函數並返回一個值或另外一個函數。咱們能夠這樣寫:

type Curry = <T>(Function) => T | Function;
const curry: Curry = (fn) => {
  ...
}
// function declaration
function curry<T>(fn: Function): T | Function {
  ...
}
複製代碼

好了。可是這並無什麼用。但這是能作到最好的程度了,Flow只增長了靜態類型的安全性,而實際上咱們有不少運行時的依賴性。此外,Flow不支持Haskell具備的跟更高階類型。這意味着沒有爲這種通用的柯里化添加更緊密的類型檢查。

If you still want a typed curry, here’s a gist by zerobias that show a 2-level and a 3-level curry function with static types: zerobias/92a48e1.

If you want to read more about curry and JS, here’s an article on 2ality.


嚴格意義上的柯里化

可變參數的柯里化是一個很好的東西,由於它爲咱們提供了一些空間。可是,咱們不要忘記,嚴格意義上的柯里化應該只接收一個參數。

... 柯里化是將函數分解爲一系列函數的過程,每一個函數都接收一個參數

讓咱們編寫一個嚴格的柯里化函數——一種只容許單個參數傳遞個柯里化函數。

function strictCurry(fn) {
  return (x) => {
    if (fn.length <= 1) {
      return fn(x);
    }
    return strictCurry(fn.bind(null, x));
  };
}

const ten = () => 10;
const times10 = (x) => 10 * x;
const multiply = (x, y) => x * y;
console.log(strictCurry(ten)())             // 10
console.log(strictCurry(times10)(123))      // 1230
console.log(strictCurry(multiply)(123)(10)) // 1230
複製代碼
相關文章
相關標籤/搜索