- 原文地址:
- 原文作者:
第 6 章:值的不可變性
在第 5 章中,我們探討了減少副作用的重要性:副作用是引起程序意外狀態(tài)改變的原因,同時也可能會帶來意想不到的驚喜(bugs)。這樣的暗雷在程序中出現(xiàn)的越少,開發(fā)者對程序的信心無疑就會越強,同時代碼的可讀性也會越高。本章的主題,將繼續(xù)朝減少程序副作用的方向努力。
如果編程風格冪等性是指定義一個數(shù)據(jù)變更操作以便只影響一次程序狀態(tài),那么現(xiàn)在我們將注意力轉(zhuǎn)向?qū)⑦@個影響次數(shù)從 1 降為 0。
現(xiàn)在我們開始探索值的不可變性,即只在我們的程序中使用不可被改變的數(shù)據(jù)。
原始值的不可變性
原始數(shù)據(jù)類型(number
、string
、boolean
、null
和 undefined
)本身就是不可變的;無論如何你都沒辦法改變它們。
// 無效,且毫無意義
2 = 2.5;復制代碼
然而 JS 確實有一個特性,使得看起來允許我們改變原始數(shù)據(jù)類型的值, 即“boxing”特性。當你訪問原始類型數(shù)據(jù)時 —— 特別是 number
、string
和 boolean
—— 在這種情況下,JS 會自動的把它們包裹(或者說“包裝”)成這個值對應(yīng)的對象(分別是 Number
、String
以及 Boolean
)。
思考下面的代碼:
var x = 2;
x.length = 4;
x; // 2
x.length; // undefined復制代碼
數(shù)值本身并沒有可用的 length
屬性,因此 x.length = 4
這個賦值操作正試圖添加一個新的屬性,不過它靜默地失敗了(也可以說是這個操作被忽略了或被拋棄了,這取決于你怎么看);變量 x
繼續(xù)承載那個簡單的原始類型數(shù)據(jù) —— 數(shù)值 2
。
但是 JS 允許 x.length = 4
這條語句正常執(zhí)行的事實著實令人困惑。如果這種現(xiàn)象真的無緣無故出現(xiàn),那么代碼的閱讀者無疑會摸不著頭腦。好消息是,如果你使用了嚴格模式("use strict";
),那么這條語句就會拋出異常了。
那么如果嘗試改變那些明確被包裝成對象的值呢?
var x = new Number( 2 );
// 沒問題
x.length = 4;復制代碼
這段代碼中的 x
保存了一個對象的引用,因此可以正常地添加或修改自定義屬性。
像 number
這樣的原始數(shù)型,值的不可變性看起來相當明顯,但字符串呢?JS 開發(fā)者有個共同的誤解 —— 字符串和數(shù)組很像,所以應(yīng)該是可變的。JS 使用 []
訪問字符串成員的語法甚至還暗示字符串真的就像數(shù)組。不過,字符串的確是不可變的:
var s = "hello";
s[1]; // "e"
s[1] = "E";
s.length = 10;
s; // "hello"復制代碼
盡管可以使用 s[1]
來像訪問數(shù)組元素一樣訪問字符串成員,JS 字符串也并不是真的數(shù)組。s[1] = "E"
和 s.length = 10
這兩個賦值操作都是失敗的,就像剛剛的 x.length = 4
一樣。在嚴格模式下,這些賦值都會拋出異常,因為 1
和 length
這兩個屬性在原始數(shù)據(jù)類型字符串中都是只讀的。
有趣的是,即便是包裝后的 String
對象,其值也會(在大部分情況下)表現(xiàn)的和非包裝字符串一樣 —— 在嚴格模式下如果改變已存在的屬性,就會拋出異常:
"use strict";
var s = new String( "hello" );
s[1] = "E"; // error
s.length = 10; // error
s[42] = "?"; // OK
s; // "hello"復制代碼
從值到值
我們將在本節(jié)詳細展開從值到值這個概念。但在開始之前應(yīng)該心中有數(shù):值的不可變性并不是說我們不能在程序編寫時不改變某個值。如果一個程序的內(nèi)部狀態(tài)從始至終都保持不變,那么這個程序肯定相當無趣!它同樣不是指變量不能承載不同的值。這些都是對值的不可變這個概念的誤解。
值的不可變性是指當需要改變程序中的狀態(tài)時,我們不能改變已存在的數(shù)據(jù),而是必須創(chuàng)建和跟蹤一個新的數(shù)據(jù)。
例如:
function addValue(arr) {
var newArr = [ ...arr, 4 ];
return newArr;
}
addValue( [1,2,3] ); // [1,2,3,4]復制代碼
注意我們沒有改變數(shù)組 arr
的引用,而是創(chuàng)建了一個新的數(shù)組(newArr
),這個新數(shù)組包含數(shù)組 arr
中已存在的值,并且新增了一個新值 4
。
使用我們在第 5 章討論的副作用的相關(guān)概念來分析 addValue(..)
。它是純的嗎?它是否具有引用透明性?給定相同的數(shù)組作為輸入,它會永遠返回相同的輸出嗎?它無副作用嗎?答案是肯定的。
設(shè)想這個數(shù)組 [1, 2, 3]
, 它是由先前的操作產(chǎn)生,并被我們保存在一個變量中,它代表著程序當前的狀態(tài)。我們想要計算出程序的下一個狀態(tài),因此調(diào)用了 addValue(..)
。但是我們希望下一個狀態(tài)計算的行為是直接的和明確的,所以 addValue(..)
操作簡單的接收一個直接輸入,返回一個直接輸出,并通過不改變 arr
引用的原始數(shù)組來避免副作用。
這就意味著我們既可以計算出新狀態(tài) [1, 2, 3, 4]
,也可以掌控程序的狀態(tài)變換。程序不會出現(xiàn)過早的過渡到這個狀態(tài)或完全轉(zhuǎn)變到另一個狀態(tài)(如 [1, 2, 3, 5]
)這樣的意外情況。通過規(guī)范我們的值并把它視為不可變的,我們大幅減少了程序錯誤,使我們的程序更易于閱讀和推導,最終使程序更加可信賴。
arr
所引用的數(shù)組是可變的,只是我們選擇不去改變他,我們實踐了值不可變的這一精神。
同樣的,可以將“以拷貝代替改變”這樣的策略應(yīng)用于對象,思考下面的代碼:
function updateLastLogin(user) {
var newUserRecord = Object.assign( {}, user );
newUserRecord.lastLogin = Date.now();
return newUserRecord;
}
var user = {
// ..
};
user = updateLastLogin( user );復制代碼
消除本地影響
下面的代碼能夠體現(xiàn)不可變性的重要性:
var arr = [1,2,3];
foo( arr );
console.log( arr[0] );復制代碼
從表面上講,你可能認為 arr[0]
的值仍然為 1
。但事實是否如此不得而知,因為 foo(..)
可能會改變你傳入其中的 arr
所引用的數(shù)組。
在之前的章節(jié)中,我們已經(jīng)見到過用下面這種帶有欺騙性質(zhì)的方法來避免意外:
var arr = [1,2,3];
foo( arr.slice() ); // 哈!一個數(shù)組副本!
console.log( arr[0] ); // 1復制代碼
當然,使得這個斷言成立的前提是 foo
函數(shù)不會忽略我們傳入的參數(shù)而直接通過相同的 arr
這個自由變量詞法引用來訪問源數(shù)組。
對于防止數(shù)據(jù)變化負面影響,稍后我們會討論另一種策略。
重新賦值
在進入下一個段落之前先思考一個問題 —— 你如何描述“常量”?
…
你可能會脫口而出“一個不能改變的值就是常量”,“一個不能被改變的變量”等等。這些回答都只能說接近正確答案,但卻并不是正確答案。對于常量,我們可以給出一個簡潔的定義:一個無法進行重新賦值(reassignment)的變量。
我們剛剛在“常量”概念上的吹毛求疵其實是很有必要的,因為它澄清了常量與值無關(guān)的事實。無論常量承載何值,該變量都不能使用其他的值被進行重新賦值。但它與值的本質(zhì)無關(guān)。
思考下面的代碼:
var x = 2;復制代碼
我們剛剛討論過,數(shù)據(jù) 2
是一個不可變的原始值。如果將上面的代碼改為:
const x = 2;復制代碼
const
關(guān)鍵字的出現(xiàn),作為“常量聲明”被大家熟知,事實上根本沒有改變 2
的本質(zhì),因為它本身就已經(jīng)不可改變了。
下面這行代碼會拋出錯誤,這無可厚非:
// 嘗試改變 x,祝我好運!
x = 3; // 拋出錯誤!復制代碼
但再次重申,我們并不是要改變這個數(shù)據(jù),而是要對變量 x
進行重新賦值。數(shù)據(jù)被卷進來純屬偶然。
為了證明 const
和值的本質(zhì)無關(guān),思考下面的代碼:
const x = [ 2 ];復制代碼
這個數(shù)組是一個常量嗎?并不是。 x
是一個常量,因為它無法被重新賦值。但下面的操作是完全可行的:
x[0] = 3;復制代碼
為何?因為盡管 x
是一個常量,數(shù)組卻是可變的。
關(guān)于 const
關(guān)鍵字和“常量”只涉及賦值而不涉及數(shù)據(jù)語義的特性是個又臭又長的故事。幾乎所有語言的高級開發(fā)者都踩 const
地雷。事實上,Java 最終不贊成使用 const
并引入了一個全新的關(guān)鍵詞 final
來區(qū)分“常量”這個語義。
拋開混亂之后開始思考,如果 const
并不能創(chuàng)建一個不可變的值,那么它對于函數(shù)式編程者來說又還有什么重要的呢?
意圖
const
關(guān)鍵字可以用來告知閱讀你代碼的讀者該變量不會被重新賦值。作為一個表達意圖的標識,const
被加入 JavaScript 不僅常常受到稱贊,也普遍提高了代碼可讀性。
在我看來,這是夸大其詞,這些說法并沒有太大的實際意義。我只看到了使用這種方法來表明意圖的微薄好處。如果使用這種方法來聲明值的不可變性,與已使用幾十年的傳統(tǒng)方式相比,const
簡直太弱了。
為了證明我的說法,讓我們來做一個實踐。const
創(chuàng)建了一個在塊級作用域內(nèi)的變量,這意味著該變量只能在其所在的代碼塊中被訪問:
// 大量代碼
{
const x = 2;
// 少數(shù)幾行代碼
}
// 大量代碼復制代碼
通常來說,代碼塊的最佳實踐是用于僅包裹少數(shù)幾行代碼的場景。如果你有一個包含了超過 10 行的代碼塊,那么大多數(shù)開發(fā)者會建議你重構(gòu)這一段代碼。因此 const x = 2
只作用于下面的9行代碼。
程序的其他部分不會影響 x
的賦值。
我要說的是:上述程序的可讀性與下面這樣基本相同:
// 大量代碼
{
let x = 2;
// 少數(shù)幾行代碼
}
// 大量代碼復制代碼
其實只要查看一下在 let x = 2
; 之后的幾行代碼,就可以判斷出 x 這個變量是否被重新賦值過了。對我來說,“實際上不進行重新賦值”相對“使用容易迷惑人的 const
關(guān)鍵字告訴讀者‘不要重新賦值’”是一個更明確的信號。
此外,讓我們思考一下,乍看這段代碼起來可能給讀者傳達什么:
const magicNums = [1,2,3,4];
// ..復制代碼
讀者可能會(錯誤地)認為,這里使用 const
的用意是你永遠不會修改這個數(shù)組 —— 這樣的推斷對我來說合情合理。想象一下,如果你的確允許 magicNums
這個變量所引用的數(shù)組被修改,那么這個 const
關(guān)鍵詞就極具混淆性了 —— 的很確容易發(fā)生意外,不是嗎?
更糟糕的是,如果你在某處故意修改了 magicNums
,但對讀者而言不夠明顯呢?讀者會在后面的代碼里(再次錯誤地)認為 magicNums
的值仍然是 [1, 2, 3, 4]
。因為他們猜測你之前使用 const
的目的就是“這個變量不會改變”。
我認為你應(yīng)該使用 var
或 let
來聲明那些你會去改變的變量,它們確實相比 const
來說是一個更明確的信號。
const
所帶來的問題還沒講完。還記得我們在本章開頭所說的嗎?值的不可變性是指當需要改變某個數(shù)據(jù)時,我們不應(yīng)該直接改變它,而是應(yīng)該使用一個全新的數(shù)據(jù)。那么當新數(shù)組創(chuàng)建出來后,你會怎么處理它?如果你使用 const
聲明變量來保存引用嗎,這個變量的確沒法被重新賦值了,那么……然后呢?
從這方面來講,我認為 const
反而增加了函數(shù)式編程的困難度。我的結(jié)論是:const
并不是那么有用。它不僅造成了不必要的混亂,也以一種很不方便的形式了我們。我只用 const
來聲明簡單的常量,例如:
const PI = 3.141592;復制代碼
3.141592
這個值本身就已經(jīng)是不可變的,并且我也清楚地表示說“PI
標識符將始終被用于代表這個字面量的占位符”。對我來說,這才是 const
所擅長的。坦白講,我在編碼時并不會使用很多這樣的聲明。
我寫過很多,也閱讀過很多 JavaScript 代碼,我認為由于重新賦值導致大量的 bug 這只是個想象中的問題,實際并不存在。
我們應(yīng)該擔心的,并不是變量是否被重新賦值,而是值是否會發(fā)生改變。為什么?因為值是可被攜帶的,但詞法賦值并不是。你可以向函數(shù)中傳入一個數(shù)組,這個數(shù)組可能會在你沒意識到的情況下被改變。但是你的其他代碼在預期之外重新給變量賦值,這是不可能發(fā)生的。
凍結(jié)
這是一種簡單廉價的(勉強)將像對象、數(shù)組、函數(shù)這樣的可變的數(shù)據(jù)轉(zhuǎn)為“不可變數(shù)據(jù)”的方式:
var x = Object.freeze( [2] );復制代碼
Object.freeze(..)
方法遍歷對象或數(shù)組的每個屬性和索引,將它們設(shè)置為只讀以使之不會被重新賦值,事實上這和使用 const
聲明屬性相差無幾。Object.freeze(..)
也會將屬性標記為“不可配置(non-reconfigurable)”,并且使對象或數(shù)組本身不可擴展(即不會被添加新屬性)。實際上,而就可以將對象的頂層設(shè)為不可變。
注意,僅僅是頂層不可變!
var x = Object.freeze( [ 2, 3, [4, 5] ] );
// 不允許改變:
x[0] = 42;
// oops,仍然允許改變:
x[2][0] = 42;復制代碼
Object.freeze(..)
提供淺層的、初級的不可變性約束。如果你希望更深層的不可變約束,那么你就得手動遍歷整個對象或數(shù)組結(jié)構(gòu)來為所有后代成員應(yīng)用 Object.freeze(..)
。
與 const
相反,Object.freeze(..)
并不會誤導你,讓你得到一個“你以為”不可變的值,而是真真確確給了你一個不可變的值。
回顧剛剛的例子:
var arr = Object.freeze( [1,2,3] );
foo( arr );
console.log( arr[0] ); // 1復制代碼
可以非常確定 arr[0]
就是 1
。
這是非常重要的,因為這可以使我們更容易的理解代碼,當我們將值傳遞到我們看不到或者不能控制的地方,我們依然能夠相信這個值不會改變。
性能
每當我們開始創(chuàng)建一個新值(數(shù)組、對象等)取代修改已經(jīng)存在的值時,很明顯迎面而來的問題就是:這對性能有什么影響?
如果每次想要往數(shù)組中添加內(nèi)容時,我們都必須創(chuàng)建一個全新的數(shù)組,這不僅占用 CPU 時間并且消耗額外的內(nèi)存。不再存在任何引用的舊數(shù)據(jù)將會被垃圾回收機制回收;更多的 CPU 資源消耗。
這樣的取舍能接受嗎?視情況而定。對代碼性能的優(yōu)化和討論都應(yīng)該有個上下文。
如果在你的程序中,只會發(fā)生一次或幾次單一的狀態(tài)變化,那么扔掉一個舊對象或舊數(shù)組完全沒必要擔心。性能損失會非常非常小 —— 頂多只有幾微秒 —— 對你的應(yīng)用程序影響甚小。追蹤和修復由于數(shù)據(jù)改變引起的 bug 可能會花費你幾分鐘甚至幾小時的時間,這么看來那幾微秒簡直沒有可比性。
但是,如果頻繁的進行這樣的操作,或者這樣的操作出現(xiàn)在應(yīng)用程序的核心邏輯中,那么性能問題 —— 即性能和內(nèi)存 —— 就有必要仔細考慮一下了。
以數(shù)組這樣一個特定的數(shù)據(jù)結(jié)構(gòu)來說,我們想要在每次操作這個數(shù)組時使每個更改都隱式地進行,就像結(jié)果是一個新數(shù)組一樣,但除了每次都真的創(chuàng)建一個數(shù)組之外,還有什么其他辦法來完成這個任務(wù)呢?像數(shù)組這樣的數(shù)據(jù)結(jié)構(gòu),我們期望除了能夠保存其最原始的數(shù)據(jù),然后能追蹤其每次改變并根據(jù)之前的版本創(chuàng)建一個分支。
在內(nèi)部,它可能就像一個對象引用的鏈表樹,樹中的每個節(jié)點都表示原始值的改變。從概念上來說,這和 git 的版本控制原理類似。
想象一下使用這個假設(shè)的、專門處理數(shù)組的數(shù)據(jù)結(jié)構(gòu):
var state = specialArray( 1, 2, 3, 4 );
var newState = state.set( 42, "meaning of life" );
state === newState; // false
state.get( 2 ); // 3
state.get( 42 ); // undefined
newState.get( 2 ); // 3
newState.get( 42 ); // "meaning of life"
newState.slice( 1, 3 ); // [2,3]復制代碼
specialArray(..)
這個數(shù)據(jù)結(jié)構(gòu)會在內(nèi)部追蹤每個數(shù)據(jù)更新操作(例如 set(..)
),類似 diff,因此不必要為原始的那些值(1
、2
、3
和 4
)重新分配內(nèi)存,而是簡單的將 "meaning of life"
這個值加入列表。重要的是,state
和 newState
分別指向兩個“不同版本”的數(shù)組,因此值的不變性這個語義得以保留。
發(fā)明你自己的性能優(yōu)化數(shù)據(jù)結(jié)構(gòu)是個有趣的挑戰(zhàn)。但從實用性來講,找一個現(xiàn)成的庫會是個更好的選擇。Immutable.js( 是一個很棒的選擇,它提供多種數(shù)據(jù)結(jié)構(gòu),包括 List
(類似數(shù)組)和 Map
(類似普通對象)。
思考下面的 specialArray
示例,這次使用 Immutable.List
:
var state = Immutable.List.of( 1, 2, 3, 4 );
var newState = state.set( 42, "meaning of life" );
state === newState; // false
state.get( 2 ); // 3
state.get( 42 ); // undefined
newState.get( 2 ); // 3
newState.get( 42 ); // "meaning of life"
newState.toArray().slice( 1, 3 ); // [2,3]復制代碼
像 Immutable.js 這樣強大的庫一般會采用非常成熟的性能優(yōu)化。如果不使用庫而是手動去處理那些細枝末節(jié),開發(fā)的難度會相當大。
當改變值這樣的場景出現(xiàn)的較少且不用太關(guān)心性能時,我推薦使用更輕量級的解決方案,例如我們之前提到過的內(nèi)置的 Object.freeze(..)
。
以不可變的眼光看待數(shù)據(jù)
如果我們從函數(shù)中接收了一個數(shù)據(jù),但不確定這個數(shù)據(jù)是可變的還是不可變的,此時該怎么辦?去修改它試試看嗎?不要這樣做。 就像在本章最開始的時候所討論的,不論實際上接收到的值是否可變,我們都應(yīng)以它們是不可變的來對待,以此來避免副作用并使函數(shù)保持純度。
回顧一下之前的例子:
function updateLastLogin(user) {
var newUserRecord = Object.assign( {}, user );
newUserRecord.lastLogin = Date.now();
return newUserRecord;
}復制代碼
該實現(xiàn)將 user
看做一個不應(yīng)該被改變的數(shù)據(jù)來對待;user
是否真的不可變完全不會影響這段代碼的閱讀。對比一下下面的實現(xiàn):
function updateLastLogin(user) {
user.lastLogin = Date.now();
return user;
}復制代碼
這個版本更容易實現(xiàn),性能也會更好一些。但這不僅讓 updateLastLogin(..)
變得不純,這種方式改變的值使閱讀該代碼,以及使用它的地方變得更加復雜。
應(yīng)當總是將 user 看做不可變的值,這樣我們就沒必要知道數(shù)據(jù)從哪里來,也沒必要擔心數(shù)據(jù)改變會引發(fā)潛在問題。
JavaScript 中內(nèi)置的數(shù)組方法就是一些很好的例子,例如 concat(..)
和 slice(..)
等:
var arr = [1,2,3,4,5];
var arr2 = arr.concat( 6 );
arr; // [1,2,3,4,5]
arr2; // [1,2,3,4,5,6]
var arr3 = arr2.slice( 1 );
arr2; // [1,2,3,4,5,6]
arr3; // [2,3,4,5,6]復制代碼
其他一些將參數(shù)看做不可變數(shù)據(jù)且返回新數(shù)組的原型方法還有:map(..)
和 filter(..)
等。reduce(..)
/ reduceRight(..)
方法也會盡量避免改變參數(shù),盡管它們并不默認返回新數(shù)組。
不幸的是,由于歷史問題,也有一部分不純的數(shù)組原型方法:splice(..)
、pop(..)
、push(..)
、shift(..)
、unshift(..)
、reverse(..)
以及 fill(..)
。
有些人建議禁止使用這些不純的方法,但我不這么認為。因為一些性能面的原因,某些場景下你仍然可能會用到它們。不過你也應(yīng)當注意,如果一個數(shù)組沒有被本地化在當前函數(shù)的作用域內(nèi),那么不應(yīng)當使用這些方法,避免它們所產(chǎn)生的副作用影響到代碼的其他部分。
不論一個數(shù)據(jù)是否是可變的,永遠將他們看做不可變。遵守這樣的約定,你程序的可讀性和可信賴度將會大大提升。
總結(jié)
值的不可變性并不是不改變值。它是指在程序狀態(tài)改變時,不直接修改當前數(shù)據(jù),而是創(chuàng)建并追蹤一個新數(shù)據(jù)。這使得我們在讀代碼時更有信心,因為我們了狀態(tài)改變的場景,狀態(tài)不會在意料之外或不易觀察的地方發(fā)生改變。
由于其自身的信號和意圖,const
關(guān)鍵字聲明的常量通常被誤認為是強制規(guī)定數(shù)據(jù)不可被改變。事實上,const
和值的不可變性聲明無關(guān),而且使用它所帶來的困惑似乎比它解決的問題還要大。另一種思路,內(nèi)置的 Object.freeze(..)
方法提供了頂層值的不可變性設(shè)定。大多數(shù)情況下,使用它就足夠了。
對于程序中性能敏感的部分,或者變化頻繁發(fā)生的地方,處于對計算和存儲空間的考量,每次都創(chuàng)建新的數(shù)據(jù)或?qū)ο螅ㄌ貏e是在數(shù)組或?qū)ο蟀芏鄶?shù)據(jù)時)是非常不可取的。遇到這種情況,通過類似 Immutable.js 的庫使用不可變數(shù)據(jù)結(jié)構(gòu)或許是個很棒的主意。
值不變在代碼可讀性上的意義,不在于不改變數(shù)據(jù),而在于以不可變的眼光看待數(shù)據(jù)這樣的約束。
【上一章】
【下一章】
iKcamp原創(chuàng)新書《移動Web前端高效開發(fā)實戰(zhàn)》已在亞馬遜、京東、當當開售。