【譯】理解JavaScript中的柯里化

LINJIAJUN發表於2018-11-10

譯文開始

函數語言程式設計是一種程式設計風格,這種程式設計風格就是試圖將傳遞函式作為引數(即將作為回撥函式)和返回一個函式,但沒有函式副作用(函式副作用即會改變程式的狀態)。
有很多語言採用這種程式設計風格,其中包括JavaScript、Haskell、Clojure、Erlang和Scala等一些很流行的程式語言。
函數語言程式設計憑藉其傳遞和返回函式的能力,帶來了許多概念:

  • 純函式
  • 柯里化
  • 高階函式
    其中一個我們將要看到的概念就是柯里化。
    在這篇文章,我們將看到柯里化是如何工作以及它如何在我們作為軟體開發者的工作中發揮作用。

什麼是柯里化

柯里化是函數語言程式設計中的一種過程,可以將接受具有多個引數的函式轉化為一個的巢狀函式佇列,然後返回一個新的函式以及期望下一個的內聯引數。它不斷返回一個新函式(期望當前引數,就像我們之前說的那樣)直到所有引數都用完為止。這些引數會一直保持“存活”不會被銷燬(利用閉包的特性)以及當柯里化鏈中最後的函式返回並執行時,所有引數都用於執行。

柯里化就是將具有多個arity的函式轉化為具有較少的arity的函式。——kbrainwave
備註:術語arity(元數):指的是函式的引數個數,例如:

function fn(a, b) {
    //...
}
function _fn(a, b, c) {
    //...
}

函式fn有兩個引數(即 2-arity函式)以及_fn有三個引數(即3-arity函式)。
因此,柯里化將一個具有多個引數的函式轉化為一系列只需一個引數的函式。
下面,我們看一個簡單的例子:

function multiply(a, b, c) {
    return a * b * c;
}

這個函式接收三個數字並且相乘,然後返回計算結果。

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、2、3的乘法結果,這些數字一個接一個傳遞,每個數字會預先填充用作下一個函式內聯呼叫。
我們可以分開這個multiply(1)(2)(3)函式呼叫步驟,更好理解一點。

const mul1 = multiply(1);
const mul2 = mul1(2);
const result = mul2(3);
log(result); // 6

我們來一個接一個地傳遞引數。首先傳引數1到multiply函式:

let mul1 = multiply(1);

以上程式碼執行會返回一個函式:

return (b) => {
        return (c) => {
            return a * b * c
        }
    }

現在,變數mul1會保持以上的函式定義,這個函式接收引數b。
我們呼叫函式mul1,傳入引數2:

let mul2 = mul1(2);

函式mul1執行後會返回第三個函式

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函式可以訪問外部函式的變數作用域,即multiply函式和mul1函式。
這就是為什麼mul2函式能使用已經執行完函式中定義的變數中進行乘法計算。雖然函式早已返回而且已經在記憶體中執行垃圾回收。但是它的變數還是以某種方式保持“存活”。

備註:以上變數保持存活是閉包特性,不明白可以檢視閉包相關文章瞭解更多
你可以看到三個數字每次只傳遞一個引數應用於函式,並且每次都返回一個新的函式,值得所有的引數用完為止。
下面來看一個其他的例子:

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

上面是一個計算任何實心形狀體積的函式。
這個柯里化版本將接受一個引數以及返回一個函式,該函式同樣也接受一個引數和返回一個新的函式。然後一直這樣迴圈/繼續,直到到達最後一個引數並返回最後一個函式。然後執行之前的引數和最後一個引數的乘法運算。

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

就像之前的multiply函式那樣,最後的函式只接受一個引數h,但是仍然會對那些早已執行完返回的函式作用域中裡的其他變數執行操作。能這樣操作是因為有閉包的特性。

譯者注:以上寫的很囉嗦,感覺另外的例子完全就是重複說明。
柯里化背後的想法其實是獲取一個函式並派生出一個返回特殊函式的函式。

柯里化在數學方面的應用

我有點喜歡數學說明?維基百科進一步展示了柯里化的概念。下面用我們的例子來進一步看下柯里化。
假設有一個方程

f(x,y) = x^2 + y = z

有兩個變數x和y,如果這兩個變數分別賦值x=3和y=4,可以得到z的值。
下面我們在函式f(x,y)中替換變數的值為y=4和x=3:

f(x,y) = f(3,4) = x^2 + y = 3^2 + 4 = 13 = z

得到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]

注:hx表示h下標為x的識別符號,同理hy表示h下標為y的識別符號。w.r.t(with respect to),數學符號,表示關於,常用於求導,或者滿足一定條件之類的情況

我們使方程f(x,y)=x^2+y的變數x=3,它將返回一個以y為變數的新方程。

h3(y) = 3^2 + y = 9 + y
注:h3 表示h下標為3的識別符號

也等同於:

h3(y) = h(3)(y) = f(3,y) = 3^2 + y = 9 + y

函式的結果還是沒有確定的,而是返回一個期望其他變數y的一個新方程 9+y。
下一步,我們傳入y=4

h3(4) = h(3)(4) = f(3,4) = 9 + 4 = 13

變數y是變數鏈中的最後一個,然後與前一個保留的變數x=3執行加法運算,值最後被解析,結果是12。
所以基本上,我們將這個方程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

好了,這就是柯里化在數學方面的一些應用,如果你覺得這些說明得還不夠清楚。可以在維基百科閱讀更詳細的內容。

柯里化和部分應用函式

現在,有些人可能開始認為柯里化函式的巢狀函式的數量取決於它接受的引數。是的,這就是柯里化。
我可以設計一個這樣的柯里化函式volume:

function volume(l) {
    return (w, h) => {
        return l * w * h
    }
}

所以,可以像這樣去呼叫:

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層巢狀函式的版本不一樣。
但是這個版本並不是柯里化。我們只是做了一個部分應用的volume函式。
柯里化和部分應用函式有關聯,但是它們是不同的概念。
部分應用函式是將一個函式轉化為具有更少的元素(即更是的引數)的函式。

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)
        }
    }
}

柯里化是根據函式的引數數量建立巢狀函式,每個函式接受一個引數。如果沒有引數,那就沒有柯里化。
可能存在一種情況,即柯里化和部分應用彼此相遇。假設我們有一個函式:

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

我們可以將discount函式柯里化,這樣我們就不用總是每次增加這0.01的折扣。

function discount(discount) {
    return (price) => {
        return price * discount;
    }
}
const tenPercentDiscount = discount(0.1);

現在,我們可以只計算你的顧客買的物品都價格了:

tenPercentDiscount(500); // $50
// $500 - $50 = $450

同樣地,有些優惠顧客比一些優惠顧客更重要-讓我們稱之為超級客戶。並且我們想給這些超級客戶提供20%的折扣。
可以使用我們的柯里化的discount函式:

const twentyPercentDiscount = discount(0.2);

我們通過這個柯里化的discount函式折扣調為0.2(即20%),給我們的超級客戶配置了一個新的函式。
返回的函式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;
}

碰巧倉庫所有的氣缸高度為100米,你將會看到你將重複呼叫此函式,h為100米

volume(200,30,100) // 2003000l
volume(32,45,100); //144000l
volume(2322,232,100) // 53870400l

要解決以上問題,你可以將volume函式柯里化(像我們之前做的):

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);
    }
}

上面程式碼做了什麼?curry函式接受一個我們想要柯里化的函式(fn)和 一些可變數量的引數(…args)。剩下的操作用於將fn之後的引數數量收集到…args中。
然後,返回一個函式,同樣地將餘下的引數收集為…args。這個函式呼叫原始函式fn通過使用spread運算子作為引數傳入… args和… args,然後,將值返回給使用。
現在我們可以用curry函式來建立特定的函式啦。
下面我們用curry函式來建立更多計算體檢的特定函式(其中一個就是計算高度100米的圓柱體積函式)

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

結語

閉包使JavaScript柯里化成為可能。能夠保留已經執行的函式的狀態,使我們能夠建立工廠函式 – 可以為其引數新增特定值的函式。柯里化、閉包和函數語言程式設計是很棘手的。但是我可以保證,投入時間和練習,你就會開始掌握它,看看它多麼有價值。

參考

柯里化-維基百科
部分應用函式
(完)

後記

以上譯文僅用於學習交流,水平有限,難免有錯誤之處,敬請指正。

原文

https://blog.bitsrc.io/understanding-currying-in-javascript-ceb2188c339

相關文章