征服 JavaScript 面試:什么是純函數?
圖像: Pure — carnagenyc (CC-BY-NC 2.0)
對于函數式編程、可靠的并發以及 React + Redux 應用程序等用途來說,純函數是必不可少的。不過,純函數到底是什么意思呢?
要搞定什么是純函數,最好是先深入研究一下函數。有幾種不同的方式可以用來研究函數,這樣會讓函數式編程更容易理解。
什么是函數?
函數是接受一些輸入,并產生一些輸出的過程。這些輸入稱為參數,輸出稱為返回值。函數可以有如下用途:
-
映射:基于給定的輸入產生一些輸出。函數將輸入值 映射 到輸出值。
-
過程:函數可以被調用,來執行一系列步驟。這一系列步驟被稱為過程,以這種風格編程被稱為過程式編程(procedural programming)。
-
I/O:有些函數是用來與系統的其它部分進行通訊,比如屏幕、存儲、系統日志或者網絡。
映射
純函數都是關于映射。函數將輸入參數映射到返回值,也就是說,對于每套輸入,都存在一個輸出。一個函數會接受輸入,并返回相對應的輸出。
Math.max() 接受數字為參數,并返回最大的數:
Math.max(2, 8, 5); // 8
在本例中,2、8 和 5 都是參數,它們是傳進函數的值。
Math.max() 是一個接受任意多個參數,并返回最大參數值的函數。本例中我們傳進來的最大數是 8,而它就是被返回的數。
函數在計算和數學中相當重要,它們幫助我們以有用的方式處理數據。好的程序員會給函數一個可描述性的名字,這樣當我們看代碼時,根據函數名就可以理解函數要做什么。
數學中也有函數,數學中的函數與 JavaScript 中的函數很像。你可能已經在代數中看到過函數。它們看起來是這樣的:
f(x) = 2x
意思是聲明了一個稱為 f 的函數,該函數接受一個稱為 x 的參數,然后用 2 乘以 x 。
要使用這個函數,只需要為 x 提供一個值:
f(2)
在代數中,這和像這樣寫是一樣的:
所以,凡是 f(2) 出現的地方都可以用 4 來替代。
現在,我們把該函數轉換為 JavaScript:
const double = x => x * 2;
可以用 console.log() 來檢查該函數的輸出:
console.log( double(5) ); // 10
還記得我說過在數學函數中,你可以用 4 來替換 f(2) 吧?在 JavaScript 中,是 JavaScript 引擎將 double(5) 用答案 10 來替換。
所以, console.log( double(5) ); 與 console.log(10); 是一樣的。
這之所以是對的,是因為 double() 是一個純函數。但是,如果 double() 有副作用,比如要將值保存到磁盤,或者輸出日志到控制臺,只用 10 替換 double(5) 就改變了函數的含義了。
如果想引用透明,就需要用純函數。
純函數
純函數是滿足如下條件的函數:
- 相同輸入總是會返回相同的輸出。
- 不產生副作用。
- 不依賴于外部狀態。
如果調用一個函數,但是不使用其返回值,這個函數還意義,那么它毫無疑問是一個非純函數。對于純函數,那就是一個空操作。
我推薦選用純函數。就是說,如果使用純函數實現程序需求是可行的,就應該使用純函數,而不是其它選項。純函數接受一些輸入,并且基于該輸入返回一些輸出。它們是程序中最簡單的可重用代碼構建塊。在計算機科學中,也許最重要的設計原則就是 KISS 原則(保持簡潔,Keep It Simple, Stupid)。我喜歡保持簡潔。純函數是以最可能的方式保持簡潔。
純函數有很多不錯的特性,它構成了 函數式編程 的基礎。純函數完全獨立于外部狀態,正因為如此,它們對于共享可變狀態情況下必須處理的所有錯誤類型都是免疫的。純函數的獨立性質,也讓其成為跨多 CPU 以及跨整個分布式計算集群并行處理的最佳候選人,這讓它們對很多類型的科學和資源密集型計算任務成了必不可少的。
純函數也是超級獨立的 - 它容易在代碼中移動、重構、重新組織,讓程序更靈活,更適應將來的改變。
共享狀態的麻煩
幾年前,我正開發一個應用。這個應用允許用戶查詢音樂家的數據庫,并將該藝術家的音樂播放列表加載到一個網頁播放器中。用戶鍵入查詢條件時,會啟動 Google Instant,即時顯示搜索結果。基于AJAX的自動完成突然風靡一時。
唯一的問題是,用戶打字的速度經常比 API 自動完成查詢返回的響應要快一些,這就導致了一些奇怪的 bug。它會觸發競態條件(Race condition),更新的建議會被過時的建議替換。
為什么會發生這種事情呢?這是因為每次訪問 AJAX 成功處理程序時,都會直接更新顯示給用戶的建議列表。最慢的 AJAX 請求通過盲目地替換結果,總是會贏得用戶的注意力,即使這些被替換的結果可能更新時也是如此。
為解決這個問題,我創建了一個建議管理器 - 一個唯一的真實數據來源 - 來管理查詢建議的狀態。它知道當前還未完成的 AJAX 請求,當用戶鍵入一些新東西時,在新請求發出之前,未完成的 AJAX 請求會被取消,這樣一次就只有一個響應處理程序能觸發 UI 狀態更新。
所有類型的異步操作或者并發都可能會導致類似的競態條件。如果輸出取決于不可控制的事件順序(比如網絡、設備延遲、用戶輸入、隨機性等),那么競態條件就會發生。實際上,如果你正使用共享的狀態,而該狀態依賴于會根據不確定性因素而變化的順序,那么實際上,輸出是不可能預測的,也就是說,不可能正確測試和完全理解。正如 Martin Odersky(Scala 的發明人)所說:
非確定性 = 并行處理 + 可變狀態
在計算中,程序的確定性通常是我們想要的屬性。也許你認為既然 JS 是運行在單線程中,那么它對并行處理的問題應該免疫的,所以對于程序確定性應該是沒問題的。但是,正如 AJAX 示例所展示的那樣,單線程 JS 引擎并不意味著沒有并發。相反,在 JavaScript 中有很多并發的來源。API I/O、事件監聽器、Web Worker、iframe 以及 timeout 都會在程序中引入不確定性。而這些與共享狀態結合在一起,就會得到一堆 bug。
純函數可以幫助你避免這些類型的 bug。
給出相同輸入,總是返回相同的輸出
用 double() 函數,你可以用結果來替換函數調用,而程序會把它們當作是一碼事。也就是說,在程序中,不管上下文是什么,不管你調用多少次,或者什么時候調用, double(5) 會總是與 10 表示同樣的事。
但是這并不適用于所有函數。有些函數產生的結果依賴于信息,而不是傳進來的參數。
考慮如下示例:
Math.random(); // => 0.4011148700956255
Math.random(); // => 0.8533405303023756
Math.random(); // => 0.3550692005082965
即使沒有給函數調用傳遞任何參數,產生的輸出也都不相同,也就是說 Math.random() 是非純函數。
每次執行 Math.random() ,都會生成一個 0 到 1 之間的新隨機數,所以很顯然,你沒法只用 0.4011148700956255 來替換它,而不改變程序的含義。
如果是這樣,每次都生成相同的結果。當我們要求計算機生成一個隨機數時,通常意味著我們想要的是一個與最后一次得到的數不同的結果。如果骰子的每一邊印的都是相同的數字有什么意義呢?
有時我們要讓計算機給出當前時間。這里我們不用深入研究時間函數的工作機制,只復制下面這段代碼:
const time = () => new Date().toLocaleTimeString();
time(); // => "5:15:45 PM"
如果用當前時間替換 time() 函數調用,會發生什么?
會總是輸出相同的時間,即函數調用被替換的時間。也就是說,它每天只有一次會產生正確的輸出,而且只有在函數被替換那一刻運行程序才會。
所以很顯然, time() 與 double() 函數不一樣。
一個函數只有在給出相同輸出,總是產生相同輸出的時候,才是純函數。你可能還記得代數課中的這條規則:相同輸入值會總是映射到相同的輸出值。不過,多個輸入值也可以映射到同一個輸出值。例如,如下的函數是純函數:
const highpass = (cutoff, value) => value >= cutoff;
相同的輸入值總會映射到相同的輸出值:
highpass(5, 5); // => true
highpass(5, 5); // => true
highpass(5, 5); // => true
多個輸入值可能會映射到相同的輸出值:
highpass(5, 123); // true
highpass(5, 6); // true
highpass(5, 18); // true
highpass(5, 1); // false
highpass(5, 3); // false
highpass(5, 4); // false
純函數不產生副作用
純函數不產生副作用,就是說它不能改變任何外部狀態。
不可變性
JavaScript 參數是按引用傳遞,就是說,如果函數要修改一個對象參數或者數組參數上的屬性,那么它就會修改在函數外部可以訪問的狀態。純函數不能修改外部狀態。
考慮如下 addToCart() 函數,該函數是一個非純函數,會修改狀態:
// 非純的 addToCart 修改已有的購物車
const addToCart = (cart, item, quantity) => {
cart.items.push({
item,
quantity
});
return cart;
};
test('addToCart()', assert => {
const msg = 'addToCart() should add a new item to the cart.';
const originalCart = {
items: []
};
const cart = addToCart(
originalCart,
{
name: "Digital SLR Camera",
price: '1495'
},
1
);
const expected = 1; // num items in cart
const actual = cart.items.length;
assert.equal(actual, expected, msg);
assert.deepEqual(originalCart, cart, 'mutates original cart.');
assert.end();
});
通過傳進一個購物車、添加到購物車的商品、以及商品數量,函數就起作用了。然后函數返回同一個購物車,購物車帶有添加給它的商品。
問題是,我們剛修改了一些共享的狀態。其它函數可能依賴于 addToCart() 函數被調用之前該購物車對象的狀態,而現在我們已經修改了這個共享的狀態,如果修改函數已經被調用的訂單,就不得不擔心它會對程序邏輯產生什么樣的影響了。重構該代碼會導致 bug 出現,從而把訂單搞砸了,讓客戶不高興。
現在考慮如下版本:
// 純函數 addToCart() 返回一個新購物車,不會修改原始購物車
const addToCart = (cart, item, quantity) => {
const newCart = lodash.cloneDeep(cart);
newCart.items.push({
item,
quantity
});
return newCart;
};
test('addToCart()', assert => {
const msg = 'addToCart() should add a new item to the cart.';
const originalCart = {
items: []
};
// deep-freeze on npm
// throws an error if original is mutated
deepFreeze(originalCart);
const cart = addToCart(
originalCart,
{
name: "Digital SLR Camera",
price: '1495'
},
1
);
const expected = 1; // num items in cart
const actual = cart.items.length;
assert.equal(actual, expected, msg);
assert.notDeepEqual(originalCart, cart,
'should not mutate original cart.');
assert.end();
});
在本例中,有一個數組嵌套在一個對象中,這是為什么我要做深拷貝的原因。這比你經常會處理的狀態更復雜。大多數情況下,你可以將其分解成更小的塊。
例如,Redux 會讓你組合 reducer,而不是在每個 reducer 中處理整個應用程序狀態。這樣做的結果是,你不必在每次只想更新一小部分時,為整個應用程序狀態創建一個深拷貝。而是用非破壞性的數組方法或者 Object.assign() ,來更新應用狀態的一小部分。
來自:http://www.zcfy.cc/article/master-the-javascript-interview-what-is-a-pure-function-2186.html