翻译连载 | 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 <<