之前的文章裡我們簡要的提到過函式柯里化。柯里化對函數語言程式設計來說是必不可少的,在這篇文章中我們會深入的研究下。
什麼是柯里化?
柯里化是這樣的一個轉換過程,把接受多個引數的函式變換成接受一個單一引數(譯註:最初函式的第一個引數)的函式,如果其他的引數是必要的,返回接受餘下的引數且返回結果的新函式。(譯註1)
當我們這麼說的時候,我想柯里化聽起來相當簡單。JavaScript中是怎麼實現的呢?
假設我們要寫一個函式,接受3個引數。
1 2 3 4 |
; html-script: false ] var sendMsg = function (from, to, msg) { alert(["Hello " + to + ",", msg, "Sincerely,", "- " + from].join("\n")); }; |
現在,假定我們有柯里化函式,能夠把傳統的JavaScript函式轉換成柯里化後的函式:
1 2 3 4 5 6 7 |
; html-script: false ] var sendMsgCurried = curry(sendMsg); // returns function(a,b,c) var sendMsgFromJohnToBob = sendMsgCurried("John")("Bob"); // returns function(c) sendMsgFromJohnToBob("Come join the curry party!"); //=> "Hello Bob, Come join the curry party! Sincerely, - John" |
手動柯里化
在上面的例子中,我們假定擁有神祕的curry函式。我會實現這樣的函式,但是現在,我們首先看看為什麼這樣的函式是如此必要。
舉個例子,手動柯里化一個函式並不困難,但是確實有點囉嗦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
; html-script: false ] // uncurried var example1 = function (a, b, c) { // do something with a, b, and c }; // curried var example2 = function(a) { return function (b) { return function (c) { // do something with a, b, and c }; }; }; |
在JavaScript,即使你不指定一個函式所有的引數,函式仍將被呼叫。這是個非常實用JavaScript的功能,但是卻給柯里化製造了麻煩。
思路是每一個函式都是有且只有一個引數的函式。如果你想擁有多個引數,你必須定義一系列相互巢狀的函式。討厭!這樣做一次兩次還可以,可是需要以這種方式定義需要很多引數的函式的時候,就會變得相當囉嗦和難於閱讀。(但是別擔心,我會馬上告訴你一個辦法)
一些函式程式語言,像Haskell和OCaml,語法中內建了函式柯里化。在這些語言中,舉個例子,每個函式是擁有一個引數的函式,並且只有一個引數。你可能會認為這種限制麻煩勝過好處,但是語言的語法就是這樣,這種限制幾乎無法察覺。
舉個例子,在OCaml,你可以用兩種方式定義上面example:
1 2 3 4 5 6 7 8 |
; html-script: false ] let example1 = fun a b c -> // (* do something with a, b, c *) let example2 = fun a -> fun b -> fun c -> // (* do something with a, b, c *) |
很容易看出這兩個例子和上面的那兩個例子是如何的相似。
區別,然而,是否在OCaml也是做了同樣的事情。OCaml,沒有擁有多個引數的函式。但是,在一行中宣告多個引數就是巢狀定義單參函式“快捷方式”。
類似的 ,我們期待呼叫柯里化函式句法上和OCaml中呼叫多參函式類似。我們期望這樣呼叫上面的函式:
1 2 3 |
; html-script: false ] example1 foo bar baz example2 foo bar baz |
而在JavaScript,我們採用明顯不同的方式:
1 2 3 |
; html-script: false ] example1(foo, bar, baz); example2(foo)(bar)(baz); |
在OCaml這類語言中,柯里化是內建的。在JavaScript,柯里化雖然可行(高階函式),但是語法上是不方便的。這也是為什麼我們決定編寫一個柯里化函式來幫我們做這些繁瑣的事情,並使得我們的程式碼簡潔。
建立一個curry輔助函式
理論上我們期望可以有一個方便的方式轉換普通老式的JavaScript函式(多個引數)到完全柯里化的函式。
這個想法不是我獨有的,其他的人已經實現過了,例如在wu.js 庫中的.autoCurry()函式(儘管你關心的是我們自己的實現方式)。
首先,讓我們建立一個簡單的輔助函式 .sub_curry:
1 2 3 4 5 6 7 |
; html-script: false ] function sub_curry(fn /*, variable number of args */) { var args = [].slice.call(arguments, 1); return function () { return fn.apply(this, args.concat(toArray(arguments))); }; } |
讓我們花點時間看看這個函式的功能。相當簡單。sub_curry接受一個函式fn作為它的第一個引數,後面跟著任何數目的輸入引數。返回的是一個函式,這個函式返回fn.apply執行結果,引數序列合併了該函式最初傳入引數的,加上fn呼叫的時候傳入引數的。
看例子:
1 2 3 4 5 6 7 8 9 |
; html-script: false ] var fn = function(a, b, c) { return [a, b, c]; }; // these are all equivalent fn("a", "b", "c"); sub_curry(fn, "a")("b", "c"); sub_curry(fn, "a", "b")("c"); sub_curry(fn, "a", "b", "c")(); //=> ["a", "b", "c"] |
很明顯,這並不是我門想要的,但是看起來有點柯里化的意思了。現在我們將定義柯里化函式curry:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
; html-script: false ] function curry(fn, length) { // capture fn's # of parameters length = length || fn.length; return function () { if (arguments.length < length) { // not all arguments have been specified. Curry once more. var combined = [fn].concat(toArray(arguments)); return length - arguments.length > 0 ? curry(sub_curry.apply(this, combined), length - arguments.length) : sub_curry.call(this, combined ); } else { // all arguments have been specified, actually call function return fn.apply(this, arguments); } }; } |
這個函式接受兩個引數,一個函式和要“柯里化”的引數數目。第二個引數是可選的,如果省略,預設使用Function.prototype.length 屬性,就是為了告訴你這個函式定義了幾個引數。
最終,我們能夠論證下面的行為:
1 2 3 4 5 6 7 8 9 10 |
; html-script: false ] var fn = curry(function(a, b, c) { return [a, b, c]; }); // these are all equivalent fn("a", "b", "c"); fn("a", "b", "c"); fn("a", "b")("c"); fn("a")("b", "c"); fn("a")("b")("c"); //=> ["a", "b", "c"] |
我知道你在想什麼…
等等…什麼?!
難道你瘋了?應該是這樣!我們現在能夠在JavaScript中編寫柯里化函式,表現就如同OCaml或者Haskell中的那些函式。甚至,如果我想要一次傳遞多個引數,我可以向我從前做的那樣,用逗號分隔下引數就可以了。不需要引數間那些醜陋的括號,即使是它是柯里化後的。
這個相當有用,我會立即馬上談論這個,可是首先我要讓這個Curry函式前進一小步。
柯里化和“洞”(“holes”)
儘管柯里化函式已經很牛了,但是它也讓你必須花費點小心思在你所定義函式的引數順序上。終究,柯里化的背後思路就是建立函式,更具體的功能,分離其他更多的通用功能,通過分步應用它們。
當然這個只能工作在當最左引數就是你想要分步應用的引數!
為了解決這個,在一些函數語言程式設計語言中,會定義一個特殊的“佔位變數”。通常會指定下劃線來幹這事,如過作為一個函式的引數被傳入,就表明這個是可以“跳過的”。是尚待指定的。
這是非常有用的,當你想要分步應用(partially apply)一個特定函式,但是你想要分佈應用(partially apply)的引數並不是最左引數。
舉個例子,我們有這樣的一個函式:
1 2 |
; html-script: false ] var sendAjax = function (url, data, options) { /* ... */ } |
也許我們想要定義一個新的函式,我們部分提供SendAjax函式特定的Options,但是允許url和data可以被指定。
當然了,我們能夠相當簡單的這樣定義函式:
1 2 3 4 |
; html-script: false ] var sendPost = function (url, data) { return sendAjax(url, data, { type: "POST", contentType: "application/json" }); }; |
或者,使用使用約定的下劃線方式,就像下面這樣:
1 2 |
; html-script: false ] var sendPost = sendAjax( _ , _ , { type: "POST", contentType: "application/json" }); |
注意兩個引數以下劃線的方式傳入。顯然,JavaScript並不具備這樣的原生支援,於是我們怎樣才能這樣做呢?
回過頭讓我們把curry函式變得智慧一點…
首先我們把我們的“佔位符”定義成一個全域性變數。
1 2 |
; html-script: false ] var _ = {}; |
我們把它定義成物件字面量{},便於我們可以通過===操作符來判等。
不管你喜不喜歡,為了簡單一點我們就使用_來做“佔位符”。現在我們就可以定義新的curry函式,就像下面這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
; html-script: false ] function curry (fn, length, args, holes) { length = length || fn.length; args = args || []; holes = holes || []; return function(){ var _args = args.slice(0), _holes = holes.slice(0), argStart = _args.length, holeStart = _holes.length, arg, i; for(i = 0; i < arguments.length; i++) { arg = arguments[i]; if(arg === _ && holeStart) { holeStart--; _holes.push(_holes.shift()); // move hole from beginning to end } else if (arg === _) { _holes.push(argStart + i); // the position of the hole. } else if (holeStart) { holeStart--; _args.splice(_holes.shift(), 0, arg); // insert arg at index of hole } else { _args.push(arg); } } if(_args.length < length) { return curry.call(this, fn, length, _args, _holes); } else { return fn.apply(this, _args); } } } |
實際程式碼還是有著巨大不同的。 我們這裡做了一些關於這些“洞”(holes)引數是什麼的記錄。概括而言,執行的職責是相同的。
展示下我們的新幫手,下面的語句都是等價的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
; html-script: false ] var f = curry(function(a, b, c) { return [a, b, c]; }); var g = curry(function(a, b, c, d, e) { return [a, b, c, d, e]; }); // all of these are equivalent f("a","b","c"); f("a")("b")("c"); f("a", "b", "c"); f("a", _, "c")("b"); f( _, "b")("a", "c"); //=> ["a", "b", "c"] // all of these are equivalent g(1, 2, 3, 4, 5); g(_, 2, 3, 4, 5)(1); g(1, _, 3)(_, 4)(2)(5); //=> [1, 2, 3, 4, 5] |
瘋狂吧?!
我為什麼要關心?柯里化能夠怎麼幫助我?
你可能會停在這兒思考…
這看起來挺酷而且…但是這真的能幫助我編寫更好的程式碼?
這裡有很多原因關於為什麼函式柯里化是有用的。
函式柯里化允許和鼓勵你分隔複雜功能變成更小更容易分析的部分。這些小的邏輯單元顯然是更容易理解和測試的,然後你的應用就會變成乾淨而整潔的組合,由一些小單元組成的組合。
為了給一個簡單的例子,讓我們分別使用Vanilla.js, Underscore.js, and “函式化方式” (極端利用函式化特性)來編寫CSV解析器。
Vanilla.js (Imperative)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
; html-script: false ] //+ String -> [String] var processLine = function (line){ var row, columns, j; columns = line.split(","); row = []; for(j = 0; j < columns.length; j++) { row.push(columns[j].trim()); } }; //+ String -> [[String]] var parseCSV = function (csv){ var table, lines, i; lines = csv.split("\n"); table = []; for(i = 0; i < lines.length; i++) { table.push(processLine(lines[i])); } return table; }; |
Underscore.js
1 2 3 4 5 6 7 8 9 10 11 12 |
; html-script: false ] //+ String -> [String] var processLine = function (row) { return _.map(row.split(","), function (c) { return c.trim(); }); }; //+ String -> [[String]] var parseCSV = function (csv){ return _.map(csv.split("\n"), processLine); }; |
函式化方式
1 2 3 4 5 6 |
; html-script: false ] //+ String -> [String] var processLine = compose( map(trim) , split(",") ); //+ String -> [[String]] var parseCSV = compose( map(processLine) , split("\n") ); |
所有這些例子功能上是等價的。我有意的儘可能的簡單的編寫這些。
想要達到某種效果是很難的,但是主觀上這些例子,我真的認為最後一個例子,函式式方式的,體現了函數語言程式設計背後的威力。
關於curry效能的備註
一些極度關注效能的人可以看看這裡,我的意思是,關注下所有這些額外的事情?
通常,是這樣,使用柯里化會有一些開銷。取決於你正在做的是什麼,可能會或不會,以明顯的方式影響你。也就是說,我敢說幾乎大多數情況,你的程式碼的擁有效能瓶頸首先來自其他原因,而不是這個。
有關效能,這裡有一些事情必須牢記於心:
- 存取arguments物件通常要比存取命名引數要慢一點
- 一些老版本的瀏覽器在arguments.length的實現上是相當慢的
- 使用fn.apply( … ) 和 fn.call( … )通常比直接呼叫fn( … ) 稍微慢點
- 建立大量巢狀作用域和閉包函式會帶來花銷,無論是在記憶體還是速度上
在大多是web應用中,“瓶頸”會發生在操控DOM上。這是非常不可能的,你在所有方面關注效能。顯然,用不用上面的程式碼自行考慮。
接下來:
我已經討論了不少,關於在JavaScript應用函數語言程式設計。接下來,在這個系列我們會討論可變引數函式,Functors, Monads, 和其它的。
同時,我被要求提供關於函數語言程式設計更深層次的例子,為了這個我將會實現病毒拼圖遊戲puzzle-game 2048,並且從零開始實現人工智慧規劃求解AI Solver!別“換臺”啊!:-)
譯註1:通常,柯里化是這樣的過程,“如果你固定某些引數,你將得到接受餘下引數的一個函式”。所以對於有兩個變數的函式y^x,如果固定了 y=2,則得到有一個變數的函式 2^x
更多內容預告:
- 第一部分:引言
- 第二部分:如何打造“函式式”程式語言
- 第三部分:.apply()、.call()以及arguments物件
- 第四部分:函式柯里化
- 第五部分:引數可變函式(敬請期待)
- 第六部分:一個真實的例子——2048 Game & Solver(敬請期待)
- 第七部分:惰性序列 / 集合(敬請期待)
- 第八部分:引數順序為何重要(敬請期待)
- 第九部分:Functors 和 Monads(敬請期待)
鳴謝:
這個系列的文章受到了其他人的影響和啟發。如果你喜歡這系列文章,我建議你去看看:
- Brian Lansdorf: Hey Underscore, You’re doing it wrong
- Functional JavaScript by Oliver Steele
- Reginald Braithwaite and allong.es
- Underscore.js
:-)