JavaScript 函數語言程式設計導論

王下邀月熊_Chevalier發表於2017-01-12

近年來,函數語言程式設計(Functional Programming)已經成為了JavaScript社群中炙手可熱的主題之一,無論你是否欣賞這種程式設計理念,相信你都已經對它有所瞭解。即使是前幾年函數語言程式設計尚未流行的時候,我已經在很多的大型應用程式碼庫中發現了不少對於函數語言程式設計理念的深度實踐。函數語言程式設計即是在軟體開發的工程中避免使用共享狀態(Shared State)、可變狀態(Mutable Data)以及副作用(Side Effects)。函數語言程式設計中整個應用由資料驅動,應用的狀態在不同純函式之間流動。與偏向指令式程式設計的物件導向程式設計而言,函數語言程式設計其更偏向於宣告式程式設計,程式碼更加簡潔明瞭、更可預測,並且可測試性也更好。。函數語言程式設計本質上也是一種程式設計正規化(Programming Paradigm),其代表了一系列用於構建軟體系統的基本定義準則。其他程式設計正規化還包括物件導向程式設計(Object Oriented Programming)與過程程式設計(Procedural Programming)。

純函式

顧名思義,純函式往往指那些僅根據輸入引數決定輸出並且不會產生任何副作用的函式。純函式最優秀的特性之一在於其結果的可預測性:

var z = 10;
function add(x, y) {
    return x + y;
}
console.log(add(1, 2)); // prints 3
console.log(add(1, 2)); // still prints 3
console.log(add(1, 2)); // WILL ALWAYS print 3

add函式中並沒有操作z變數,即沒有讀取z的數值也沒有修改z的值。它僅僅根據引數輸入的xy變數然後返回二者相加的和。這個add函式就是典型的純函式,而如果在add函式中涉及到了讀取或者修改z變數,那麼它就失去了純潔性。我們再來看另一個函式:

function justTen() {
    return 10;
}

對於這樣並沒有任何輸入引數的函式,如果它要保持為純函式,那麼該函式的返回值就必須為常量。不過像這種固定返回為常量的函式還不如定義為某個常量呢,就沒必要大材小用用函式了,因此我們可以認為絕大部分的有用的純函式至少允許一個輸入引數。再看看下面這個函式:

function addNoReturn(x, y) {
    var z = x + y
}

注意這個函式並沒有返回任何值,它確實擁有兩個輸入引數xy,然後將這兩個變數相加賦值給z,因此這樣的函式也可以認為是無意義的。這裡我們可以說,絕大部分有用的純函式必須要有返回值。總結而言,純函式應該具有以下幾個特效:

  • 絕大部分純函式應該擁有一或多個引數值。
  • 純函式必須要有返回值。
  • 相同輸入的純函式的返回值必須一致。
  • 純函式不能夠產生任何的副作用。

共享狀態與副作用

共享狀態(Shared State)可以是存在於共享作用域(全域性作用域與閉包作用域)或者作為傳遞到不同作用域的物件屬性的任何變數、物件或者記憶體空間。在物件導向程式設計中,我們常常是通過新增屬性到其他物件的方式共享某個物件。共享狀態問題在於,如果開發者想要理解某個函式的作用,必須去詳細瞭解該函式可能對於每個共享變數造成的影響。譬如我們現在需要將客戶端生成的使用者物件儲存到服務端,可以利用saveUser()函式向服務端發起請求,將使用者資訊編碼傳遞過去並且等待服務端響應。而就在你發起請求的同時,使用者修改了個人頭像,觸發了另一個函式updateAvatar()以及另一次saveUser()請求。正常來說,服務端會先響應第一個請求,並且根據第二個請求中使用者引數的變更對於儲存在記憶體或者資料庫中的使用者資訊作相應的修改。不過某些意外情況下,可能第二個請求會比第一個請求先到達服務端,這樣使用者選定的新的頭像反而會被第一個請求中的舊頭像覆寫。這裡存放在服務端的使用者資訊就是所謂的共享狀態,而因為多個併發請求導致的資料一致性錯亂也就是所謂的競態條件(Race Condition),也是共享狀態導致的典型問題之一。另一個共享狀態的常見問題在於不同的呼叫順序可能會觸發未知的錯誤,這是因為對於共享狀態的操作往往是時序依賴的。

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

副作用指那些在函式呼叫過程中沒有通過返回值表現的任何可觀測的應用狀態變化,常見的副作用包括但不限於:

  • 修改任何外部變數或者外部物件屬性
  • 在控制檯中輸出日誌
  • 寫入檔案
  • 發起網路通訊
  • 觸發任何外部程式事件
  • 呼叫任何其他具有副作用的函式

在函數語言程式設計中我們會盡可能地規避副作用,保證程式更易於理解與測試。Haskell或者其他函數語言程式設計語言通常會使用Monads來隔離與封裝副作用。在絕大部分真實的應用場景進行程式設計開始時,我們不可能保證系統中的全部函式都是純函式,但是我們應該儘可能地增加純函式的數目並且將有副作用的部分與純函式剝離開來,特別是將業務邏輯抽象為純函式,來保證軟體更易於擴充套件、重構、除錯、測試與維護。這也是很多前端框架鼓勵開發者將使用者的狀態管理與元件渲染相隔離,構建鬆耦合模組的原因。

不變性

不可變物件(Immutable Object)指那些建立之後無法再被修改的物件,與之相對的可變物件(Mutable Object)指那些建立之後仍然可以被修改的物件。不可變性(Immutability)是函數語言程式設計的核心思想之一,保證了程式執行中資料流的無損性。如果我們忽略或者拋棄了狀態變化的歷史,那麼我們很難去捕獲或者復現一些奇怪的小概率問題。使用不可變物件的優勢在於你在程式的任何地方訪問任何的變數,你都只有只讀許可權,也就意味著我們不用再擔心意外的非法修改的情況。另一方面,特別是在多執行緒程式設計中,每個執行緒訪問的變數都是常量,因此能從根本上保證執行緒的安全性。總結而言,不可變物件能夠幫助我們構建簡單而更加安全的程式碼。
在JavaScript中,我們需要搞清楚const與不可變性之間的區別。const宣告的變數名會繫結到某個記憶體空間而不可以被二次分配,其並沒有建立真正的不可變物件。你可以不修改變數的指向,但是可以修改該物件的某個屬性值,因此const建立的還是可變物件。JavaScript中最方便的建立不可變物件的方法就是呼叫Object.freeze()函式,其可以建立一層不可變物件:

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}`);

如上所見,頂層的基礎型別屬性是不可以改變的,不過如果物件型別的屬性,譬如陣列等,仍然是可以變化的。在很多函數語言程式設計語言中,會提供特殊的不可變資料結構Trie Data Structures來實現真正的不可變資料結構,任何層次的屬性都不可以被改變。Tries還可以利用結構共享(Structural Sharing)的方式來在新舊物件之間共享未改變的物件屬性值,從而減少記憶體佔用並且顯著提升某些操作的效能。JavaScript中雖然語言本身並沒有提供給我們這個特性,但是可以通過Immutable.js與Mori這些輔助庫來利用Tries的特性。我個人兩個庫都使用過,不過在大型專案中會更傾向於使用Immutable.js。估計到這邊,很多習慣了指令式程式設計的同學都會大吼一句:在沒有變數的世界裡我又該如何程式設計呢?不要擔心,現在我們考慮下我們何時需要去修改變數值:譬如修改某個物件的屬性值,或者在迴圈中修改某個迴圈計數器的值。而函數語言程式設計中與直接修改原變數值相對應的就是建立原值的一個副本並且將其修改之後賦予給變數。而對於另一個常見的迴圈場景,譬如我們所熟知的for,while,do,repeat這些關鍵字,我們在函數語言程式設計中可以使用遞迴來實現原本的迴圈需求:

// 簡單的迴圈構造
var acc = 0;
for (var i = 1; i <= 10; ++i)
    acc += i;
console.log(acc); // prints 55
// 遞迴方式實現
function sumRange(start, end, acc) {
    if (start > end)
        return acc;
    return sumRange(start + 1, end, acc + start)
}
console.log(sumRange(1, 10, 0)); // prints 55

注意在遞迴中,與變數i相對應的即是start變數,每次將該值加1,並且將acc+start作為當前和值傳遞給下一輪遞迴操作。在遞迴中,並沒有修改任何的舊的變數值,而是根據舊值計算出新值並且進行返回。不過如果真的讓你把所有的迭代全部轉變成遞迴寫法,估計得瘋掉,這個不可避免地會受到JavaScript語言本身的混亂性所影響,並且迭代式的思維也不是那麼容易理解的。而在Elm這種專門面向函數語言程式設計的語言中,語法會簡化很多:

sumRange start end acc =
    if start > end then
        acc
    else
        sumRange (start + 1) end (acc + start)

其每一次的迭代記錄如下:

sumRange 1 10 0 =      -- sumRange (1 + 1)  10 (0 + 1)
sumRange 2 10 1 =      -- sumRange (2 + 1)  10 (1 + 2)
sumRange 3 10 3 =      -- sumRange (3 + 1)  10 (3 + 3)
sumRange 4 10 6 =      -- sumRange (4 + 1)  10 (6 + 4)
sumRange 5 10 10 =     -- sumRange (5 + 1)  10 (10 + 5)
sumRange 6 10 15 =     -- sumRange (6 + 1)  10 (15 + 6)
sumRange 7 10 21 =     -- sumRange (7 + 1)  10 (21 + 7)
sumRange 8 10 28 =     -- sumRange (8 + 1)  10 (28 + 8)
sumRange 9 10 36 =     -- sumRange (9 + 1)  10 (36 + 9)
sumRange 10 10 45 =    -- sumRange (10 + 1) 10 (45 + 10)
sumRange 11 10 55 =    -- 11 > 10 => 55
55

高階函式

函數語言程式設計傾向於重用一系列公共的純函式來處理資料,而物件導向程式設計則是將方法與資料封裝到物件內。這些被封裝起來的方法複用性不強,只能作用於某些型別的資料,往往只能處理所屬物件的例項這種資料型別。而函數語言程式設計中,任何型別的資料則是被一視同仁,譬如map()函式允許開發者傳入函式引數,保證其能夠作用於物件、字串、數字,以及任何其他型別。JavaScript中函式同樣是一等公民,即我們可以像其他型別一樣處理函式,將其賦予變數、傳遞給其他函式或者作為函式返回值。而高階函式(Higher Order Function)則是能夠接受函式作為引數,能夠返回某個函式作為返回值的函式。高階函式經常用在如下場景:

  • 利用回撥函式、Promise或者Monad來抽象或者隔離動作、作用以及任何的非同步控制流
  • 構建能夠作用於泛資料型別的工具函式
  • 函式重用或者建立柯里函式
  • 將輸入的多個函式並且返回這些函式複合而來的複合函式

典型的高階函式的應用就是複合函式,作為開發者,我們天性不希望一遍一遍地重複構建、測試與部分相同的程式碼,我們一直在尋找合適的只需要寫一遍程式碼的方法以及如何將其重用於其他模組。程式碼重用聽上去非常誘人,不過其在很多情況下是難以實現的。如果你編寫過於偏向具體業務的程式碼,那麼就會難以重用。而如果你把每一段程式碼都編寫的過於泛化,那麼你就很難將這些程式碼應用於具體的有業務場景,而需要編寫額外的連線程式碼。而我們真正追尋的就是在具體與泛化之間尋求一個平衡點,能夠方便地編寫短小精悍而可複用的程式碼片,並且能夠將這些小的程式碼片快速組合而解決複雜的功能需求。在函數語言程式設計中,函式就是我們能夠面向的最基礎程式碼塊,而在函數語言程式設計中,對於基礎塊的組合就是所謂的函式複合(Function Composition)。我們以如下兩個簡單的JavaScript函式為例:

var add10 = function(value) {
    return value + 10;
};
var mult5 = function(value) {
    return value * 5;
};

如果你習慣了使用ES6,那麼可以用Arrow Function重構上述程式碼:

var add10 = value => value + 10; 
var mult5 = value => value * 5;

現在看上去清爽多了吧,下面我們考慮面對一個新的函式需求,我們需要構建一個函式,首先將輸入引數加10然後乘以5,我們可以建立一個新函式如下:

var mult5AfterAdd10 = value => 5 * (value + 10)

儘管上面這個函式也很簡單,我們還是要避免任何函式都從零開始寫,這樣也會讓我們做很多重複性的工作。我們可以基於上文的add10與mult5這兩個函式來構建新的函式:

var mult5AfterAdd10 = value => mult5(add10(value));

在mult5AfterAdd10函式中,我們已經站在了add10與mult5這兩個函式的基礎上,不過我們可以用更優雅的方式來實現這個需求。在數學中,我們認為f ∘ g是所謂的Function Composition,因此`f ∘ g可以認為等價於f(g(x)),我們同樣可以基於這種思想重構上面的mult5AfterAdd10。不過JavaScript中並沒有原生的Function Composition支援,在Elm中我們可以用如下寫法:

add10 value =
    value + 10
mult5 value =
    value * 5
mult5AfterAdd10 value =
    (mult5 << add10) value

這裡的<<操作符也就指明瞭在Elm中是如何組合函式的,同時也較為直觀的展示出了資料的流向。首先value會被賦予給add10,然後add10的結果會流向mult5。另一個需要注意的是,(mult5 << add10)中的中括號是為了保證函式組合會在函式呼叫之前。你也可以組合更多的函式:

f x =
   (g << h << s << r << t) x

如果在JavaScript中,你可能需要以如下的遞迴呼叫來實現該功能:

g(h(s(r(t(x)))))

相關文章