玩轉 JavaScript 面試:何為函數式編程?


函數式編程在 JavaScript 領域着實已經成為一個熱門話題。就在幾年前,很多 JavaScript 程序員甚至都不知道啥是函數式編程,但是就在近三年里我看到過的每一個大型應用的代碼庫中都包含了函數式編程思想的大規模使用。

函數式編程(縮寫為 FP)是一種通過組合純函數來構建軟件的過程,避免狀態共享可變數據副作用的產生。函數式編程是一種聲明式編程而不是指令式編程,應用的狀態全部流經的是純函數。與面向對象編程思想形成對比的是,其應用程序的狀態通常都是與對象中的方法共享的。

函數式編程是一種編程范式,意指它是一種基於一些基本的、限定原則的軟件架構的思維方式,其他編程范式的例子還包括面向對象編程和面向過程編程。

相比指令式編程或面向對象,函數式編程的代碼傾向於更為簡潔、可預測且更容易測試。但如果你不熟悉這種方式或與其常見的幾種相關模式的話,函數式編程的代碼同樣可以看起來很緊湊,相關文檔對於新手來說可能也較為難以理解。

如果你開始去搜索函數式編程的相關術語,你可能很快就會碰壁,大量專業術語完全可以唬住一個新手。單純的討論其學習曲線有點兒過於輕描淡寫了,但是如果你已經從事 JavaScript 編程工作有一段時間了,那么你應該已經在你的項目中使用過很多函數式編程的思想或工具了。

別讓新詞匯把你嚇跑。它們會比聽起來更容易。

這其中最難的部分可以說就是讓一堆陌生詞匯充斥你的腦袋了。各種術語一臉無辜,因為在掌握它們之前你還需要了解下面這些術語的含義:

  • 純函數
  • 函數組合
  • 避免狀態共享
  • 避免狀態改變
  • 避免副作用

一個純函數定義如下:

  • 每次給定相同的輸入,其輸出結果總是相同的
  • 無任何副作用

純函數中的很多特性在函數式編程中都很重要,包括引用透明度(如果表達式可以替換為其相應的值而不更改程序的行為,則該表達式稱為引用透明)。

引用透明度說白了就是相同的輸入總是得到相同的輸出,也就是說函數中未使用任何外部狀態:

function plusOne(x) { return x + 1; } 復制代碼

上面的例子即為引用透明度函數,我們可以用 6 來代替 plusOne(5) 的函數調用。詳細解釋可參考 stack overflow - What is referential transparency?

函數組合是指將兩個或多個函數進行組合以便產生一個新的函數或執行某些計算的過程。比如組合函數f.g(.的意識是指由...組成)在 JavaScript 中等價於 f(g(x))。理解函數組合對於理解使用函數式編程編寫軟件來說是個十分重要的步驟。

狀態共享

狀態共享是指任何變量、對象或內存空間在一個共享的作用域中存在,或者是被用來作為對象的屬性在作用域之間傳遞。一個共享的作用域可以包括全局作用域或者閉包作用域。在面向對象編程中,對象通常都是通過添加一個屬性到其他對象中來在作用域間共享的。

狀態共享的問題在於為了了解一個函數的作用,你不得不去了解函數中使用的或影響的每一個共享的變量的過往。

假定你有一個用戶對象需要保存,你的saveUser()函數會向服務器上的接口發起請求。與此同時,用戶又進行了更換頭像的操作,調用updateAvatar()來更換頭像的同時也會觸發另一次saveUser()請求。在保存時,服務器返回一個規范的用戶對象,該對象應該替換內存中的任何內容以便與服務器上的更改或響應其他 API 調用同步。

但是問題來了,第二次響應比第一次返回要早。所以當第一個響應(已過期)返回時,新頭像被從內存中抹去了,替換回了舊頭像。這就是一個爭用條件的例子 —— 是與狀態共享有關的一個很常見的 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的屬性,而不是在原處改變x。該例中,不使用Object.assign()的話,它相當於簡單的從頭開始創建一個新對象,但這是 JavaScript 中創建現有狀態副本而不是使用變換的常見模式,我們在第一個示例中演示了這一點。

如果你仔細的看了本例中的console.log()語句,你應該會注意到我已經提到過的一些東西:函數組合。回憶一下之前說過的知識點,函數組合看起來像這樣:f(g(x))。在本例中為x1(x2()),也即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 復制代碼

這種凍結方式僅僅是淺層的不可變,例如:

const a = Object.freeze({ foo: { greeting: 'Hello' }, bar: 'world', baz: '!' }); a.foo.greeting = 'Goodbye'; console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);// Goodbye world! 復制代碼

可以看到,一個被凍結的頂層的原始屬性是不可變的。但如果屬性值為對象的話,該對象依然可變(包括數組等)。除非你遍歷整個對象樹,將其層層凍結。

在很多函數式編程語言中都又比較特殊的不可變數據結構,稱之為查找樹數據結構,這種數據結構是可以有效的進行深度凍結的。

查找樹通過結構共享來共享內存空間的引用,其在對象被復制后依然是不變的,從而節省了內存,使得某類操作的性能有顯著的提升。

例如,你可以在一個對象樹的根節點使用身份對照來進行比較。如果身份相同,如果身份相同,那你就不用去遍歷整顆樹來對比差異了。

在 JavaScript 中有一些比較優秀的利用樹的類庫,比如 Immutable.jsMori

這倆庫我都用過,我更傾向於在需要很多不可變狀態的大型項目中使用 Immutable.js

副作用

副作用就是指當調用函數時,除了返回函數值之外,還對主調用函數產生附加的影響。副作用的函數不僅僅只是返回了一個值,而且還做了其他的事情:

  • 改變了外部對象或變量屬性(全局變量或父函數作用域鏈中的變量)
  • 在控制台中有輸出打印
  • 向屏幕中寫了東西
  • 向文件中寫了東西
  • 向網絡中寫了東西
  • 觸發了外部過程
  • 調用了其他有副作用的函數

副作用在函數式編程中大多數時候都是要避免的,這樣才能使得程序的作用一目了然,也更容易被測試。

Haskell 等其他編程語言總是從純函數中使用 Monads 將副作用獨立並封裝。有關 Monads 內容太多了,大家可以去了解一下。

但你現在就需要了解的是,副作用行為需要從你的軟件中獨立出來,這樣你的軟件就更易擴展、重構、debug、測試和維護。

這也是大多數前端框架鼓勵用戶單獨的管理狀態和組件渲染、解耦模塊。

通過高階函數提高復用性

函數式編程傾向於復用一系列函數工具來處理數據。面向對象編程則傾向於將方法和數據放在對象中,這些合並起來的方法只能用來操作那些被設計好的數據,經常還是包含在特定組件實例中的。

在函數式編程中,任何類型的數據都是一樣的地位,同一個 map() 函數可以遍歷對象、字符串、數字或任何類型的數據,因為它接收一個函數作為參數,而這個函數參數可以恰當的處理給定的數據類型。函數式編程通過高階函數來實現這種特性。

JavaScript 秉承函數是一等公民的觀點,允許我們把函數當數據對待 —— 把函數賦值給變量、將函數傳給其他函數、讓函數返回函數等...

高階函數就是指任何可以接收函數作為參數的函數、或者返回一個函數的函數,或者兩者同時。高階函數經常被用於:

  • 抽象或獨立的動作、回調函數的異步流控制、promises,、monads 等等...
  • 創建可以處理各種數據類型的實用工具函數
  • 使用函數的部分參數或以復用目的或函數組合創建的柯里化函數
  • 接收一組函數作為參數然后返回其中的一些作為組合

容器、函子、列表、流

函子就是一種可以被映射的東西。換句話說,它就是一個有接口的容器,該接口可以被用來apply到函數內部的一個值(這句翻譯太奇怪了,功力不夠。原文 it’s a container which has an interface which can be used to apply a function to the values inside it.)。

前面我們知道了相同的 map()函數可以在多種數據類型上執行。它通過提升映射操作以使用函子 API 來實現。關鍵的流控制操作可以通過 map() 函數利用該接口使用。如果是 Array.prototype.map() 的話,容器就是個數組,其他數據結構可以作為函子,只要它們提供了 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 ] 復制代碼

要是我們想對一個游戲中的目標進行操作,讓其得分數翻倍呢?只需要在 double() 函數中傳入 map() 的值上稍作改動即可:

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 ] 復制代碼

使用如函子/高階函數的概念來使用原生工具函數來操作不同的數據類型在函數式編程中很重要。類似的概念被應用在 all sorts of different ways。

列表在時間上的延續即為流。

你現在只需要知道數組和函數不是容器和值在容器中應用的唯一方式。比如說,一個數組就是一組數據。列表在時間上的延續即為流 -- 因此你可以使用同類工具函數來處理進來的事件流 —— 在日后實踐函數式編程中你會對此有所體會。

聲明式編程 & 指令式編程

函數式編程是一種聲明式編程范式,程序的邏輯在表達時沒有明確的描述流控制。

指令式編程用一行行代碼來描述特定步驟來達到預期結果。而根本不在乎流控制是啥?

聲明式編程抽象了流控制過程,用代碼來描述數據流該怎么做,如何去獲得抽象的方式。

下面的例子中給出了指令式編程映射數組中數字並返回將值乘 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] 復制代碼

指令式編程常使用語句,語句即一段執行某個動作的代碼,包括forifswitchthrow 等等。

聲明式編程的代碼更多依賴的是表達式,表達式是一段有返回值的代碼。表達式的例子如下:

2 * 2 doubleMap([2, 3, 4]) Math.max(4, 3, 2) 復制代碼

你會在代碼中經常看見一個表達式被賦給一個變量、從函數中返回一個表達式或是被傳入一個函數。

結論

本文要點:

  • 使用純函數而不是共享狀態或者有副作用的函數
  • 發揚不可變性而不是可變數據
  • 使用函數組合而不是指令式的流控制
  • 很多原生、可復用的工具函數可以通過高階函數應用到很多數據類型上,而不是只能處理指定數據
  • 聲明式編程而不是指令式編程(要知道做什么,而不是如何做)
  • 表達式和語句
  • 容器 & 高階函數對比 特設多態




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM