征服 JavaScript 面試:什麼是純函式?

發表於2017-01-30

t017fa00ab267bf25bd

影象: Pure — carnagenyc (CC-BY-NC 2.0)

對於函數語言程式設計、可靠的併發以及 React + Redux 應用程式等用途來說,純函式是必不可少的。不過,純函式到底是什麼意思呢?

我們打算用 “Eric Elliott 教你學 JavaScript” 中的一個免費課程來回答此問題:

Eric Elliott 的什麼是純函式教學視訊

要搞定什麼是純函式,最好是先深入研究一下函式。有幾種不同的方式可以用來研究函式,這樣會讓函數語言程式設計更容易理解。

什麼是函式?

函式是接受一些輸入,併產生一些輸出的過程。這些輸入稱為引數,輸出稱為返回值。函式可以有如下用途:

  • 對映:基於給定的輸入產生一些輸出。函式將輸入值對映到輸出值。
  • 過程:函式可以被呼叫,來執行一系列步驟。這一系列步驟被稱為過程,以這種風格程式設計被稱為程式式程式設計(procedural programming)。
  • I/O:有些函式是用來與系統的其它部分進行通訊,比如螢幕、儲存、系統日誌或者網路。

對映

純函式都是關於對映。函式將輸入引數對映到返回值,也就是說,對於每套輸入,都存在一個輸出。一個函式會接受輸入,並返回相對應的輸出。

Math.max() 接受數字為引數,並返回最大的數:

在本例中,2、8 和 5 都是引數,它們是傳進函式的值。

Math.max() 是一個接受任意多個引數,並返回最大引數值的函式。本例中我們傳進來的最大數是 8,而它就是被返回的數。

函式在計算和數學中相當重要,它們幫助我們以有用的方式處理資料。好的程式設計師會給函式一個可描述性的名字,這樣當我們看程式碼時,根據函式名就可以理解函式要做什麼。

數學中也有函式,數學中的函式與 JavaScript 中的函式很像。你可能已經在代數中看到過函式。它們看起來是這樣的:

意思是宣告瞭一個稱為 f 的函式,該函式接受一個稱為 x 的引數,然後用 2 乘以 x

要使用這個函式,只需要為 x 提供一個值:

在代數中,這和像這樣寫是一樣的:

所以,凡是 f(2) 出現的地方都可以用 4 來替代。

現在,我們把該函式轉換為 JavaScript:

可以用 console.log() 來檢查該函式的輸出:

還記得我說過在數學函式中,你可以用 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() 是非純函式。

每次執行 Math.random(),都會生成一個 01 之間的新隨機數,所以很顯然,你沒法只用0.4011148700956255 來替換它,而不改變程式的含義。

如果是這樣,每次都生成相同的結果。當我們要求計算機生成一個隨機數時,通常意味著我們想要的是一個與最後一次得到的數不同的結果。如果骰子的每一邊印的都是相同的數字有什麼意義呢?

有時我們要讓計算機給出當前時間。這裡我們不用深入研究時間函式的工作機制,只複製下面這段程式碼:

如果用當前時間替換 time() 函式呼叫,會發生什麼?

會總是輸出相同的時間,即函式呼叫被替換的時間。也就是說,它每天只有一次會產生正確的輸出,而且只有在函式被替換那一刻執行程式才會。

所以很顯然,time()double() 函式不一樣。

一個函式只有在給出相同輸出,總是產生相同輸出的時候,才是純函式。你可能還記得代數課中的這條規則:相同輸入值會總是對映到相同的輸出值。不過,多個輸入值也可以對映到同一個輸出值。例如,如下的函式是純函式:

相同的輸入值總會對映到相同的輸出值:

多個輸入值可能會對映到相同的輸出值:

純函式不產生副作用

純函式不產生副作用,就是說它不能改變任何外部狀態。

不可變性

JavaScript 引數是按引用傳遞,就是說,如果函式要修改一個物件引數或者陣列引數上的屬性,那麼它就會修改在函式外部可以訪問的狀態。純函式不能修改外部狀態。

考慮如下 addToCart() 函式,該函式是一個非純函式,會修改狀態:

通過傳進一個購物車、新增到購物車的商品、以及商品數量,函式就起作用了。然後函式返回同一個購物車,購物車帶有新增給它的商品。

問題是,我們剛修改了一些共享的狀態。其它函式可能依賴於 addToCart() 函式被呼叫之前該購物車物件的狀態,而現在我們已經修改了這個共享的狀態,如果修改函式已經被呼叫的訂單,就不得不擔心它會對程式邏輯產生什麼樣的影響了。重構該程式碼會導致 bug 出現,從而把訂單搞砸了,讓客戶不高興。

現在考慮如下版本:

在本例中,有一個陣列巢狀在一個物件中,這是為什麼我要做深拷貝的原因。這比你經常會處理的狀態更復雜。大多數情況下,你可以將其分解成更小的塊。

例如,Redux 會讓你組合 reducer,而不是在每個 reducer 中處理整個應用程式狀態。這樣做的結果是,你不必在每次只想更新一小部分時,為整個應用程式狀態建立一個深拷貝。而是用非破壞性的陣列方法或者 Object.assign(),來更新應用狀態的一小部分。

現在該讓你試試了。Fork 這段程式碼,將非純函式修改為純函式。不要修改測試,讓單元測試通過。

相關文章