有時,優雅的實現是一個函數。不是方法。不是類。不是框架。只是函數。
- John Carmack,游戲《毀滅戰士》首席程序員
函數式編程全都是關於如何把一個問題分解為一系列函數的。通常,函數會鏈在一起,互相嵌套, 來回傳遞,被視作頭等公民。如果你使用過諸如jQuery或Node.js這樣的框架,你應該用過一些這樣的技術, 只不過你沒有意識到。
我們從Javascript的一個小尴尬開始。
假設我們需要一個值的列表,這些值會賦值給普通的對象。這些對象可能包含任何東西:數據、HTML對象等等。
var
obj1 = {value: 1},
obj2 = {value: 2},
obj3 = {value: 3};
var values = [];
function accumulate(obj) {
values.push(obj.value);
}
accumulate(obj1);
accumulate(obj2);
console.log(values); // Output: [obj1.value, obj2.value]
這個代碼能用但是不穩定。任何代碼都可以不通過accumulate()函數改變values對象。 而且如果我們忘記了給values賦上空數組[],這個代碼壓根兒就不會工作。
但是如果變量聲明在函數內部,他就不會被任何搗蛋的代碼給更改。
function accumulate2(obj) {
var values = [];
values.push(obj.value);
return values;
}
console.log(accumulate2(obj1)); // Returns: [obj1.value]
console.log(accumulate2(obj2)); // Returns: [obj2.value]
console.log(accumulate2(obj3)); // Returns: [obj3.value]
不行呀!只有最後傳入的那個對象的值才被返回。
我們也許可以通過在第一個函數內部嵌套一個函數來解決這個問題。
var ValueAccumulator = function(obj) {
var values = []
var accumulate = function() {
values.push(obj.value);
};
accumulate();
return values;
};
可是問題依然存在,而且我們現在無法訪問accumulate函數和values變量了。
我們需要的是一個自調用函數
自調用函數和閉包
如果我們能夠返回一個可以依次返回values數組的函數表達式怎麼樣?在函數內聲明的變量可以被函數內的所有代碼訪問到, 包括自調用函數。
通過使用自調用函數,前面的尴尬消失了。
var ValueAccumulator = function() {
var values = [];
var accumulate = function(obj) {
if (obj) {
values.push(obj.value);
return values;
} else {
return values;
}
};
return accumulate;
};
//This allows us to do this:
var accumulator = ValueAccumulator();
accumulator(obj1);
accumulator(obj2);
console.log(accumulator());
// Output: [obj1.value, obj2.value]
ValueAccumulator = ->
values = []
(obj) ->
values.push obj.value if obj
values
這些都是關於作用域的。變量values在內部函數accumulate()中可見,即便是在外部的代碼在調用這個函數時。 這叫做閉包。
Javascript中的閉包就是函數可以訪問父作用域,哪怕父函數已經執行完畢。
閉包是所有函數式語言都具有的特征。傳統的命令式語言沒有閉包。
高階函數
自調用函數實際上是高階函數的一種形式。高階函數就是以其它函數為輸入,或者返回一個函數為輸出的函數。
高階函數在傳統的編程中並不常見。當命令式程序員使用循環來迭代數組的時候,函數式程序員會采用完全不同的一種實現方式。 通過高階函數,數組中的每一個元素可以被應用到一個函數上,並返回新的數組。
這是函數式編程的中心思想。高階函數具有把邏輯像對象一樣傳遞給函數的能力。
在Javascript中,函數被作為頭等公民對待,這和Scheme、Haskell等經典函數是語言一樣的。 這話聽起來可能有點古怪,其實實際意思就是函數被當做基本類型,就像數字和對象一樣。 如果數字和對象可以被來回傳遞,那麼函數也可以。
來實際看看。現在把上一節的ValueAccumulator()函數配合高階函數使用:
// 使用forEach()來遍歷一個數組,並對其每個元素調用回調函數accumulator2
var accumulator2 = ValueAccumulator();
var objects = [obj1, obj2, obj3]; // 這個數組可以很大
objects.forEach(accumulator2);
console.log(accumulator2());
純函數
純函數返回的計算結果僅與傳入的參數相關。這裡不會使用外部的變量和全局狀態,並且沒有副作用。 換句話說就是不能改變作為輸入傳入的變量。所以,程序裡只能使用純函數返回的值。
用數學函數來舉一個簡單的例子。Math.sqrt(4)將總是返回2,不使用任何隱藏的信息,如設置或狀態, 而且不會帶來任何副作用。
純函數是對數學上的“函數”的真實演繹,就是輸入和輸出的關系。它們思路簡單也便於重用。 由於純函數是完全獨立的,它們更適合被一次又一次地使用。
舉例說明來對比一下非純函數和純函數。
// 把信息打印到屏幕中央的函數
var printCenter = function(str) {
var elem = document.createElement("div");
elem.textContent = str;
elem.style.position = 'absolute';
elem.style.top = window.innerHeight / 2 + "px";
elem.style.left = window.innerWidth / 2 + "px";
document.body.appendChild(elem);
};
printCenter('hello world');
// 純函數完成相同的事情
var printSomewhere = function(str, height, width) {
var elem = document.createElement("div");
elem.textContent = str;
elem.style.position = 'absolute';
elem.style.top = height;
elem.style.left = width;
return elem;
};
document.body.appendChild(
printSomewhere('hello world',
window.innerHeight / 2) + 10 + "px",
window.innerWidth / 2) + 10 + "px"));
非純函數依賴window對象的狀態來計算寬度和高度,自給自足的純函數則要求這些值作為參數傳入。 實際上它就允許了信息打印到任何地方,這也讓這個函數有了更多用途。
非純函數看起來是一個更容易的選擇,因為它在自己內部實現了追加元素,而不是返回元素。 返回了值的純函數printSomewhere()則會在跟其他函數式編程技術的配合下有更好的表現。
var messages = ['Hi', 'Hello', 'Sup', 'Hey', 'Hola'];
messages.map(function(s, i) {
return printSomewhere(s, 100 * i * 10, 100 * i * 10);
}).forEach(function(element) {
document.body.appendChild(element);
});
當一個函數是純的,也就是不依賴於狀態和環境,我們就不用管它實際是什麼時候被計算出來。 後面的惰性求值將講到這個。
匿名函數
把函數作為頭等對象的另一個好處是匿名函數。
就像名字暗示的那樣,匿名函數就是沒有名字的函數。實際不止這些。它允許了在現場定義臨時邏輯的能力。 通常這帶來的好處就是方便:如果一個函數只用一次,沒有必要給它浪費一個變量名。
下面是一些匿名函數的例子:
// 寫匿名函數的標准方式
function() {
return "hello world"
};
// 匿名函數可以賦值給變量
var anon = function(x, y) {
return x + y
};
// 匿名函數用於代替具名回調函數,這是匿名函數的一個更常見的用處
setInterval(function() {
console.log(new Date().getTime())
}, 1000);
// Output: 1413249010672, 1413249010673, 1413249010674, ...
// 如果沒有把它包含在一個匿名函數中,他將立刻被執行,
// 並且返回一個undefined作為回調函數:
setInterval(console.log(new Date().getTime()), 1000)
// Output: 1413249010671
下面是匿名函數和高階函數配合使用的例子
function powersOf(x) {
return function(y) {
// this is an anonymous function!
return Math.pow(x, y);
};
}
powerOfTwo = powersOf(2);
console.log(powerOfTwo(1)); // 2
console.log(powerOfTwo(2)); // 4
console.log(powerOfTwo(3)); // 8
powerOfThree = powersOf(3);
console.log(powerOfThree(3)); // 9
console.log(powerOfThree(10)); // 59049
這裡返回的那個函數不需要命名,它可以在powersOf()函數外的任何地方使用,這就是匿名函數。
還記得累加器的那個函數嗎?它可以用匿名函數重寫
var
obj1 = { value: 1 },
obj2 = { value: 2 },
obj3 = { value: 3 };
var values = (function() {
// 匿名函數
var values = [];
return function(obj) {
// 有一個匿名函數!
if (obj) {
values.push(obj.value);
return values;
} else {
return values;
}
}
})(); // 讓它自執行
console.log(values(obj1)); // Returns: [obj.value]
console.log(values(obj2)); // Returns: [obj.value, obj2.value]
obj1 = { value: 1 }
obj2 = { value: 2 }
obj3 = { value: 3 }
values = do ->
valueList = []
(obj) ->
valueList.push obj.value if obj
valueList
console.log(values(obj1)); # Returns: [obj.value]
console.log(values(obj2)); # Returns: [obj.value, obj2.value]
真棒!一個高階匿名純函數。我們怎麼這麼幸運?實際上還不止這些,這裡面還有個自執行的結構, (function(){...})();。函數後面跟的那個括號可以讓函數立即執行。在上面的例子裡, 給外面values賦的值是函數執行的結果。
匿名函數不僅僅是語法糖,他們是lambda演算的化身。請聽我說下去…… lambda演算早在計算機和計算機語言被發明的很久以前就出現了。它只是個研究函數的數學概念。 非同尋常的是,盡管它只定義了三種表達式:變量引用,函數調用和匿名函數,但它被發現是圖靈完整的。 如今,lambda演算處於所有函數式語言的核心,包括javascript。
由於這個原因,匿名函數往往被稱作lambda表達式。
匿名函數也有一個缺點,那就是他們在調用棧中難以被識別,這會對調試造成一些困難。要小心使用匿名函數。
方法鏈
在Javascript中,把方法鏈在一起很常見。如果你使用過jQuery,你應該用過這種技巧。它有時也被叫做“建造者模式”。
這種技術用於簡化多個函數依次應用於一個對象的代碼。
// 每個函數占用一行來調用,不如…… arr = [1, 2, 3, 4]; arr1 = arr.reverse(); arr2 = arr1.concat([5, 6]); arr3 = arr2.map(Math.sqrt); // ……把它們串到一起放在一行裡面 console.log([1, 2, 3, 4].reverse().concat([5, 6]).map(Math.sqrt)); // 括號也許可以說明是怎麼回事 console.log(((([1, 2, 3, 4]).reverse()).concat([5, 6])).map(Math.sqrt));
這只有在函數是目標對象所擁有的方法時才有效。如果你要創建自己的函數,比如要把兩個數組zip到一起, 你必須把它聲明為Array.prototype對象的成員.看一下下面的代碼片段:
Array.prototype.zip = function(arr2) {
// ...
}
這樣我們就可以寫成下面的樣子
arr.zip([11,12,13,14).map(function(n){return n*2});
// Output: 2, 22, 4, 24, 6, 26, 8, 28
遞歸
遞歸應該是最著名的函數式編程技術。就是一個函數調用它自己。
當函數調用自己,有時奇怪的事情就發生了。它的表現即是一個循環,多次執行同樣的代碼,也是一個函數棧。
使用遞歸函數時必須十分小心地避免無限循環(這裡應該說是無限遞歸)。就像循環一樣,必須有個停止條件。 這叫做基准情形(base case)。
下面有個例子
var foo = function(n) {
if (n < 0) {
// 基准情形
return 'hello';
} else {
// 遞歸情形
return foo(n - 1);
}
}
console.log(foo(5));
譯注:原文中的代碼有誤,遞歸情形的函數調用缺少return,導致函數執行得最後沒有結果。這裡已經糾正。
遞歸和循環可以相互轉換。但是遞歸算法往往更合適,甚至是必要的,因為有些情形用循環很費勁。
一個明顯的例子就是遍歷樹。
var getLeafs = function(node) {
if (node.childNodes.length == 0) {
// base case
return node.innerText;
} else {
// recursive case:
return node.childNodes.map(getLeafs);
}
}
分而治之
遞歸不只是代替for和while循環的有趣的方式。有個叫分而治之的算法,它遞歸地把問題拆分成更小的情形, 直到小到可以解決。
歷史上有個歐幾裡得算法用於找出兩個數的最大公分母
function gcd(a, b) {
if (b == 0) {
// 基准情形 (治)
return a;
} else {
// 遞歸情形 (分)
return gcd(b, a % b);
}
}
console.log(gcd(12,8));
console.log(gcd(100,20));
gcb = (a, b) -> if b is 0 then a else gcb(b, a % b)
理論上來說,分而治之很牛逼,但是現實中有用嗎?當然!用Javascript的函數對數組排序不是很好, 它不但替換了原數組,也就是說數據不是不變的,並且它還不夠可靠、靈活。通過分而治之,我們可以做得更好。
全部的實現代碼大概要40行,這裡只展示偽代碼:
var mergeSort = function(arr) {
if (arr.length < 2) {
// 基准情形: 只有0或1個元素的數組是不用排序的
return items;
} else {
// 遞歸情形: 把數組拆分、排序、合並
var middle = Math.floor(arr.length / 2);
// 分
var left = mergeSort(arr.slice(0, middle));
var right = mergeSort(arr.slice(middle));
// 治
// merge是一個輔助函數,返回一個新數組,它將兩個數組合並到一起
return merge(left, right);
}
}
譯注:關於用分而治之的思路進行排序的一個更好的例子是快排,使用Javascript也只有13行代碼。 具體請參考我以前的博文 《優雅的函數式編程語言》
惰性求值
惰性求值,也叫做非嚴格求值,它會按需調用並推遲執行,它是一種直到需要時才計算函數結果的求值策略, 這對函數式編程特別有用。比如有行代碼是 x = func(),調用這個func()函數得到的返回值會賦值給x。 但是x等於什麼一開始並不重要,直到需要用到x的時候。等到需要用x的時候才調用func()就是惰性求值。
這一策略可以讓性能明顯增強,特別是當使用方法鏈和數組這些函數式程序員最喜愛的程序流技術的時候。 惰性求值讓人興奮的一個優點是讓無限序列成為可能。因為在它實在無法繼續延遲之前,什麼都不需要被真正計算出來。 它可以是這個樣子:
// 理想化的JavaScript偽代碼: var infinateNums = range(1 to infinity); var tenPrimes = infinateNums.getPrimeNumbers().first(10);
這為很多可能性敞開了大門,比如異步執行、並行計算、組合,這只列舉了一點。
然而,還有個問題,Javascript本身並不支持惰性求值,也就是說存在讓Javascript模擬惰性求值的函數庫, 這是第三章的主題。