函數式編程中局部應用(Partial Application)和局部套用(Currying)的區別

局部應用(Partial Application,也譯做「偏應用」或「部分應用」)和局部
套用( Currying, 也譯做「柯里化」),是函數式編程範式中很經常使用的技巧。
本文着重於闡述它們的特色和(更重要的是)差別。編程

元(arity)

在後續的代碼示例中,會頻繁出現 unary(一元),binary(二元),
ternary(三元)或 polyadic(多元,即多於一元)以及 variadic(可變
元)等數學用語。在本文所表述的範圍內,它們都是用來描述函數的參數數量的。數組

局部應用

先來一個「無聊」的例子,實現一個 map 函數:數據結構

function map(list, unaryFn) {
  return [].map.call(list, unaryFn);
}

function square(n) {
  return n * n;
}

map([2, 3, 5], square);   // => [4, 9, 25]

這個例子固然缺少實用價值,咱們僅僅是仿造了數組的原型方法 map 而已,不
過相似的應用場景仍是能夠想象獲得的。那麼這個例子和局部應用有什麼關聯呢?app

如下是一些客觀陳述的事實(可是很重要,確保你看明白了):函數式編程

  1. 咱們的 map 是一個二元函數;
  2. square 是一個一元函數;
  3. 調用咱們的 map 時,咱們傳入了兩個參數([2, 3, 5]square),
    這兩個參數都應用在 map 函數裏,並返回給咱們最終的結果。

簡單明瞭吧?因爲 map 要兩個參數,咱們也給了兩個參數,因而咱們能夠說:函數

map 函數 徹底應用 了咱們傳入的參數。code

而所謂局部應用就像它的字面意思同樣,函數調用的時候只提供部分參數供其應用
——比方說上例,調用 map 的時候只傳給它一個參數。htm

但是這要怎麼實現呢?對象

首先,咱們把 map 包裝一下:ip

function mapWith(list, unaryFn) {
  return map(list, unaryFn);
}

而後,咱們把二元的包裝函數變成兩個層疊的一元函數:

function mapWith(unaryFn) {
  return function (list) {
    return map(list, unaryFn);
  };
}

因而,這個包裝函數就變成了:先接收一個參數,而後返回給咱們一個函數來接受
第二個參數,最終再返回結果。也就是這樣:

mapWith(square)([2, 3, 5]);  // => [4, 9, 25]

到目前爲止,局部應用彷佛沒有體現出什麼特別的價值,然而若是咱們把應用場景
稍微擴展一下的話……

var squareAll = mapWith(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]

咱們把對象 square(函數即對象)做爲部分參數應用在 map 函數中,獲得一
個一元函數,即 squareAll,因而咱們能夠想怎麼用就怎麼用。這就是局部應用
,恰當的使用這個技巧會很是有用。

局部套用

咱們能夠在局部應用的例子的基礎上繼續探索局部套用,首先把前面的 mapWith
稍微修整修整:

function wrapper(unaryFn) {
  return function(list) {
    return binaryFn(list, unaryFn);
  };
}
function wrapper(secondArg) {
  return function(firstArg) {
    return binaryFn(firstArg, secondArg);
  };
}

如上,我刻意把修整分做兩步來寫。第一步,咱們把 map 用一個更抽象的
binaryFn 取代,暗示咱們不侷限於作數組映射,能夠是任何一種二元函數的處
理;同時,最外層的 mapWith 也就沒有必要了,使用更抽象的 wrapper 取代
。第二步,既然用做處理的函數都抽象化了,傳入的參數天然也沒有必要限定其類
型,因而就獲得了最終的形態。

接下來的思考很是關鍵,請跟緊咯!

考慮一下未修整前的形態,最裏層的 map 是哪裏來的?——那是咱們在最開始
的時候本身定義的。然而到了修整後的形態,binaryFn 是個抽象的概念,此時
此刻咱們並無對應的函數能夠直接調用它,那麼咱們要如何提供這一步?

再包裝一層,把 binaryFn 做爲參數傳進來——

1 function rightmostCurry(binaryFn) {
2   return function (secondArg) {
3     return function (firstArg) {
4       return binaryFn(firstArg, secondArg);
5     };
6   };
7 }

你是否意識到這其實就是函數式編程的本質(的體現形式之一)?

那麼,局部套用是如何體現出來的呢?咱們把一開始寫的那個 map 函數套用進
來玩玩:

var rightmostCurriedMap = rightmostCurry(map);

var squareAll = rightmostCurriedMap(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]

最後三句和以前講局部應用的例子是同樣的,局部套用的體現就在第一句上。乍一
看,這貌似就是又多了一層局部應用而已啊?不,它們是有差異的!

對比一下兩個例子:

// 局部應用
function mapWith(unaryFn) {
  return function (list) {
    return map(list, unaryFn);
  };
}

// 局部套用
1 function rightmostCurry(binaryFn) {
2   return function (secondArg) {
3     return function (firstArg) {
4       return binaryFn(firstArg, secondArg);
5     };
6   };
7 }

在局部應用的例子裏,最內層的處理函數是肯定的;換言之,咱們對最終的處理方
式是有預期的。咱們只是把傳入參數分批完成,以得到:一)較大的應用靈活性;
二)更單純的函數調用形態。

而在局部套用的例子裏,第 2~6 行仍是局部應用——這沒差異;可是能夠看出
最內層的處理在定義的時候實際上是未知的,而第 1 行的目的是爲了傳入用於最
終處理的函數。所以咱們須要先傳入進行最終處理的函數,而後再給它分批傳入參
數(局部應用),以得到更大的應用靈活性。

回過頭來解讀一下這兩個名詞:

  • 局部應用: 返回最終結果的處理方式是限定的,每一層的函數調用所傳入
    的參數都將逐次參與最終處理過程當中去;
  • 局部套用: 返回最終結果的處理方式是未知的,須要咱們在使用的時候將
    其做爲參數傳入。

最左形式(leftmost)與最右形式(rightmost)的局部套用

在前面的例子中,爲何要把局部套用函數命名爲 rightmostCurry?另外,是
否還有與之對應的 leftmostCurry 呢?

請回頭再看一眼上例的第 2~6 行,會發現層疊的兩個一元函數先傳入
secondArg,再傳入 firstArg,而最內層的處理函數則是反過來的。如此一來
,咱們先接受最右邊的,再接受最左邊的,這就叫最右形式的局部套用;反之則是
最左形式的局部套用。

即便在本文的例子裏都使用二元參數,但其實多元也是同樣的,無非就是增長局
部應用的層疊數量;而可變元的應用也不難,徹底能夠用某種數據結構來封裝多
個元參數(如數組)而後再進行解構處理——ES6 的改進會讓這一點變得更加簡
單。

可是這又有什麼實際意義呢?仔細對比下面兩個代碼示例:

function rightmostCurry(binaryFn) {
  return function (secondArg) {
    return function (firstArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

var rightmostCurriedMap = rightmostCurry(map);

function square(n) { return n * n; }

var squareAll = rightmostCurriedMap(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]
function leftmostCurry(binaryFn) {
  return function (firstArg) {
    return function (secondArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

var leftmostCurriedMap = leftmostCurry(map);

function square(n) { return n * n; }
function double(n) { return n + n; }

var oneToThreeEach = leftmostCurriedMap([1, 2, 3]);
oneToThreeEach(square);   // => [1, 4, 9]
oneToThreeEach(double);   // => [2, 4, 6]

這兩個例子很容易理解,我想就無須贅述了。值得注意的是,因爲「從左向右」的
處理更合邏輯一些,因此現實中最左形式的局部套用比較常見,並且習慣上直接把
最左形式的局部套用就叫作 curry,因此若是沒有顯式的 rightmost 出現,
那麼就能夠按照慣例認爲它是最左形式的。

最後,什麼時候用最左形式什麼時候用最右形式?嗯……這個其實沒有規定的,徹底取決於
你的應用場景更適合用哪一種形式來表達。從上面的對比中能夠發現一樣的局部套用
(都套用 map),最左形式和最右形式會對應用形態的語義化表達產生不一樣的影
響:

  1. 對於最右形式的應用,如 squareAll([...]),它的潛臺詞是:無論傳入
    的是什麼,把它們挨個都平方咯。從語義角度來看,square 是主體,而
    傳入的數組是客體;
  2. 對於最左形式的應用,如 oneToThreeEach(...),沒必要說,天然是以前傳入
    [1, 2, 3] 是主體,而以後傳入的 squaredouble 纔是客體;

因此說,根據應用的場景來選擇最合適的形式吧,沒必要拘泥於特定的某種形式。

回到現實

至此,咱們已經把局部應用和局部套用的微妙差異分析的透徹了,但這更多的是理
論性質的研究罷了,現實中這二者的界限則很是模糊——因此不少人習慣混爲一談
也就不很意外了。

就拿 rightmostCurry 那個例子來講吧:

function rightmostCurry(binaryFn) {
  return function (secondArg) {
    return function (firstArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

像這樣局部套用摻雜着局部應用的代碼在現實中只能算是「半成品」,爲何呢?
由於你很快會發現這樣的尷尬:

var squareAll = rightmostCurry(map)(square);
var doubleAll = rightmostCurry(map)(double);

像這樣的「先局部套用而後緊接着局部應用」的模式是很是廣泛的,咱們爲何不
進一步抽象化它呢?

對於廣泛化的模式,人們習慣於給它一個命名。對於上面的例子,可分解描述爲:

  1. 最右形式的局部套用
  2. 針對 map
  3. 一元
  4. 局部應用

理一理語序能夠組合成:針對 map 的最右形式(局部套用)的一元局部應用。

真尼瑪的囉嗦!

實際上咱們真正想作的是:先給 map 函數局部應用一個參數,返回的結果能夠
繼續應用 map 須要的另一個參數(固然,你能夠把 map 替換成其餘的函
數,這就是局部套用的職責表現了)。真正留給咱們要實現的僅僅是返回另一部
分用於局部應用的一元函數罷了。

所以按照函數式編程的習慣,rightmostCurry 能夠簡化成:

function rightmostUnaryPartialApplication(binaryFn, secondArg) {
  return rightmostCurry(binaryFn, secondArg);
}

先別管冗長的命名,接着咱們套用局部應用的技巧,進一步改寫成更簡明易懂的形
式:

function rightmostUnaryPartialApplication(binaryFn, secondArg) {
  return function (firstArg) {
    return binaryFn(firstArg, secondArg);
  };
}

這纔是你在現實中隨處可見的「徹底形態」!至於冗長的命名,小問題啦:

var applyLast = rightmostUnaryPartialApplication;

var squareAll = applyLast(map, square);
var doubleAll = applyLast(map, double);

如此一來,最左形式的類似實現就能夠無腦出爐了:

function applyFirst(binaryFn, firstArg) {
  return function (secondArg) {
    return binaryFn(firstArg, secondArg);
  };
}

其實這樣的代碼不少開發者都已經寫過無數次了,但是若是你請教這是什麼寫法,
回答你「局部應用」或「局部套用」的都會有。對於初學者來講就容易鬧不清楚到
底有什麼區別,長此以往就乾脆認爲是一回事兒了。不過如今你應該明白過來了,
這個徹底體實際上是「局部應用」和「局部套用」的綜合應用。

總結

各用一句話作個小結吧:

  • 局部應用(Partial Application):是一種轉換技巧,經過預先傳入一個或多
    個參數來把多元函數轉變爲更少一些元的函數甚或是一元函數。

  • 局部套用(Currying):是一種解構技巧,用於把多元函數分解爲多個可鏈式調
    用的層疊式的一元函數,這種解構能夠容許你在其中局部應用一個或多個參數,但
    是局部套用自己不提供任何參數——它提供的是調用鏈裏的最終處理函數。

後記:撰寫本文的時間跨度較長,期間參考的資料和代碼沒法一一計數。可是
Raganwald 的書和博客 以及 Michael Fogue 的 Functional JavaScript 給 予個人幫助和指導是我難以忘記的,在此向兩位以及全部幫助個人大牛們致謝!

相關文章
相關標籤/搜索