[譯]掌握 JavaScript 面試:什麼是純函式?

niayyy發表於2020-03-28

掌握 JavaScript 面試:什麼是純函式?

Image: Pure — carnagenyc (CC-BY-NC 2.0)

純函式對於實現各種目的是必不可少的,包括函數語言程式設計,可靠地併發和 React + Redux 構造的應用程式。但是純函式是什麼意思?

我們將通過來自 《跟 Eric Elliott 學習 JavaScript》 的免費課程來回答的這個問題:

在我們解決什麼是純函式這個問題之前,仔細研究一下什麼是函式可能更好。也許存在一種不一樣的看待函式的方式,會讓函數語言程式設計更易於理解。

什麼是函式?

函式是一個過程:它需要一些叫做引數的輸入,然後產生一些叫做返回值的輸出。函式可以用於以下目的:

  • 對映: 基於輸入值產生一些的輸出。函式把輸入值對映到輸出值。
  • 過程化: 可以呼叫一個函式去執行一系列步驟。該一系列步驟稱為過程,而這種方式的程式設計稱為程式導向程式設計
  • I/O: 一些函式存在與系統其他部分進行通訊,例如螢幕,儲存,系統日誌或網路。

對映

純函式都是關於對映的。函式將輸入引數對映到返回值,這意味著對於每組輸入,都存在對應的輸出。函式將獲取輸入並返回相應的輸出。

Math.max() 以一組數字作為引數並返回最大數字:

Math.max(2, 8, 5); // 8
複製程式碼

在此示例中,2,8 和 5 是引數。它們是傳遞給函式的值。

Math.max() 是一個可以接受任意數量的引數並返回最大引數值的函式。在這個案例中,我們傳入的最大數是 8,對應了返回的數字。

函式在計算和數學中非常重要。它們幫助我們用合適的方式處理資料。好的程式設計師會給函式起描述性的名稱,以便當我們檢視程式碼時,我們可以通過函式名瞭解函式的作用。

數學也有函式,它們的工作方式與 JavaScript 中的函式非常相似。您可能見過代數函式。他們看起來像這樣:

f(x) = 2x

這意味著我們要宣告瞭一個名為 f 的函式,它接受一個叫 x 的引數並將 x 乘以 2。

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

f(2)

在代數中,這意味著與下面的寫法完全相同:

4

因此,在任何看到 f(2) 的地方都可以替換 4。

現在讓我們用 JavaScript 來描述這個函式:

const double = x => x * 2;
複製程式碼

你可以使用 console.log() 檢查函式輸出:

console.log( double(5) ); // 10
複製程式碼

還記得我說過的在數學函式中,你可以替換 f(2)4 嗎?在這種情況下,JavaScript 引擎用 10 替換 double(5)

因此, console.log( double(5) );console.log(10); 相同

這是真實存在的,因為 double() 是一個純函式,但是如果 double() 有副作用,例如將值儲存到磁碟或列印到控制檯,用 10 替換 double(5) 會改變函式的含義。

如果想要引用透明,則需要使用純函式。

純函式

純函式是一個函式,其中:

  • 給定相同的輸入,將始終返回相同的輸出。
  • 無副作用。

如果你合理呼叫一個函式,而沒有使用它的返回值,則毫無疑問不是純函式。對於純函式來說,相當於未進行呼叫。

我建議你偏向於使用純函式。意思是,如果使用純函式實現程式需求是可行的,應該優先選擇使用。純函式接受一些輸入,並根據輸入返回一些輸出。它們是程式中最簡單的可重用程式碼塊。也許電腦科學中最重要的設計原理是 KISS(保持簡單明瞭)。我更喜歡保持傻瓜式的簡單。純函式是傻瓜式簡單的最佳方式。

純函式具有許多有益的特性,並構成了函數語言程式設計的基礎。純函式完全獨立於外部狀態,因此,它們不受所有與共享可變狀態有關問題的影響。它們的獨立特性也使其成為跨多個 CPU 以及整個分散式計算叢集進行並行處理的極佳選擇,這使其對許多型別的科學和資源密集型計算任務至關重要。

純函式也是非常獨立的 —— 在程式碼中可以輕鬆移動,重構以及重組,使程式更靈活並能夠適應將來的更改。

共享狀態問題

幾年前,我正在開發一個應用程式,該程式允許使用者搜尋藝術家的資料庫,並將該藝術家的音樂播放列表載入到 Web 播放器中。大約在 Google Instant 上線的那個時候,當輸入搜尋查詢時,它會顯示即時搜尋結果。AJAX 驅動的自動完成功能風靡一時。

唯一的問題是,使用者輸入的速度通常快於 API 的自動完成搜尋並返回響應的速度,從而導致一些奇怪的錯誤。這將觸發競爭條件(race condition),在此情況下,較新的結果可能會被過期的所取代。

為什麼會這樣呢?因為每個 AJAX 成功處理程式都有權直接更新顯示給使用者的建議列表。最慢的 AJAX 請求總是可以通過盲目替換獲得使用者的注意,即使這些替換的結果可能是較新的。

為了解決這個問題,我建立了一個建議管理器 —— 一個單一資料來源去管理查詢建議的狀態。它知道當前有一個待處理的 AJAX 請求,並且當使用者輸入新內容時,這個待處理的 AJAX 請求將在發出新請求之前被取消,因此一次只有一個響應處理程式將能夠觸發 UI 狀態更新。

任何種類的非同步操作或併發都可能導致類似的競爭條件。如果輸出取決於不可控事件的順序(例如網路,裝置延遲,使用者輸入,隨機性等),則會發生競爭條件。實際上,如果你正在使用共享狀態,並且該狀態依賴於一系列不確定性因素,總而言之,輸出都是無法預測的,這意味著無法正確測試或完全理解。正如 Martin Odersky(Scala 語言的建立者)所說:

不確定性 = 並行處理 + 可變狀態

程式的確定性通常是計算中的理想屬性。可能你認為還好,因為 JS 在單執行緒中執行,因此不受並行處理問題的影響,但是正如 AJAX 示例所示,單執行緒JS引擎並不意味著沒有併發。相反,JavaScript 中有許多併發來源。API I/O,事件偵聽,Web Worker,iframe 和超時都可以將不確定性引入程式中。將其與共享狀態結合起來,就可以得出解決 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 函式改變了現有的 cart 物件
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; // cart 中的商品數
  const actual = cart.items.length;

  assert.equal(actual, expected, msg);

  assert.deepEqual(originalCart, cart, 'mutates original cart.');
  assert.end();
});

複製程式碼

這個函式通過傳遞 cart 物件,新增商品和商品數量到 cart 物件上來呼叫的。然後,該函式返回相同的 cart 物件,並新增了商品。

這樣做的問題是,我們剛剛改變了一些共享狀態。其他函式可能依賴於 cart 物件狀態 —— 被該函式呼叫之前的狀態,而現在我們已經更改了這個共享狀態,如果我們改變函式呼叫的順序,我們不得不擔心將會對程式邏輯上產生怎樣的影響。重構程式碼可能會導致 bug 出現,從而可能破壞訂單並導致客戶不滿意。

現在考慮這個版本:

// 純 addToCart() 函式返回一個新的 cart 物件
// 這不會改變原始物件
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: []
  };

  // npm 上的 deep-freeze
  // 如果原始物件被改變,則丟擲一個錯誤
  deepFreeze(originalCart);

  const cart = addToCart(
    originalCart,
    {
      name: "Digital SLR Camera",
      price: '1495'
    },
    1
  );


  const expected = 1;  // cart 中的商品數
  const actual = cart.items.length;

  assert.equal(actual, expected, msg);

  assert.notDeepEqual(originalCart, cart,
    'should not mutate original cart.');
  assert.end();
});

複製程式碼

在此示例中,我們在物件中巢狀了一個陣列,這是我要進行深克隆的原因。這比你通常要處理的狀態更為複雜。對於大多數事情,你可以將其分解為較小的塊。

例如,Redux 讓你可以組成 reducers,而不是在每個 reducers 中的解決整個應用程式狀態。結果是,你不必每次更新整個應用程式狀態的一小部分時就建立一個深克隆。相反,你可以使用非破壞性陣列方法,或 Object.assign() 更新應用狀態的一小部分。

輪到你了。Fork 這個 codepen 程式碼,並將非純函式轉換為純函式。使單元測試通過而不更改測試。

探索這個系列

該帖子已包含在《Composing Software》書中。 買這本書 | 索引 | <上一頁 | 下一頁>


埃裡克·埃利奧特(Eric Elliott) 是一名分散式系統專家,並且是《Composing Software》《Programming JavaScript Applications》這兩本書的作者。作為 DevAnywhere.io 的聯合創始人,他教開發人員遠端工作和實現工作 / 生活平衡所需的技能。他建立併為加密專案的開發團隊提供諮詢,併為 Adobe 公司、Zumba Fitness、《華爾街日報》、ESPN、BBC 和包括 Usher、Frank Ocean、Metallica 在內的頂級唱片藝術家的軟體體驗做出了貢獻。

他與世界上最美麗的女人一起享受著遠端辦公的生活方式。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章