影象: Pure — carnagenyc (CC-BY-NC 2.0)
對於函數語言程式設計、可靠的併發以及 React + Redux 應用程式等用途來說,純函式是必不可少的。不過,純函式到底是什麼意思呢?
我們打算用 “Eric Elliott 教你學 JavaScript” 中的一個免費課程來回答此問題:
要搞定什麼是純函式,最好是先深入研究一下函式。有幾種不同的方式可以用來研究函式,這樣會讓函數語言程式設計更容易理解。
什麼是函式?
函式是接受一些輸入,併產生一些輸出的過程。這些輸入稱為引數,輸出稱為返回值。函式可以有如下用途:
- 對映:基於給定的輸入產生一些輸出。函式將輸入值對映到輸出值。
- 過程:函式可以被呼叫,來執行一系列步驟。這一系列步驟被稱為過程,以這種風格程式設計被稱為程式式程式設計(procedural programming)。
- I/O:有些函式是用來與系統的其它部分進行通訊,比如螢幕、儲存、系統日誌或者網路。
對映
純函式都是關於對映。函式將輸入引數對映到返回值,也就是說,對於每套輸入,都存在一個輸出。一個函式會接受輸入,並返回相對應的輸出。
Math.max()
接受數字為引數,並返回最大的數:
1 |
Math.max(2, 8, 5); // 8 |
在本例中,2、8 和 5 都是引數,它們是傳進函式的值。
Math.max()
是一個接受任意多個引數,並返回最大引數值的函式。本例中我們傳進來的最大數是 8,而它就是被返回的數。
函式在計算和數學中相當重要,它們幫助我們以有用的方式處理資料。好的程式設計師會給函式一個可描述性的名字,這樣當我們看程式碼時,根據函式名就可以理解函式要做什麼。
數學中也有函式,數學中的函式與 JavaScript 中的函式很像。你可能已經在代數中看到過函式。它們看起來是這樣的:
1 |
f(x) = 2x |
意思是宣告瞭一個稱為 f
的函式,該函式接受一個稱為 x
的引數,然後用 2
乘以 x
。
要使用這個函式,只需要為 x
提供一個值:
1 |
f(2) |
在代數中,這和像這樣寫是一樣的:
1 |
4 |
所以,凡是 f(2)
出現的地方都可以用 4
來替代。
現在,我們把該函式轉換為 JavaScript:
1 |
const double = x => x * 2; |
可以用 console.log()
來檢查該函式的輸出:
1 |
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
表示同樣的事。
但是這並不適用於所有函式。有些函式產生的結果依賴於資訊,而不是傳進來的引數。
考慮如下示例:
1 2 3 |
Math.random(); // => 0.4011148700956255 Math.random(); // => 0.8533405303023756 Math.random(); // => 0.3550692005082965 |
即使沒有給函式呼叫傳遞任何引數,產生的輸出也都不相同,也就是說 Math.random()
是非純函式。
每次執行 Math.random()
,都會生成一個 0
到 1
之間的新隨機數,所以很顯然,你沒法只用0.4011148700956255
來替換它,而不改變程式的含義。
如果是這樣,每次都生成相同的結果。當我們要求計算機生成一個隨機數時,通常意味著我們想要的是一個與最後一次得到的數不同的結果。如果骰子的每一邊印的都是相同的數字有什麼意義呢?
有時我們要讓計算機給出當前時間。這裡我們不用深入研究時間函式的工作機制,只複製下面這段程式碼:
1 2 |
const time = () => new Date().toLocaleTimeString(); time(); // => "5:15:45 PM" |
如果用當前時間替換 time()
函式呼叫,會發生什麼?
會總是輸出相同的時間,即函式呼叫被替換的時間。也就是說,它每天只有一次會產生正確的輸出,而且只有在函式被替換那一刻執行程式才會。
所以很顯然,time()
與 double()
函式不一樣。
一個函式只有在給出相同輸出,總是產生相同輸出的時候,才是純函式。你可能還記得代數課中的這條規則:相同輸入值會總是對映到相同的輸出值。不過,多個輸入值也可以對映到同一個輸出值。例如,如下的函式是純函式:
1 |
const highpass = (cutoff, value) => value >= cutoff; |
相同的輸入值總會對映到相同的輸出值:
1 2 3 |
highpass(5, 5); // => true highpass(5, 5); // => true highpass(5, 5); // => true |
多個輸入值可能會對映到相同的輸出值:
1 2 3 4 5 6 7 |
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()
函式,該函式是一個非純函式,會修改狀態:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// 非純的 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 出現,從而把訂單搞砸了,讓客戶不高興。
現在考慮如下版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// 純函式 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()
,來更新應用狀態的一小部分。
現在該讓你試試了。Fork 這段程式碼,將非純函式修改為純函式。不要修改測試,讓單元測試通過。