征服 JavaScript 面試:什么是純函數?

IOSAnt 7年前發布 | 12K 次閱讀 JavaScript開發 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

 

 本文由用戶 IOSAnt 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!