《JavaScript 模式》讀書筆記(4)— 函數5
- 2020 年 3 月 30 日
- 筆記
這一篇是函數部分的最後一篇。我們來聊聊Curry化。
十、Curry
這部分我們主要討論Curry化和部分函數應用的內容。但是在深入討論之前,我們需要先了解一下函數應用的含義。
函數應用
在一些純粹的函數式程式語言中,函數並不描述為被調用(即called或invoked),而是描述為應用(applied)。在JavaScript中,我們可以做同樣的事情,使用方法Function.prototype.apply()來應用函數,這是由於JavaScript中的函數實際上是對象,並且它們還具有如下方法。
// 定義函數 var sayHi = function(who) { console.log("Hello" + (who? ", " + who : "") + "!"); }; // 調用函數 sayHi(); // 輸出"Hello" sayHi('world'); // 輸出"Hello, world!" // 應用函數 sayHi.apply(null, ["hello"]); // 輸出"Hello, hello!"
正如上面的例子所看到的,調用(invoking)函數和應用(applying)函數可以得到完全相同的結果。apply()帶有兩個參數:第一個參數為將要綁定到該函數內部this的一個對象,而第二個參數是一個數組或多個參數變數,這些參數將變成可用於該函數內部的類似數組的arguments對象。如果第一個參數為null(空),那麼this將指向全局對象,此時得到的結果就恰好如同調用一個非指定對象時的方法。
當函數是一個對象的方法時,此時不能傳遞null引用。這種情況下,這裡的對象將成為apply()的第一個參數:
// 定義函數 var alien= { sayHi: function(who) { console.log("Hello" + (who? ", " + who : "") + "!"); } } alien.sayHi('world'); // 輸出"Hello, world!" sayHi.apply(alien, ["humans"]); // 輸出"Hello, humans!"
在上面的程式碼中,sayHi()內部的this指向了alien對象。而在之前的例子中,this指向了全局對象。
正如上面的兩個例子所展示的那樣,這些都表明我們考慮的“調用函數”並不只是“句法糖(syntactic sugar)”,而是等價於函數應用。
請注意,除了apply()以外,Function.prototype對象還有一個call()方法,但是這仍然只是建立在apply()之上的語法糖而已。有時候最好使用該語法糖:即當函數僅帶有一個參數時,可以根據實際情況避免創建只有一個元素的數組的工作。
// 在這種情況下,第二種更有效率,節省了一個數組 sayHi.apply(alien,["humans"]); sayHi.call(alien,"humans");
部分應用
現在我們知道,調用函數實際上就是將一個參數集合應用到一個函數中,那有沒有可能只傳遞部分參數,而不是所有參數?這種情況就和手動處理一個數學函數所常採用的方法是相似的。假定有一個函數add()用以將兩個數字加在一起:x和y。下面的程式碼片段展示了給定x值為5,且y值為4的情況下的解決方案。
// 出於演示的目的 // 並不是合法的JavaScript function add(x,y) { return x + y; } // 有以下函數 add(5,4); // 第1步,替換一個參數 function add(5, y){ return 5 + y; } // 第2步,替換其他參數 function add(5, 4) { return 5 + 4; }
再提醒一遍,第1、2步的程式碼是不合法的,僅演示目的。
上面的程式碼段演示了如何手工解決部分函數應用的問題。可以獲取第一個參數的值,並且在整個函數中用已知的值5替代未知的x,然後重複同樣的步驟直至用完了所有的參數。
對這個例子中的步驟1可以稱為部分應用(partial application),即我們金鷹用了第一個參數。當執行部分應用時,並不會獲得結果,相反會獲得另一個函數。
下面的程式碼片段演示了家鄉的partialApply()方法的使用示例:
var add = function (x,y) { return x + y; }; // 完全應用 add.apply(null,[5,4]); // 9 // 部分應用 var newadd = add.partialApply(null,[5]); // 應用一個參數到新函數中 newadd.apply(null,[4]); // 9
如上面的程式碼所示,部分應用向我們提供了另一個新函數,隨後再以其他參數調用該函數。這種運行方式實際上與add(5)(4)有一些類似,這是由於add(5)返回了一個可在後來用(4)來調用的函數。
此外,我們所熟悉的add(5, 4)調用方式可能並不像是“句法糖(syntactic sugar)”,相反,使用add(5)(4)才像是“句法糖(syntactic sugar)”。
現在,返回到現實,JavaScript中並沒有partialApply()方法和函數,默認情況下也並不會出現與上面類似的行為。但是可以構造出這些函數,因為JavaScript的動態性足夠支援這種行為。
使函數理解並處理部分應用的過程就成為Curry過程(Currying)。
Curry化
這裡的curry源於數學家Haskell Curry的名字。Curry化是一個轉換過程,即我們執行函數轉換的過程。那麼,我們如何Curry化一個函數?其他的函數式語言可能已經將這種Curry化轉換構建到語言本身中,並且所有的函數已經默認轉換過,在JavaScript中,可以將add()函數修改成一個用於處理部分應用的Curry化函數。
下面,我們來看個例子:
// curry化的add()函數 // 接受部分參數列表 function add(x,y) { var oldx = x,oldy = y; if(typeof oldy === 'undefined') { // 部分 return function(newy) { return oldx + newy; }; } // 完全應用 return x + y; } // 測試 console.log(typeof add(5)); // 輸出“function” add(3)(4); // 7 // 創建並存儲一個新函數 var add2000 = add(2000); add2000(19); //輸出2010
在上面的程式碼段中,當第一次調用add()時,它為返回的內部函數創建了一個閉包。該閉包將原始的x和y值存儲到私有變數oldx和oldy中。第一個私有變數oldx將在內部函數執行的時候使用。如果沒有部分應用,並且同時傳遞x和y值,該函數則繼續執行,並簡單將其相加。這種add()實現與實際需求相比顯得比較冗長,在這裡只是出於演示的目的這樣實現。下面將顯示一個更為精簡的實現版本。其中並沒有oldx和oldy,僅是因為原始x隱式的存儲在閉包中,並且還將y作為局部變數復用,而不是像之前那樣創建一個新的變數newy:
// curry化的add()函數 // 接受部分參數列表 function add(x, y) { if(typeof y === 'undefined') { //部分 return function(y) { return x + y; }; } // 完全應用 return x + y; }
在這些例子中,函數add()本身負責處理部分應用。但是能夠以更通用的方式執行相同給的任務么?也就是說,是否可以將任意的函數轉換成一個新的可以接收部分參數的函數?
function schonfinkelize(fn) { var slice = Array.prototype.slice, stored_args = slice.call(arguments,1); return function () { var new_args = slice.call(arguments), args = stored_args.concat(new_args); return fn.apply(null,args); } }
schonfinkelize()函數可能不應該有這麼複雜,只是由於JavaScript中arguments並不是一個真實的數組。從Array.prototype中借用slice()方法可以幫助我們將arguments變成一個數組,並且使用該數組更加方便。當schonfinkelize()第一次調用時,它存儲了一個指向slice()方法的私有引用(名為slice),並且還存儲了調用該方法後的參數(存入stored_args中),該方法僅剝離了第一個參數,這是因為第一個參數是將被curry化的函數。然後,schonfinkelize()返回了一個新函數。當這個新函數被調用時,它訪問了已經私有存儲的參數stored_args以及slice引用。這個新函數必須將原有的部分應用參數(stored_args)合併到新參數(new_args),然後再將它們應用到原始函數fn中(也僅在閉包中私有可用)。
我們來測試下上面的轉換方法:
function schonfinkelize(fn) { var slice = Array.prototype.slice, stored_args = slice.call(arguments,1); return function () { var new_args = slice.call(arguments), args = stored_args.concat(new_args); return fn.apply(null,args); } } // 普通函數 function add(x, y){ return x + y; } // 將一個函數curry化並獲得一個新的函數 var newadd = schonfinkelize(add,5); console.log(newadd(4)); //輸出9 // 另一種選擇,直接調用新函數 console.log(schonfinkelize(add,6)(7)); //輸出13 // 轉換函數並不局限於單個參數或者單步Curry化 // 普通函數 function addSome(a, b, c, d, e) { return a + b + c + d + e; } // 可運行於任意數量的參數 console.log(schonfinkelize(addSome,1,2,3)(5,5)); // 兩步curry化 var addOne = schonfinkelize(addSome,1); console.log(addOne(10,10,10,10)); //41 var addSix = schonfinkelize(addOne,2,3); console.log(addSix(5,5)); // 16
上面是完整的例子和測試。
那什麼時候適合使用Curry化呢?當發現正在調用同一個函數,並且傳遞的參數絕大多數都是相同的,那麼該函數可能是用於Curry化的一個很好的候選參數。可以通過將一個函數集合部分應用到函數中,從而動態創建一個新函數。這個新函數將會保存重複的參數(因此,不必每次都傳遞這些參數),並且還會使用預填充原始函數所期望的完整參數列表。
小結
在JavaScript中,有關函數的部分是十分重要的,我們本系列文章相關的主要函數部分已經到此告一段落了。本篇討論了有關函數的背景和術語。學習了JavaScript中兩個重要的特徵。即:
- 函數是第一類對象,可以作為帶有屬性和方法的值以及參數進行傳遞。
- 函數提供了局部作用域,而其他打括弧並不能提供這種局部作用域(當然現在的let是可以的)。此外還需要記住的是,聲明的局部變數可被提升到局部作用域的頂部。
創建函數的語法包括:
- 1. 函數命名表達式。
- 2. 函數表達式(與上面的相同,但是缺少一個名字),通常也稱為匿名函數。
- 3. 函數聲明,與其他語言中的函數的語法類似。
在涵蓋了函數的背景和語法之後,我們學習了一些有用的模式:
1、API模式,它們可以幫助您為函數提供更好且更整潔的介面:
回調模式:將函數作為參數進行傳遞。
配置對象:有助於保持受到控制的函數的參數數量。
返回函數:當一個函數的返回值是另一個函數時。
Curry化:當新函數是基於現有函數,並加上部分參數列表創建時。
2、初始化模式,它們可以幫助您在不污染全局命名空間的情況下,使用臨時變數以一種更加整潔、結構化的方式執行初始化以及設置任務(當涉及web網頁和應用程式時是非常普遍的)。這些模式包括:
即時函數:只要定義之後就立即執行。
即時對象初始化:匿名對象組織了初始化任務,提供了可被立即調用的方法。
初始化時分支:幫助分支程式碼在初始化程式碼執行過程中僅檢測一次,這與以後在程式生命周期內多次檢測相反。
3、性能模式,可以幫助加速程式碼運行,這些模式包括:
備忘模式:使用函數屬性以便使得計算過的值無須再次計算。
自定義模式:以新的主體重寫本身,以使得在第二次或以後調用時僅需執行更少的工作。
好了,函數部分到此結束了。我們下面會開始學習對象模式部分。加油!fighting!