翻譯:安歌javascript
校對:咕嚕鏟屎官前端
譯者按:本文采用意譯,文章偏理論型,主要從概念上介紹函數式編程,後面計劃翻譯一篇《JavaScript函數式編程》,從實戰中介紹函數式編程,有興趣能夠關注下~java
原文:Master JavaScript Interview: What is Functional Programming? node
正文開始:程序員
「精通JavaScript面試」系列文章是專門爲中級晉升高級JavaScript開發的求職者準備的常見考覈問題,這些也是我在實際面試中常用的問題。面試
當下,函數式編程已經成爲JavaScript界很是熱門的一個話題。而在幾年前,幾乎不多有JavaScript程序員知道什麼是函數式編程,可是在我過去三年看到的每個大型應用程序的代碼中都大量使用了函數式編程的思想。編程
函數式編程(Functional Programming,一般簡稱FP)是經過組合純函數(pure functions)來構建軟件的過程,避免共享狀態(shared state)、數據可變(mutable date)和反作用(side-effects)。函數式編程是聲明式而非命令式的,應用程序的狀態經過純函數流動。與面向對象編程不一樣,它的程序狀態一般與對象中的方法共享和協做。redux
函數式編程是一種編程範式,這意味着它是基於一些特定的原則(上面列出的)去考慮軟件架構的方法論。其餘的編程範式還包括面向對象和麪向過程編程。數組
相比於命令式或面向對象的代碼,函數式代碼更加簡潔、可預測和易測試,但若是你不熟悉函數式編程以及與之相關的常見模式,那麼函數式代碼看起來會更加密集,而且與之相關的文章對於新手來講也很難理解。瀏覽器
若是你開始在谷歌上搜索函數式編程術語,你很快就會遇到一堵學術術語的磚牆,這對初學者來講是很是可怕的。但若是你已經用JavaScript編程有一段時間,那麼極可能你已經在實際的軟件開發中使用了不少函數式編程的思想和實用函數。
不要讓全部的生詞把你嚇跑,這遠比它聽起來要簡單得多。
在開始理解函數式編程的含義以前,你須要理解上面那個看似簡單的定義中的幾個概念:
換句話說,若是你想知道函數式編程在實踐中意味着什麼,那你就必須先理解這些核心概念。
純函數有兩個特色:
在函數式編程中純函數還有不少重要特性,包括引用透明性(指的是函數的運行不依賴於外部變量或狀態),閱讀「什麼是純函數」瞭解更多詳情。
函數組合是將兩個或以上函數組合造成一個新函數的過程。例如,複合函數 f · g
(·
表示一種組合)在JavaScript中等價於f(g(x))
。理解組合函數是使用函數式編程構建軟件的重要一步,閱讀「什麼是組合函數」瞭解更多詳情。
共享狀態是指任何變量、對象或內存空間存在與共享做用域下,或者對象的屬性在做用域之間傳遞。共享做用域能夠包括全局做用域或閉包做用域,一般,在面向對象編程中,對象經過向其餘對象添加屬性的方式在做用域之間共享。
好比,計算機遊戲可能有一個主遊戲對象,其中的角色和道具做爲該對象的屬性存儲。函數式編程避免了共享狀態——依賴不可變的數據結構和純計算從現有數據派生新數據。更多關於函數式的軟件如何處理程序狀態的信息,查閱「10個更好的Redux架構技巧」。
共享狀態的問題在於,爲了理解函數的效果,你必須瞭解函數中使用或影響的每個共享變量的完整歷史。
假設你有一個須要保存的user
對象,使用saveUser()
函數向服務器上的一個API發起請求,在這個過程當中,用戶使用updateAvatar()
更改頭像,並觸發了另外一個saveUser()
請求。在保存時,服務器返回一個規範的user
對象,該對象應該替換內存中的任何內容,以便與服務器上發生的更改同步,或者響應其餘API的調用。
不幸的是,第二個(saveUser
)響應在第一個(saveUser
)響應以前被接收,因此當第一個(如今已通過時了)響應返回時,新頭像將在內存中刪除,並被替換爲舊的。這是竟態條件中,一個與共享狀態相關的很是常見的bug。
與共享狀態相關的另外一個常見問題是,更改函數的調用順序可能會致使級聯故障,由於做用於共享狀態裏面的函數依賴於時序。
// 對於共享狀態,函數的調用會影響結果
const x = {
val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1();
x2();
console.log(x.val); // 6
// 這個例子於上面的相同,除了...
const y = {
val: 2
};
const y1 = () => y.val += 1;
const y2 = () => y.val *= 2;
// 倒置調用順序
y2();
y1();
console.log(y.val); // 5
複製代碼
當避免了共享狀態時,函數調用的時序不會改變結果。對於純函數,給定相同的輸入,你老是獲得相同的輸出,這使得函數調用徹底獨立於其餘函數的調用,從而在根本上簡化修改和重構。
const x = {
val: 2
};
const x1 = x => Object.assign({}, x, { val: x.val + 1});
const x2 = x => Object.assign({}, x, { val: x.val * 2});
console.log(x1(x2(x)).val); // 5
const y = {
val: 2
};
x2(y);
x1(y);
console.log(x1(x2(y)).val); // 5
複製代碼
在上面的例子中,咱們使用Object.assign()
而且傳遞一個空對象做爲第一個參數去複製對象x中的屬性,在這種狀況下,它至關於從零建立一個對象。
若是你仔細看了本例中console.log
語句,你會注意到一些我前面已經提到過的內容:函數組合。前面咱們的函數組合是這樣的:f(g(x))
。但在這個例子中,咱們分別用x1()
和x2()
替換了原來的f()
和g(
)以得到了新的組合:x1 · x2
。
固然,若是更改組合函數中的順序,輸出的結果也會相應改變。操做的順序仍是很重要的。f(g(x))
並不老是等價與g(f(x))
,可是函數外部的變量會發生什麼已經變得再也不重要。對於非純函數,除非知道函數使用或影響的每個變量的完整歷史,不然不可能徹底理解這個函數的做用。
消除函數調用的時序依賴性,也就消除了一整類潛在bug。
不可變的對象在其建立後不可再更改,相反地,可變對象是任何建立以後仍然可被修改。 不變性是函數式編程中的核心概念,由於沒有它,程序中的數據流就會丟失。狀態的歷史丟失,奇怪的bug就會蔓延在你的軟件中。更多關於不變性的意義,參閱「不變性之道」。(老外也懂道?)
在JavaScript中,不能混淆const和不變性。const建立了一個變量名綁定,該綁定在建立以後不可再賦值。const並無建立一個不可變的對象,咱們仍然能夠更改對象的屬性,這意味着const建立的綁定對象是可變的,而非不可變。
經過深度凍結對象,可使對象真正不可變。JavaScript中有一個方法能夠將對象的第一層進行凍結:
const a = Object.freeze({
foo: 'Hello',
bar: 'world',
baz: '!'
});
a.foo = 'Goodbye';
// Error: Cannot assign to read only property 'foo' of object Object
複製代碼
譯者注:在非
strict
模式下,瀏覽器或者node環境對a.foo
賦值操做不會引發報錯,但也沒法修改值。
但凍結的對象只是第一層不可變。例如,下面的對象仍然可變:
const a = Object.freeze({
foo: { greeting: 'Hello' },
bar: 'world',
baz: '!'
});
a.foo.greeting = 'Goodbye';
console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
複製代碼
正如你所見,凍結對象中頂層的原始屬性不可變,但對於同時也是對象的任何屬性(包含Array等)仍然可變——所以即使凍結對象也是可變的,除非遍歷整個對象樹並凍結每個對象屬性。
在許多函數式編程語言中,有一種特殊的不可變數據結構稱爲tire數據結構(又稱字典樹,發音tree),它其實是深凍結的——這意味着不論對象的屬性層級有多深,屬性都不可更改。
Tires(字典樹)經過結構共享的方式共享對象中全部節點的內存引用地址,這些內存地址在對象被操做符複製以後保持不變,這將消耗更少的內存,併爲某些類型的操做帶來顯著的性能提高。
例如,你能夠在對象樹的根位置經過標識進行比較,若是標識相同,則沒必要遍歷整個樹去檢查差別。
這裏有幾個JavaScript庫利用了tires數據結構,包括:Immutable.js
和Mori
。 對上面兩個庫我都進行了實踐,在須要大量不可變狀態的大型項目中更傾向於使用Immutable.js
。更多關於這方面的信息,參閱「改善Redux體系結構的10個技巧」。
反作用是指在被調用函數以外可被察覺的的除了返回值以外的任何應用程序狀態的更改,包括:
函數式編程一般能夠避免反作用,這使得程序的效果更易理解和測試。
Haskell和其餘函數語言常用Monads(單子)從純函數中分離和封裝反作用。monads的內容足夠複雜到能夠寫一本書,咱們之後再討論。
譯者注:更多關於Monads,參考:前端中的Monad
你如今須要明白的是,反作用的操做須要與軟件的其餘部分隔離。若是將反作用與程序邏輯部分分開,你的軟件將變得更易擴展、重構、調試、測試和維護。
這也是大多數前端框架鼓勵用戶在獨立的、低耦合的模塊中管理狀態和組件渲染的緣由。
函數式編程傾向於重複利用一組公共函數庫來處理數據,面向對象編程則傾向於將對象中的方法和數據放在一塊兒,這些方法對操做的數據有類型的限制,而且一般只能對特定類實例中的數據進行操做。
在函數式編程中,全部的數據類型都是對等的。同一個map()
工具函數能夠映射對象、字符串、數字或其餘數據類型,由於它接收一個函數做爲參數,該參數會適當地處理給定的數據類型。
JavaScript中函數是一等公民,容許咱們把函數看成數據看待——把它們賦值給變量、做爲參數傳入函數或者從函數中返回等等。
高階函數是以函數爲參數、返回函數或者二者兼有的函數。高階函數一般用於:
函子是可映射的。換句話說,它是一個容器,包含了一個接口,能夠將函數應用於其中的值。當你看到「函子」這個詞,你就應該想到「可映射的」。
前面咱們瞭解了同一個map()
能夠做用於各類數據類型。它經過函子API提高映射操做來實現這一點。map()
中很重要的流控制操做使用了該接口。對於Array.prototype.map()
,容器是一個數組,固然,其餘數據結構也能夠是函子——只要它們提供映射API。
讓咱們看看Array.prototype.map()
是如何容許你從映射實用程序中抽象數據類型,從而使map()
能夠用於任何數據類型的。下面咱們將建立一個簡單的double
映射,它會將每個傳入的值乘以2:
const double = n => n * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([2, 3, 4])); // [ 4, 6, 8 ]
複製代碼
若是咱們想給下面對象中的點數翻倍呢?咱們所要作的僅是對傳遞給map()
的double()
函數作一些細微的修改,一切仍然正常:
const double = n => n.points * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([
{ name: 'ball', points: 2 },
{ name: 'coin', points: 3 },
{ name: 'candy', points: 4}
])); // [ 4, 6, 8 ]
複製代碼
在函數式編程中,使用諸如函子和高階函數這樣的抽象並利用工具函數操做任意數據類型的思想很是重要。
"流便是隨着時間推移而變化的列表"
如今你須要清楚的是,數組和函子並非應用容器和其中的值的概念的惟一方式。例如,數組是一個列表,隨着時間推移所表達的列表是一個流——所以你能夠應用相同類型的工具函數來處理傳入的事件流。當你真正開始使用FP構建軟件時,你會常常看到這種狀況。
函數式編程是一種聲明範式,這意味着程序邏輯的表達不須要顯示地描述控制流。
命令式程序花費數行代碼去描述用於實現預期結果所需的特定步驟——流程控制:怎麼作;
聲明式程序抽象了流程控制過程,而且經過數行代碼描述數據流:作什麼。
例如,這個命令式的映射接受一個數字數組,並返回每一個元素乘以2後的新數組:
const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
複製代碼
這個聲明式映射作了一樣的事情,可是經過Array.prototype.map()
函數抽象了控制流,使得咱們能夠更清晰地表達數據流:
const doubleMap = numbers => numbers.map(n => n * 2);
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
複製代碼
命令式代碼常用諸如for
、if
、switch
、throw
…這樣的語句
而聲明式代碼更多地依賴於表達式,表達式是一段用於計算某個值的代碼,一般是用於產生相應結果值的函數調用、值和運算符的某種組合。
這些都是表達式的例子:
2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)
複製代碼
一般在代碼中,你會看到將表達式賦值給標識符、從函數返回或傳遞給函數,在賦值、返回或傳遞以前,首先會對錶達式求值,而後使用結果值。
函數式編程支持: