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

關於譯者:這是一個流淌着滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裏最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。通過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,但願能夠幫助你們在學習函數式編程的道路上走的更順暢。比心。前端

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

第 6 章:值的不可變性

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

若是編程風格冪等性是指定義一個數據變動操做以便隻影響一次程序狀態,那麼如今咱們將注意力轉向將這個影響次數從 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";),那麼這條語句就會拋出異常了。app

那麼若是嘗試改變那些明確被包裝成對象的值呢?

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

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息