1. 程式人生 > >玩轉 JavaScript 面試:何為函數語言程式設計?

玩轉 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)
複製程式碼

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

結論

本文要點:

  • 使用純函式而不是共享狀態或者有副作用的函式
  • 發揚不可變性而不是可變資料
  • 使用函式組合而不是指令式的流控制
  • 很多原生、可複用的工具函式可以通過高階函式應用到很多資料型別上,而不是隻能處理指定資料
  • 宣告式程式設計而不是指令式程式設計(要知道做什麼,而不是如何做)
  • 表示式和語句
  • 容器 & 高階函式對比 特設多型