[譯] JavaScript中的函式柯里化

西麥發表於2018-12-19

原文

函式柯里化

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


柯里化例子

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

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

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

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

直到返回的函式接收到最後一個引數時,這個迴圈才結束。這個最後的函式將會返回數字的總和,如下所示。

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

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

以上的程式碼能跑起來,是因為JavaScript支援閉包

一個閉包是由函式和宣告這個函式的詞法環境組成的 -- MDN

注意函式鏈中的最後一個函式只接收一個z,但它同時也對外層的變數進行操作,在這個例子中,這些外層的變數對於最後一個函式來說類似於全域性變數。實際上只是相當於不同函式下的區域性變數

// 相當於全域性變數
let x = ...?
let y = ...?

// 只接收一個引數 z 但也操作 x 和 y
return function(z) {
  return x + y + z;
}
複製程式碼

通用的柯里化

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

在大多數函數語言程式設計語言中,比如haskell,我們所要做的就是定義函式,它會自動地進行柯里化。

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
複製程式碼

相關文章