征服 JavaScript 面試:什麼是函數語言程式設計?

發表於2017-01-30

t01c79775dce5c2651e

函數語言程式設計在 JavaScript 界已經成為了一個非常熱門的話題。而僅在幾年之前,還幾乎沒有 JavaScript 程式設計師瞭解函數語言程式設計是什麼,但在最近三年裡,我看到非常多的應用程式程式碼庫裡大量使用著函數語言程式設計思想。

函數語言程式設計 (通常簡稱為 FP)是指通過複合純函式來構建軟體的過程,它避免了共享的狀態(share state)易變的資料(mutable data)、以及副作用(side-effects)。函數語言程式設計是宣告式而不是命令式,並且應用程式狀態通過純函式流轉。對比物件導向程式設計,後者的應用程式狀態通常是共享並共用於物件方法。

函數語言程式設計是一種程式設計正規化意味著它是一種軟體構建的思維方式,有著自己的理論基礎和界定法則。其他程式設計正規化的例子包括物件導向程式設計和程式式程式設計。

與命令式或物件導向程式碼相比,函式式程式碼傾向於更簡潔、更可預測以及更易於測試 —— 但是如果你對它以及與它相關的常見模式不熟悉,讀函式式程式碼會讓你覺得資訊量太大,而且相關文獻對於初學者來說往往難以理解。

如果你開始 google 函數語言程式設計的術語,你很可能一下子碰壁,那些學術術語對新人來說著實有點嚇人。它有一個非常陡峭的學習曲線。但是如果你已經用 JavaScript 寫了一段時間的程式碼,你很可能不知不覺中在你的軟體裡已經使用了很多函數語言程式設計原理和功能。

不要讓那些新名詞把你嚇跑。實際上它比你所聽說的要簡單很多。

最難的部分是記住那些以前不熟悉的詞彙。在這些名詞定義中蘊含了許多思想,你只有理解了它們,才能夠開始掌握函數語言程式設計真正的意義:

  • 純函式(Pure functions)
  • 函式複合(Function composition)
  • 避免共享狀態(Avoid shared state)
  • 避免改變狀態(Avoid mutating state)
  • 避免副作用(Avoid side effects)

換句話說,如果你想要了解函數語言程式設計在實際中的意義,你需要從理解那些核心概念開始。

一個純函式是這樣的一個函式:

  • 給它同樣的輸入,總是返回同樣的結果,並且
  • 沒有副作用

純函式有著許多對函數語言程式設計而言非常重要的屬性,包括引用透明(你可以將一個函式呼叫替換成它的結果值,而不會對程式的執行造成影響)。獲取更多細節,可以閱讀“什麼是純函式”

函式複合是結合兩個或多個函式,從而產生一個新函式或進行某些計算的過程。例如,複合操作f·g(點號意思是對兩者執行復合運算)在 JavaScript 中相當於執行 f(g(x))。理解函式複合是理解軟體如何用函數語言程式設計模型來構建的很重要的一步。獲取更多細節,可以閱讀“什麼是函式組合”

共享狀態

共享狀態 的意思是任意變數、物件或者記憶體空間存在於共享作用域下,或者作為物件的屬性在各個作用域之間被傳遞。共享作用域包括全域性作用域和閉包作用域。通常,在物件導向程式設計中,物件以新增屬性到其他物件上的方式在作用域之間共享。

舉個例子,一個電腦遊戲可能會控制一個遊戲物件(game object),它上面有角色(characters)和遊戲道具(items),這些資料作為屬性儲存在遊戲物件之上。而函數語言程式設計避免共享狀態 —— 與前者不同地,它依賴於不可變資料結構和純粹的計算過程來從已存在的資料中派生出新的資料。要獲取更多關於軟體開發如何使用函數語言程式設計處理應用程式狀態的詳細內容,可以閱讀“10 Tips for Better Redux Architecture”

共享狀態的問題是為了理解函式的作用,你需要了解那個函式所用到的全部共享變數的變化歷史。

想象你有一個 user 物件需要儲存。你的 saveUser() 函式向伺服器 API 發起一個請求。此時,使用者改變了他們的頭像,通過 updateAvatar() 並觸發了另一次 saveUser() 請求。在儲存動作執行後,伺服器返回一個更新的 user 物件,客戶端要將這個物件替換記憶體中的物件,以保持與伺服器同步。

不幸地是,第二次請求有可能比第一次請求更早返回,所以當第一次請求(現在已經過時了)返回時,新的頭像又從記憶體中丟失了,被替換回舊的頭像。這是一個同步競爭的例子,是一個非常常見的共享狀態 bug。

共享狀態的另一個常見問題是改變函式呼叫次序可能導致一連串的錯誤,因為函式操作共享資料是依時序的:

如果你避免共享狀態,函式的呼叫時序不同就不會改變函式的呼叫結果。使用純函式,給定同樣的輸入,你將總是能得到同樣的輸出。這使得函式呼叫完全獨立於其他函式呼叫,可以從根本上簡化變更和重構。改變函式內容,或者改變函式呼叫的時序不會波及和破壞程式的其他部分。

在上面的例子裡,我們使用了 Object.assign() 並傳入一個空的 object 作為第一個引數來拷貝 x 的屬性,以防止 x 在函式內部被改變。在這個例子裡,它等價由於重新建立一個物件,而這是一種 JavaScript 裡的通用模式, 用來拷貝已存在狀態而不是使用引用,從而避免像我們第一個例子裡產生的問題。

如果你仔細看例子裡的 console.log() 語句,你會發現我前面已經提到過的概念:函式複合。回顧一下,函式複合看起來像是這樣:f(g(x))。在這個例子裡,我們的 f()g()x1()x2(),所以複合是 x1·x2

當然,如果你改變複合的順序,輸出將改變。操作的順序仍然很重要。f(g(x)) 並不總是等價於g(f(x)),但是,有一件事情發生了改變,那就是函式外部的變數不會被修改 —— 原本函式修改外部變數是一個大問題。要是函式不純,我們如果不瞭解函式使用或操作的每個變數的完整歷史,就不可能完全理解它做了什麼。

移除函式時序依賴,你就完全消除了一大類潛在的 bug。

不可變性

一個不可變的(immutable)物件是指一個物件不會在它建立之後被改變。對應地,一個可變的(mutable)物件是指任何在建立之後可以被改變的物件。

不可變性是函數語言程式設計的一個核心概念,因為沒有它,你的程式中的資料流是有損的。狀態歷史被拋棄而奇怪的 bug 可能會在你的軟體中產生。關於更多不變性的意義,閱讀 “The Dao of Immutability.”

在 JavaScript 中,很重要的一點是不要混淆了 const 和不變性。const 建立一個變數繫結,讓該變數不能再次被賦值。const 並不建立不可變物件。你雖然不能改變繫結到這個變數名上的物件,但你仍然可以改變它的屬性,這意味著 const 的變數仍然是可變的,而不是不可變的。

不可變物件完全不能被改變。你可以通過深度凍結物件來創造一個真正的不可變的值。JavaScript 提供了一個方法,能夠淺凍結一個物件:

然而凍結的物件只是表面一層不可變,例如,深層的屬性還是可以被改變:

如你所見,被凍結的 object 的頂層基本屬性不能被改變,但是如果有一個屬性本身也是 object(包括陣列等),它依然可以被改變 —— 因此甚至被凍結的物件也不是不可變的,除非你遍歷整個物件樹並凍結每一個物件屬性。

在許多函數語言程式設計語言中,有特殊的不可變資料結構,被稱為 trie 資料結構(trie 的發音為 tree),這一結構有效地深凍結 —— 意味任何屬性無論它的物件層級如何都不能被改變。

當一個物件被拷貝給一個操作符時,tries 使用結構共享來共用不可變物件的引用記憶體地址,這減少記憶體佔用,而且能夠顯著地改善一些型別的操作的效能。

例如,你可以使用 ID 來比較物件,如果兩個物件的根 ID 相同,你不需要繼續遍歷比較整個物件樹來尋找差異。

有一些 JavaScript 的庫使用了 tries,包括 Immutable.jsMori

我體驗了這兩個庫,最終決定在需要大量不可變狀態大的型專案中使用 Immutable.js。想要了解這一部分的更多內容,請移步 “10 Tips for Better Redux Architecture”

副作用(Side Effects)

副作用是指除了函式返回值以外,任何在函式呼叫之外觀察到的應用程式狀態改變。副作用包括:

  • 改變了任何外部變數或物件屬性(例如,全域性變數,或者一個在父級函式作用域鏈上的變數)
  • 寫日誌
  • 在螢幕輸出
  • 寫檔案
  • 髮網路請求
  • 觸發任何外部程式
  • 呼叫另一個有副作用的函式

在函數語言程式設計中,副作用被儘可能避免,這使得程式的作用更容易理解,也使得程式更容易被測試。

Haskell 以及其他函數語言程式設計語言經常從純函式中隔離和封裝副作用,使用 monads 技巧。Mondas 這個話題要深入下去可以寫一本書,所以我們先放一放。

你現在需要做的是要從你的軟體中隔離副作用行為。如果你讓副作用與你的程式邏輯分離,你的軟體將會變得更易於擴充套件、重構、除錯、測試和維護。

這也是為什麼大部分前端框架鼓勵我們分開管理狀態和元件渲染,採用鬆耦合的模型。

通過高階函式提升可重用性

函數語言程式設計傾向於複用一組通用的函式功能來處理資料。物件導向程式設計傾向於把方法和資料集中到物件上。那些被集中的方法只能用來操作設計好的資料型別,通常是那些包含在特定物件例項上的資料。

在函數語言程式設計裡,對任何型別的資料一視同仁。同樣的 map() 操作可以 map 物件、字串、數字或任何別的型別,因為它接受一個函式引數,來適當地操作給定型別。函數語言程式設計通過使用高階函式來實現這一技巧。

在 JavaScript 裡,函式是一等公民,JavaScript 允許使用者將函式作為資料 —— 可以將它們賦值給變數、作為引數傳遞給其他函式、將它們作為返回值返回,等等……

高階函式指的是一個函式以函式為引數,或以函式為返回值,或者既以函式為引數又以函式為返回值。高階函式經常用於:

  • 抽象或隔離行為、作用,非同步控制流程作為回撥函式,promises,monads,等等……
  • 建立可以泛用於各種資料型別的功能
  • 部分應用於函式引數(偏函式應用)或建立一個柯里化的函式,用於複用或函式複合。
  • 接受一個函式列表並返回一些由這個列表中的函式組成的複合函式。

容器、函子(Functor)、列表和流

Functor 是可以被用來執行具體 map 操作的資料。換句話說,它是一個有介面的容器,能夠遍歷其中的值。當你看到“functor”這個詞,你在腦海裡應該想到“mappable”。

之前我們說同樣的 map() 函式能夠操作各種資料型別。它是通過將 map 操作抽象出來,提供給 functor API 使用。map() 利用該介面執行重要的流程控制操作。在 Array.prototype.map() 的場景裡,容器是一個陣列,但是其他資料介面也可以作為 functor,同樣它也提供了 mapping 操作的 API。

讓我們看一下 Array.prototype.map() 是如何讓你從 mapping 功能裡抽象資料型別,讓 map() 可以適用於任何資料型別的。我們建立一個簡單的 double() mapping,它簡單地將傳給它的值乘以 2:

假設我們相對遊戲中的目標執行獎勵翻倍操作,我們所需要做的只是小小改變一下我們傳給 map()double() 函式,這樣便一切正常:

使用 functors 以及使用高階函式抽象來建立通用功能函式,以處理任意數值或不同型別的資料,這是函數語言程式設計中很重要的概念。你還能看到類似的概念以各種不同的方式被應用。

“流即是隨著時間推移而變化的列表。”

現在你所需要知道的是容器和容器的值所能應用的形式不僅僅只有陣列和 functor。一個陣列只是一些內容的列表。如果這個列表隨著時間推移而變化則成為一個流 —— 所以你可以應用同樣的功能來處理時間流 —— 如果你用函數語言程式設計實際開始構建一個真正的軟體時,你就會看到很多這種用法。

對比宣告式與命令式

函數語言程式設計是一個宣告式正規化,意思是說程式邏輯不需要通過明確描述控制流程來表達。

命令式 程式花費大量程式碼來描述用來達成期望結果的特定步驟 —— 控制流:即如何做。

宣告式 程式抽象了控制流過程,花費大量程式碼描述的是資料流:即做什麼。

舉個例子,下面是一個用 命令式 方式實現的 mapping 過程,接收一個數值陣列,並返回一個新的陣列,新陣列將原陣列的每個值乘以 2:

而實現同樣功能的 宣告式 mapping 用函式 Array.prototype.map() 將控制流抽象了,從而我們可以表達更清晰的資料流:

命令式 程式碼中頻繁使用語句。語句是指一小段程式碼,它用來完成某個行為。通用的語句例子包括forifswitchthrow,等等……

宣告式 程式碼更多依賴表示式。表示式是指一小段程式碼,它用來計算某個值。表示式通常是某些函式呼叫的複合、一些值和操作符,用來計算出結果值。

以下都是表示式:

通常在程式碼裡,你會看到一個表示式被賦給某個變數,或者作為函式返回值,或者作為引數傳給一個函式。在被賦值、返回或傳遞之前,表示式首先被計算,之後它的結果值被使用。

結論

函數語言程式設計偏好:

  • 使用純函式而不是使用共享狀態和副作用
  • 讓可變資料成為不可變的
  • 用函式複合替代命令控制流
  • 使用高階函式來操作許多資料型別,建立通用、可複用功能取代只是操作集中的資料的方法。
  • 使用宣告式而不是命令式程式碼(關注做什麼,而不是如何做)
  • 使用表示式替代語句
  • 使用容器與高階函式替代多型

作業

學習和練習這一組核心的資料擴充套件

  • .map()
  • .filter()
  • .reduce()

使用 map 來轉換如下陣列的值為 item 名字:

使用 filter 來選擇出 points 大於等於 3 的元素:

使用 reduce 來求出 points 的和:

更進一步

準備好更深入學習了嗎?閱讀 Jafar Husain 的 Learn Rx exercises 來學習一些最重要的函數語言程式設計工具。

想要了解更詳細的關於函數語言程式設計的細節以及如何使用函數語言程式設計結合實際每天使用的 JavaScript 來構建真正的應用程式?

與 Eric Elliott 一同學習 JavaScript 吧。這麼好的機會,別錯過。

t0136c9315909f9cee2


Eric Elliott is the author of “Programming JavaScript Applications” (O’Reilly), and “Learn JavaScript with Eric Elliott”. He has contributed to software experiences for Adobe Systems,Zumba Fitness, The Wall Street Journal, ESPN, BBC, and top recording artists includingUsher, Frank Ocean, Metallica, and many more.

He spends most of his time in the San Francisco Bay Area with the most beautiful woman in the world.

感謝 JS_Cheerleader.

相關文章