[譯]JavaScript中的函數(shù)柯里化

原文

函數(shù)柯里化

函數(shù)柯里化以Haskell Brooks Curry命名萧豆,柯里化是指將一個(gè)函數(shù)分解為一系列函數(shù)的過程,每個(gè)函數(shù)都只接收一個(gè)參數(shù)。(譯注:這些函數(shù)不會(huì)立即求值沼头,而是通過閉包的方式把傳入的參數(shù)保存起來羹令,直到真正需要的時(shí)候才會(huì)求值)


柯里化例子

以下是一個(gè)簡(jiǎn)單的柯里化例子。我們寫一個(gè)接收三個(gè)數(shù)字并返回它們總和的函數(shù)sum3孝治。

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

console.log(sum3(1, 2, 3))  // 6

sum3的柯里化版本的結(jié)構(gòu)不一樣列粪。它接收一個(gè)參數(shù)并返回一個(gè)函數(shù)审磁。返回的函數(shù)。返回的函數(shù)中又接收一個(gè)餐你輸岂座,返回另一個(gè)仍然只接收一個(gè)參數(shù)的函數(shù)...(以此往復(fù))

直到返回的函數(shù)接收到最后一個(gè)參數(shù)時(shí)态蒂,這個(gè)循環(huán)才結(jié)束。這個(gè)最后的函數(shù)將會(huì)返回?cái)?shù)字的總和费什,如下所示钾恢。

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

console.log(sum(1)(2)(3)) // 6

以上的代碼能跑起來,是因?yàn)镴avaScript支持閉包

一個(gè)閉包是由函數(shù)和聲明這個(gè)函數(shù)的詞法環(huán)境組成的
-- MDN

注意函數(shù)鏈中的最后一個(gè)函數(shù)只接收一個(gè)z鸳址,但它同時(shí)也對(duì)外層的變量進(jìn)行操作瘩蚪,在這個(gè)例子中,這些外層的變量對(duì)于最后一個(gè)函數(shù)來說類似于全局變量稿黍。實(shí)際上只是相當(dāng)于不同函數(shù)下的局部變量

// 相當(dāng)于全局變量
let x = ...?
let y = ...?

// 只接收一個(gè)參數(shù) z 但也操作 x 和 y
return function(z) {
  return x + y + z;
}

通用的柯里化

寫一個(gè)柯里化函數(shù)還好疹瘦,但如果要編寫多個(gè)函數(shù)時(shí),這就不夠用了巡球,因此我們需要一種更加通用的編寫方式言沐。

在大多數(shù)函數(shù)式編程語言中,比如haskell酣栈,我們所要做的就是定義函數(shù)险胰,它會(huì)自動(dòng)地進(jìn)行柯里化。

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 -- 函數(shù)名 括號(hào)中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定義柯里化函數(shù) 括號(hào)中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最后返回 括號(hào)中的部分

我們不能JS引擎重寫為curry-ify所有函數(shù)钉嘹,但是我們可以使用一個(gè)策略來實(shí)現(xiàn)鸯乃。


柯里化策略

通過上述兩種sum3的形式發(fā)現(xiàn),實(shí)際上處理加法邏輯的函數(shù)被移動(dòng)到閉包鏈的最后一個(gè)函數(shù)中跋涣。在到達(dá)最后一級(jí)之前缨睡,我們不會(huì)在執(zhí)行環(huán)境中獲得所有需要的參數(shù)。

這意味著我們可以創(chuàng)建一個(gè)包裝哈數(shù)來收集這些參數(shù)陈辱,然后把它們傳遞給實(shí)際要執(zhí)行的函數(shù) (sum3)奖年。所有中間嵌套的函數(shù)都稱為累加器函數(shù) - 至少我們可以這樣稱呼它們。

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

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

sum3(1)(2)(3)  // 6

用柯里化包裹之

由于我們要使用一個(gè)包裝后的函數(shù)來替代實(shí)際的函數(shù)沛贪,因此我們可以創(chuàng)建另一個(gè)函數(shù)來包裹陋守。我們將這個(gè)新生成的函數(shù)稱之為curry —— 一個(gè)更高階的函數(shù),它的作用是返回一系列嵌套的累加器函數(shù)利赋,最后調(diào)用回調(diào)函數(shù)fn

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

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

sum3(1)(2)(3) // 6

現(xiàn)在我們需要滿足有不同參數(shù)的柯里化函數(shù):它可能有0個(gè)參數(shù)水评,1個(gè)參數(shù),2個(gè)參數(shù)等等....


遞歸的柯里化

實(shí)際上我們并不是真的要編寫多個(gè)滿足不同參數(shù)的柯里化函數(shù)媚送,而是應(yīng)當(dāng)編寫一個(gè)適用于多個(gè)參數(shù)的柯里化函數(shù)中燥。

如果我們真的寫多個(gè)curry函數(shù),那將會(huì)如下所示...:

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 個(gè)嵌套函數(shù)
        return fn(a1, a2, ... aN);
      };
    };
  };
}

以上函數(shù)有以下特征:

  1. i 個(gè)累加器返回另一個(gè)函數(shù)(也就是第(i+1)個(gè)累加器)塘偎,也可以稱它為第j個(gè)累加器疗涉。
  2. i個(gè)累加器接收i個(gè)參數(shù)拿霉,同時(shí)把之前的i-1個(gè)參數(shù)都保存其閉包環(huán)境中。
  3. 將會(huì)有N個(gè)嵌套函數(shù)咱扣,其中N是函數(shù)fn
  4. N個(gè)函數(shù)總是會(huì)調(diào)用fn函數(shù)

根據(jù)以上的特征绽淘,我們可以看到柯里化函數(shù)返回一個(gè)擁有多個(gè)相似的累加器的嵌套函數(shù)。因此我們可以使用遞歸輕松生成這樣的結(jié)構(gòu)闹伪。

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

function curry(fn) {
  return nest(fn);
}

為了避免無限嵌套下去沪铭,需要一個(gè)讓嵌套中斷的情況。我們將當(dāng)前嵌套深度存儲(chǔ)在變量i中偏瓤,那么此條件是i === N

function nest(fn, i) {
  return (x) => {
    if (i === fn.length) {    // 當(dāng)執(zhí)行到第 i 個(gè)時(shí)返回 fn
      return fn(...);
    }
    return nest(fn, i + 1);
  };
}
function curry(fn) {
  return nest(fn, 1);
}

接下來伦意,我們需要存儲(chǔ)所有參數(shù),并把它們傳遞給fn()硼补。最簡(jiǎn)單的解決方案就是在curry中年創(chuàng)建一個(gè)數(shù)組args并將其傳遞給nest

function nest(fn, i, args) {
  return (x) => {
    args.push(x);      // 存儲(chǔ)每一個(gè)參數(shù)
    if (i === fn.length) {
      return fn(...args);      // 最后把參數(shù)都傳遞給 fn()
    }
    return nest(fn, i + 1, args);
  };
}
function curry(fn) {
  const args = [];      // 需要傳入的參數(shù)列表

  return nest(fn, 1, args);
}

然后再添加一個(gè)沒有參數(shù)時(shí)的臨界處理:

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

  return nest(fn, 1, args);
}

此時(shí)來測(cè)試一下我們的代碼:

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上運(yùn)行測(cè)試


優(yōu)化

對(duì)于初學(xué)者,我們可以在把nest放到curry中熏矿,從而可以通過在閉包中讀取fnargs來已骇,以此減少傳給nest的參數(shù)數(shù)量。

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);
}

讓我們把這個(gè)新的curry變得更加函數(shù)式褪储,而不是依賴于閉包變量。我們通過提供argsfn.length作為參數(shù)嵌套來實(shí)現(xiàn)慧域。此外鲤竹,我們把剩余的遞歸深度(譯注:也就是除最后一層的函數(shù)),而不是傳遞目標(biāo)深度(fn.length)進(jìn)行比較昔榴。

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]);    // 根據(jù)fn.length - 1 遞歸那些嵌套的中間函數(shù)
    };
  }
  return nest(fn.length, []);  // 傳入 fn 的參數(shù)個(gè)數(shù)
}

可變的柯里化

讓我們來比較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!

毫無意外辛藻,它是正確的,但這個(gè)代碼是有點(diǎn)惡心互订。

在haskell和許多其他函數(shù)式語言中吱肌,它們的設(shè)計(jì)更為簡(jiǎn)潔,和上面惡心的相比仰禽,我們來看看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以下面的使用方式來調(diào)用會(huì)更好:

sum5(1, 2, 3, 4, 5) // 15

但這并不意味著我們不得不放棄currying。我們能做到的是找到一個(gè)兩全其美的方式吐葵。一個(gè)即是“柯里化”又不是“柯里化”的調(diào)用方式规揪。

sum3(1, 2, 3) // 清晰的
sum3(1, 2)(3)
sum3(1)(2, 3)
sum3(1)(2)(3) // 柯里化的

因此我們需要做一個(gè)簡(jiǎn)單的修改——用可變函數(shù)替換累加器函數(shù)。

當(dāng)?shù)?code>i個(gè)累加器接收k個(gè)參數(shù)時(shí)温峭,下一個(gè)累加器將不是N-1的深度猛铅,而是N-k``的深度。使用N-1```是由于所有的累加器都只接收一個(gè)參數(shù)诚镰,這也意味著我們不再需要判斷參數(shù)為0的情況(Why?)奕坟。

由于我們現(xiàn)在每個(gè)層級(jí)都收集多個(gè)參數(shù)祥款,我們需要檢查參數(shù)的數(shù)量來判斷是否超過fn的參數(shù)個(gè)數(shù),然后再調(diào)用它月杉。

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, []);
}

接下來是測(cè)試時(shí)間刃跛,你可以在codepen上運(yùn)行測(cè)試。

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

調(diào)用空的累加器

當(dāng)使用可變參數(shù)的柯里化時(shí)苛萎,我們可以不向它傳遞任何參數(shù)來調(diào)用累加器函數(shù)桨昙。這將返回另一個(gè)與前一個(gè)累加器相同的累加器。

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

這種調(diào)用十分惡心腌歉,有一系列的空括號(hào)蛙酪。雖然技術(shù)上沒有問題,但這個(gè)寫法是很糟糕的翘盖,因此需要有一個(gè)避免這種糟糕寫法的方式桂塞。

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

另一種柯里化的方式

我們成功了!我們創(chuàng)造了一個(gè)curry函數(shù)馍驯,它接收多個(gè)函數(shù)參數(shù)并返回帶有可變參數(shù)的柯里化函數(shù)阁危。但我想展示JavaScript中的另一種柯里化方法

在JavaScript中,我們可以將參數(shù)bind(綁定)到函數(shù)并創(chuàng)建綁定副本汰瘫。返回的函數(shù)是只是“部分應(yīng)用”狂打,因?yàn)楹瘮?shù)已經(jīng)擁有它所需的一些參數(shù),但在調(diào)用之前需要更多混弥。

到目前為止趴乡,curry將返回一個(gè)函數(shù),該函數(shù)在收到所有參數(shù)之前在不停地累積參數(shù)蝗拿,然后使用這些參數(shù)來調(diào)用fn晾捏。通過將參數(shù)綁(譯注:bind方法)定到函數(shù),我們可以消除對(duì)多個(gè)嵌套累加器函數(shù)蛹磺。

因此可以得到:

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采用多個(gè)參數(shù)的函數(shù)并返回累加器函數(shù)。當(dāng)用k個(gè)參數(shù)調(diào)用累加器時(shí)萤捆,我們檢查k>=N裙品,即判斷是否滿足函數(shù)所需的參數(shù)個(gè)數(shù)。

如果滿足俗或,我們傳入?yún)?shù)并調(diào)用fn市怎,如果沒滿足,則創(chuàng)建一個(gè)fn的副本辛慰,它具有綁定調(diào)用fn前的那些累加器的k個(gè)參數(shù)区匠,并將其作為下一個(gè)fn傳遞給curry,以達(dá)到減少N-k的目的。


最后

我們通過累加器的方式編寫了通用的柯里化方法驰弄。這種方法適用于函數(shù)是一等公民的語言麻汰。我們看到了嚴(yán)格的柯里化和可變參數(shù)的柯里化之間的區(qū)別。感謝JavaScript中提供了bind方法戚篙,用bind方法實(shí)現(xiàn)柯里化是非常容易的五鲫。

如果您對(duì)源代碼感興趣,請(qǐng)戳codepen岔擂。


后記

給柯里化添加靜態(tài)類型檢查

在2018年位喂,人們喜歡JavaScript中的靜態(tài)類型。而且我認(rèn)為現(xiàn)在是時(shí)候添加一些類型約束以保證類型安全了乱灵。

讓我們從基礎(chǔ)開始:curry()接收一個(gè)函數(shù)并返回一個(gè)值或另一個(gè)函數(shù)塑崖。我們可以這樣寫:

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

好了。但是這并沒有什么用痛倚。但這是能做到最好的程度了规婆,F(xiàn)low只增加了靜態(tài)類型的安全性,而實(shí)際上我們有很多運(yùn)行時(shí)的依賴性蝉稳。此外聋呢,F(xiàn)low不支持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.


嚴(yán)格意義上的柯里化

可變參數(shù)的柯里化是一個(gè)很好的東西,因?yàn)樗鼮槲覀兲峁┝艘恍┛臻g通铲。但是毕莱,我們不要忘記,嚴(yán)格意義上的柯里化應(yīng)該只接收一個(gè)參數(shù)颅夺。

... 柯里化是將函數(shù)分解為一系列函數(shù)的過程朋截,每個(gè)函數(shù)都接收一個(gè)參數(shù)

讓我們編寫一個(gè)嚴(yán)格的柯里化函數(shù)——一種只允許單個(gè)參數(shù)傳遞個(gè)柯里化函數(shù)。

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
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末吧黄,一起剝皮案震驚了整個(gè)濱河市部服,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌拗慨,老刑警劉巖廓八,帶你破解...
    沈念sama閱讀 222,590評(píng)論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異赵抢,居然都是意外死亡剧蹂,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,157評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門烦却,熙熙樓的掌柜王于貴愁眉苦臉地迎上來宠叼,“玉大人,你說我怎么就攤上這事其爵∶岸” “怎么了伸蚯?”我有些...
    開封第一講書人閱讀 169,301評(píng)論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)简烤。 經(jīng)常有香客問我剂邮,道長(zhǎng),這世上最難降的妖魔是什么乐埠? 我笑而不...
    開封第一講書人閱讀 60,078評(píng)論 1 300
  • 正文 為了忘掉前任抗斤,我火速辦了婚禮,結(jié)果婚禮上丈咐,老公的妹妹穿的比我還像新娘瑞眼。我一直安慰自己,他們只是感情好棵逊,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,082評(píng)論 6 398
  • 文/花漫 我一把揭開白布伤疙。 她就那樣靜靜地躺著,像睡著了一般辆影。 火紅的嫁衣襯著肌膚如雪徒像。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,682評(píng)論 1 312
  • 那天蛙讥,我揣著相機(jī)與錄音锯蛀,去河邊找鬼。 笑死次慢,一個(gè)胖子當(dāng)著我的面吹牛旁涤,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播迫像,決...
    沈念sama閱讀 41,155評(píng)論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼劈愚,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了闻妓?” 一聲冷哼從身側(cè)響起菌羽,我...
    開封第一講書人閱讀 40,098評(píng)論 0 277
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎由缆,沒想到半個(gè)月后注祖,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,638評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡均唉,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,701評(píng)論 3 342
  • 正文 我和宋清朗相戀三年氓轰,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片浸卦。...
    茶點(diǎn)故事閱讀 40,852評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡署鸡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情靴庆,我是刑警寧澤时捌,帶...
    沈念sama閱讀 36,520評(píng)論 5 351
  • 正文 年R本政府宣布,位于F島的核電站炉抒,受9級(jí)特大地震影響奢讨,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜焰薄,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,181評(píng)論 3 335
  • 文/蒙蒙 一拿诸、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧塞茅,春花似錦亩码、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,674評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至鞭光,卻和暖如春吏廉,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背惰许。 一陣腳步聲響...
    開封第一講書人閱讀 33,788評(píng)論 1 274
  • 我被黑心中介騙來泰國打工席覆, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人汹买。 一個(gè)月前我還...
    沈念sama閱讀 49,279評(píng)論 3 379
  • 正文 我出身青樓娜睛,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親卦睹。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,851評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容