JavaScript是函數語言程式設計語言嗎?
沒有神奇的公式能夠判定一種語言是不是“函式式”語言。有些語言很明顯就是函式式的,就像另外一些語言很明顯不是函式式的,但是有大量語言的是模稜兩可的中間派。
於是這裡給出一些常用的、重要的函式式語言的“配料”(JavaScript能實現用粗體標誌)
- 函式是“第一等公民”
- 函式能夠返回函式
- 詞法上支援閉包
- 函式要“純粹”
- 可靠遞迴
- 沒有變異狀態
這決不是一個排它的列表,但是我們至少要逐個討論Javascript中最重要的三個特性,它們支撐我們可以用函式式的方式來編寫程式。
讓我們逐個詳細的瞭解下:
函式是“第一等公民”
這條可能是在所有的配料中最明顯的,並且可能是在很多現代程式語言中最常見到的。
在JavaScript區域性變數是通過var關鍵字來定義的。
1 2 |
; html-script: false ] var foo = "bar"; |
JavaScript中把函式以區域性變數的方式定義是非常容易做到的。
1 2 3 |
; html-script: false ] var add = function (a, b) { return a + b; }; var even = function (a) { return a % 2 === 0; }; |
這些都是事實,變數:變數add和變數even通過被賦值的方式,與函式定義建立引用關係,這種引用關係是在任何時候如果需要是可以被改變的。
1 2 3 4 5 6 |
; html-script: false ] // capture the old version of the function var old_even = even; // assign variable `even` to a new, different function even = function (a) { return a & 1 === 0; }; |
當然,這沒有什麼特別的。但是成為“第一等公民”這個重要的特性使得我們能夠把函式以引數的方式傳遞給另一個函式。舉個例子:
1 2 |
; html-script: false ] var binaryCall = function (f, a, b) { return f(a, b); }; |
這是一個函式,他接受了一個二元函式f,和兩個引數a,b,然後呼叫這個二元函式f,該二元函式f以a、b為輸入引數。
1 2 |
; html-script: false ] add(1,2) === binaryCall(add, 1, 2); // true |
這樣做看起來有點笨拙,但是當把接下來的函數語言程式設計“配料”合併考慮的時候,牛叉之處就顯而易見了…
函式能返回函式(換個說法“高階函式”)
事情開始變的酷起來。儘管開始比較簡單。函式最終以新的函式作為返回值。舉個例子:
1 2 3 4 |
; html-script: false ] var applyFirst = function (f, a) { return function (b) { return f(a, b); }; }; |
這個函式(applyFirst)接受一個二元函式作為其中一個引數,可以把第一個引數(即二元函式)看作是這個applyFirst函式的“部分操作”,然後返回一個一元(一個引數)函式,該一元函式被呼叫的時候返回外部函式的第一個引數(f)的二元函式f(a, b)。返回兩個引數的二元函式。
讓我們再談談一些函式,例如mult(乘法)函式:
1 2 |
; html-script: false ] var mult = function(a, b) { return a * b; }; |
依循mult(乘法)函式的邏輯,我們可以寫一個新的函式double(乘方):
1 2 3 4 5 |
; html-script: false ] var double = applyFirst(mult, 2); double(32); // 64 double(7.5); // 15 |
這就是偏函式,在FP中經常會用到。(譯註:FP全名為 Functional Programming 函式式程式設計 )
我們當然可以像applyFirst那樣定義函式:
1 2 3 4 5 6 7 8 |
; html-script: false ] var curry2 = function (f) { return function (a) { return function (b) { return f(a, b); }; }; }; |
現在,我想要一個double(乘方)函式,我們換種方式做:
1 2 |
; html-script: false ] var double = curry2(mult)(2); |
這種方式被稱作“函式柯里化”。有點類似partial application(偏函式應用),但是更強大一點。
在這個系列文章的後半部分會詳細討論柯里化。
準確的說,函數語言程式設計之所以強大,大部分因於此。簡單和易理解的函式成為我們構築軟體的基礎構件。當擁有高水平的組織能力、很少重用的邏輯的時候,函式能夠被組合和混合在一起用來表達出更復雜的行為。
高階函式可以得到的樂趣更多。讓我們看兩個例子:
翻轉二元函式引數順序
1 2 3 4 5 6 7 |
; html-script: false ] // flip the argument order of a function var flip = function (f) { return function (a, b) { return f(b, a); }; }; divide(10, 5) === flip(divide)(5, 10); // true |
建立一個組合了其他函式的函式
1 2 3 4 5 6 7 8 9 10 11 12 13 |
; html-script: false ] // return a function that's the composition of two functions... // compose (f, g)(x) -> f(g(x)) var compose = function (f1, f2) { return function (x) { return f1(f2(x)); }; }; // abs(x) = Sqrt(x^2) var abs = compose(sqrt, square); abs(-2); // 2 |
這個例子建立了一個實用的函式,我們可以使用它來記錄下每次函式呼叫。
1 2 3 4 5 6 7 |
; html-script: false ] var logWrapper = function (f) { return function (a) { console.log('calling "' + f.name + '" with argument "' + a); return f(a); }; }; |
1 2 3 4 5 6 7 8 9 10 11 12 |
; html-script: false ] var app_init = function(config) { /* ... */ }; if(DEBUG) { // log the init function if in debug mode app_init = logWrapper(app_init); } // logs to the console if in debug mode app_init({ /* ... */ }); |
詞法閉包+作用域
我深信理解如何有效利用閉包和作用域是成為一個偉大JavaScript開發者的關鍵。
那麼…什麼是閉包?
1 |
; html-script: false ]簡單的說,閉包就是內部函式一直擁有父函式作用域的訪問許可權,即使父函式已經返回。<譯註4> |
可能需要個例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
; html-script: false ] var createCounter = function () { var count = 0; return function () { return ++count; }; }; var counter1 = createCounter(); counter1(); // 1 counter1(); // 2 var counter2 = createCounter(); counter2(); // 1 counter1(); // 3 |
一旦createCounter函式被呼叫,變數count就被分配一個新的記憶體區域。然後,返回一個函式,這個函式持有對變數count的引用,並且每次呼叫的時候執行count加1操作。
注意從createCounter函式的作用域之外,我們是沒有辦法直接操作count的值。Counter1和Counter2函式可以操作各自的count變數的副本,但是隻有在這種非
常具體的方式操作count(自增1)才是被支援的。
在JavaScript,作用域的邊界檢查只在函式被宣告的時候。逐個函式,並且僅僅逐個函式,擁有它們各自的作用域表。(注:在ECMAScript 6中不再是這樣,因為let的引入)
一些進一步的例子來證明這論點:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
; html-script: false ] // global scope var scope = "global"; var foo = function(){ // inner scope 1 var scope = "inner"; var myscope = function(){ // inner scope 2 return scope; }; return myscope; }; console.log(foo()()); // "inner" console.log(scope); // "global" |
關於作用域還有一些重要的事情需要考慮。例如,我們需要建立一個函式,接受一個數字(0-9),返回該數字相應的英文名稱。
簡單點,有人會這樣寫:
1 2 3 4 5 6 |
; html-script: false ] // global scope... var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; var digit_name1 = function(n){ return names[n]; }; |
但是缺點是,names定義在了全域性作用域,可能會意外的被修改,這樣可能致使digit_name1函式所返回的結果不正確。
那麼,這樣寫:
1 2 3 4 5 |
; html-script: false ] var digit_name2 = function(n){ var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; return names[n]; }; |
這次把names陣列定義成函式digit_name2區域性變數.這個函式遠離了意外風險,但是帶來了效能損失,由於每次digit_name2被呼叫的時候,都將重新為names陣列定義和分配空間。換個例子如果names是個非常大的陣列,或者可能digit_name2函式在一個迴圈中被呼叫多次,這時候效能影響將非常明顯。
1 2 3 4 5 6 7 8 |
; html-script: false ] // "An inner function enjoys that context even after the parent functions have returned." var digit_name3 = (function(){ var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; return function(n){ return names[n]; }; })(); |
這時候我們面臨第三個選擇。這裡我們實現立即呼叫的函式表示式,僅僅例項化names變數一次,然後返回digit_name3函式,在 IIFE (Immediately-Invoked-Function-Expression 立即執行表示式)的閉包函式持有names變數的引用。
這個方案兼具前兩個的優點,迴避了缺點。搞定!這是一個常用的模式用來建立一個不可被外部環境修改“private”(私有)狀態。
2014-4-25更新:JavaScript不具備的配料該怎麼辦呢?
我們已經細緻的討論了JavaScript具備的“配料”,但那三條我曾提到的JS不具備的咋辦?
- 函式要“純粹”
- 可靠遞迴
- 沒有變異狀態
純粹函式 和 無變異狀態
純粹函式具備“沒有副作用”的特性,是沒有變異狀態的另一種說法。這使得你的程式碼更容易被分析、測試、並行化,等等…
儘管你可以編寫程式碼讓變數不被改變,但這不如擁有不會發生的保證更有用。
可靠遞迴
儘管以可以以遞迴的方式呼叫JavaScript程式碼,解釋程式不以尾遞迴優化方式編譯。這意味著如果不小心點兒,任何遞迴方式定義的函式將很快導致堆疊溢位錯誤。
這使得在某些情況下使用for/while迴圈是必要的。(這顯然就不那麼優雅了)
能更好點嗎?接下來乾點兒什麼?
JavaScript是極其靈活的語言。然而,這篇文章中的大多數例子在實踐中會很少用到。
舉個例子,curry2這個函式非常有用但它僅僅適用於二元函式。大多數的函數語言程式設計語言都在語法上內建了柯里化,但是JavaScript並沒有。
當然了還是有辦法的:JavaScript提供了一些非常有用的函式,像Function.prototype.call, Function.prototype.bind, 和與眾不同的 arguments 物件。能夠利用這些函式實現一些強大的函數語言程式設計所約定的。(像柯里化)
我將在這個系列接下來的文章中更詳細的討論這些東西:
接下來 -> 第三部分:.apply()、.call()以及arguments物件
更多內容預告:
- 第一部分:引言
- 第二部分:如何打造“函式式”程式語言
- 第三部分:.apply()、.call()以及arguments物件
- 第四部分:函式柯里化
- 第五部分:引數可變函式(敬請期待)
- 第六部分:一個真實的例子——2048 Game & Solver(敬請期待)
- 第七部分:惰性序列 / 集合(敬請期待)
- 第八部分:引數順序為何重要(敬請期待)
- 第九部分:Functors 和 Monads(敬請期待)
【譯註1】:所謂”第一等公民”(first class),指的是函式與其他資料型別一樣,處於平等地位,可以賦值給其他變數,也可以作為引數,傳入另一個函式,或者作為其他的函式的返回值。
【譯註2】:什麼是純粹?先看另外一個詞,”副作用”(side effect),指的是函式內部與外部互動(最典型的情況,就是修改全域性變數的值),產生運算以外的其他結果。函數語言程式設計強調沒有"副作用",意味著函式要保持獨立,所有功能就是返回一個新的值,沒有其他行為,尤其是不得修改外部變數的值。保持計算過程的純粹性。
【譯註3】:Currying:因為是美國數理邏輯學家哈斯凱爾•加里(Haskell Curry)發明了這種函式使用技巧,所以這樣用法就以他的名字命名為Currying,中文翻譯為“柯里化”。我感覺很多人都對函式加里化(Currying)和偏函式應用(Partial Application)之間的區別搞不清楚,尤其是在相似的上下文環境中它們同時出現的時候。偏函式解決這樣的問題:如果我們有函式是多個引數的,我們希望能固定其中某幾個引數的值。
【譯註4】:各種專業文獻上的"閉包"(closure)定義非常抽象,很難看懂。我的理解是,閉包就是能夠讀取其他函式內部變數的函式。由於在Javascript語言中,只有父函式內部的子函式才能讀取父函式的區域性變數,因此可以把閉包簡單理解成“定義在一個函式內部的函式”。所以,在本質上,閉包就是將函式內部和函式外部連線起來的一座橋樑。》這就是Javascript語言特有的“鏈式作用域”結構(chain scope),子物件會一級一級地向上尋找所有父物件的變數。所以,父物件的所有變數,對子物件都是可見的,反之則不成立。
:-)