1. 程式人生 > >[譯] 柯里化與函式組合

[譯] 柯里化與函式組合

煙霧藝術從方塊到煙霧 — MattysFlicks — (CC BY 2.0)

注意:此篇文章是“組合軟體”系列的一部分,這個系列的目的是從頭在 JavaScript ES6+ 環境下學習函數語言程式設計和組合軟體技術。敬請關注。我們會講述大量關於這方面的知識!

< 上一篇 | << 第一篇

隨著在主流 JavaScript 中函數語言程式設計戲劇般地興起,在許多應用中柯里化函式變得普遍起來。理解它們是什麼、如何運作和怎樣有效地運用非常重要。

什麼是柯里化函式?

柯里化函式是一種由需要接受多個引數的函式轉化為一次只接受一個引數的函式。如果一個函式需要 3 個引數,那柯里化後的函式會接受一個引數並返回一個函式來接受下一個引數,這個函式返回的函式去傳入第三個引數。最後一個函式會返回應用了所有引數的函式結果。

你可以用更多或更少數量的引數來做同樣的事。例如有兩個數字,ab 的柯里化形式會返回 ab 之和。

// add = a => b => Number
const add = a => b => a + b;
複製程式碼

為了使用它,我們必須使用函式應用語法應用到這兩個函式上。在 JavaScript 中,函式後的括號 () 觸發函式呼叫。當函式返回另一個函式,被返回的函式可以通過一對額外的括號被立即呼叫:

const result = add(2)(3); // => 5
複製程式碼

首先,函式接受引數 a返回一個新的函式,新函式接受 b 返回 ab 之和。一次接受一個引數。如果函式有更多引數,它會簡單地繼續返回新函式直到所有的引數都被提供,這時應用完成。

add 函式接受一個引數,然後返回自己的 偏函式應用a 固定在偏函式應用的閉包作用域中。閉包指函式繫結其語法作用域。閉包在建立函式執行時被建立。固定意味著在閉包繫結的作用域內變數被賦值。

上例中的括號代表的函式呼叫過程:使用 2 做引數呼叫 add,返回偏函式應用並且 a 的值固定為 2。我們不會將返回值賦值給變數或以其他方式使用它,而是通過在括號中將 3 傳遞給它來立即呼叫返回函式,從而完成應用並返回 5

什麼是偏函式應用(Partial Application)?

偏函式應用是指使用一個函式並將其應用一個或多個引數,但不是全部引數。換句話說,它是一種在閉包作用域中已擁有一些固定引數的函式。偏函式應用是擁有部分固定引數的函式。

它們之間的不同之處?

偏函式應用可以根據需要一次接受多或少的引數。而柯里化函式總是返回一元函式:函式總是接受一個引數

所有的柯里化函式都返回偏函式應用,但不是所有的偏函式應用都是柯里化函式的結果。

柯里化函式的一元需求是一個重要特性。

什麼是無點風格(point-free style)?

無點風格是一種程式設計風格,其函式定義不會關聯函式的引數。讓我們來看 JavaScript 中的函式定義:

function foo (/* 這裡定義引數*/) {
  // ...
}

const foo = (/* 這裡定義引數 */) => // ...

const foo = function (/* 這裡定義引數 */) {
  // ...
}
複製程式碼

你如何能在 JavaScript 中定義不關聯引數的函式?我們不能使用 function 關鍵字,也不能使用箭頭函式(=>),因為這些都要求正式的引數宣告。所以我們要做的是呼叫一個會返回函式的函式。

使用無點風格建立一個函式,該方法會把你傳入的任何數字加一。記住,我們已經有一個叫 add 的函式,它需要一個數字做引數,並且無論你傳入了什麼值都會返回一個第一個引數固定的偏函式。我們可以使用這種方法建立一個叫 inc() 的新函式。

// inc = n => Number
// 把任何數字加一。
const inc = add(1);

inc(3); // => 4
複製程式碼

作為一種泛化和專用機制,這很有趣。返回的函式不過是更加通用的 add() 函式的一種專用版。我們可以按需要使用 add() 來建立許多專用版本。

const inc10 = add(10);
const inc20 = add(20);

inc10(3); // => 13
inc20(3); // => 23
複製程式碼

當然,所有這些都有它們自己的閉包作用域(閉包在函式建立時被建立 —— 在 add() 被呼叫時),所以原來 inc() 可以保持功能:

inc(3) // 4
複製程式碼

當我們呼叫 add(1) 來建立 inc() 時,add() 中的 a 引數在返回的函式中固定為 1,這個返回的函式賦值給inc

當我們呼叫 inc(3) 時,add() 中的 b 引數被引數 3 替換,函式結束,返回 13 之和。

所有的柯里化函式都是高階形式函式,它允許你為了專門用途建立原函式的專用版本。

為什麼要把函式柯里化?

柯里化函式在函式組合中極其有用。

在代數學中,假設有兩個函式,fg

f: a -> b
g: b -> c
複製程式碼

你可以把這兩個函式組合來建立一個新函式 h,從 a 直接得到 c

// 代數定義,從 Haskell 借鑑了組合操作符 `.`

h: a -> c
h = f . g = f(g(x))
複製程式碼

在 JavaScript 中:

const g = n => n + 1;
const f = n => n * 2;

const h = x => f(g(x));

h(20); //=> 42
複製程式碼

代數定義:

f . g = f(g(x))
複製程式碼

可以被轉換成 JavaScript:

const compose = (f, g) => f(g(x));
複製程式碼

但這隻能一次組合兩個函式。在代數中,有可能這麼寫:

g . f . h
複製程式碼

我們可以隨意把任意多個函式組合成一個函式。換句換說,compose() 在函式中建立了一個管道,把一個函式的輸出與下一個函式的輸入連線起來。

我經常以這種方法來寫:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);
複製程式碼

此版本使用任意多個函式並返回一個需要初始值的函式,然後使用 reduceRight() 從右到左遍歷每一個函式,即 fns 中的 f,並把它變成累積值 y。函式中累加器的計算值 y 就是函式 compose() 的返回值。

現在我們可以這樣組合:

const g = n => n + 1;
const f = n => n * 2;

// 使用 `compose(f, g)` 替換 `x => f(g(x))` `
const h = compose(f, g);

h(20); //=> 42
複製程式碼

跟蹤(Trace)

函式組合使用無點風格建立非常簡潔易懂的程式碼,不過若想簡單的除錯則要花點功夫。如果你想檢查函式間的值?你可以使用一種方便的工具 trace()。它需要柯里化函式的形式:

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
複製程式碼

現在我們來檢查管道:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

/*
注意:函式應用的順序是從下到上:
*/

const h = compose(
  trace('after f'),
  f,
  trace('after g'),
  g
);

h(20);
/*
after g: 21
after f: 42
*/
複製程式碼

compose() 是非常有用的工具,但當我們需要組合多於兩個函式時,從上到下的順序會更方便我們閱讀。我們可以通過反轉被呼叫函式的順序來做到。這裡有另一個名為 pipe 的組合工具,它反轉了組合的順序:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
複製程式碼

現在我們可以這樣寫上面的程式碼:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

/*
現在函式應用的順序是從上到下:
*/
const h = pipe(
  g,
  trace('after g'),
  f,
  trace('after f'),
);

h(20);
/*
after g: 21
after f: 42
*/
複製程式碼

結合柯里化和函式組合

即便不在函式組合的範疇中講,柯里化無疑也是一種非常有用的抽象,我們可以運用到專用函式。例如,柯里化版本的 map 可以被專用化來做很多不同的事情:

const map = fn => mappable => mappable.map(fn);

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const log = (...args) => console.log(...args);

const arr = [1, 2, 3, 4];
const isEven = n => n % 2 === 0;

const stripe = n => isEven(n) ? 'dark' : 'light';
const stripeAll = map(stripe);
const striped = stripeAll(arr);
log(striped);
// => ["light", "dark", "light", "dark"]

const double = n => n * 2;
const doubleAll = map(double);
const doubled = doubleAll(arr);
log(doubled);
// => [2, 4, 6, 8]
複製程式碼

但是柯里化函式的真正能力是它們可以簡化函式組合。一個函式可以接受任意數量的輸入,但是隻返回一個輸出。為了使函式可組合,輸出型別必須與期望輸入型別統一:

f: a => b
g:      b => c
h: a    =>   c
複製程式碼

如果上面的函式 g 期望兩個引數,f 的輸出就會和 g 的輸入不一致:

f: a => b
g:     (x, b) => c
h: a    =>   c
複製程式碼

在這種情況下如何把 x 傳入 g,答案是g 柯里化

記住柯里化函式的定義:一種由需要多個引數的函式轉化為一次只接受一個引數的函式,並且通過使用第一個引數並返回一系列函式直到所有的引數都已被收集。

上述定義的關鍵詞是“一次傳入一個引數”。對於函式組合來說柯里化函式如此方便的原因是它們把需要多個引數的函式變成了只需要一個引數的函式,允許它們適配函式組合管道。拿前面的 trace() 函式為例:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

const h = pipe(
  g,
  trace('after g'),
  f,
  trace('after f'),
);

h(20);
/*
after g: 21
after f: 42
*/
複製程式碼

trace() 定義兩個引數,但是每次只取一個引數,允許我們專用化行內函式。如果 trace() 沒有被柯里化,就不能這樣使用它。我們就必須這樣寫管道函式:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = (label, value) => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

const h = pipe(
  g,
  // trace() 不在是無點風格,並引入 `x` 作為中間變數。
  x => trace('after g', x),
  f,
  x => trace('after f', x),
);

h(20);
複製程式碼

但是單純的柯里化函式仍然不夠。你還需要保證函式期望的引數以按正確的順序來專用化它們。再看一遍我們柯里化 trace() 時發生了什麼,不過這次我們反轉引數的順序:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = value => label => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

const h = pipe(
  g,
  // trace() 不能為無點風格,因為期望的引數順序錯誤
  x => trace(x)('after g'),
  f,
  x => trace(x)('after f'),
);

h(20);
複製程式碼

如果有必要,你可以使用 flip 方法來解決這個問題,它簡單地反轉了兩個引數的順序:

const flip = fn => a => b => fn(b)(a);
複製程式碼

現在我們可以建立 flippedTrace() 函式:

const flippedTrace = flip(trace);
複製程式碼

並這樣使用它:

const flip = fn => a => b => fn(b)(a);
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = value => label => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const flippedTrace = flip(trace);

const g = n => n + 1;
const f = n => n * 2;

const h = pipe(
  g,
  flippedTrace('after g'),
  f,
  flippedTrace('after f'),
);

h(20);
複製程式碼

不過更好的方式是在開始就寫出正確的函式。有時這種風格被稱為“資料置後”,這意味著你需要首先傳入專用化引數,並在最後傳入引數執行函式。這裡展示了原始的函式形式:

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
複製程式碼

trace() 每次應用 label 時會建立專用版本的跟蹤函式,它會在管道中用到,管道中 labeltrace 返回的偏函式應用中是固定的。所以:

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

const traceAfterG = trace('after g');
複製程式碼

...等同於:

const traceAfterG = value => {
  const label = 'after g';
  console.log(`${ label }: ${ value }`);
  return value;
};
複製程式碼

如果我們把 trace('after g') 換成 traceAfterG,就等同於下面:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

// 柯里化版本的 trace() 能讓我們避免這種程式碼...
const traceAfterG = value => {
  const label = 'after g';
  console.log(`${ label }: ${ value }`);
  return value;
};

const g = n => n + 1;
const f = n => n * 2;

const h = pipe(
  g,
  traceAfterG,
  f,
  trace('after f'),
);

h(20);
複製程式碼

總結

柯里化函式是一種把接受多引數的函式變為接受單一引數的函式,通過使用第一個引數並返回使用餘下引數的一系列函式,直到所有的引數都被使用,並且函式應用結束,此時結果就會被返回。

偏函式應用是一種已經應用一些但非全部引數的函式。函式已經應用的引數被稱為固定引數(Fixed Parameters)

無點風格是一種不需要引用引數的函式定義風格。一般來說,無點函式通過呼叫返回函式的函式來建立,例如柯里化函式。

柯里化函式對於函式組合非常有用,因為由於函式組合的需要,你可以把 n 元函式輕鬆地轉換成一元函式形式:管道內的函式必須是單一引數。

資料置後函式對於函式組合來說非常方便,因為它們可以輕鬆地被用在無點風格中。

下一步

EricElliottJS.com 的會員可以看到此話題的完全指南視訊。會員可以訪問 ES6 Curry & Composition 課程


Eric Elliott 是 Programming JavaScript Applications(O’Reilly) 的作者,並且是軟體導師制平臺 DevAnywhere.io 的合作創始人。他擁有為 Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN、BBC 和頂尖音樂藝術家包括 Usher、Frank Ocean、Metallica 等工作的經驗。

他有著世界上最漂亮的女人陪著他在世界各地遠端工作。

感謝 JS_Cheerleader

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄