「譯」理解JavaScript的柯里化

阿里雲前端發表於2018-11-18

理解JavaScript的柯里化

函數語言程式設計是一種程式設計風格,它可以將函式作為引數傳遞,並返回沒有副作用(改變程式狀態)的函式

許多計算機語言都採用了這種程式設計風格。在這些語言中,JavaScript、Haskell、Clojure、Erlang 和 Scala 是最流行的幾種。

由於這種風格具有傳遞和返回函式的能力,它帶來了許多概念:

  • 純函式
  • 柯里化
  • 高階函式

我們接下來要談到的概念就是這其中的柯里化

在這篇文章?中,我們會看到柯里化如何工作以及它是如何被軟體開發者運用到實踐中的。

提示:除了複製貼上,你可以使用 Bit 把可複用的 JavaScript 功能轉換為元件,這樣可以快速地和你的團隊在專案之間共享。

什麼是柯里化?

柯里化其實是函數語言程式設計的一個過程,在這個過程中我們能把一個帶有多個引數的函式轉換成一系列的巢狀函式。它返回一個新函式,這個新函式期望傳入下一個引數。

它不斷地返回新函式(像我們之前講的,這個新函式期望當前的引數),直到所有的引數都被使用。引數會一直保持 alive (通過閉包),當柯里化函式鏈中最後一個函式被返回和呼叫的時候,它們會用於執行。

柯里化是一個把具有較多 arity 的函式轉換成具有較少 arity 函式的過程 -- Kristina Brainwave

注意:上面的術語 arity ,指的是函式的引數數量。舉個例子,

function fn(a, b)
    //...
}
function _fn(a, b, c) {
    //...
}
複製程式碼

函式fn接受兩個引數(2-arity函式),_fn接受3個引數(3-arity函式)

所以,柯里化把一個多引數函式轉換為一系列只帶單個引數的函式。

讓我們來看一個簡單的示例:

function multiply(a, b, c) {
    return a * b * c;
}
複製程式碼

這個函式接受3個數字,將數字相乘並返回結果。

multiply(1,2,3); // 6
複製程式碼

你看,我們如何呼叫這個具有完整引數的乘法函式。讓我們建立一個柯里化後的版本,然後看看在一系列的呼叫中我們如何呼叫相同的函式(並且得到相同的結果):

function multiply(a) {
    return (b) => {
        return (c) => {
            return a * b * c
        }
    }
}
log(multiply(1)(2)(3)) // 6
複製程式碼

我們已經將 multiply(1,2,3) 函式呼叫轉換為多個 multiply(1)(2)(3) 的多個函式呼叫。

一個獨立的函式已經被轉換為一系列函式。為了得到1, 23三個數字想成的結果,這些引數一個接一個傳遞,每個數字都預先傳遞給下一個函式以便在內部呼叫。

我們可以拆分 multiply(1)(2)(3) 以便更好的理解它:

const mul1 = multiply(1);
const mul2 = mul1(2);
const result = mul2(3);
log(result); // 6
複製程式碼

讓我們依次呼叫他們。我們傳遞了1multiply函式:

let mul1 = multiply(1);
複製程式碼

它返回這個函式:

return (b) => {
        return (c) => {
            return a * b * c
        }
    }
複製程式碼

現在,mul1持有上面這個函式定義,它接受一個引數b

我們呼叫mul1函式,傳遞2

let mul2 = mul1(2);
複製程式碼

num1會返回第三個引數:

return (c) => {
  return a * b * c
}
複製程式碼

返回的引數現在儲存在變數mul2

mul2會變成:

mul2 = (c) => {
    return a * b * c
}
複製程式碼

當傳遞引數3給函式mul2並呼叫它,

const result = mul2(3);
複製程式碼

它和之前傳遞進來的引數:a = 1, b = 2做了計算,返回了6

log(result); // 6
複製程式碼

作為巢狀函式,mul2可以訪問外部函式的變數作用域。

這就是mul2能夠使用在已經退出的函式中定義的變數做加法運算的原因。儘管這些函式很早就返回了,並且從記憶體進行了垃圾回收,但是它們的變數仍然保持 alive

你會看到,三個數字一個接一個地應用於函式呼叫,並且每次都返回一個新函式,直到所有數字都被應用。

讓我們看另一個示例:

function volume(l,w,h) {
    return l * w * h;
}
const aCylinder = volume(100,20,90) // 180000
複製程式碼

我們有一個函式volume來計算任何一個固體形狀的體積。

被柯里化的版本將接受一個引數並且返回一個函式,這個新函式依然會接受一個引數並且返回一個新函式。這個過程會一直持續,直到最後一個引數到達並且返回最後一個函式,最後返回的函式會使用之前接受的引數和最後一個引數進行乘法運算。

function volume(l) {
    return (w) => {
        return (h) => {
            return l * w * h
        }
    }
}
const aCylinder = volume(100)(20)(90) // 180000
複製程式碼

像我們在函式multiply一樣,最後一個函式只接受引數h,但是會使用早已返回的其它作用域的變數來進行運算。由於閉包的原因,它們仍然可以工作。

柯里化背後的想法是,接受一個函式並且得到一個函式,這個函式返回專用的函式。

數學中的柯里化

我比較喜歡數學插圖?Wikipedia,它進一步演示了柯里化的概念。讓我們看看我們自己的示例。

假設我們有一個方程式:

f(x,y) = x^2 + y = z
複製程式碼

這裡有兩個變數 x 和 y 。如果這兩個變數被賦值,x=3y=4,最後得到 z 的值。

:如果我們在方法f(z,y)中,給y 賦值4,給x賦值3

f(x,y) = f(3,4) = x^2 + y = 3^2 + 4 = 13 = z
複製程式碼

我們會的到結果,13

我們可以柯里化f(x,y),分離成一系列函式:

h = x^2 + y = f(x,y)
hy(x) = x^2 + y = hx(y) = x^2 + y
[hx => w.r.t x] and [hy => w.r.t y]

複製程式碼

注意hxxh 的下標;hyyh 的下標。

如果我們在方程式 hx(y) = x^2 + y 中設定 x=3,它會返回一個新的方程式,這個方程式有一個變數y

h3(y) = 3^2 + y = 9 + y
Note: h3 is h subscript 3
複製程式碼

它和下面是一樣的:

h3(y) = h(3)(y) = f(3,y) = 3^2 + y = 9 + y
複製程式碼

這個值並沒有被求出來,它返回了一個新的方程式9 + y,這個方程式接受另一個變數, y

接下來,我們設定y=4

h3(4) = h(3)(4) = f(3,4) = 9 + 4 = 13
複製程式碼

y是這條鏈中的最後一個變數,加法操作會對它和依然存在的之前的變數x = 3做運算並得出結果,13

基本上,我們柯里化這個方程式,將f(x,y) = 3^2 + y劃分成了一個方程組:

3^2 + y -> 9 + y
f(3,y) = h3(y) = 3^2 + y = 9 + y
f(3,y) = 9 + y
f(3,4) = h3(4) = 9 + 4 = 13
複製程式碼

在最後得到結果之前。

Wow!!這是一些數學問題,如果你覺得不夠清晰?。可以在Wikipedia檢視?完整的細節。

柯里化和部分函式應用

現在,有些人可能開始認為,被柯里化的函式所具有的巢狀函式數量取決於它所依賴的引數個數。是的,這是決定它成為柯里化的原因。

我設計了一個被柯里化的求體積的函式:

function volume(l) {
    return (w, h) => {
        return l * w * h
    }
}
複製程式碼

我們可以如下呼叫L:

const hCy = volume(70);
hCy(203,142);
hCy(220,122);
hCy(120,123);
複製程式碼

或者

volume(70)(90,30);
volume(70)(390,320);
volume(70)(940,340);
複製程式碼

我們定義了一個用於專門計算任何長度的圓柱體體積(l)的函式,70

它有3個引數和2個巢狀函式。不像我們之前的版本,有3個引數和3個巢狀函式。

這不是一個柯里化的版本。我們只是做了體積計算函式的部分應用。

柯里化和部分應用是相似的,但是它們是不同的概念。

部分應用將一個函式轉換為另一個較小的函式。

function acidityRatio(x, y, z) {
    return performOp(x,y,z)
}
|
V
function acidityRatio(x) {
    return (y,z) => {
        return performOp(x,y,z)
    }
}
複製程式碼

注意:我故意忽略了performOp函式的實現。在這裡,它不是必要的。你只需要知道柯里化和部分應用背後的概念。

這是 acidityRatio 函式的部分應用。這裡面不涉及到柯里化。acidityRatio被部分應用化,它期望接受比原始函式更少的引數。

讓它變成柯里化,會是這樣:

function acidityRatio(x) {
    return (y) = > {
        return (z) = > {
            return performOp(x,y,z)
        }
    }
}
複製程式碼

柯里化根據函式的引數數量建立巢狀函式。每個函式接受一個引數。如果沒有引數,那就不是柯里化。

柯里化在具有兩個引數以上的函式工作 -  Wikipedia

柯里化將一個函式轉換為一系列只接受單個引數的函式。、

這裡有一個柯里化和部分應用相同的例子。假設我們有一個函式:

function div(x,y) {
    return x/y;
}
複製程式碼

如果我們部分應用化這個函式。會得到:

function div(x) {
    return (y) => {
        return x/y;
    }
}
複製程式碼

而且,柯里化會得出相同的結果:

function div(x) {
    return (y) => {
        return x/y;
    }
}
複製程式碼

儘管柯里化和部分應用得出了相同的結果,但是它們是兩個完全不同的概念。

像我們之前說的,柯里化和部分應用是相似的,但是實際上定義卻不同。它們之間的相同點就是依賴閉包。

柯里化有用嗎?

當然,只要你想,柯里化就信手拈來:

1、編寫小模組的程式碼,可以更輕鬆的重用和配置,就行 npm 做的那樣:

舉個例子,你有一個商店?,你想給你的顧客 10% 的折扣:

function discount(price, discount) {
    return price * discount
}
複製程式碼

當一個有價值的客戶買了一件$500的商品,你會給他:

const price = discount(500,0.10); // $50 
// $500 - $50 = $450
複製程式碼

你會發現從長遠來看,我們每天都自己計算10%的折扣。

const price = discount(1500,0.10); // $150
// $1,500 - $150 = $1,350
const price = discount(2000,0.10); // $200
// $2,000 - $200 = $1,800
const price = discount(50,0.10); // $5
// $50 - $5 = $45
const price = discount(5000,0.10); // $500
// $5,000 - $500 = $4,500
const price = discount(300,0.10); // $30
// $300 - $30 = $270
複製程式碼

我們可以柯里化這個折扣函式,這樣就不需要每天都新增0.10這個折扣值:

function discount(discount) {
    return (price) => {
        return price * discount;
    }
}
const tenPercentDiscount = discount(0.1);
複製程式碼

現在,我們可以只用你有價值的客戶購買的商品價格來進行計算了:

tenPercentDiscount(500); // $50
// $500 - $50 = $450
複製程式碼

再一次,發生了這樣的情況,有一些有價值的客戶比另一些有價值的客戶更重要 -- 我們叫他們超級價值客戶。並且我們想給超級價值客戶20%的折扣。

我們使用被柯里化的折扣函式:

const twentyPercentDiscount = discount(0.2);
複製程式碼

我們為超級價值客戶設定了一個新函式,這個新函式呼叫了接受折扣值為0.2的柯里化函式。

返回的函式twentyPercentDiscount將被用於計算超級價值客戶的折扣:

twentyPercentDiscount(500); // 100
// $500 - $100 = $400
twentyPercentDiscount(5000); // 1000
// $5,000 - $1,000 = $4,000
twentyPercentDiscount(1000000); // 200000
// $1,000,000 - $200,000 = $600,000
複製程式碼

2、避免頻繁呼叫具有相同引數的函式:

舉個例子,我們有一個函式來計算圓柱體的體積:

function volume(l, w, h) {
    return l * w * h;
}
複製程式碼

碰巧,你的倉庫所有的圓柱體高度都是 100m。你會發現你會重複呼叫接受高度為 100 的引數的函式:

volume(200,30,100) // 2003000l
volume(32,45,100); //144000l
volume(2322,232,100) // 53870400l
複製程式碼

為了解決這個問題,需要柯里化這個計算體積的函式(像我們之前做的一樣):

function volume(h) {
    return (w) => {
        return (l) => {
            return l * w * h
        }
    }
}
複製程式碼

我們可以定義一個特定的函式,這個函式用於計算特定的圓柱體高度:

const hCylinderHeight = volume(100);
hCylinderHeight(200)(30); // 600,000l
hCylinderHeight(2322)(232); // 53,870,400l
複製程式碼

通用的柯里化函式

讓我們開發一個函式,它能接受任何函式並返回一個柯里化版本的函式。

為了做到這一點,我們需要這個(儘管你自己使用的方法和我的不同):

function curry(fn, ...args) {
    return (..._arg) => {
        return fn(...args, ..._arg);
    }
}
複製程式碼

我們在這裡做了什麼呢?我們的柯里化函式接受一個我們希望柯里化的函式(fn),還有一系列的引數(...args)。擴充套件運算子是用來收集fn後面的引數到...args中。

接下來,我們返回一個函式,這個函式同樣將剩餘的引數收集為..._args。這個函式將...args傳入原始函式fn並呼叫它,通過使用擴充套件運算子將..._args也作為引數傳入,然後,得到的值會返回給使用者。

現在我們可以使用我們自己的curry函式來創造專用的函式了。

讓我們使用自己的柯里化函式來建立更多的專用函式(其中一個就是專門用來計算高度為100m的圓柱體體積的方法)

function volume(l,h,w) {
    return l * h * w
}
const hCy = curry(volume,100);
hCy(200,900); // 18000000l
hCy(70,60); // 420000l
複製程式碼

總結

閉包使柯里化在JavaScript中得以實現。它保持著已經執行過的函式的狀態,使我們能夠建立工廠函式 - 一種我們能夠新增特定引數的函式。

要想將你的頭腦充滿著柯里化、閉包和函數語言程式設計是非常困難的。但我向你保證,花時間並且在日常應用,你會掌握它的訣竅並看到價值?。

參考

?Currying—Wikipedia

?Partial Application Function—Wikipedia

相關文章