翻譯連載 | JavaScript輕量級函數語言程式設計-第6章:值的不可變性 |《你不知道的JS》姊妹篇

iKcamp發表於2017-09-06

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

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

第 6 章:值的不可變性

在第 5 章中,我們探討了減少副作用的重要性:副作用是引起程式意外狀態改變的原因,同時也可能會帶來意想不到的驚喜(bugs)。這樣的暗雷在程式中出現的越少,開發者對程式的信心無疑就會越強,同時程式碼的可讀性也會越高。本章的主題,將繼續朝減少程式副作用的方向努力。

如果程式設計風格冪等性是指定義一個資料變更操作以便隻影響一次程式狀態,那麼現在我們將注意力轉向將這個影響次數從 1 降為 0。

現在我們開始探索值的不可變性,即只在我們的程式中使用不可被改變的資料。

原始值的不可變性

原始資料型別(numberstringbooleannullundefined)本身就是不可變的;無論如何你都沒辦法改變它們。

// 無效,且毫無意義
2 = 2.5;複製程式碼

然而 JS 確實有一個特性,使得看起來允許我們改變原始資料型別的值, 即“boxing”特性。當你訪問原始型別資料時 —— 特別是 numberstringboolean —— 在這種情況下,JS 會自動的把它們包裹(或者說“包裝”)成這個值對應的物件(分別是 NumberString 以及 Boolean)。

思考下面的程式碼:

var x = 2;

x.length = 4;

x;                // 2
x.length;        // undefined複製程式碼

數值本身並沒有可用的 length 屬性,因此 x.length = 4 這個賦值操作正試圖新增一個新的屬性,不過它靜默地失敗了(也可以說是這個操作被忽略了或被拋棄了,這取決於你怎麼看);變數 x 繼續承載那個簡單的原始型別資料 —— 數值 2

但是 JS 允許 x.length = 4 這條語句正常執行的事實著實令人困惑。如果這種現象真的無緣無故出現,那麼程式碼的閱讀者無疑會摸不著頭腦。好訊息是,如果你使用了嚴格模式("use strict";),那麼這條語句就會丟擲異常了。

那麼如果嘗試改變那些明確被包裝成物件的值呢?

var x = new Number( 2 );

// 沒問題
x.length = 4;複製程式碼

這段程式碼中的 x 儲存了一個物件的引用,因此可以正常地新增或修改自定義屬性。

number 這樣的原始數型,值的不可變性看起來相當明顯,但字串呢?JS 開發者有個共同的誤解 —— 字串和陣列很像,所以應該是可變的。JS 使用 [] 訪問字串成員的語法甚至還暗示字串真的就像陣列。不過,字串的確是不可變的:

var s = "hello";

s[1];                // "e"

s[1] = "E";
s.length = 10;

s;                    // "hello"複製程式碼

儘管可以使用 s[1] 來像訪問陣列元素一樣訪問字串成員,JS 字串也並不是真的陣列。s[1] = "E"s.length = 10 這兩個賦值操作都是失敗的,就像剛剛的 x.length = 4 一樣。在嚴格模式下,這些賦值都會丟擲異常,因為 1length 這兩個屬性在原始資料型別字串中都是隻讀的。

有趣的是,即便是包裝後的 String 物件,其值也會(在大部分情況下)表現的和非包裝字串一樣 —— 在嚴格模式下如果改變已存在的屬性,就會丟擲異常:

"use strict";

var s = new String( "hello" );

s[1] = "E";            // error
s.length = 10;        // error

s[42] = "?";        // OK

s;                    // "hello"複製程式碼

從值到值

我們將在本節詳細展開從值到值這個概念。但在開始之前應該心中有數:值的不可變性並不是說我們不能在程式編寫時不改變某個值。如果一個程式的內部狀態從始至終都保持不變,那麼這個程式肯定相當無趣!它同樣不是指變數不能承載不同的值。這些都是對值的不可變這個概念的誤解。

值的不可變性是指當需要改變程式中的狀態時,我們不能改變已存在的資料,而是必須建立和跟蹤一個新的資料。

例如:

function addValue(arr) {
    var newArr = [ ...arr, 4 ];
    return newArr;
}

addValue( [1,2,3] );    // [1,2,3,4]複製程式碼

注意我們沒有改變陣列 arr 的引用,而是建立了一個新的陣列(newArr),這個新陣列包含陣列 arr 中已存在的值,並且新增了一個新值 4

使用我們在第 5 章討論的副作用的相關概念來分析 addValue(..)。它是純的嗎?它是否具有引用透明性?給定相同的陣列作為輸入,它會永遠返回相同的輸出嗎?它無副作用嗎?答案是肯定的。

設想這個陣列 [1, 2, 3], 它是由先前的操作產生,並被我們儲存在一個變數中,它代表著程式當前的狀態。我們想要計算出程式的下一個狀態,因此呼叫了 addValue(..)。但是我們希望下一個狀態計算的行為是直接的和明確的,所以 addValue(..) 操作簡單的接收一個直接輸入,返回一個直接輸出,並通過不改變 arr 引用的原始陣列來避免副作用。

這就意味著我們既可以計算出新狀態 [1, 2, 3, 4],也可以掌控程式的狀態變換。程式不會出現過早的過渡到這個狀態或完全轉變到另一個狀態(如 [1, 2, 3, 5])這樣的意外情況。通過規範我們的值並把它視為不可變的,我們大幅減少了程式錯誤,使我們的程式更易於閱讀和推導,最終使程式更加可信賴。

arr 所引用的陣列是可變的,只是我們選擇不去改變他,我們實踐了值不可變的這一精神。

同樣的,可以將“以拷貝代替改變”這樣的策略應用於物件,思考下面的程式碼:

function updateLastLogin(user) {
    var newUserRecord = Object.assign( {}, user );
    newUserRecord.lastLogin = Date.now();
    return newUserRecord;
}

var user = {
    // ..
};

user = updateLastLogin( user );複製程式碼

消除本地影響

下面的程式碼能夠體現不可變性的重要性:

var arr = [1,2,3];

foo( arr );

console.log( arr[0] );複製程式碼

從表面上講,你可能認為 arr[0] 的值仍然為 1。但事實是否如此不得而知,因為 foo(..) 可能會改變你傳入其中的 arr 所引用的陣列。

在之前的章節中,我們已經見到過用下面這種帶有欺騙性質的方法來避免意外:

var arr = [1,2,3];

foo( arr.slice() );            // 哈!一個陣列副本!

console.log( arr[0] );        // 1複製程式碼

當然,使得這個斷言成立的前提是 foo 函式不會忽略我們傳入的引數而直接通過相同的 arr 這個自由變數詞法引用來訪問源陣列。

對於防止資料變化負面影響,稍後我們會討論另一種策略。

重新賦值

在進入下一個段落之前先思考一個問題 —— 你如何描述“常量”?

你可能會脫口而出“一個不能改變的值就是常量”,“一個不能被改變的變數”等等。這些回答都只能說接近正確答案,但卻並不是正確答案。對於常量,我們可以給出一個簡潔的定義:一個無法進行重新賦值(reassignment)的變數。

我們剛剛在“常量”概念上的吹毛求疵其實是很有必要的,因為它澄清了常量與值無關的事實。無論常量承載何值,該變數都不能使用其他的值被進行重新賦值。但它與值的本質無關。

思考下面的程式碼:

var x = 2;複製程式碼

我們剛剛討論過,資料 2 是一個不可變的原始值。如果將上面的程式碼改為:

const x = 2;複製程式碼

const 關鍵字的出現,作為“常量宣告”被大家熟知,事實上根本沒有改變 2 的本質,因為它本身就已經不可改變了。

下面這行程式碼會丟擲錯誤,這無可厚非:

// 嘗試改變 x,祝我好運!
x = 3;        // 丟擲錯誤!複製程式碼

但再次重申,我們並不是要改變這個資料,而是要對變數 x 進行重新賦值。資料被捲進來純屬偶然。

為了證明 const 和值的本質無關,思考下面的程式碼:

const x = [ 2 ];複製程式碼

這個陣列是一個常量嗎?並不是。 x 是一個常量,因為它無法被重新賦值。但下面的操作是完全可行的:

x[0] = 3;複製程式碼

為何?因為儘管 x 是一個常量,陣列卻是可變的。

關於 const 關鍵字和“常量”只涉及賦值而不涉及資料語義的特性是個又臭又長的故事。幾乎所有語言的高階開發者都踩 const 地雷。事實上,Java 最終不贊成使用 const 並引入了一個全新的關鍵詞 final 來區分“常量”這個語義。

拋開混亂之後開始思考,如果 const 並不能建立一個不可變的值,那麼它對於函數語言程式設計者來說又還有什麼重要的呢?

意圖

const 關鍵字可以用來告知閱讀你程式碼的讀者該變數不會被重新賦值。作為一個表達意圖的標識,const 被加入 JavaScript 不僅常常受到稱讚,也普遍提高了程式碼可讀性。

在我看來,這是誇大其詞,這些說法並沒有太大的實際意義。我只看到了使用這種方法來表明意圖的微薄好處。如果使用這種方法來宣告值的不可變性,與已使用幾十年的傳統方式相比,const 簡直太弱了。

為了證明我的說法,讓我們來做一個實踐。const 建立了一個在塊級作用域內的變數,這意味著該變數只能在其所在的程式碼塊中被訪問:

// 大量程式碼

{
    const x = 2;

    // 少數幾行程式碼
}

// 大量程式碼複製程式碼

通常來說,程式碼塊的最佳實踐是用於僅包裹少數幾行程式碼的場景。如果你有一個包含了超過 10 行的程式碼塊,那麼大多數開發者會建議你重構這一段程式碼。因此 const x = 2 只作用於下面的9行程式碼。

程式的其他部分不會影響 x 的賦值。

我要說的是:上述程式的可讀性與下面這樣基本相同:

// 大量程式碼

{
    let x = 2;

    // 少數幾行程式碼
}

// 大量程式碼複製程式碼

其實只要檢視一下在 let x = 2; 之後的幾行程式碼,就可以判斷出 x 這個變數是否被重新賦值過了。對我來說,“實際上不進行重新賦值”相對“使用容易迷惑人的 const 關鍵字告訴讀者‘不要重新賦值’”是一個更明確的訊號

此外,讓我們思考一下,乍看這段程式碼起來可能給讀者傳達什麼:

const magicNums = [1,2,3,4];

// ..複製程式碼

讀者可能會(錯誤地)認為,這裡使用 const 的用意是你永遠不會修改這個陣列 —— 這樣的推斷對我來說合情合理。想象一下,如果你的確允許 magicNums 這個變數所引用的陣列被修改,那麼這個 const 關鍵詞就極具混淆性了 —— 的很確容易發生意外,不是嗎?

更糟糕的是,如果你在某處故意修改了 magicNums,但對讀者而言不夠明顯呢?讀者會在後面的程式碼裡(再次錯誤地)認為 magicNums 的值仍然是 [1, 2, 3, 4]。因為他們猜測你之前使用 const 的目的就是“這個變數不會改變”。

我認為你應該使用 varlet 來宣告那些你會去改變的變數,它們確實相比 const 來說是一個更明確的訊號

const 所帶來的問題還沒講完。還記得我們在本章開頭所說的嗎?值的不可變性是指當需要改變某個資料時,我們不應該直接改變它,而是應該使用一個全新的資料。那麼當新陣列建立出來後,你會怎麼處理它?如果你使用 const 宣告變數來儲存引用嗎,這個變數的確沒法被重新賦值了,那麼……然後呢?

從這方面來講,我認為 const 反而增加了函數語言程式設計的困難度。我的結論是:const 並不是那麼有用。它不僅造成了不必要的混亂,也以一種很不方便的形式限制了我們。我只用 const 來宣告簡單的常量,例如:

const PI = 3.141592;複製程式碼

3.141592 這個值本身就已經是不可變的,並且我也清楚地表示說“PI 識別符號將始終被用於代表這個字面量的佔位符”。對我來說,這才是 const 所擅長的。坦白講,我在編碼時並不會使用很多這樣的宣告。

我寫過很多,也閱讀過很多 JavaScript 程式碼,我認為由於重新賦值導致大量的 bug 這只是個想象中的問題,實際並不存在。

我們應該擔心的,並不是變數是否被重新賦值,而是值是否會發生改變。為什麼?因為值是可被攜帶的,但詞法賦值並不是。你可以向函式中傳入一個陣列,這個陣列可能會在你沒意識到的情況下被改變。但是你的其他程式碼在預期之外重新給變數賦值,這是不可能發生的。

凍結

這是一種簡單廉價的(勉強)將像物件、陣列、函式這樣的可變的資料轉為“不可變資料”的方式:

var x = Object.freeze( [2] );複製程式碼

Object.freeze(..) 方法遍歷物件或陣列的每個屬性和索引,將它們設定為只讀以使之不會被重新賦值,事實上這和使用 const 宣告屬性相差無幾。Object.freeze(..) 也會將屬性標記為“不可配置(non-reconfigurable)”,並且使物件或陣列本身不可擴充套件(即不會被新增新屬性)。實際上,而就可以將物件的頂層設為不可變。

注意,僅僅是頂層不可變!

var x = Object.freeze( [ 2, 3, [4, 5] ] );

// 不允許改變:
x[0] = 42;

// oops,仍然允許改變:
x[2][0] = 42;複製程式碼

Object.freeze(..) 提供淺層的、初級的不可變性約束。如果你希望更深層的不可變約束,那麼你就得手動遍歷整個物件或陣列結構來為所有後代成員應用 Object.freeze(..)

const 相反,Object.freeze(..) 並不會誤導你,讓你得到一個“你以為”不可變的值,而是真真確確給了你一個不可變的值。

回顧剛剛的例子:

var arr = Object.freeze( [1,2,3] );

foo( arr );

console.log( arr[0] );            // 1複製程式碼

可以非常確定 arr[0] 就是 1

這是非常重要的,因為這可以使我們更容易的理解程式碼,當我們將值傳遞到我們看不到或者不能控制的地方,我們依然能夠相信這個值不會改變。

效能

每當我們開始建立一個新值(陣列、物件等)取代修改已經存在的值時,很明顯迎面而來的問題就是:這對效能有什麼影響?

如果每次想要往陣列中新增內容時,我們都必須建立一個全新的陣列,這不僅佔用 CPU 時間並且消耗額外的記憶體。不再存在任何引用的舊資料將會被垃圾回收機制回收;更多的 CPU 資源消耗。

這樣的取捨能接受嗎?視情況而定。對程式碼效能的優化和討論都應該有個上下文

如果在你的程式中,只會發生一次或幾次單一的狀態變化,那麼扔掉一箇舊物件或舊陣列完全沒必要擔心。效能損失會非常非常小 —— 頂多只有幾微秒 —— 對你的應用程式影響甚小。追蹤和修復由於資料改變引起的 bug 可能會花費你幾分鐘甚至幾小時的時間,這麼看來那幾微秒簡直沒有可比性。

但是,如果頻繁的進行這樣的操作,或者這樣的操作出現在應用程式的核心邏輯中,那麼效能問題 —— 即效能和記憶體 —— 就有必要仔細考慮一下了。

以陣列這樣一個特定的資料結構來說,我們想要在每次操作這個陣列時使每個更改都隱式地進行,就像結果是一個新陣列一樣,但除了每次都真的建立一個陣列之外,還有什麼其他辦法來完成這個任務呢?像陣列這樣的資料結構,我們期望除了能夠儲存其最原始的資料,然後能追蹤其每次改變並根據之前的版本建立一個分支。

在內部,它可能就像一個物件引用的連結串列樹,樹中的每個節點都表示原始值的改變。從概念上來說,這和 git 的版本控制原理類似。

想象一下使用這個假設的、專門處理陣列的資料結構:

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(..) 這個資料結構會在內部追蹤每個資料更新操作(例如 set(..)),類似 diff,因此不必要為原始的那些值(1234)重新分配記憶體,而是簡單的將 "meaning of life" 這個值加入列表。重要的是,statenewState 分別指向兩個“不同版本”的陣列,因此值的不變性這個語義得以保留

發明你自己的效能優化資料結構是個有趣的挑戰。但從實用性來講,找一個現成的庫會是個更好的選擇。Immutable.jsfacebook.github.io/immutable-j… 是一個很棒的選擇,它提供多種資料結構,包括 List(類似陣列)和 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 這樣強大的庫一般會採用非常成熟的效能優化。如果不使用庫而是手動去處理那些細枝末節,開發的難度會相當大。

當改變值這樣的場景出現的較少且不用太關心效能時,我推薦使用更輕量級的解決方案,例如我們之前提到過的內建的 Object.freeze(..)

以不可變的眼光看待資料

如果我們從函式中接收了一個資料,但不確定這個資料是可變的還是不可變的,此時該怎麼辦?去修改它試試看嗎?不要這樣做。 就像在本章最開始的時候所討論的,不論實際上接收到的值是否可變,我們都應以它們是不可變的來對待,以此來避免副作用並使函式保持純度。

回顧一下之前的例子:

function updateLastLogin(user) {
    var newUserRecord = Object.assign( {}, user );
    newUserRecord.lastLogin = Date.now();
    return newUserRecord;
}複製程式碼

該實現將 user 看做一個不應該被改變的資料來對待;user 是否真的不可變完全不會影響這段程式碼的閱讀。對比一下下面的實現:

function updateLastLogin(user) {
    user.lastLogin = Date.now();
    return user;
}複製程式碼

這個版本更容易實現,效能也會更好一些。但這不僅讓 updateLastLogin(..) 變得不純,這種方式改變的值使閱讀該程式碼,以及使用它的地方變得更加複雜。

應當總是將 user 看做不可變的值,這樣我們就沒必要知道資料從哪裡來,也沒必要擔心資料改變會引發潛在問題。

JavaScript 中內建的陣列方法就是一些很好的例子,例如 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]複製程式碼

其他一些將引數看做不可變資料且返回新陣列的原型方法還有:map(..)filter(..) 等。reduce(..) / reduceRight(..) 方法也會盡量避免改變引數,儘管它們並不預設返回新陣列。

不幸的是,由於歷史問題,也有一部分不純的陣列原型方法:splice(..)pop(..)push(..)shift(..)unshift(..)reverse(..) 以及 fill(..)

有些人建議禁止使用這些不純的方法,但我不這麼認為。因為一些效能面的原因,某些場景下你仍然可能會用到它們。不過你也應當注意,如果一個陣列沒有被本地化在當前函式的作用域內,那麼不應當使用這些方法,避免它們所產生的副作用影響到程式碼的其他部分。

不論一個資料是否是可變的,永遠將他們看做不可變。遵守這樣的約定,你程式的可讀性和可信賴度將會大大提升。

總結

值的不可變性並不是不改變值。它是指在程式狀態改變時,不直接修改當前資料,而是建立並追蹤一個新資料。這使得我們在讀程式碼時更有信心,因為我們限制了狀態改變的場景,狀態不會在意料之外或不易觀察的地方發生改變。

由於其自身的訊號和意圖,const 關鍵字宣告的常量通常被誤認為是強制規定資料不可被改變。事實上,const 和值的不可變性宣告無關,而且使用它所帶來的困惑似乎比它解決的問題還要大。另一種思路,內建的 Object.freeze(..) 方法提供了頂層值的不可變性設定。大多數情況下,使用它就足夠了。

對於程式中效能敏感的部分,或者變化頻繁發生的地方,處於對計算和儲存空間的考量,每次都建立新的資料或物件(特別是在陣列或物件包含很多資料時)是非常不可取的。遇到這種情況,通過類似 Immutable.js 的庫使用不可變資料結構或許是個很棒的主意。

值不變在程式碼可讀性上的意義,不在於不改變資料,而在於以不可變的眼光看待資料這樣的約束。

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

【下一章】翻譯連載 | JavaScript輕量級函數語言程式設計-第7章: 閉包vs物件 |《你不知道的JS》姊妹篇

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

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

相關文章