js的curry和函式組合

xiaohesong發表於2018-11-14

原文: Eric Elliott - Curry and Function Composition

譯文: curry和函式組合

提醒: 本文略長,慎重閱讀

之前看到有文章說柯里化函式,大致看了下,就是高階函式,只是名字聽起來比較高大上一點,今天逛medium又發現了這個,看了下感覺還不錯,有涉及到閉包,涉及到point-free style, 並不是一股腦的安利demo了事,這個得記錄下。

什麼是curried 函式

curried函式是個一次一個的去獲取多個引數的函式。 再明白點,就是比如 給定一個帶有3個引數的函式,curried版的函式將接受一個引數並返回一個接受下一個引數的函式,該函式返回一個接受第三個引數的函式。最後一個函式返回將函式應用於其所有引數的結果。

看下面的例子,例如,給定兩個數字,abcurried形式,返回ab的總和:

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

然後就可以直接呼叫了:

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

首先,函式接受a作為引數,然後返回一個新的函式,然後將b傳遞給這個新的函式,最後返回ab的和。每次只傳遞一個引數。如果函式有更多引數,不止上面的a,b兩個引數,它可以繼續像上面這樣返回新的函式,直到最後結束這個函式。

add函式接受一個引數之後,然後在這個閉包的範圍內返回固定的一部分功能。閉包就是與詞法範圍捆綁在一起的函式。在執行函式建立時建立了閉包,可以在這裡瞭解更多固定的意思是說變數在閉包的繫結範圍內賦值。

在來看看上面的程式碼: add用引數2去呼叫,返回一個部分應用的函式,並且固定a2。我們不是將返回值賦值給變數或以其他方式使用它,而是通過在括號中將3傳遞給它來立即呼叫返回的函式,從而完成整個函式並返回5

什麼是部分功能

部分應用程式( partial application )是一個已應用於某些但並不是全部引數的函式。直白的來說就是一個在閉包範圍內固定了(不變)的一些引數的函式。具有一些引數被固定的功能被認為是部分應用的。

有什麼不同?

部分功能(partial application)可以根據需要一次使用多個或幾個引數。 柯里化函式(curried function)每次返回一個一元函式: 每次攜帶一個引數的函式。

所有curried函式都返回部分應用程式,但並非所有部分應用程式都是curried函式的結果。

對於curried來說,一元函式的這個要求是一個重要的特徵。

什麼是point-free風格

point-free是一種程式設計風格,其中函式定義不引用函式的引數。

我們先來看看js中函式的定義:

function foo (/* parameters are declared here*/) {
  // ...
}
const foo = (/* parameters are declared here */) => // ...
const foo = function (/* parameters are declared here */) {
  // ...
}
複製程式碼

如何在不引用所需引數的情況下在JavaScript中定義函式?好吧,我們不能使用function這個關鍵字,我們也不能使用箭頭函式(=>),因為它們需要宣告形式引數(引用它的引數)。所以我們需要做的就是 呼叫一個返回函式的函式。

建立一個函式,使用point-free增加傳遞給它的任何數字。記住,我們已經有一個名為add的函式,它接受一個數字並返回一個部分應用(partial application)的函式,其第一個引數固定為你傳入的任何內容。我們可以用它來建立一個名為inc()的新函式:

/ inc = n => Number
// Adds 1 to any 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之和。

所有curried函式都是高階函式的一種形式,它允許你為手頭的特定用例建立原始函式的專用版本。

為什麼要curry

curried函式在函式組合的上下文中特別有用。

在代數中,給出了兩個函式fg

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

我們可以將這些函式組合在一起建立一個新的函式(h),ha直接到c

//代數定義,借用`.`組合運算子 
//來自Haskell
h: a -> c
h = f . g = f(g(x))
複製程式碼

js中:

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

此版本接受任意數量的函式並返回一個取初始值x的函式,然後使用reduceRight()fns中從右到左迭代每個函式f,然後將其依次應用於累積值y 。我們在累加器中積累的函式,y在此函式中是由compose()返回的函式的返回值。

現在我們可以像這樣編寫我們的組合:

const g = n => n + 1;
const f = n => n * 2;
// replace `x => f(g(x))` with `compose(f, g)`
const h = compose(f, g);
h(20); //=> 42
複製程式碼

程式碼追蹤(trace)

使用point-free風格的函式組合建立了非常簡潔,可讀的程式碼,但是他不易於除錯。如果要檢查函式之間的值,該怎麼辦? trace()是一個方便實用的函式,可以讓你做到這一點。它採用curried函式的形式:

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;
/*
Note: function application order is
bottom-to-top:
*/
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);
複製程式碼

現在我們可以用pipe把上面的重寫下:

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;
/*
Now the function application order
runs top-to-bottom:
*/
const h = pipe(
  g,
  trace('after g'),
  f,
  trace('after f'),
);
h(20);
/*
after g: 21
after f: 42
*/
複製程式碼

curry和功能組合一起

即使在函式組合的上下文之外,curry無疑是一個有用的抽象,可以來做一些特定的事情。例如,一個curried版本的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]
複製程式碼

但是,curried函式的真正強大之處在於它們簡化了函式組合。函式可以接受任意數量的輸入,但只能返回單個輸出。為了使函式可組合,輸出型別必須與預期的輸入型別對齊:

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

如果上面的g函式預期有兩個引數,則f的輸出不會與g的輸入對齊:

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

在這種情況下我們如何獲得x?通常,答案是curry g

記住curried函式的定義是一個函式,它通過獲取第一個引數並返回一系列的函式一次獲取一個引數並且每個引數都採用下一個引數,直到收集完所有引數。

這個定義中的關鍵詞 是“一次一個”。curry函式對函式組合如此方便的原因是它們將期望多個引數的函式轉換為可以採用單個引數的函式,允許它們適合函式組合管道。以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不是curry,我們就不能以這種方式使用它。我們必須像這樣編寫管道:

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`呼叫不再是`point-free`風格,
  // 引入中間變數, `x`.
  x => trace('after g', x),
  f,
  x => trace('after f', x),
);
h(20);
複製程式碼

但是簡單的curry函式是不夠的,還需要確保函式按正確的引數順序來專門化它們。看看如果我們再次curry 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,
  // the trace() calls can't be point-free,
  // because arguments are expected in the wrong order.
  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);
複製程式碼

再這樣使用這個flippedTrace

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的每個應用程式都建立了一個在管道中使用的trace函式的專用版本,其中label被固定在返回的trace部分應用程式中。所以這:

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

如果我們為traceAfterG交換trace('after g'),那就意味著同樣的事情:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
// The curried version of trace()
// saves us from writing all this code...
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);
複製程式碼

總結

curried函式是一個函式,通過取第一個引數,一次一個地獲取多個引數,並返回一系列函式,每個函式接受下一個引數,直到所有引數都已修復,並且函式應用程式可以完成,此時返回結果值。

部分應用程式( partial application )是一個已經應用於某些 - 但尚未全部引數參與的函式。函式已經應用的引數稱為固定引數。

point-free style是一種定義函式而不引用其引數的方法。通常,通過呼叫返回函式的函式(例如curried函式)來建立point-free函式。

curried函式非常適合函式組合 ,因為它們允許你輕鬆地將n元函式轉換為函式組合管道所需的一元函式形式:管道中的函式必須只接收一個引數。

資料最後 的功能便於功能組合,因為它們可以輕鬆地用於point-free style

相關文章