輕量函數(shù)式 JavaScript 第五章:降低副作用

感謝社區(qū)中各位的大力支持扯键,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠珊肃,并抽取幸運(yùn)大獎(jiǎng):點(diǎn)擊這里領(lǐng)取

在第二章中荣刑,我們討論了一個(gè)函數(shù)如何能夠擁有 return 值之外的輸出。至此你應(yīng)當(dāng)對(duì)一個(gè)函數(shù)的 FP 定義感到非常適應(yīng)了近范,那么這種副輸出 —— 副作用嘶摊!—— 的想法應(yīng)當(dāng)散發(fā)出臭味了。

我們將要檢視各種不同形式的副作用评矩,并看看為什么它們對(duì)我們代碼的質(zhì)量和可讀性有害叶堆。

但別讓我在這里喧賓奪主。這一章的要點(diǎn)是:寫出一個(gè)沒有副作用的程序是不可能的斥杜。好吧虱颗,不是不可能沥匈;你當(dāng)然能。但是那樣的程序?qū)⒉粫?huì)有什么用忘渔,也無法觀察高帖。如果你寫了一個(gè)副作用為零的程序,那么你將無法說出它與一個(gè)被刪除的或空的程序有什么區(qū)別畦粮。

FP 程序員不會(huì)消滅所有的副作用散址。他們的目標(biāo)是盡量地限制它們。為此宣赔,我們需要完全地理解它們预麸。

拜托,副作用靠邊站

因與果:我們?nèi)祟悓?duì)周圍世界可以做出的最基礎(chǔ)的儒将、最直覺的觀察之一吏祸。將一本書推離桌子的邊緣,它將掉到地上钩蚊。你不需要物理學(xué)學(xué)位就能知道贡翘,其原因是你推了這本書而且重力的效果將它拉到了地上。這里有一個(gè)清晰且直接的關(guān)系砰逻。

在程序中鸣驱,我們也完全是在處理因與果。如果你調(diào)用一個(gè)函數(shù)(因)诱渤,它就會(huì)在屏幕上打印一個(gè)消息(果)丐巫。

在閱讀一段程序時(shí),讀者能夠清晰地定位每一個(gè)因與每一個(gè)果是極其重要的勺美。在通讀程序之后不能輕易地看出因果之間的直接聯(lián)系 —— 任意程度的這種事情都會(huì)使你程序的可讀性降低递胧。

考慮如下代碼:

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

var y = foo( 3 );

在這個(gè)不起眼的程序中,以下事情是可以立即明確的:使用值 3 調(diào)用 foo(原因)將會(huì)有返回值 6 的效果赡茸,然后它被賦值給 y(結(jié)果)缎脾。這里沒有任何歧義。

但現(xiàn)在:

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

var y;

foo( 3 );

這段程序擁有完全一樣的結(jié)果占卧。但這里有一個(gè)巨大的不同遗菠。原因與結(jié)果脫節(jié)了。結(jié)果是間接的华蜒。這種設(shè)置 y 的方式就是我們稱之為副作用的東西辙纬。

注意: 當(dāng)一個(gè)函數(shù)引用一個(gè)它外部的變量時(shí),它稱為一個(gè)自由變量叭喜。不是所有自由變量都是壞的贺拣,但我們將非常小心地對(duì)待它們。

要是我給你一個(gè)你看不到代碼的函數(shù) bar(..) 的調(diào)用引用,但我告訴你它沒有這樣的間接副作用譬涡,而只有一個(gè)明確的 return 值的效果呢闪幽?

bar( 4 );           // 42

因?yàn)槟阒?bar(..) 的內(nèi)部不會(huì)制造任何副作用,所以你現(xiàn)在可以用更加直接了當(dāng)?shù)姆绞酵评砣魏我粋€(gè)像 bar(..) 這樣的調(diào)用涡匀。但如果你不知道 bar(..) 沒有副作用盯腌,那么要理解調(diào)用它的結(jié)果,你就不得不去閱讀并剖析它所有的邏輯陨瘩。對(duì)讀者來說這是額外的思維負(fù)擔(dān)腕够。

一個(gè)帶有副作用的函數(shù)的可讀性要差一些,因?yàn)樗蟾蟮拈喿x量才能理解程序拾酝。

但是問題會(huì)變得更嚴(yán)重燕少】ㄕ撸考慮如下代碼:

var x = 1;

foo();

console.log( x );

bar();

console.log( x );

baz();

console.log( x );

你對(duì)每一個(gè) console.log(x) 將要打印出的值有多確定蒿囤?

正確答案是:完全無法確定。如果你不能確定 foo()崇决、bar()材诽、和 baz() 是否有副作用,你就無法保證 x 在每一步中是什么恒傻。除非你檢查每一個(gè)函數(shù)的實(shí)現(xiàn)脸侥,并且 從第一行開始追蹤程序,一邊走一邊監(jiān)視狀態(tài)的所有改變盈厘。

換言之睁枕,最終的 console.log(x) 是不可能被分析和預(yù)測(cè)的,除非你在大腦中將整個(gè)程序執(zhí)行到那個(gè)地方沸手。

猜猜誰更擅長(zhǎng)運(yùn)行你的程序外遇?JS 引擎。再猜猜誰不擅長(zhǎng)運(yùn)行你的程序契吉?你的代碼的讀者跳仿。而且,你選擇在這些函數(shù)中的一個(gè)或幾個(gè)里面編寫帶有(潛在)副作用的代碼捐晶,意味著你使讀者背上了這樣一種負(fù)擔(dān):他們?yōu)榱死斫饽骋恍蟹朴铮筒坏貌辉诖竽X中將你的程序完整地運(yùn)行到那一行。

如果 foo()惑灵、bar()山上、和 baz() 都是無副作用的,它們不會(huì)影響 x英支,這意味著我們不必在大腦中執(zhí)行它們來跟蹤 x 上發(fā)生了什么佩憾。這樣思維成本更低,而且使得代碼可讀性更高潭辈。

隱藏的原因

輸出鸯屿、狀態(tài)的改變澈吨,是最常被提到的副作用的表現(xiàn)。但是另一種有損可讀性的做法是一些人稱之為側(cè)因(side causes)的東西寄摆×吕保考慮如下代碼:

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

var y = 3;

foo( 1 );           // 4

y 沒有被 foo(..) 改變,所以這不是我們以前看到的那種副作用婶恼。但現(xiàn)在桑阶,foo(..) 的調(diào)用實(shí)際上依賴于 y 的存在和當(dāng)前狀態(tài)。如果稍后我們這么做:

y = 5;

// ..

foo( 1 );           // 6

也許我們會(huì)因 foo(1) 在調(diào)用與調(diào)用之間返回不同的結(jié)果而感到詫異勾邦?

foo(..) 有一個(gè)損害可讀性的間接起因蚣录。如果不仔細(xì)檢查 foo(..) 的實(shí)現(xiàn),讀者就看不到是什么原因在影響著輸出的結(jié)果眷篇。看起來 參數(shù) 1 是唯一的起因萎河,但事實(shí)證明它不是。

為了增強(qiáng)可讀性蕉饼,所有將會(huì)影響判定 foo(..) 的輸出結(jié)果的起因都應(yīng)當(dāng)作為 foo(..) 的直接的虐杯、明顯的輸入。代碼的讀者將可以清楚地看到起因和結(jié)果昧港。

固定的狀態(tài)

避免側(cè)因意味著函數(shù) foo(..) 不能引用任何自由變量嗎擎椰?

考慮這段代碼:

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

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

foo( 3 );           // 9

很清楚,對(duì)于 foo(..)bar(..) 兩者來說唯一的直接起因就是形式參數(shù) x创肥。那么 bar(x) 的調(diào)用呢达舒?bar 只是一個(gè)標(biāo)識(shí)符,而且在 JS 中它甚至默認(rèn)地不是一個(gè)常量(不可再被賦值的變量)叹侄。函數(shù) foo(..) 依賴于 bar 的值 —— 一個(gè)引用第二個(gè)函數(shù)的變量 —— 一個(gè)自由變量巩搏。

那么這個(gè)程序是依賴于側(cè)因的嗎?

我說不圈膏。即使使用其他函數(shù)來覆蓋變量 bar 的值是 可能 的塔猾,我也沒在這段代碼中這么做,這不是我的常見做法稽坤,也沒有這樣的先例丈甸。對(duì)于我所有的意圖和目的來說,我的函數(shù)就是常量(從不被重新賦值)尿褪。

考慮如下代碼:

const PI = 3.141592;

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

foo( 3 );           // 9.424776000000001

注意: JavaScript 有一個(gè) Math.PI 內(nèi)建值睦擂,我們?cè)谶@本書里使用 PI 的例子只是為了方便展示。在實(shí)際應(yīng)用中杖玲,要總是使用 Math.PI 而不是定義你自己的顿仇!

這個(gè)代碼段呢?PIfoo(..) 的一個(gè)側(cè)因嗎?

兩個(gè)觀點(diǎn)將幫助我們以一種合理的方式回答這個(gè)問題:

  1. 考慮你可能發(fā)起的每一個(gè) foo(3) 調(diào)用臼闻。它們將總是返回值 9.424.. 嗎鸿吆?是的。 每一次述呐。如果你給它相同的輸入(x)惩淳,它就總是返回相同的輸出。

  2. 你能使用 PI 的立即值替換每一個(gè)用到 PI 的地方乓搬,而且程序還能 完全 和以前一樣運(yùn)行嗎思犁?是的。 這個(gè)程序沒有其他部分可以改變 PI 的值 —— 確實(shí)进肯,因?yàn)樗且粋€(gè) const激蹲,不能被重新賦值 —— 所以這里的變量 PI 只是為了可讀性/可維護(hù)性而存在的。它的值可以被內(nèi)聯(lián)而不改變程序的任何行為江掩。

我的結(jié)論:這里的 PI 沒有違反最小化/避免副作用(或側(cè)因)的精神学辱。前一個(gè)代碼段中的 bar(x) 也沒有。

在這兩種情況下频敛,PIbar 都不是程序狀態(tài)的一部分项郊。它們是固定的,不可被重新賦值的引用(“常量”)斟赚。如果它們貫穿程序始終都不改變,我們就不必費(fèi)心將它們視為可變狀態(tài)追蹤差油。因此拗军,它們沒有損害我們的可讀性。而且它們不可能是由于變量以意外的方式改變而引起的 bug 的源頭蓄喇。

注意: 依我看发侵,上面 const 的使用并不是 PI 沒有成為側(cè)因的理由;var PI 也會(huì)得出相同的結(jié)論妆偏。沒有給 PI 重新賦值才是重要的刃鳄,而不是沒有這種能力。我們將會(huì)在后面的章節(jié)中討論 const钱骂。

隨機(jī)性

你可能從沒考慮過叔锐,但隨機(jī)性是不純粹的。一個(gè)使用了 Math.random() 的函數(shù)絕不可能是純函數(shù)见秽,因?yàn)槟悴荒芑谒妮斎氡WC/預(yù)測(cè)它的輸出愉烙。所以任何生成唯一隨機(jī) ID 等東西的代碼,根據(jù)定義都將被認(rèn)為是依賴于你程序的側(cè)因的解取。

在計(jì)算機(jī)科學(xué)中步责,我們使用稱為偽隨機(jī)算法的東西來生成隨機(jī)數(shù)。事實(shí)證明隨機(jī)性相當(dāng)難以實(shí)現(xiàn),所以我們只是使用一些產(chǎn)生看起來隨機(jī)的值的復(fù)雜算法來假冒它蔓肯。這些算法計(jì)算出一些很長(zhǎng)的數(shù)字流遂鹊,但其中的秘密是,如果你知道它的起點(diǎn)蔗包,這些序列實(shí)際上是可以預(yù)測(cè)的稿辙。這個(gè)起點(diǎn)稱為種子(seed)。

有些語言允許你為隨機(jī)數(shù)的生成指定種子值气忠。如果你總是指定相同的種子邻储,那么你將總是從后續(xù)的“隨機(jī)數(shù)”生成中得到相同的輸出序列。這對(duì)測(cè)試來說具有不可估量的價(jià)值旧噪,但是對(duì)現(xiàn)實(shí)世界中程序的使用有不可估量的危險(xiǎn)吨娜。

在 JS 中,Math.random(..) 計(jì)算出的隨機(jī)性是基于一個(gè)間接輸入的淘钟,因?yàn)槟悴荒苤付ǚN子宦赠。因此,我們不得不將內(nèi)建的隨機(jī)數(shù)生成視為一種不純粹的側(cè)因米母。

I/O 效應(yīng)

可能還不是很明顯勾扭,但是副作用/側(cè)因的最常見形式是 I/O(輸入/輸出)。一個(gè)沒有 I/O 的程序是完全無意義的铁瞒,因?yàn)樗瓿傻墓ぷ鳠o論以什么方式都不可見妙色。有用的程序必須至少擁有輸出,而且可能還需要輸入慧耍。輸入是一種側(cè)因身辨,而輸出是一種副作用。

在瀏覽器的 JS 程序中最常見的輸入就是用戶事件(鼠標(biāo)芍碧,鍵盤)煌珊,而輸出就是 DOM。如果你用 Node.js 比較多泌豆,那么你更可能從文件系統(tǒng)定庵、網(wǎng)絡(luò)連接、和/或 stdin/stdout 流中接收輸入與發(fā)送輸出踪危。

事實(shí)上蔬浙,這些源頭既可以是輸入也可以是輸出,同為因果陨倡。例如 DOM敛滋。我們更新(副作用)一個(gè) DOM 元素來向用戶展示一段文字或一張圖片,但 DOM 的當(dāng)前狀態(tài)對(duì)于這些操作來說也是一種隱含的輸入(側(cè)因)兴革。

側(cè)面的 Bugs

側(cè)因與副作用導(dǎo)致 bug 的場(chǎng)景會(huì)因它們?cè)诔绦蛑写嬖诘男螒B(tài)而不同绎晃。但讓我們來檢視一種場(chǎng)景來展示一下這些災(zāi)難蜜唾,希望它們能幫你在你自己的程序中找出相似的錯(cuò)誤。

考慮如下代碼:

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++) {
            // 為每個(gè)用于保持一個(gè)最新訂單的引用
            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);

    // 刪除一個(gè)用戶的最近訂單嗎庶艾?
    if (isLatestOrder) {
        hideLatestOrderDisplay();
    }

    ajax( "http://some.api/delete/order/" + orderId, function onDelete(success){
        if (success) {
            // 一個(gè)用戶的最近訂單被刪除了袁余?
            if (isLatestOrder) {
                user.latestOrder = null;
            }

            userOrders[orderId] = null;
        }
        else if (isLatestOrder) {
            showLatestOrderDisplay();
        }
    } );
}

我打賭對(duì)于一些讀者來說這里的潛在的 bug 之一是相當(dāng)明顯的。如果回調(diào) onOrders(..) 在回調(diào) onUserData(..) 之前運(yùn)行咱揍,它就會(huì)試圖將一個(gè) latestOrder 屬性添加到一個(gè)還沒有被設(shè)置的值上(user[userId] 上的 userData 對(duì)象)颖榜。

所以在依賴于側(cè)因/副作用的邏輯中可能發(fā)生的一種形式的 bug 是兩個(gè)不同操作(異步或者同步!)的競(jìng)合狀態(tài)煤裙,我們期望它們以一種特定的順序運(yùn)行掩完,但在某些情況下它們可能以一種不同的順序運(yùn)行。有一些策略可以保證操作的順序硼砰,但是在這種場(chǎng)景下順序的重要性是相當(dāng)明顯的且蓬。

這里還有另一個(gè)微妙的 bug 可能會(huì)咬到我們。你發(fā)現(xiàn)了嗎题翰?

考慮一下這種調(diào)用順序:

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

// 稍后

fetchOrders( 123 );
deleteOrder( 456 );
onOrders(..);
onDelete(..);

你看到 fetchOrders(..) / onOrders(..)deleteOrder(..) / onDelete(..) 之間的穿插了嗎恶阴?在我們狀態(tài)管理的側(cè)因/副作用中,這種潛在的序列暴露出了一個(gè)奇怪的狀態(tài)豹障。

在我們?cè)O(shè)置 isLatestOrder 標(biāo)志冯事,和我們使用它來決定我們是否應(yīng)當(dāng)清空 user 中用戶數(shù)據(jù)的 latestOrder 屬性之間存在一個(gè)時(shí)間的延遲(因?yàn)榛卣{(diào))。在這個(gè)延遲期間血公,如果 onOrders(..) 被觸發(fā)昵仅,它就可能潛在地改變用戶的 latestOrder 引用的訂單值。而之后在 onDelete(..) 被觸發(fā)時(shí)坞笙,它將假定它依然需要解除 latestOrder 引用岩饼。

bug 就是:現(xiàn)在數(shù)據(jù)(狀態(tài))可能 已經(jīng)不同步了。在 latestOrder 本應(yīng)潛在地保持指向來自 onOrders(..) 的更新的訂單時(shí)薛夜,這種指向被解除了。

這種 bug 最可怕的地方就是它不會(huì)像其他 bug 那樣版述,給你一個(gè)程序崩潰的異常梯澜。我們就這樣得到一個(gè)不正確的狀態(tài);我們應(yīng)用程序的行為 “平靜地” 壞掉了渴析。

fetchUserData(..)fetchOrders(..) 之間順序的依賴關(guān)系相當(dāng)明顯晚伙,而且解決起來直截了當(dāng)。但 fetchOrders(..)deleteOrder(..) 之間存在的順序依賴關(guān)系可就不那么明顯了俭茧。它們倆看起來更加獨(dú)立咆疗。而且維護(hù)它們的順序更加棘手,因?yàn)槟悴粫?huì)提前知道(在 fetchOrders(..) 的結(jié)果之前)是否應(yīng)當(dāng)強(qiáng)制這個(gè)順序母债。

是的午磁,你可以在 deleteOrder(..) 被觸發(fā)時(shí)重新計(jì)算 isLatestOrder 標(biāo)志尝抖。但是現(xiàn)在你又有了一個(gè)不同的問題:你的 UI 狀態(tài)可能不同步了。

如果你之前已經(jīng)調(diào)用了 hideLatestOrderDisplay()迅皇,那么現(xiàn)在你需要調(diào)用 showLatestOrderDisplay() 了昧辽,但是僅在新的 latestOrder 被實(shí)際設(shè)定了的情況下調(diào)用。所以你現(xiàn)在至少需要跟蹤三個(gè)狀態(tài):被刪除的訂單是否本來就是“最近”的登颓?“最近”的訂單是否被設(shè)置了搅荞?這兩個(gè)訂單是否不同?當(dāng)然框咙,這些問題可以解決咕痛。但從任何意義上講它們都不是顯而易見的。

所有這些麻煩都是因?yàn)槲覀儧Q定在一組共享的狀態(tài)上帶著側(cè)因/副作用來構(gòu)建我們的代碼而引起的喇嘱。

函數(shù)式程序員痛恨這種側(cè)因/副作用 bug茉贡,因?yàn)樗鼧O大地傷害了我們的可讀性、可推理性婉称、可驗(yàn)證性块仆,而且最終傷害到了代碼的 可信任性。這就是為什么他們?nèi)绱藝?yán)肅地對(duì)待避免側(cè)因/副作用的原則王暗。

有多種不同的策略可以避免/修復(fù)側(cè)因/副作用悔据。我們會(huì)在本章稍后談到一些,另外一些在后續(xù)章節(jié)討論俗壹。我可以確信一件事情:帶著側(cè)因/副作用編寫程序經(jīng)常是我們一般的默認(rèn)狀態(tài)科汗,所以避免它們就要求小心和有意識(shí)的努力。

謝謝绷雏,一次就夠了

如果你必須制造副作用來改變狀態(tài)头滔,有一類稱為冪等性的操作對(duì)于限制潛在的麻煩十分有用。如果你對(duì)一個(gè)值的更新是冪等的涎显,那么數(shù)據(jù)就能承受來自不同副作用源頭的多次同種類的更新坤检。

冪等性的定義有些令人糊涂;與程序員經(jīng)常使用的含義相比期吓,數(shù)學(xué)家們使用的含義稍有不同早歇。但是對(duì)于函數(shù)式程序員來說兩種角度都有用。

首先讨勤,讓我們給出一個(gè)計(jì)數(shù)器的例子箭跳,它既不是數(shù)學(xué)上冪等的也不是程序上冪等的:

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

    return false;
}

這個(gè)函數(shù)通過引用遞增 obj.count 來改變一個(gè)對(duì)象,所以它在這個(gè)對(duì)象上產(chǎn)生了一個(gè)副作用潭千。如果 updateCounter(o) 被調(diào)用了多次 —— 在 o.count 小于 10 的時(shí)候 —— 那么程序的狀態(tài)每次都會(huì)改變谱姓。另外,updateCounter(..) 的輸出是一個(gè)布爾值刨晴,它不適合作為后續(xù) updateCounter(..) 調(diào)用的輸入屉来。

數(shù)學(xué)的冪等性

從數(shù)學(xué)的視角來看路翻,冪等性意味著一個(gè)操作的輸出在第一次調(diào)用之后就不會(huì)再改變了,即使你將這個(gè)輸出一次又一次地送回這個(gè)操作奶躯。換言之帚桩,foo(x) 產(chǎn)生的輸出將與 foo(foo(x))foo(foo(foo(x))) 等相同嘹黔。

一個(gè)典型的數(shù)學(xué)的例子是 Math.abs(..)(絕對(duì)值)账嚎。Math.abs(-2)2,它的結(jié)果與 Math.abs(Math.abs(Math.abs(Math.abs(-2)))) 相同儡蔓。像 Math.min(..)郭蕉、Math.max(..)Math.round(..)喂江、Math.floor(..)Math.ceil(..) 這樣的工具也都是冪等的召锈。

我們可以用與此相同的性質(zhì)定義一些自己的數(shù)學(xué)操作:

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

數(shù)學(xué)上的冪等性 局限于數(shù)學(xué)操作。我們可以展示這種形式的冪等性的另一個(gè)地方是 JavaScript 的基本類型強(qiáng)制轉(zhuǎn)換:

var x = 42, y = "hello";

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

Boolean( y ) === Boolean( Boolean( y ) );           // true

在本書先前的部分中,我們探索過一個(gè)滿足這種形式的冪等性的常見的 FP 工具:

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

我們甚至可以用冪等的方式來設(shè)計(jì)更精巧的字符串格式化操作常熙,比如:

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(..) 展示了一種重要的技術(shù):在某些情況下開發(fā)者可以采取額外的步驟來規(guī)范化一個(gè)輸入/輸出操作只估,以保證這個(gè)通常不是冪等的操作是冪等的皂吮。

無論何處,將副作用限制為冪等操作要比無限制的更新好多了。

編程的冪等性

冪等性面向編程的定義是相似的,但沒那么正式秉撇。與要求 f(x) === f(f(x)) 不同,這種觀點(diǎn)的冪等性只要求 f(x); 在程序行為上結(jié)果與 f(x); f(x); 相同秋泄。換言之琐馆,在第一次調(diào)用 f(x) 之后,對(duì) f(x) 的后續(xù)多次調(diào)用不會(huì)改變?nèi)魏螙|西恒序。

這種角度更符合我們對(duì)副作用的觀察瘦麸,因?yàn)橐粋€(gè)這樣的 f(..) 操作更像是制造了一個(gè)冪等的副作用,而不是必然返回一個(gè)冪等的輸出值歧胁。

這種冪等風(fēng)格經(jīng)常被 HTTP 操作(動(dòng)詞)引用瞎暑,比如 GET 或 PUT。如果一個(gè) HTTP REST API 恰當(dāng)?shù)匕凑諆绲刃缘囊?guī)范指引設(shè)計(jì)与帆,PUT 被定義為完全替換一個(gè)資源的更新操作。那么墨榄,一個(gè)客戶端就可以發(fā)送 PUT 請(qǐng)求一次或多次(用相同的數(shù)據(jù))玄糟,而服務(wù)器將無論如何都擁有相同的結(jié)果狀態(tài)。

使用編程中更具體的術(shù)語考慮這個(gè)問題袄秩,讓我們檢視一些副作用操作的冪等性:

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

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

記渍篝帷:在這里冪等性的概念是逢并,每個(gè)冪等的操作都可以被重復(fù)多次,而除了第一次更新以外都不會(huì)改變程序的狀態(tài)郭卫。而非冪等操作每次都會(huì)改變狀態(tài)砍聊。

那 DOM 的更新呢?

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

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

// 非冪等:
var update = document.createTextNode( order.latestUpdate );
hist.appendChild( update );

這里展示的關(guān)鍵的不同是贰军,冪等更新替換了 DOM 元素的內(nèi)容玻蝌。DOM 元素的當(dāng)前狀態(tài)無關(guān)緊要,因?yàn)樗粺o條件地覆蓋了词疼。非冪等操作向元素添加內(nèi)容俯树;DOM 元素當(dāng)前的狀態(tài)隱含地成為了下一個(gè)狀態(tài)的計(jì)算的一部分。

以冪等的方式定義你在數(shù)據(jù)上的操作不總是可能的贰盗,但如果你能许饿,它絕對(duì)能幫你降低這種可能性 —— 副作用在你最預(yù)想不到的時(shí)候產(chǎn)生并毀了你的預(yù)想。

純粹的福佑

一個(gè)沒有側(cè)因/副作用的函數(shù)稱為一個(gè)純函數(shù)舵盈。一個(gè)純函數(shù)在編程的意義上是冪等的陋率,因?yàn)樗荒苡腥魏胃弊饔谩秽晚?紤]如下代碼:

function add(x,y) {
    return x + y;
}

所有的輸入(xy)和輸出(return ..)都是直接的瓦糟;沒有自由變量的引用。調(diào)用 add(3,4) 多次與僅調(diào)用它一次沒有區(qū)別爆惧。add(..) 是純粹的狸页,而且是編程上冪等的。

然而扯再,不是所有的純函數(shù)都在數(shù)學(xué)的意義上是冪等的芍耘,因?yàn)樗鼈儾槐胤祷匾粋€(gè)適于傳遞給自己作為輸入的值∠ㄗ瑁考慮如下代碼:

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 不是一個(gè)數(shù)組斋竞,所以你不能這樣把它傳遞回去:calculateAverage(calculateAverage( .. ))

正如我們?cè)缦扔懻撨^的秃殉,一個(gè)純函數(shù) 可以 引用自由變量坝初,只要那些自由變量不是側(cè)因。

一些例子是:

const PI = 3.141592;

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

function cylinderVolume(radius,height) {
    return height * circleArea( radius );
}

circleArea(..) 引用了自由變量 PI钾军,但它是一個(gè)常量所以它不是側(cè)因鳄袍。cylinderVolume(..) 引用了自由變量 circleArea,它也不是一個(gè)側(cè)因吏恭,因?yàn)檫@個(gè)程序沒有這樣看待它拗小,而實(shí)際上將它作為一個(gè)它函數(shù)值的常量引用。這兩個(gè)函數(shù)都是純粹的樱哼。

另一個(gè)函數(shù)引用自由變量但依然純粹的例子是通過閉包:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

unary(..) 自身顯然是純粹的 —— 它唯一的輸入是 fn 唯一的輸出是被 return 的函數(shù) —— 但是閉包著自由變量 fn 的內(nèi)部函數(shù) onlyOneArg(..) 呢哀九?

它依然是純粹的剿配,因?yàn)?fn 絕不會(huì)改變。事實(shí)上阅束,我們對(duì)此有足夠的信心呼胚,因?yàn)閺脑~法上講這幾行是唯一可能對(duì) fn 重新賦值的地方。

注意: fn 是一個(gè)函數(shù)對(duì)象的引用息裸,它默認(rèn)是可變的蝇更。比如程序的其他一些地方 可能 會(huì)給這個(gè)函數(shù)對(duì)象添加一個(gè)屬性,從而在技術(shù)上“改變”(改變界牡,不是重新賦值)這個(gè)值簿寂。但是,因?yàn)槌四軌蛘{(diào)用 fn 的能力以外宿亡,我們不依賴于它任何其他的東西常遂,而且這不可能影響函數(shù)的能力,所以對(duì)于我們目的來說 fn 實(shí)際上依然是不變的挽荠;它不可能是一個(gè)側(cè)因克胳。

另一種準(zhǔn)確描述函數(shù)純粹性的常見方式是:給定相同的輸入,它總是產(chǎn)生相同的輸出圈匆。 如果你向 circleArea(..) 傳遞 3漠另,它將總是輸出相同的結(jié)果(28.274328)。

如果一個(gè)函數(shù) 在每次被給予相同輸入時(shí)產(chǎn)生不同的輸出跃赚,那么它就不是純粹的笆搓。即便一個(gè)函數(shù)總是 return 相同的值,如果他產(chǎn)生了一個(gè)間接的副作用輸出纬傲,那么程序的狀態(tài)也會(huì)在每次它被調(diào)用時(shí)改變满败;這不是純粹的。

不純粹的函數(shù)不受歡迎是因?yàn)樗鼈兪沟盟袑?duì)它們的調(diào)用都更難推理叹括。一個(gè)純函數(shù)的調(diào)用是完全可以預(yù)測(cè)的算墨。當(dāng)某人閱讀代碼看到多個(gè) circleArea(3) 調(diào)用時(shí),他不必花費(fèi)任何額外的努力就能搞清楚它的 每一次 輸出是什么汁雷。

純粹地相對(duì)

當(dāng)我們談?wù)撘粋€(gè)函數(shù)是否純粹的時(shí)候必須非常小心净嘀。JavaScript 動(dòng)態(tài)值的天性使得隱晦的側(cè)因/副作用太容易發(fā)生了。

考慮如下代碼:

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

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

var simpleList = rememberNumbers( list );

simpleList(..) 看起來是一個(gè)純函數(shù)侠讯,它是一個(gè)內(nèi)部函數(shù) caller(..) 的引用挖藏,這個(gè)內(nèi)部函數(shù)閉包著自由變量 nums。然而厢漩,其實(shí)有好幾種方式可以使 simpleList(..) 成為不純粹的熬苍。

首先,我們對(duì)純粹性的斷言是基于數(shù)組值(同時(shí)被 listnums 引用著)絕不會(huì)改變:

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

simpleList( median );       // 3

// ..

list.push( 6 );

// ..

simpleList( median );       // 3.5

當(dāng)我們改變這個(gè)數(shù)組時(shí),simpleList(..) 調(diào)用改變了它的輸出柴底。那么,simpleList(..) 是純粹的還是不純粹的粱胜?這要看你的角度柄驻。對(duì)于給定的一組假設(shè)來說它是純粹的。在任何沒有 list.push(6) 變化的程序中它都可以是純粹的焙压。

我們可以通過改變 rememberNumbers(..) 的定義來防止這種不純粹性鸿脓。一種方式是復(fù)制 nums 數(shù)組:

function rememberNumbers(nums) {
    // 制造一個(gè)數(shù)組的拷貝
    nums = nums.slice();

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

但可能潛伏著一個(gè)更刁鉆的隱藏副作用:

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

// 使 `list[0]` 成為一個(gè)帶有副作用的 getter
Object.defineProperty(
    list,
    0,
    {
        get: function(){
            console.log( "[0] was accessed!" );
            return 1;
        }
    }
);

var simpleList = rememberNumbers( list );
// [0] was accessed!

也許一個(gè)更健壯的選項(xiàng)是改變 rememberNumbers(..) 的簽名,使它不要一上來就接收一個(gè)數(shù)組涯曲,而是接收各個(gè)獨(dú)立的實(shí)際參數(shù):

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

var simpleList = rememberNumbers( ...list );
// [0] was accessed!

兩個(gè) ... 的效果是將 list 拷貝到 nums野哭,而非通過引用傳遞。

注意: 控制臺(tái)消息的副作用不是來自于 rememberNumbers(..) 而是來自于 ...list 擴(kuò)散幻件。所以在這種情況下拨黔,rememberNumbers(..)simpleList(..) 都是純粹的。

但要是改變更難以發(fā)現(xiàn)呢绰沥?將一個(gè)純函數(shù)與一個(gè)非純函數(shù)組合 總是 產(chǎn)生一個(gè)非純函數(shù)篱蝇。如果我們將一個(gè)不純粹的函數(shù)傳入本來是純粹的 simpleList(..),那么它現(xiàn)在就是不純粹的了:

// 沒錯(cuò)徽曲,一個(gè)矯揉造作的例子 :)
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() 返回了一個(gè)反向的數(shù)組而且看起來安全(就像其他 JS 的數(shù)組方法一樣)零截,但它其實(shí)改變了數(shù)組而不是創(chuàng)建了一個(gè)新的。

我們需要一個(gè)更健壯的 rememberNumbers(..) 定義來防止 fn(..) 通過引用來改變它閉包著的 nums

function rememberNumbers(...nums) {
    return function caller(fn){
        // 發(fā)送一個(gè)拷貝秃臣!
        return fn( nums.slice() );
    };
}

那么 simpleList(..) 可靠地純粹了=а谩?沒有奥此。 :(

我們只防御了我們可控的副作用(通過引用進(jìn)行改變)弧哎。我們傳遞的任何函數(shù)都可能有另外的副作用會(huì)污染 simpleList(..) 的純粹性:

simpleList( function impureIO(nums){
    console.log( nums.length );
} );

事實(shí)上,沒有辦法能定義 rememberNumbers(..) 而使 simpleList(..) 成為一個(gè)完美的純函數(shù)得院。

純粹性就是信心傻铣。但在許多情況下我們不得不承認(rèn),我們感到的信心實(shí)際上都是相對(duì)于我們程序的上下文環(huán)境祥绞,以及我們對(duì)它知道多少非洲。在(JavaScript 的)實(shí)際應(yīng)用中,函數(shù)純粹性的問題不是關(guān)于是否絕對(duì)純粹蜕径,而是關(guān)于對(duì)它純粹性的信心的范圍两踏。

越純粹越好。你在使一個(gè)函數(shù)變得純粹上付出的努力越多兜喻,你就在閱讀使用它的代碼時(shí)越有信心梦染,而這將會(huì)使這部分代碼可讀性更好。

在或不在

至此,我們將函數(shù)純粹性定義為一個(gè)沒有側(cè)因/副作用的函數(shù)帕识,以及一個(gè)只要給出相同輸入就總是產(chǎn)生相同輸出的函數(shù)泛粹。這些只是看待一個(gè)相同性質(zhì)的兩種不同方式。

但是第三種看待函數(shù)純粹性的方式肮疗,而且也許是最廣為人接受的定義是晶姊,純函數(shù)擁有引用透明性。

引用透明性是指一個(gè)函數(shù)的調(diào)用可以用它的輸出值替換伪货,而整個(gè)程序的行為不會(huì)改變们衙。換句話說,是程序的執(zhí)行發(fā)起了對(duì)這個(gè)函數(shù)的調(diào)用碱呼,還是它的返回值被內(nèi)聯(lián)地寫在了函數(shù)被調(diào)用的地方 —— 是不可能知道的蒙挑。

從引用透明性的視角出發(fā),這兩個(gè)程序都因?yàn)樵诮ㄔ鞎r(shí)使用了純函數(shù)而具有相同的行為

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

這兩個(gè)代碼段的唯一區(qū)別是愚臀,在后者中我們跳過了 calculateAverage(nums) 調(diào)用而只是內(nèi)聯(lián)了它的輸出(9)忆蚀。因?yàn)槌绦蚱溆嗖糠值男袨橥耆粯樱?calculateAverage(..) 具有引用透明性懊悯,因此是一個(gè)純函數(shù)蜓谋。

思維上透明

一個(gè)引用透明的純函數(shù) 可以 被它的輸出替換的概念不意味著它 就應(yīng)當(dāng)被 替換掉。遠(yuǎn)遠(yuǎn)不是炭分。

我們?cè)诔绦蛑薪ㄔ旌瘮?shù)而不使用提前計(jì)算好的魔法常量桃焕,不只是為了對(duì)數(shù)據(jù)的改變作出反應(yīng),還是為了恰當(dāng)抽象的可讀性等等捧毛。與只是進(jìn)行明確賦值的那一行比起來观堂,計(jì)算那一組數(shù)值的平均值的調(diào)用使程序的那一部分更具可讀性。它給讀者講述了一個(gè)故事呀忧,avg 從何而來师痕,它是什么意思等等。

引用透明性的真正含義是而账,在你閱讀一個(gè)程序時(shí)胰坟,一旦你在思維上計(jì)算出了一個(gè)純函數(shù)調(diào)用的輸出是什么,你就不再需要在代碼中看到它時(shí)考慮這個(gè)函數(shù)調(diào)用究竟在做什么泞辐,特別是當(dāng)它出現(xiàn)許多次的時(shí)候笔横。

這個(gè)結(jié)果變成了某種思維上的 const 聲明,在閱讀的時(shí)候你可以透明地將它替換進(jìn)來咐吼,而不必再花思維上的精力計(jì)算它吹缔。

但愿純函數(shù)這種性質(zhì)的重要性講清楚了。我們?cè)谠囍刮覀兊某绦蚋子陂喿x锯茄。我們這么做的一種方式就是讓讀者少負(fù)擔(dān)一些工作 —— 通過提供一些輔助來跳過不必要的東西厢塘,使他們可以將精力集中在重要的東西上茶没。

讀者不應(yīng)該總是重新計(jì)算某些不會(huì)改變(以及不需要改變)的結(jié)果。如果你定義了一個(gè)引用透明的純函數(shù)晚碾,讀者就不必這么做抓半。

沒那么透明?

如果一個(gè)函數(shù)有副作用迄薄,但是這種副作用永遠(yuǎn)不會(huì)被觀察到琅关,或者程序的其他地方永遠(yuǎn)不會(huì)依賴于這種副作用呢?這個(gè)函數(shù)依然擁具有引用透明性嗎讥蔽?

這里有一個(gè):

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

你發(fā)現(xiàn)了嗎?

sum 是一個(gè) calculateAverage(..) 用來完成工作的外部自由變量画机。但是冶伞,每次我們用相同的列表調(diào)用 calculateAverage(..) 都會(huì)得到輸出 9。而且就程序行為上而言步氏,將 calculateAverage(nums) 調(diào)用替換為值 9 是沒有區(qū)別的响禽。程序中沒有其他任何部分在乎變量 sum,所以它是一個(gè)不可觀測(cè)的副作用荚醒。

一個(gè)不可觀測(cè)的側(cè)因/副作用像下面這棵樹一樣嗎芋类?

如果一棵樹在森林中倒下,但周圍沒有人聽到界阁,那么它發(fā)出倒下聲音了嗎侯繁?

根據(jù)引用透明性的最狹義的定義,我認(rèn)為你不得不承認(rèn) calculateAverage(..) 依然是一個(gè)純函數(shù)泡躯。但因?yàn)槲覀円恢痹囍刮覀兊膶W(xué)習(xí)不僅學(xué)術(shù)化贮竟,而且要與實(shí)用主義平衡,我想這個(gè)結(jié)論需要更多的觀察角度较剃。讓我們探索一下咕别。

性能上的影響

通常,你會(huì)發(fā)現(xiàn)這些不可觀測(cè)的副作用被用來優(yōu)化一個(gè)操作的性能写穴。舉例來說:

var cache = [];

function specialNumber(n) {
    // 如果我們已經(jīng)計(jì)算過這個(gè)特殊的數(shù)字惰拱,
    // 那么就跳過計(jì)算工作而直接從緩存中返回它
    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

這個(gè)呆萌的 specialNumber(..) 算法是確定性的,而且從對(duì)相同的輸入總是給出相同的輸出這個(gè)定義上講是純粹的啊送。它從引用透明性的角度上講也是純粹的 —— 使用 22 替換所有 specialNumber(42)偿短,程序的最終結(jié)果是相同的。

然而删掀,為了計(jì)算某些大一點(diǎn)兒的數(shù)字這個(gè)函數(shù)不得不做相當(dāng)多的工作翔冀,特別是 987654321 這個(gè)輸入。如果我們需要在程序中多次取得這個(gè)特別的數(shù)字披泪,緩存(cache)結(jié)果可以使后續(xù)的調(diào)用高效得多纤子。

注意: 一個(gè)值得深思的有趣的事情:即使對(duì)于最純粹的函數(shù)/程序來說,在執(zhí)行任何給定的操作時(shí) CPU 產(chǎn)生的熱量是一種不可避免的副作用嗎?那么 CPU 在一個(gè)純粹的操作上花費(fèi)時(shí)間控硼,而使另一個(gè)操作發(fā)生的延遲呢泽论?

別那么快就假定你可以運(yùn)行 specialNumber(987654321) 計(jì)算一次并手動(dòng)把結(jié)果貼在某個(gè)變量/常量上。程序通常是高度模塊化的卡乾,而且全局的可訪問作用域通常不是你想要在那些獨(dú)立的部分之間共享狀態(tài)的方式翼悴。讓 specialNumber(..) 實(shí)現(xiàn)它的自己的緩存(盡管它剛好是使用一個(gè)全局變量這么做的!)是這種狀態(tài)共享的更好的抽象幔妨。

重點(diǎn)是如果 specialNumber(..) 是程序中唯一可以訪問和更新 cache 側(cè)因/副作用的部分鹦赎,那么引用透明性看起來就是成立的,而且這可能看起來可以作為實(shí)現(xiàn)純函數(shù)典范的一種可接受的實(shí)用的“作弊”手段误堡。

但它應(yīng)該是嗎古话?

通常,這種性能優(yōu)化副作用是這樣完成的:隱藏結(jié)果的緩存使它們?cè)诔绦虻娜魏纹渌糠侄加^察不到锁施。這種處理被稱為默記(memoization)陪踩。我總是認(rèn)為這個(gè)詞是“記憶(memorization)”;我甚至不知道這個(gè)詞是打哪兒來的悉抵,但它確實(shí)幫我更好地理解了這個(gè)概念肩狂。

考慮如下代碼:

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

    return function specialNumber(n){
        // 如果我們已經(jīng)計(jì)算過這個(gè)特殊的數(shù)字,
        // 那么就跳過計(jì)算工作而直接從緩存中返回它
        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];
    };
})();

我們將側(cè)因/副作用 cache 包含在了 IIFE memoization() 內(nèi)部姥饰,于是現(xiàn)在我們可以確信程序中沒有其他部分 能夠 觀察到它了傻谁,而不只是它們 不去 觀察它。

這最后一句話可能聽起來在說一件不起眼兒的事媳否,但實(shí)際上我認(rèn)為它可能是 這整個(gè)章節(jié)中最重要的觀點(diǎn)栅螟。再把它讀一遍。

回到這個(gè)哲學(xué)沉思:

如果一棵樹在森林中倒下篱竭,但周圍沒有人聽到力图,那么它發(fā)出倒下聲音了嗎?

我在這個(gè)類比中得到的啟示是:無論有沒有發(fā)出聲音掺逼,最好是我們絕不制造樹倒下而我們不在場(chǎng)的場(chǎng)景吃媒;在一棵樹倒下時(shí)我們將總是聽到聲響。

減少側(cè)因/副作用的行為本質(zhì)上不是去制造一個(gè)人們無法觀察到的程序吕喘,而是為了設(shè)計(jì)一個(gè)側(cè)因/副作用盡可能少的程序赘那,因?yàn)檫@會(huì)使程序更易于推理。一段帶有側(cè)因/副作用的程序 碰巧 沒有被觀察到氯质,對(duì)于達(dá)成一個(gè) 不能 觀察到它們的程序的目標(biāo)來說根本沒有效果募舟。

如果側(cè)因/副作用可能發(fā)生,那么作者與讀者就必須在思維上演練它們闻察。如果使它們不可能發(fā)生拱礁,那么作者與讀者就將會(huì)對(duì)在任何地方什么會(huì)發(fā)生和什么不會(huì)發(fā)生有更多的信心琢锋。

純粹化

如果你有一個(gè)你無法將之重構(gòu)為純函數(shù)的非純函數(shù),你該怎么做呢灶?

你需要搞清楚這個(gè)函數(shù)有什么種類的側(cè)因/副作用吴超。側(cè)因/副作用可能來自于各種途徑,詞法自由變量鸯乃、通過引用的修改鲸阻、或者甚至是 this 綁定。我們將看一看解決這些場(chǎng)景的方式缨睡。

牽制副作用

如果我們關(guān)心的側(cè)因/副作用來自于詞法自由變量鸟悴,而且你可以修改周圍的代碼,那么你就可以使用作用域來封裝它們奖年。

回憶一下:

var users = {};

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

純粹化這段代碼的一個(gè)選項(xiàng)是在變量和非純函數(shù)周圍創(chuàng)建一個(gè)包裝函數(shù)遣臼。實(shí)質(zhì)上,這個(gè)包裝函數(shù)必須接收所有它能夠操作的“一切”狀態(tài)拾并。

function safer_fetchUserData(userId,users) {
    // 簡(jiǎn)單、幼稚的 ES6+ 對(duì)象淺拷貝鹏浅,
    // 也可以通過各種庫或框架做到
    users = Object.assign( {}, users );

    fetchUserData( userId );

    // 返回拷貝的狀態(tài)
    return users;


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

    // 沒有被碰過的原版非純函數(shù):
    function fetchUserData(userId) {
        ajax( "http://some.api/user/" + userId, function onUserData(userData){
            users[userId] = userData;
        } );
    }
}

userIdusers 都是原始 fetchUserData 的輸入嗅义,而且 users 還是輸出。safer_fetchUserData(..) 接收這兩個(gè)輸入隐砸,并返回 users之碗。為了確保我們沒有在后面修改 users 時(shí)制造副作用,我們制造了一個(gè) users 的本地拷貝季希。

這種技術(shù)幾乎只有有限的用處褪那,因?yàn)槿绻悴荒馨押瘮?shù)本身修改為純粹的,那么你也不太可能修改它周圍的代碼式塌。然而博敬,在可能的情況下探索它一下還是有幫助的,因?yàn)樗俏覀兊男薷闹凶詈?jiǎn)單的一種峰尝。

不論這對(duì)于重構(gòu)為純函數(shù)來說是不是一種實(shí)際可行的技術(shù)偏窝,重點(diǎn)是函數(shù)的純粹性只需要如皮毛一般膚淺。也就是武学,一個(gè)函數(shù)的純粹性是從外部判斷的祭往,而不管內(nèi)部發(fā)生了什么。只要一個(gè)函數(shù)的使用表現(xiàn)為純粹的火窒,那么它就是純粹的硼补。在一個(gè)純函數(shù)內(nèi)部,可以由于各種原因 —— 適度地熏矿!—— 使用非純粹的技術(shù)已骇,包括最常見的為了性能而這樣做离钝。它不一定是像人們說的那樣,是“海龜背地球”疾捍。

但是要非常小心奈辰。程序中任何不純粹的部分,即便它被一個(gè)純函數(shù)包裝而且僅為純函數(shù)所用乱豆,也是潛在的 bug 以及代碼讀者困惑的源頭奖恰。我們的總體目標(biāo)是盡可能減少副作用,而不是僅將它們藏起來宛裕。

掩蓋副作用

很多時(shí)候你都不能通過修改代碼來將詞法自由變量封裝在一個(gè)包裝函數(shù)的作用域中瑟啃。例如,非純函數(shù)存在于一個(gè)不可控的第三方庫的文件中揩尸,包含這樣一些東西:

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 );
    }
}

在我們程序的其他部分使用這個(gè)工具時(shí) 隔離 側(cè)因/副作用的粗暴策略是蛹屿,創(chuàng)建一個(gè)執(zhí)行下列步驟的接口函數(shù):

  1. 捕獲將要被影響的當(dāng)前狀態(tài)
  2. 設(shè)置初始輸入狀態(tài)
  3. 運(yùn)行這個(gè)非純函數(shù)
  4. 捕獲副作用狀態(tài)
  5. 恢復(fù)原始狀態(tài)
  6. 返回捕獲的副作用狀態(tài)
function safer_generateMoreRandoms(count,initial) {
    // (1) 保存原始狀態(tài)
    var orig = {
        nums,
        smallCount,
        largeCount
    };

    // (2) 建立副作用之前的初始狀態(tài)
    nums = initial.nums.slice();
    smallCount = initial.smallCount;
    largeCount = initial.largeCount;

    // (3) 小心不純粹性!
    generateMoreRandoms( count );

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

    // (5) 重置原始狀態(tài)
    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

為了避免幾個(gè)側(cè)因/副作用要做許多手動(dòng)工作岩榆;要是它們一開始就不存在就容易多了错负。但如果我們別無選擇,那么為了在我們程序中避免意外這種額外的努力還是值得的勇边。

注意: 這種技術(shù)其實(shí)只會(huì)在你對(duì)付同步代碼時(shí)有效犹撒。異步代碼不能用這種方式可靠地管理,因?yàn)樗荒芊乐钩绦蚱渌糠峙R時(shí)地訪問/修改狀態(tài)變量粒褒。

回避副作用

當(dāng)我們要對(duì)付的副作用的性質(zhì)是通過引用修改了一個(gè)直接輸入值(對(duì)象识颊,數(shù)組等等),我們同樣可以創(chuàng)建一個(gè)接口函數(shù)來與之互動(dòng)奕坟,從而取代原始的非純函數(shù)祥款。

考慮如下代碼:

function handleInactiveUsers(userList,dateCutoff) {
    for (let i = 0; i < userList.length; i++) {
        if (userList[i].lastLogin == null) {
            // 從列表中移除用戶
            userList.splice( i, 1 );
            i--;
        }
        else if (userList[i].lastLogin < dateCutoff) {
            userList[i].inactive = true;
        }
    }
}

userList 數(shù)組本身,外加它里面的對(duì)象月杉,都被修改了刃跛。防護(hù)這種副作用的一個(gè)策略是,首先進(jìn)行一次深拷貝(好吧沙合,只是不是淺拷貝):

function safer_handleInactiveUsers(userList,dateCutoff) {
    // 為列表以及它的用戶對(duì)象制造一個(gè)拷貝
    let copiedUserList = userList.map( function mapper(user){
        // 拷貝一個(gè) `user` 對(duì)象
        return Object.assign( {}, user );
    } );

    // 使用拷貝調(diào)用原版函數(shù)
    handleInactiveUsers( copiedUserList, dateCutoff );

    // 將被改變的列表作為直接的輸出
    return copiedUserList;
}

這種技術(shù)的成功取決于你對(duì)值的 拷貝 進(jìn)行得多徹底奠伪。這里 userList.slice() 不好用,因?yàn)樗粍?chuàng)建了 userList 數(shù)組本身的一個(gè)淺拷貝首懈。數(shù)組中的每一個(gè)元素都是一個(gè)需要被拷貝的對(duì)象绊率,所以我們得額外地花些心思。當(dāng)然究履,如果這些對(duì)象內(nèi)部還有對(duì)象(有可能B朔瘛),那么拷貝就需要更加健壯最仑。

重溫 this

另外一種由引用引起的側(cè)因/副作用是在 this 敏感的函數(shù)中使用 this 作為一種隱含的輸入藐俺。關(guān)于為什么 this 關(guān)鍵字對(duì) FP 程序員來說是個(gè)問題炊甲,詳見第二章的“This 是什么”。

考慮如下代碼:

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

我們的策略與前一節(jié)中的討論類似:創(chuàng)建一個(gè)接口函數(shù)欲芹,強(qiáng)制 generate() 函數(shù)使用一個(gè)可預(yù)測(cè)的 this 上下文環(huán)境:

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

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

safer_generate( { prefix: "foo" } );
// "foo0.8988802158307285"

這些策略都不是天衣無縫的卿啡;對(duì)側(cè)因/副作用的最安全的防護(hù)是不要產(chǎn)生它們。但如果你在試著改進(jìn)你程序的可讀性和信用等級(jí)菱父,那么盡量減少側(cè)因/副作用是向前邁進(jìn)的一大步颈娜。

實(shí)質(zhì)上,我們沒有真正地消滅側(cè)因/副作用浙宜,而是包容并限制它們官辽,以使我們的代碼更禁得住檢驗(yàn)和可靠。如果稍后我們的程序出現(xiàn) bug粟瞬,那么我們就知道代碼中依然使用側(cè)因/副作用的部分最有可能是罪魁禍?zhǔn)住?/p>

總結(jié)

副作用對(duì)代碼的可讀性與質(zhì)量是有害的同仆,因?yàn)樗鼈兪鼓愕拇a更難于理解。副作用還是程序中最常見的 bug 起因 之一裙品,因?yàn)榘崤鼈兒芾щy俗批。冪等性是一種通過創(chuàng)建實(shí)質(zhì)上一次性的操作來限制副作用的策略。

純函數(shù)是我們最好的避免副作用的方式市怎。一個(gè)純函數(shù)總是對(duì)相同的輸入返回相同的輸出扶镀,而且沒有側(cè)因或副作用。引用透明性進(jìn)一步說明焰轻,一個(gè)純函數(shù)調(diào)用可以用它的輸出替換 —— 更多地是思維上的行使而非真這么做 —— 而程序的行為不會(huì)有變化。

將一個(gè)非純函數(shù)重構(gòu)為純函數(shù)是不錯(cuò)的選擇昆雀。但如果那不可能辱志,就可以封裝側(cè)因/副作用,或者創(chuàng)建一個(gè)純粹的接口來防護(hù)它們狞膘。

沒有程序是完全沒有副作用的揩懒。但在實(shí)際中要在盡可能多的地方首選純函數(shù)。盡可能多地將非純函數(shù)的副作用集中在一起挽封,這樣如果 bug 發(fā)生已球,定位并檢查嫌疑最大的禍?zhǔn)讜r(shí)會(huì)容易一些。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末辅愿,一起剝皮案震驚了整個(gè)濱河市智亮,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌点待,老刑警劉巖阔蛉,帶你破解...
    沈念sama閱讀 206,126評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異癞埠,居然都是意外死亡状原,警方通過查閱死者的電腦和手機(jī)聋呢,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來颠区,“玉大人削锰,你說我怎么就攤上這事”侠常” “怎么了器贩?”我有些...
    開封第一講書人閱讀 152,445評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)央串。 經(jīng)常有香客問我磨澡,道長(zhǎng),這世上最難降的妖魔是什么质和? 我笑而不...
    開封第一講書人閱讀 55,185評(píng)論 1 278
  • 正文 為了忘掉前任稳摄,我火速辦了婚禮,結(jié)果婚禮上饲宿,老公的妹妹穿的比我還像新娘厦酬。我一直安慰自己,他們只是感情好瘫想,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評(píng)論 5 371
  • 文/花漫 我一把揭開白布仗阅。 她就那樣靜靜地躺著,像睡著了一般国夜。 火紅的嫁衣襯著肌膚如雪减噪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 48,970評(píng)論 1 284
  • 那天车吹,我揣著相機(jī)與錄音筹裕,去河邊找鬼。 笑死窄驹,一個(gè)胖子當(dāng)著我的面吹牛朝卒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播乐埠,決...
    沈念sama閱讀 38,276評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼抗斤,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了丈咐?” 一聲冷哼從身側(cè)響起瑞眼,我...
    開封第一講書人閱讀 36,927評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎棵逊,沒想到半個(gè)月后负拟,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,400評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡歹河,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評(píng)論 2 323
  • 正文 我和宋清朗相戀三年掩浙,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了花吟。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 37,997評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡厨姚,死狀恐怖衅澈,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情谬墙,我是刑警寧澤今布,帶...
    沈念sama閱讀 33,646評(píng)論 4 322
  • 正文 年R本政府宣布,位于F島的核電站拭抬,受9級(jí)特大地震影響部默,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜造虎,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評(píng)論 3 307
  • 文/蒙蒙 一傅蹂、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧算凿,春花似錦份蝴、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至署鸡,卻和暖如春案糙,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背靴庆。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評(píng)論 1 260
  • 我被黑心中介騙來泰國(guó)打工侍筛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人撒穷。 一個(gè)月前我還...
    沈念sama閱讀 45,423評(píng)論 2 352
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像裆熙,于是被迫代替她去往敵國(guó)和親端礼。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容