翻譯連載 | JavaScript輕量級函數式編程-第5章:減小反作用 |《你不知道的JS》姊妹篇

關於譯者:這是一個流淌着滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裏最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。通過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,但願能夠幫助你們在學習函數式編程的道路上走的更順暢。比心。前端

譯者團隊(排名不分前後):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry蘿蔔vavd317vivaxy萌萌zhouyaogit

第 5 章:減小反作用

在第 2 章,咱們討論了一個函數除了它的返回值以外還有什麼輸出。如今你應該很熟悉用函數式編程的方法定義一個函數了,因此對於函數式編程的反作用你應該有所瞭解。程序員

咱們將檢查各類各樣不一樣的反作用而且要看看他們爲何會對咱們的代碼質量和可讀性形成損害。github

這一章的要點是:編寫出沒有反作用的程序是不可能的。固然,也不是不可能,你固然能夠編寫出沒有反作用的程序。可是這樣的話程序就不會作任何有用和明顯的事情。若是你編寫出來一個零反作用的程序,你就沒法區分它和一個被刪除的或者空程序的區別。ajax

函數式編程者並無消除全部的反作用。實際上,咱們的目標是儘量地限制他們。要作到這一點,咱們首先須要徹底理解函數式編程的反作用。算法

什麼是反作用

因果關係:舉一個咱們人類對周圍世界影響的最基本、最直觀的例子,推一下放在桌子邊沿上的一本書,書會掉落。不須要你擁有一個物理學的學位你也會知道,這是由於你剛剛推了書而且書掉落是由於地心引力,這是一個明確並直接的關係。編程

在編程中,咱們也徹底會處理因果關係。若是你調用了一個函數(原由),就會在屏幕上輸出一條消息(結果)。api

當咱們在閱讀程序的時候,可以清晰明確的識別每個原由和每個結果是很是重要的。在某種程度上,通讀程序但不能看到因果的直接關係,程序的可讀性就會下降。數組

思考一下:瀏覽器

function foo(x) {
    return x * 2;
}

var y = foo( 3 );複製代碼

在這段代碼中,有很直接的因果關係,調用值爲 3 的 foo 將具備返回值 6 的效果,調用函數 foo() 是原由,而後將其賦值給 y 是結果。這裏沒有歧義,傳入參數爲 3 將會返回 6,將函數結果賦值給變量 y 是結果。

可是如今:

function foo(x) {
    y = x * 2;
}

var y;

foo( 3 );複製代碼

這段代碼有相同的輸出,可是卻有很大的差別,這裏的因果是沒有聯繫的。這個影響是間接的。這種方式設置 y 就是咱們所說的反作用。

注意: 當函數引用外部變量時,這個變量就稱爲自由變量。並非全部的自由變量引用都是很差的,可是咱們要對它們很是當心。

假使給你一個引用來調用函數 bar(..),你看不到代碼,可是我告訴你這段代碼並無間接的反作用,只有一個顯式的 return 值會怎麼樣?

bar( 4 );            // 42複製代碼

由於你知道 bar(..) 的內部結構不會有反作用,你能夠像這樣直接地調用 bar(..)。可是若是你不知道 bar(..) 沒有反作用,爲了理解調用這個函數的結果,你必須去閱讀和分析它的邏輯。這對讀者來講是額外的負擔。

有反作用的函數可讀性更低,由於它須要更多的閱讀來理解程序。

可是程序每每比這個要複雜,思考一下:

var x = 1;

foo();

console.log( x );

bar();

console.log( x );

baz();

console.log( x );複製代碼

你能肯定每次 console.log(x) 的值都是你想要的嗎?

答案是否認的。若是你不肯定函數 foo()bar()baz() 是否有反作用,你就不能保證每一步的 x 將會是什麼,除非你檢查每一個步驟的實現,而後從第一行開始跟蹤程序,跟蹤全部狀態的改變。

換句話說,console.log(x) 最後的結果是不能分析和預測的,除非你已經在內心將整個程序執行到這裏了。

猜猜誰擅長運行你的程序?JS 引擎。猜猜誰不擅長運行你的程序?你代碼的讀者。然而,若是你選擇在一個或多個函數調用中編寫帶有(潛在)反作用的代碼,那麼這意味着你已經使你的讀者必須將你的程序完整地執行到某一行,以便他們理解這一行。

若是 foo()bar()、和 baz() 都沒有反作用的話,它們就不會影響到 x,這就意味着咱們不須要在內心默默地執行它們而且跟蹤 x 的變化。這在精力上負擔更小,而且使得代碼更加地可讀。

潛在的緣由

輸出和狀態的變化,是最常被引用的反作用的表現。可是另外一個有損可讀性的實踐是一些被認爲的側因,思考一下:

function foo(x) {
    return x + y;
}

var y = 3;

foo( 1 );            // 4複製代碼

y 不會隨着 foo(..) 改變,因此這和咱們以前看到的反作用有所不一樣。可是如今,對函數 foo(..) 的調用實際上取決於 y 當前的狀態。以後咱們若是這樣作:

y = 5;

// ..

foo( 1 );            // 6複製代碼

咱們可能會感到驚訝兩次調用 foo(1) 返回的結果不同。

foo(..) 對可讀性有一個間接的破壞性。若是沒有對函數 foo(..) 進行仔細檢查,使用者可能不會知道致使這個輸出的緣由。這看起來僅僅像是參數 1 的緣由,但卻不是這樣的。

爲了幫助可讀性,全部決定 foo(..) 輸出的緣由應該被設置的直接並明顯。函數的使用者將會直接看到緣由和結果。

使用固定的狀態

避免反作用就意味着函數 foo(..) 不能引用自由變量了嗎?

思考下這段代碼:

function foo(x) {
    return x + bar( x );
}

function bar(x) {
    return x * 2;
}

foo( 3 );            // 9複製代碼

很明顯,對於函數 foo(..) 和函數 bar(..),惟一和直接的緣由就是參數 x。可是 bar(x) 被稱爲何呢?bar 僅僅只是一個標識符,在 JS 中,默認狀況下,它甚至不是一個常量(不可從新分配的變量)。foo(..) 函數依賴於 bar 的值,bar 做爲一個自由變量被第二個函數引用。

因此說這個函數還依賴於其餘的緣由嗎?

我認爲不。雖然能夠用其餘的函數來重寫 bar 這個變量,可是在代碼中我沒有這樣作,這也不是個人慣例或先例。不管出於什麼意圖和目的,個人函數都是常量(從不從新分配)。

思考一下:

const PI = 3.141592;

function foo(x) {
    return x * PI;
}

foo( 3 );            // 9.424776000000001複製代碼

注意: JavaScript 有內置的 Math.PI 屬性,因此咱們在本文中僅僅是用 PI 作一個方便的說明。在實踐中,老是使用 Math.PI 而不是你本身定義的。

上面的代碼怎麼樣呢?PI 是函數 foo(..) 的一個反作用嗎?

兩個觀察結果將會合理地幫助咱們回答這個問題:

  1. 想一下是否每次調用 foo(3),都將會返回 9.424..答案是確定的。 若是每一次都給一個相同的輸入(x),那麼都將會返回相同的輸出。

  2. 你能用 PI 的當前值來代替每個 PI 嗎,而且程序可以和以前同樣正確地的運行嗎?是的。 程序沒有任何一部分依賴於 PI 值的改變,由於 PI 的類型是 const,它是不能再分配的,因此變量 PI 在這裏只是爲了便於閱讀和維護。它的值能夠在不改變程序行爲的狀況下內聯。

個人結論是:這裏的 PI 並不違反減小或避免反作用的精神。在以前的代碼也沒有調用 bar(x)

在這兩種狀況下,PIbar 都不是程序狀態的一部分。它們是固定的,不可從新分配的(「常量」)的引用。若是他們在整個程序中都不改變,那麼咱們就不須要擔憂將他們做爲變化的狀態追蹤他們。一樣的,他們不會損害程序的可讀性。並且它們也不會由於變量以不可預測的方式變化,而成爲錯誤的源頭。

注意: 在我看來,使用 const 並不能說明 PI 不是反作用;使用 var PI 也會是一樣的結果。PI 沒有被從新分配是問題的關鍵,而不是使用 const。咱們將在後面的章節討論 const

隨機性

你之前可能歷來沒有考慮過,可是隨機性是不純的。一個使用 Math.random() 的函數永遠都不是純的,由於你不能根據它的輸入來保證和預測它的輸出。因此任何生成惟一隨機的 ID 等都須要依靠程序的其餘緣由。

在計算中,咱們使用的是僞隨機算法。事實證實,真正的隨機是很是難的,因此咱們只是用複雜的算法來模擬它,產生的值看起來是隨機的。這些算法計算很長的一串數字,但祕密是,若是你知道起始點,實際上這個序列是能夠預測的。這個起點被稱之爲種子。

一些語言容許你指定生成隨機數的種子。若是你老是指定了相同的種子,那麼你將始終從後續的「隨機數」中獲得相同的輸出序列。這對於測試是很是有用的,可是在真正的應用中使用也是很是危險的。

在 JS 中,Math.random() 的隨機性計算是基於間接輸入,由於你不能明確種子。所以,咱們必須將內建的隨機數生成視爲不純的一方。

I/O 效果

這可能不太明顯,可是最多見(而且本質上不可避免)的反作用就是 I/O(輸入/輸出)。一個沒有 I/O 的程序是徹底沒有意義的,由於它的工做不能以任何方式被觀察到。一個有用的程序必須最少有一個輸出,而且也須要輸入。輸入會產生輸出。

用戶事件(鼠標、鍵盤)是 JS 編程者在瀏覽器中使用的典型的輸入,而輸出的則是 DOM。若是你使用 Node.js 比較多,你更有可能接收到和輸出到文件系統、網絡系統和/或者 stdin / stdout(標準輸入流/標準輸出流)的輸入和輸出。

事實上,這些來源既能夠是輸入也能夠是輸出,是因也是果。以 DOM 爲例,咱們更新(產生反作用的結果)一個 DOM 元素爲了給用戶展現文字或圖片信息,可是 DOM 的當前狀態是對這些操做的隱式輸入(產生反作用的緣由)。

其餘的錯誤

在程序運行期間反作用可能致使的錯誤是多種多樣的。讓咱們來看一個場景來講明這些危害,但願它們能幫助咱們辨認出在咱們本身的程序中相似的錯誤。

思考一下:

var users = {};
var userOrders = {};

function fetchUserData(userId) {
    ajax( "http://some.api/user/" + userId, function onUserData(userData){
        users[userId] = userData;
    } );
}

function fetchOrders(userId) {
    ajax( "http://some.api/orders/" + userId, function onOrders(orders){
        for (let i = 0; i < orders.length; i++) {
                // 對每一個用戶的最新訂單保持引用
            users[userId].latestOrder = orders[i];
            userOrders[orders[i].orderId] = orders[i];
        }
    } );
}

function deleteOrder(orderId) {
    var user = users[ userOrders[orderId].userId ];
    var isLatestOrder = (userOrders[orderId] == user.latestOrder);

    // 刪除用戶的最新訂單?
    if (isLatestOrder) {
        hideLatestOrderDisplay();
    }

    ajax( "http://some.api/delete/order/" + orderId, function onDelete(success){
        if (success) {
                // 刪除用戶的最新訂單?
            if (isLatestOrder) {
                user.latestOrder = null;
            }

            userOrders[orderId] = null;
        }
        else if (isLatestOrder) {
            showLatestOrderDisplay();
        }
    } );
}複製代碼

我敢打賭,一些讀者顯然會發現其中潛在的錯誤。若是回調 onOrders(..) 在回調 onUserData(..) 以前運行,它會給一個還沒有設置的值(users[userId]userData 對象)添加一個 latestOrder 屬性

所以,這種依賴於因果關係的「錯誤」是在兩種不一樣操做(是否異步)紊亂狀況下發生的,咱們指望以肯定的順序運行,但在某些狀況下,可能會以不一樣的順序運行。有一些策略能夠確保操做的順序,很明顯,在這種狀況下順序是相當重要的。

這裏還有另外一個細小的錯誤,你發現了嗎?

思考下這個調用順序:

fetchUserData( 123 );
onUserData(..);
fetchOrders( 123 );
onOrders(..);

// later

fetchOrders( 123 );
deleteOrder( 456 );
onOrders(..);
onDelete(..);複製代碼

你發現每一對 fetchOrders(..) / onOrders(..)deleteOrder(..) / onDelete(..) 都是交替出現了嗎?這個潛在的排序會伴隨着咱們狀態管理的側因/反作用暴露出一個古怪的狀態。

在設置 isLatestOrder 標誌和使用它來決定是否應該清空 users 中的用戶數據對象的 latestOrder 屬性時,會有一個延遲(由於回調)。在此延遲期間,若是 onOrders(..) 銷燬,它能夠潛在地改變用戶的 latestOrder 引用的順序值。當 onDelete(..) 在銷燬以後,它會假定它仍然須要從新引用 latestOrder

錯誤:數據(狀態)可能不一樣步。當進入 onOrders(..) 時,latestOrder 可能仍然指向一個較新的順序,這樣 latestOrder 就會被重置。

這種錯誤最糟糕的是你不能和其餘錯誤同樣獲得程序崩潰的異常。咱們只是有一個不正確的狀態,同時咱們的應用程序「默默地」崩潰。

fetchUserData(..)fetchOrders(..) 的序列依賴是至關明顯的,而且被直截了當地處理。可是,在 fetchOrders(..)deleteOrder(..) 之間存在潛在的序列依賴關係,就不太清楚了。這兩個彷佛更加獨立。而且確保他們的順序被保留是比較棘手的,由於你事先不知道(在 fetchOrders(..) 產生結果以前)是否必需要按照這樣的順序執行。

是的,一旦 deleteOrder(..) 銷燬,你就能從新計算 isLatestOrder 標誌。可是如今你有另外一個問題:你的 UI 狀態可能不一樣步。

若是你以前已經調用過 hideLatestOrderDisplay(),如今你須要調用 showLatestOrderDisplay(),可是若是一個新的 latestOrder 已經被設置好了,你將要跟蹤至少三個狀態:被刪除的狀態是否原本是「最新的」、是不是「最新」設置的,和這兩個順序有什麼不一樣嗎?這些都是能夠解決的問題,但不管如何都是不明顯的。

全部這些麻煩都是由於咱們決定在一組共享的狀態下構造出有反作用的代碼。

函數式編程人員討厭這類因果的錯誤,由於這有損咱們的閱讀、推理、驗證和最終相信代碼的能力。這就是爲何他們要如此嚴肅地對待避免反作用的緣由。

有不少避免/修復反作用的策略。咱們將在本章後面和後面的章節中討論。我要說一個肯定的事情:寫出有反作用/效果的代碼是很正常的, 因此咱們須要謹慎和刻意地避免產生有反作用的代碼。

一次就好

若是你必需要使用反作用來改變狀態,那麼一種對限制潛在問題有用的操做是冪等。若是你的值的更新是冪次的,那麼數據將會適應你可能有不一樣反作用來源的多個此類更新的狀況。

冪等的定義有點讓人困惑,同時數學家和程序員使用冪等的含義稍有不一樣。然而,這兩種觀點對於函數式編程人員都是有用的。

首先,讓咱們給出一個計數器的例子,它既不是數學上的,也不是程序上的冪等:

function updateCounter(obj) {
    if (obj.count < 10) {
        obj.count++;
        return true;
    }

    return false;
}複製代碼

這個函數經過引用遞增 obj.count 來該改變一個對象,因此對這個對象產生了反作用。當 o.count 小於 10 時,若是 updateCounter(o) 被屢次調用,即程序狀態每次都要更改。另外,updateCounter(..) 的輸出是一個布爾值,這不適合返回到 updateCounter(..) 的後續調用。

數學中的冪等

從數學的角度來看,冪等指的是在第一次調用後,若是你將該輸出一次又一次地輸入到操做中,其輸出永遠不會改變的操做。換句話說,foo(x) 將產生與 foo(foo(x))foo(foo(foo(x))) 等相同的輸出。

一個典型的數學例子是 Math.abs(..)(取絕對值)。Math.abs(-2) 的結果是 2,和 Math.abs(Math.abs(Math.abs(Math.abs(-2)))) 的結果相同。像Math.min(..)Math.max(..)Math.round(..)Math.floor(..)Math.ceil(..)這些工具函數都是冪等的。

咱們能夠用一樣的特徵來定義一些數學運算:

function toPower0(x) {
    return Math.pow( x, 0 );
}

function snapUp3(x) {
    return x - (x % 3) + (x % 3 > 0 && 3);
}

toPower0( 3 ) == toPower0( toPower0( 3 ) );            // true

snapUp3( 3.14 ) == snapUp3( snapUp3( 3.14 ) );        // true複製代碼

數學上的冪等僅限於數學運算。咱們還能夠用 JavaScript 的原始類型來講明冪等的另外一種形式:

var x = 42, y = "hello";

String( x ) === String( String( x ) );                // true

Boolean( y ) === Boolean( Boolean( y ) );            // true複製代碼

在本文的前面,咱們探究了一種常見的函數式編程工具,它能夠實現這種形式的冪等:

identity( 3 ) === identity( identity( 3 ) );    // true複製代碼

某些字符串操做天然也是冪等的,例如:

function upper(x) {
    return x.toUpperCase();
}

function lower(x) {
    return x.toLowerCase();
}

var str = "Hello World";

upper( str ) == upper( upper( str ) );                // true

lower( str ) == lower( lower( str ) );                // true複製代碼

咱們甚至能夠以一種冪等方式設計更復雜的字符串格式操做,好比:

function currency(val) {
    var num = parseFloat(
        String( val ).replace( /[^\d.-]+/g, "" )
    );
    var sign = (num < 0) ? "-" : "";
    return `${sign}$${Math.abs( num ).toFixed( 2 )}`;
}

currency( -3.1 );                                    // "-$3.10"

currency( -3.1 ) == currency( currency( -3.1 ) );    // true複製代碼

currency(..) 舉例說明了一個重要的技巧:在某些狀況下,開發人員能夠採起額外的步驟來規範化輸入/輸出操做,以確保操做是冪等的來避免意外的發生。

在任何可能的狀況下經過冪等的操做限制反作用要比不作限制的更新要好得多。

編程中的冪等

冪等的面向程序的定義也是相似的,但不太正式。編程中的冪等僅僅是 f(x); 的結果與 f(x); f(x) 相同而不是要求 f(x) === f(f(x))。換句話說,以後每一次調用 f(x) 的結果和第一次調用 f(x) 的結果沒有任何改變。

這種觀點更符合咱們對反作用的觀察。由於這更像是一個 f(..) 建立了一個冪等的反作用而不是必需要返回一個冪等的輸出值。

這種冪等性的方式常常被用於 HTTP 操做(動詞),例如 GET 或 PUT。若是 HTTP REST API 正確地遵循了冪等的規範指導,那麼 PUT 被定義爲一個更新操做,它能夠徹底替換資源。一樣的,客戶端能夠一次或屢次發送 PUT 請求(使用相同的數據),而服務器不管如何都將具備相同的結果狀態。

讓咱們用更具體的編程方法來考慮這個問題,來檢查一下使用冪等和沒有使用冪等是否產生反作用:

// 冪等的:
obj.count = 2;
a[a.length - 1] = 42;
person.name = upper( person.name );

// 非冪等的:
obj.count++;
a[a.length] = 42;
person.lastUpdated = Date.now();複製代碼

記住:這裏的冪等性的概念是每個冪等運算(好比 obj.count = 2)能夠重複屢次,而不是在第一次更新後改變程序操做。非冪等操做每次都改變狀態。

那麼更新 DOM 呢?

var hist = document.getElementById( "orderHistory" );

// 冪等的:
hist.innerHTML = order.historyText;

// 非冪等的:
var update = document.createTextNode( order.latestUpdate );
hist.appendChild( update );複製代碼

這裏的關鍵區別在於,冪等的更新替換了 DOM 元素的內容。DOM 元素的當前狀態是獨立的,由於它是無條件覆蓋的。非冪等的操做將內容添加到元素中;隱式地,DOM 元素的當前狀態是計算下一個狀態的一部分。

咱們將不會一直用冪等的方式去定義你的數據,但若是你能作到,這確定會減小你的反作用在你最意想不到的時候忽然出現的可能性。

純粹的快樂

沒有反作用的函數稱爲純函數。在編程的意義上,純函數是一種冪等函數,由於它不可能有任何反作用。思考一下:

function add(x,y) {
    return x + y;
}複製代碼

全部輸入(xy)和輸出(return ..)都是直接的,沒有引用自由變量。調用 add(3,4) 屢次和調用一次是沒有區別的。add(..) 是純粹的編程風格的冪等。

然而,並非全部的純函數都是數學概念上的冪等,由於它們返回的值不必定適合做爲再次調用它們時的輸入。思考一下:

function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

calculateAverage( [1,2,4,7,11,16,22] );            // 9複製代碼

輸出的 9 並非一個數組,因此你不能在 calculateAverage(calculateAverage(..)) 中將其傳入。

正如咱們前面所討論的,一個純函數能夠引用自由變量,只要這些自由變量不是側因。

例如:

const PI = 3.141592;

function circleArea(radius) {
    return PI * radius * radius;
}

function cylinderVolume(radius,height) {
    return height * circleArea( radius );
}複製代碼

circleArea(..) 中引用了自由變量 PI,可是這是一個常量因此不是一個側因。cylinderVolume(..) 引用了自由變量 circleArea,這也不是一個側因,由於這個程序把它看成一個常量引用它的函數值。這兩個函數都是純的。

另外一個例子,一個函數仍然能夠是純的,但引用的自由變量是閉包:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}複製代碼

unary(..) 自己顯然是純函數 —— 它惟一的輸入是 fn,而且它惟一的輸出是返回的函數,可是閉合了自由變量 fn 的內部函數 onlyOneArg(..) 是否是純的呢?

它仍然是純的,由於 fn 永遠不變。事實上,咱們對這一事實有充分的自信,由於從詞法上講,這幾行是惟一可能從新分配 fn 的代碼。

注意: fn 是一個函數對象的引用,它默認是一個可變的值。在程序的其餘地方可能爲這個函數對象添加一個屬性,這在技術上「改變」這個值(改變,而不是從新分配)。然而,由於咱們除了調用 fn,不依賴 fn 之外的任何事情,而且不可能影響函數值的可調用性,所以 fn 在最後的結果中仍然是有效的不變的;它不多是一個側因。

表達一個函數的純度的另外一種經常使用方法是:給定相同的輸入(一個或多個),它老是產生相同的輸出。 若是你把 3 傳給 circleArea(..) 它老是輸出相同的結果(28.274328)。

若是一個函數每次在給予相同的輸入時,可能產生不一樣的輸出,那麼它是不純的。即便這樣的函數老是返回相同的值,只要它產生間接輸出反作用,而且程序狀態每次被調用時都會被改變,那麼這就是不純的。

不純的函數是不受歡迎的,由於它們使得全部的調用都變得更加難以理解。純的函數的調用是徹底可預測的。當有人閱讀代碼時,看到多個 circleArea(3) 調用,他們不須要花費額外的精力來計算每次的輸出結果。

相對的純粹

當咱們討論一個函數是純的時,咱們必須很是當心。JavaScript 的動態值特性使其很容易產生不明顯的反作用。

思考一下:

function rememberNumbers(nums) {
    return function caller(fn){
        return fn( nums );
    };
}

var list = [1,2,3,4,5];

var simpleList = rememberNumbers( list );複製代碼

simpleList(..) 看起來是一個純函數,由於它只涉及內部的 caller(..) 函數,它僅僅是閉合了自由變量 nums。然而,有不少方法證實 simpleList(..) 是不純的。

首先,咱們對純度的斷言是基於數組的值(經過 listnums 引用)一直不改變:

function median(nums) {
    return (nums[0] + nums[nums.length - 1]) / 2;
}

simpleList( median );        // 3

// ..

list.push( 6 );

// ..

simpleList( median );        // 3.5複製代碼

當咱們改變數組時,simpleList(..) 的調用改變它的輸出。因此,simpleList(..) 是純的仍是不純的呢?這就取決於你的視角。對於給定的一組假設來講,它是純函數。在任何沒有 list.push(6) 的狀況下是純的。

咱們能夠經過改變 rememberNumbers(..) 的定義來修改這種不純。一種方法是複製 nums 數組:

function rememberNumbers(nums) {
        // 複製一個數組
    nums = nums.slice();

    return function caller(fn){
        return fn( nums );
    };
}複製代碼

但這可能會隱含一個更棘手的反作用:

var list = [1,2,3,4,5];

// 把 list[0] 做爲一個有反作用的接收者
Object.defineProperty(
    list,
    0,
    {
        get: function(){
            console.log( "[0] was accessed!" );
            return 1;
        }
    }
);

var simpleList = rememberNumbers( list );

// [0] 已經被使用!複製代碼

一個更粗魯的選擇是更改 rememberNumbers(..) 的參數。首先,不要接收數組,而是把數字做爲單獨的參數:

function rememberNumbers(...nums) {
    return function caller(fn){
        return fn( nums );
    };
}

var simpleList = rememberNumbers( ...list );

// [0] 已經被使用!複製代碼

這兩個 ... 的做用是將列表複製到 nums 中,而不是經過引用來傳遞。

注意: 控制檯消息的反作用不是來自於 rememberNumbers(..),而是 ...list 的擴展中。所以,在這種狀況下,rememberNumbers(..)simpleList(..) 是純的。

可是若是這種突變動難被發現呢?純函數和不純的函數的合成老是產生不純的函數。若是咱們將一個不純的函數傳遞到另外一個純函數 simpleList(..) 中,那麼這個函數就是不純的:

// 是的,一個愚蠢的人爲的例子 :)
function firstValue(nums) {
    return nums[0];
}

function lastValue(nums) {
    return firstValue( nums.reverse() );
}

simpleList( lastValue );    // 5

list;                        // [1,2,3,4,5] -- OK!

simpleList( lastValue );    // 1複製代碼

注意: 無論 reverse() 看起來多安全(就像 JS 中的其餘數組方法同樣),它返回一個反向數組,實際上它對數組進行了修改,而不是建立一個新的數組。

咱們須要對 rememberNumbers(..) 下一個更斬釘截鐵的定義來防止 fn(..) 改變它的閉合的 nums 變量的引用。

function rememberNumbers(...nums) {
    return function caller(fn){
            // 提交一個副本!
        return fn( nums.slice() );
    };
}複製代碼

因此 simpleList(..) 是可靠的純函數嗎!?不。 :(

咱們只防範咱們能夠控制的反作用(經過引用改變)。咱們傳遞的任何帶有反作用的函數,都將會污染 simpleList(..) 的純度:

simpleList( function impureIO(nums){
    console.log( nums.length );
} );複製代碼

事實上,沒有辦法定義 rememberNumbers(..) 去產生一個完美純粹的 simpleList(..) 函數。

純度是和自信是有關的。但咱們不得不認可,在不少狀況下,咱們所感覺到的自信其實是與咱們程序的上下文和咱們對程序瞭解有關的。在實踐中(在 JavaScript 中),函數純度的問題不是純粹的純粹性,而是關於其純度的一系列信心。

越純潔越好。製做純函數時越努力,當您閱讀使用它的代碼時,你的自信就會越高,這將使代碼的一部分更加可讀。

有或者無

到目前爲止,咱們已經將函數純度定義爲一個沒有反作用的函數,而且做爲這樣一個函數,給定相同的輸入,老是產生相同的輸出。這只是看待相同特徵的兩種不一樣方式。

可是,第三種看待函數純性的方法,也許是廣爲接受的定義,即純函數具備引用透明性。

引用透明性是指一個函數調用能夠被它的輸出值所代替,而且整個程序的行爲不會改變。換句話說,不可能從程序的執行中分辨出函數調用是被執行的,仍是它的返回值是在函數調用的位置上內聯的。

從引用透明的角度來看,這兩個程序都有徹底相同的行爲由於它們都是用純粹的函數構建的:

function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var nums = [1,2,4,7,11,16,22];

var avg = calculateAverage( nums );

console.log( "The average is:", avg );        // The average is: 9複製代碼
function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var nums = [1,2,4,7,11,16,22];

var avg = 9;

console.log( "The average is:", avg );        // The average is: 9複製代碼

這兩個片斷之間的惟一區別在於,在後者中,咱們跳過了調用 calculateAverage(nums) 並內聯。由於程序的其餘部分的行爲是相同的,calculateAverage(..) 是引用透明的,所以是一個純粹的函數。

思考上的透明

一個引用透明的純函數可能會被它的輸出替代,這並不意味着它應該被替換。遠非如此。

咱們用在程序中使用函數而不是使用預先計算好的常量的緣由不只僅是應對變化的數據,也是和可讀性和適當的抽象等有關。調用函數去計算一列數字的平均值讓這部分程序比只是使用肯定的值更具備可讀性。它向讀者講述了 avg 從何而來,它意味着什麼,等等。

咱們真正建議使用引用透明是當你閱讀程序,一旦你已經在心裏計算出純函數調用輸出的是什麼的時候,當你看到它的代碼的時候不須要再去思考確切的函數調用是作什麼,特別是若是它出現不少次。

這個結果有一點像你在內心面定義一個 const,當你閱讀的時候,你能夠直接跳過而且不須要花更多的精力去計算。

咱們但願純函數的這種特性的重要性是顯而易見的。咱們正在努力使咱們的程序更容易讀懂。咱們能作的一種方法是給讀者較少的工做,經過提供幫助來跳過沒必要要的東西,這樣他們就能夠把注意力集中在重要的事情上。

讀者不須要從新計算一些不會改變(也不須要改變)的結果。若是用引用透明定義一個純函數,讀者就沒必要這樣作了。

不夠透明?

那麼若是一個有反作用的函數,而且這個反作用在程序的其餘地方沒有被觀察到或者依賴會怎麼樣?這個功能還具備引用透明性嗎?

這裏有一個例子:

function calculateAverage(list) {
    sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var sum, nums = [1,2,4,7,11,16,22];

var avg = calculateAverage( nums );複製代碼

你發現了嗎?

sum 是一個 calculateAverage(..) 使用的外部自由變量。可是,每次咱們使用相同的列表調用 calculateAverage(..),咱們將獲得 9 做爲輸出。而且這個程序沒法和使用參數 9 調用 calculateAverage(nums) 在行爲上區分開來。程序的其餘部分和 sum 變量有關,因此這是一個不可觀察的反作用。
這是一個像這棵樹同樣不能觀察到的反作用嗎?

假如一棵樹在森林裏倒下而沒有人在附近聽見,它有沒有發出聲音?

經過引用透明的狹義的定義,我想你必定會說 calculateAverage(..) 仍然是一個純函數。可是,由於在咱們的學習中不只僅是學習學術,並且與實用主義相平衡,我認爲這個結論須要更多的觀點。讓咱們探索一下。

性能影響

你常常會發現這些不易觀察的反作用被用於性能優化的操做。例如:

var cache = [];

function specialNumber(n) {
        // 若是咱們已經計算過這個特殊的數,
    // 跳過這個操做,而後從緩存中返回
    if (cache[n] !== undefined) {
        return cache[n];
    }

    var x = 1, y = 1;

    for (let i = 1; i <= n; i++) {
        x += i % 2;
        y += i % 3;
    }

    cache[n] = (x * y) / (n + 1);

    return cache[n];
}

specialNumber( 6 );                // 4
specialNumber( 42 );            // 22
specialNumber( 1E6 );            // 500001
specialNumber( 987654321 );        // 493827162複製代碼

這個愚蠢的 specialNumber(..) 算法是肯定性的,而且,純函數從定義來講,它老是爲相同的輸入提供相同的輸出。從引用透明的角度來看 —— 用 22 替換對 specialNumber(42) 的任何調用,程序的最終結果是相同的。

可是,這個函數必須作一些工做來計算一些較大的數字,特別是輸入像 987654321 這樣的數字。若是咱們須要在咱們的程序中屢次得到特定的特殊號碼,那麼結果的緩存意味着後續的調用效率會更高。

注意: 思考一個有趣的事情:CPU 在執行任何給定操做時產生的熱量,即便是最純粹的函數 / 程序,也是不可避免的反作用嗎?那麼 CPU 的時間延遲,由於它花時間在一個純操做上,而後再執行另外一個操做,是否也算做反作用?

不要這麼快地作出假設,你僅僅運行 specialNumber(987654321) 計算一次,並手動將該結果粘貼到一些變量 / 常量中。程序一般是高度模塊化的而且全局可訪問的做用域並非一般你想要在這些獨立部分之間分享狀態的方式。讓specialNumber(..) 使用本身的緩存(即便它剛好是使用一個全局變量來實現這一點)是對狀態共享更好的抽象。

關鍵是,若是 specialNumber(..) 只是程序訪問和更新 cache 反作用的惟一部分,那麼引用透明的觀點顯然能夠適用,這能夠被看做是能夠接受的實際的「欺騙」的純函數思想。

可是真的應該這樣嗎?

典型的,這種性能優化方面的反作用是經過隱藏緩存結果產生的,所以它們不能被程序的任何其餘部分所觀察到。這個過程被稱爲記憶化。我一直稱這個詞是 「記憶化」,我不知道這個想法是從哪裏來的,但它確實有助於我更好地理解這個概念。

思考一下:

var specialNumber = (function memoization(){
    var cache = [];

    return function specialNumber(n){
            // 若是咱們已經計算過這個特殊的數,
            // 跳過這個操做,而後從緩存中返回
        if (cache[n] !== undefined) {
            return cache[n];
        }

        var x = 1, y = 1;

        for (let i = 1; i <= n; i++) {
            x += i % 2;
            y += i % 3;
        }

        cache[n] = (x * y) / (n + 1);

        return cache[n];
    };
})();複製代碼

咱們已經遏制 memoization() 內部 specialNumber(..) IIFE 範圍內的 cache 的反作用,因此如今咱們肯定程序任何的部分都不能觀察到它們,而不只僅是觀察它們。

最後一句話彷佛是一個的微妙觀點,但實際上我認爲這多是整章中最重要的一點。 再讀一遍。

回到這個哲學理論:

假如一棵樹在森林裏倒下而沒有人在附近聽見,它有沒有發出聲音?

經過這個暗喻,我所獲得的是:不管是否產生聲音,若是咱們從不創造一個當樹落下時周圍沒有人的情景會更好一些。當樹落下時,咱們老是會聽到聲音。

減小反作用的目的並非他們在程序中不能被觀察到,而是設計一個程序,讓反作用盡量的少,由於這使代碼更容易理解。一個沒有觀察到的發生的反作用的程序在這個目標上並不像一個不能觀察它們的程序那麼有效。

若是反作用可能發生,做者和讀者必須儘可能應對它們。使它們不發生,做者和讀者都要對任何可能或不可能發生的事情更有自信。

純化

若是你有不純的函數,且你沒法將其重構爲純函數,此時你能作些什麼?

您須要肯定該函數有什麼樣的反作用。反作用來自不一樣的地方,多是因爲詞法自由變量、引用變化,甚至是 this 的綁定。咱們將研究解決這些狀況的方法。

封閉的影響

若是反作用的本質是使用詞法自由變量,而且您能夠選擇修改周圍的代碼,那麼您可使用做用域來封裝它們。

回憶一下:

var users = {};

function fetchUserData(userId) {
    ajax( "http://some.api/user/" + userId, function onUserData(userData){
        users[userId] = userData;
    } );
}複製代碼

純化此代碼的一個方法是在變量和不純的函數週圍建立一個容器。本質上,容器必須接收全部的輸入。

function safer_fetchUserData(userId,users) {
        // 簡單的、原生的 ES6 + 淺拷貝,也能夠
    // 用不一樣的庫或框架
    users = Object.assign( {}, users );

    fetchUserData( userId );

        // 返回拷貝過的狀態 
    return users;


    // ***********************

        // 原始的沒被改變的純函數:
    function fetchUserData(userId) {
        ajax( "http://some.api/user/" + userId, function onUserData(userData){
            users[userId] = userData;
        } );
    }
}複製代碼

userIdusers 都是原始的的 fetchUserData 的輸入,users 也是輸出。safer_fetchUserData(..) 取出他們的輸入,並返回 users。爲了確保在 users 被改變時咱們不會在外部建立反作用,咱們製做一個 users 本地副本。

這種技術的有效性有限,主要是由於若是你不能將函數自己改成純的,你也幾乎不可能修改其周圍的代碼。然而,若是可能,探索它是有幫助的,由於它是全部修復方法中最簡單的。

不管這是不是重構純函數的一個實際方法,最重要的是函數的純度僅僅須要深刻到皮膚。也就是說,函數的純度是從外部判斷的, 無論內部是什麼。只要一個函數的使用表現爲純的,它就是純的。在純函數的內部,因爲各類緣由,包括最多見的性能方面,能夠適度的使用不純的技術。正如他們所說的「世界是一隻馱着一隻一直馱下去的烏龜羣」。

不過要當心。程序的任何部分都是不純的,即便它僅僅是用純函數包裹的,也是代碼錯誤和困惑讀者的潛在的根源。整體目標是儘量減小反作用,而不只僅是隱藏它們。

覆蓋效果

不少時候,你沒法在容器函數的內部爲了封裝詞法自由變量來修改代碼。例如,不純的函數可能位於一個你沒法控制的第三方庫文件中,其中包括:

var nums = [];
var smallCount = 0;
var largeCount = 0;

function generateMoreRandoms(count) {
    for (let i = 0; i < count; i++) {
        let num = Math.random();

        if (num >= 0.5) {
            largeCount++;
        }
        else {
            smallCount++;
        }

        nums.push( num );
    }
}複製代碼

蠻力的策略是,在咱們程序的其他部分使用此通用程序時隔離反作用的方法時建立一個接口函數,執行如下步驟:

  1. 捕獲受影響的當前狀態
  2. 設置初始輸入狀態
  3. 運行不純的函數
  4. 捕獲反作用狀態
  5. 恢復原來的狀態
  6. 返回捕獲的反作用狀態
function safer_generateMoreRandoms(count,initial) {
        // (1) 保存原始狀態
    var orig = {
        nums,
        smallCount,
        largeCount
    };

        // (2) 設置初始反作用狀態
    nums = initial.nums.slice();
    smallCount = initial.smallCount;
    largeCount = initial.largeCount;

        // (3) 小心雜質!
    generateMoreRandoms( count );

        // (4) 捕獲反作用狀態
    var sides = {
        nums,
        smallCount,
        largeCount
    };

        // (5) 從新存儲原始狀態
    nums = orig.nums;
    smallCount = orig.smallCount;
    largeCount = orig.largeCount;

        // (6) 做爲輸出直接暴露反作用狀態
    return sides;
}複製代碼

而且使用 safer_generateMoreRandoms(..)

var initialStates = {
    nums: [0.3, 0.4, 0.5],
    smallCount: 2,
    largeCount: 1
};

safer_generateMoreRandoms( 5, initialStates );
// { nums: [0.3,0.4,0.5,0.8510024448959794,0.04206799238...

nums;            // []
smallCount;        // 0
largeCount;        // 0複製代碼

這須要大量的手動操做來避免一些反作用,若是咱們一開始就沒有它們,那就容易多了。但若是咱們別無選擇,那麼這種額外的努力是值得的,以免咱們的項目出現意外。

注意: 這種技術只有在處理同步代碼時纔有用。異步代碼不能可靠地使用這種方法被管理,由於若是程序的其餘部分在期間也在訪問 / 修改狀態變量,它就沒法防止意外。

迴避影響

當要處理的反作用的本質是直接輸入值(對象、數組等)的突變時,咱們能夠再次建立一個接口函數來替代原始的不純的函數去交互。

考慮一下:

function handleInactiveUsers(userList,dateCutoff) {
    for (let i = 0; i < userList.length; i++) {
        if (userList[i].lastLogin == null) {
                // 將 user 從 list 中刪除
            userList.splice( i, 1 );
            i--;
        }
        else if (userList[i].lastLogin < dateCutoff) {
            userList[i].inactive = true;
        }
    }
}複製代碼

userList 數組自己,加上其中的對象,都發生了改變。防護這些反作用的一種策略是先作一個深拷貝(不是淺拷貝):

function safer_handleInactiveUsers(userList,dateCutoff) {
        // 拷貝列表和其中 `user` 的對象
    let copiedUserList = userList.map( function mapper(user){
            // 拷貝 user 對象
        return Object.assign( {}, user );
    } );

        // 使用拷貝過的對象調用最初的函數
    handleInactiveUsers( copiedUserList, dateCutoff );

    // 將突變的 list 做爲直接的輸出暴露出來
    return copiedUserList;
}複製代碼

這個技術的成功將取決於你所作的複製的深度。使用 userList.slice() 在這裏不起做用,由於這隻會建立一個 userList 數組自己的淺拷貝。數組的每一個元素都是一個須要複製的對象,因此咱們須要格外當心。固然,若是這些對象在它們以內有對象(可能會這樣),則複製須要更加完善。

再看一下 this

另外一個參數變化的反作用是和 this 有關的,咱們應該意識到 this 是函數隱式的輸入。查看第 2 章中的「什麼是This」獲取更多的信息,爲何 this 關鍵字對函數式編程者是不肯定的。

思考一下:

var ids = {
    prefix: "_",
    generate() {
        return this.prefix + Math.random();
    }
};複製代碼

咱們的策略相似於上一節的討論:建立一個接口函數,強制 generate() 函數使用可預測的 this 上下文:

function safer_generate(context) {
    return ids.generate.call( context );
}

// *********************

safer_generate( { prefix: "foo" } );
// "foo0.8988802158307285"複製代碼

這些策略絕對不是愚蠢的,對反作用的最安全的保護是不要產生它們。可是,若是您想提升程序的可讀性和你對程序的自信,不管在什麼狀況下儘量減小反作用 / 效果是巨大的進步。

本質上,咱們並無真正消除反作用,而是剋制和限制它們,以便咱們的代碼更加的可驗證和可靠。若是咱們後來遇到程序錯誤,咱們就知道代碼仍然產生反作用的部分最有多是罪魁禍首。

總結

反作用對代碼的可讀性和質量都有害,由於它們使您的代碼難以理解。反作用也是程序中最多見的錯誤緣由之一,由於很難應對他們。冪等是經過本質上建立僅有一次的操做來限制反作用的一種策略。

避免反作用的最優方法是使用純函數。純函數給定相同輸入時總返回相同輸出,而且沒有反作用。引用透明更近一步的狀態是 —— 更多的是一種腦力運動而不是文字行爲 —— 純函數的調用是能夠用它的輸出來代替,而且程序的行爲不會被改變。

將一個不純的函數重構爲純函數是首選。可是,若是沒法重構,嘗試封裝反作用,或者建立一個純粹的接口來解決問題。

沒有程序能夠徹底沒有反作用。可是在實際狀況中的不少地方更喜歡純函數。儘量地收集純函數的反作用,這樣當錯誤發生時更加容易識別和審查出最像罪魁禍首的錯誤。

【上一章】翻譯連載 | JavaScript輕量級函數式編程-第4章:組合函數 |《你不知道的JS》姊妹篇

【下一章】翻譯連載 | JavaScript輕量級函數式編程-第6章:值的不可變性 |《你不知道的JS》姊妹篇

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

>> 滬江Web前端上海團隊招聘【Web前端架構師】,有意者簡歷至:zhouyao@hujiang.com <<

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息