翻譯連載 | JavaScript輕量級函數語言程式設計-第5章:減少副作用 |《你不知道的JS》姊妹篇

iKcamp發表於2017-09-03

關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。

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

第 5 章:減少副作用

在第 2 章,我們討論了一個函式除了它的返回值之外還有什麼輸出。現在你應該很熟悉用函數語言程式設計的方法定義一個函式了,所以對於函數語言程式設計的副作用你應該有所瞭解。

我們將檢查各種各樣不同的副作用並且要看看他們為什麼會對我們的程式碼質量和可讀性造成損害。

這一章的要點是:編寫出沒有副作用的程式是不可能的。當然,也不是不可能,你當然可以編寫出沒有副作用的程式。但是這樣的話程式就不會做任何有用和明顯的事情。如果你編寫出來一個零副作用的程式,你就無法區分它和一個被刪除的或者空程式的區別。

函數語言程式設計者並沒有消除所有的副作用。實際上,我們的目標是儘可能地限制他們。要做到這一點,我們首先需要完全理解函數語言程式設計的副作用。

什麼是副作用

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

在程式設計中,我們也完全會處理因果關係。如果你呼叫了一個函式(起因),就會在螢幕上輸出一條訊息(結果)。

當我們在閱讀程式的時候,能夠清晰明確的識別每一個起因和每一個結果是非常重要的。在某種程度上,通讀程式但不能看到因果的直接關係,程式的可讀性就會降低。

思考一下:

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 <<

相關文章