物件導向程式設計和麵向過程程式設計都是程式設計正規化,函數語言程式設計也是一種程式設計正規化,意味著它們都是軟體構建的思維方式。與命令式或物件導向程式碼相比,函式式程式碼傾向於更簡潔、更可預測以及更易於測試。
什麼是函數語言程式設計 (通常簡稱為 FP)
是指通過複合純函式來構建軟體的過程,它避免了共享的狀態、易變的資料、以及副作用。函數語言程式設計是宣告式而不是命令式,並且應用程式狀態通過純函式流轉。
理解函數語言程式設計中核心概念
- 純函式(Pure functions)
- 函式複合(Function composition)
- 避免共享狀態(Avoid shared state)
- 避免改變狀態(Avoid mutating state)
- 避免副作用(Avoid side effects)
- 宣告式與命令式(Declarative and Imperative)
純函式
純函式是滿足如下條件的函式:
- 相同輸入總是會返回相同的輸出
- 不產生副作用
- 不依賴於外部狀態
JS中純函式的例子:
String.prototype.toUpperCase
Array.prototype.map
Function.prototype.bind
複製程式碼
JS中非純函式的例子:
Date.now
Math.random
Array.prototype.sort
document.body.appendChild
複製程式碼
純函式的好處:
- 易於測試(上下文無關)
- 可平行計算(時序無關)
- bug 自限性(問題不會擴散)
函式複合
函式複合是結合兩個或多個函式,從而產生一個新函式或進行某些計算的過程。
在 JavaScript 中相當於執行 f(g(x))。
共享狀態
共享狀態 的意思是:任意變數、物件或者記憶體空間存在於共享作用域(包括全域性作用域和閉包作用域)下,或者作為物件的屬性在各個作用域之間被傳遞。
通常,在物件導向程式設計中,物件以新增屬性到其他物件上的方式在作用域之間共享。與物件導向程式設計不同,函數語言程式設計避免共享狀態,它依賴於不可變資料結構和純粹的計算過程來從已存在的資料中派生出新的資料。
共享狀態的一個常見問題是改變函式呼叫次序函式呼叫的次序可能會改變函式呼叫的結果,進而可能導致一連串的錯誤:
const x = {
val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1(); // -> 3
x2(); // -> 6
複製程式碼
下面的例子與上面的相同,除了函式呼叫的次序不同:
const x = {
val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x2(); // -> 4
x1(); // -> 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});
x1(x); // -> 3
x2(x); // -> 4
/**
x2(x); // -> 4
x1(x); // -> 3
*/
複製程式碼
不修改狀態
在其他型別的語言中,變數往往用來儲存”狀態”。而函數語言程式設計只是返回新的值,不修改系統變數,即是無破壞性的資料轉換。
副作用
副作用是指除了函式返回值以外,任何在函式呼叫之外觀察到的應用程式狀態改變。
副作用包括:
- 改變了任何外部變數或物件屬性
- 寫檔案
- 髮網路請求
- 在螢幕輸出
- 呼叫另一個有副作用的函式
在函數語言程式設計中,副作用被儘可能避免。
宣告式與命令式
- 命令式:程式花費大量程式碼來描述用來達成期望結果的特定步驟,即”How to do”
- 宣告式:程式抽象了控制流過程,花費大量程式碼描述的是資料流,即”What to do”
函數語言程式設計是一個宣告式正規化,意思是說程式邏輯不需要通過明確描述控制流程來表達。
命令式:
let list = [1, 2, 3, 4];
let map1 = [];
for (let i = 0; i < list.length; i++) {
map1.push(list[i] * 2);
}
複製程式碼
宣告式:
let list = [1, 2, 3, 4];
let map2 = list.map(x => 2 * x);
複製程式碼
再來看宣告式例子中引出的兩個重要概念:
在講容器前不得不提什麼是範疇:
彼此之間存在某種關係的概念、事物、物件等等,都構成”範疇”。
範疇的數學模型簡單理解就是:”集合 + 函式”。
- 容器(Container):可以把”範疇”想象成是一個容器,裡面包含:值和值的變形(函式)
- 函子(Functor):是一個有介面的容器,能夠遍歷其中的值。能夠將容器裡面的每一個值,對映到另一個容器。
函數語言程式設計的應用
在函數語言程式設計中,通常使用functors以及使用高階函式抽象來建立通用功能函式,以處理任意數值或不同型別的資料。
高階函式
高階函式指的是一個函式以函式為引數,或以函式為返回值,或者既以函式為引數又以函式為返回值。
高階函式常用於:
- 部分應用於函式引數(偏函式應用)或建立一個柯里化的函式,用於複用或函式複合。
- 接受一個函式列表並返回一些由這個列表中的函式組成的複合函式。
物件導向程式設計傾向於把方法和資料集中到物件上。那些被集中的方法通常只能用來操作包含在特定物件例項上的資料。而函數語言程式設計傾向於複用一組通用的函式功能來處理資料。
偏函式
通過指定部分引數來產生一個新定製函式的形式就是偏函式。
const isType = function (type) {
return function (obj) {
return toString.call(obj) == `[object` + type + `]`;
};
};
const isString = isType(`string`);
const isFunction = isType(`function`);
複製程式碼
柯里化
柯里化是將一個多引數函式轉換成多個單引數函式。
// 柯里化之前
function add(x, y) {
return x + y;
}
add(1, 2) // 3
// 柯里化之後
function addX(y) {
return function (x) {
return x + y;
};
}
addX(2)(1) // 3
複製程式碼
函式的複合
如果一個值要經過多個函式,才能變成另外一個值,就可以把所有中間步驟合併成一個函式,這叫做”函式的複合”。
一個簡單的函式的複合例子:
const compose = function (f, g) {
return function (x) {
return f(g(x));
};
}
複製程式碼
實現一個高階函式用來減少非純函式:
function batch (fn) {
return function (target, ...args) {
if (target.length >= 0) {
return Array.from(target).map(item => fn.apply(this, [item, ...args]));
} else {
return fn.apply(this, [target, ...args]);
}
}
}
複製程式碼
例如:兩個非純函式 -> batch(fn) -> 一個非純函式
結論
函數語言程式設計偏好:
- 使用表示式替代語句
- 讓可變資料成為不可變的
- 用函式複合替代命令控制流
- 使用宣告式而不是命令式程式碼
- 使用純函式而不是使用共享狀態和副作用
- 使用容器與高階函式替代多型
- 使用高階函式來操作許多資料型別,建立通用、可複用功能取代只是操作集中的資料的方法