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

原文

函數(shù)柯里化

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


柯里化例子

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

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

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

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

直到返回的函數(shù)接收到最后一個參數(shù)時,這個循環(huán)才結(jié)束。這個最后的函數(shù)將會返回數(shù)字的總和,如下所示。

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

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

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

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

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

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

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

通用的柯里化

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

在大多數(shù)函數(shù)式編程語言中,比如haskell,我們所要做的就是定義函數(shù),它會自動地進(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ù)名 括號中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定義柯里化函數(shù) 括號中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最后返回 括號中的部分

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


柯里化策略

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

這意味著我們可以創(chuàng)建一個包裝哈數(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ù)都傳給這個最終執(zhí)行的函數(shù)
    }
  }
}

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

用柯里化包裹之

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

function curry(fn) {     // 定義一個包裹它們的柯里化函數(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個參數(shù),1個參數(shù),2個參數(shù)等等....


遞歸的柯里化

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

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

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

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

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

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

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

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

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

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

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

function nest(fn, i, args) {
  return (x) => {
    args.push(x);      // 存儲每一個參數(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);
}

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

function curry(fn) {
  if (fn.length === 0) {  // 當(dāng)沒有參數(shù)時直接返回
    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上運(yùn)行測試


優(yōu)化

對于初學(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);
}

讓我們把這個新的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ù)個數(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!

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

在haskell和許多其他函數(shù)式語言中,它們的設(shè)計更為簡潔,和上面惡心的相比,我們來看看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)用會更好:

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

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

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

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

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

由于我們現(xiàn)在每個層級都收集多個參數(shù),我們需要檢查參數(shù)的數(shù)量來判斷是否超過fn的參數(shù)個數(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, []);
}

接下來是測試時間,你可以在codepen上運(yùn)行測試。

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ù)來調(diào)用累加器函數(shù)。這將返回另一個與前一個累加器相同的累加器。

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)用十分惡心,有一系列的空括號。雖然技術(shù)上沒有問題,但這個寫法是很糟糕的,因此需要有一個避免這種糟糕寫法的方式。

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

另一種柯里化的方式

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

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

到目前為止,curry將返回一個函數(shù),該函數(shù)在收到所有參數(shù)之前在不停地累積參數(shù),然后使用這些參數(shù)來調(diào)用fn。通過將參數(shù)綁(譯注:bind方法)定到函數(shù),我們可以消除對多個嵌套累加器函數(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采用多個參數(shù)的函數(shù)并返回累加器函數(shù)。當(dāng)用k個參數(shù)調(diào)用累加器時,我們檢查k>=N,即判斷是否滿足函數(shù)所需的參數(shù)個數(shù)。

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


最后

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

如果您對源代碼感興趣,請戳codepen


后記

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

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

讓我們從基礎(chǔ)開始:curry()接收一個函數(shù)并返回一個值或另一個函數(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)行時的依賴性。此外,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ù)的柯里化是一個很好的東西,因?yàn)樗鼮槲覀兲峁┝艘恍┛臻g。但是,我們不要忘記,嚴(yán)格意義上的柯里化應(yīng)該只接收一個參數(shù)。

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

讓我們編寫一個嚴(yán)格的柯里化函數(shù)——一種只允許單個參數(shù)傳遞個柯里化函數(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)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容