[譯] 玩轉 JavaScript 面試:何爲函數式編程?

原文地址 Medium - Master the JavaScript Interview: What is Functional Programming? javascript

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

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

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

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

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

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

這其中最難的部分能夠說就是讓一堆陌生詞彙充斥你的腦殼了。各類術語一臉無辜,由於在掌握它們以前你還須要瞭解下面這些術語的含義:數組

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

一個純函數定義以下:promise

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

純函數中的不少特性在函數式編程中都很重要,包括引用透明度(若是表達式能夠替換爲其相應的值而不更改程序的行爲,則該表達式稱爲引用透明)。前端框架

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

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)
複製代碼

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

結論

本文要點:

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

參考

什麼是 Monad (Functional Programming)?函子究竟是什麼?ApplicativeMonad

相關文章
相關標籤/搜索