感謝社區(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è)代碼段呢?PI
是 foo(..)
的一個(gè)側(cè)因嗎?
兩個(gè)觀點(diǎn)將幫助我們以一種合理的方式回答這個(gè)問題:
考慮你可能發(fā)起的每一個(gè)
foo(3)
調(diào)用臼闻。它們將總是返回值9.424..
嗎鸿吆?是的。 每一次述呐。如果你給它相同的輸入(x
)惩淳,它就總是返回相同的輸出。你能使用
PI
的立即值替換每一個(gè)用到PI
的地方乓搬,而且程序還能 完全 和以前一樣運(yùn)行嗎思犁?是的。 這個(gè)程序沒有其他部分可以改變PI
的值 —— 確實(shí)进肯,因?yàn)樗且粋€(gè)const
激蹲,不能被重新賦值 —— 所以這里的變量PI
只是為了可讀性/可維護(hù)性而存在的。它的值可以被內(nèi)聯(lián)而不改變程序的任何行為江掩。
我的結(jié)論:這里的 PI
沒有違反最小化/避免副作用(或側(cè)因)的精神学辱。前一個(gè)代碼段中的 bar(x)
也沒有。
在這兩種情況下频敛,PI
和 bar
都不是程序狀態(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;
}
所有的輸入(x
與 y
)和輸出(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í)被 list
和 nums
引用著)絕不會(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;
} );
}
}
userId
與 users
都是原始 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ù):
- 捕獲將要被影響的當(dāng)前狀態(tài)
- 設(shè)置初始輸入狀態(tài)
- 運(yùn)行這個(gè)非純函數(shù)
- 捕獲副作用狀態(tài)
- 恢復(fù)原始狀態(tài)
- 返回捕獲的副作用狀態(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ì)容易一些。