章节 6: 值的不变性质

第5章中,我们讨论了减少副作用的重要性:应用程序的状态可能意外改变并导致意外(bug)的方式。使用这种地雷的地方越少,我们对代码就越有信心,代码的可读性也就越好。我们这一章的主题也是做同样的努力去减少副作用。

如果编程风格的幂等性是关于定义一个值更改操作,以便它只能影响状态一次,那么现在我们将注意力转向将发生更改的次数从1减少到0的目标。

现在让我们来探讨值的不变性,即在程序中我们只使用不可更改的值。

原始类型的不变性

js原始类型(number, string, boolean, null, and undefined)的值已经是不可变的;你无法改变他们:

  1. // 无效,也没有任何意义
  2. 2 = 2.5;

然而,js确实有一个特殊的行为,看起来它允许改变这样的原始类型值:“boxing”(boxing所谓的装箱,是指将基本数据类型转换为对应的引用类型的操作。而装箱又分为隐式装箱和显式装箱)。当您访问某些基本类型值(特别是number, stringboolean)的属性时,js会自动将该值包装在其对象对应项(分别为number, stringboolean)中。

考虑:

  1. var x = 2;
  2. x.length = 4;
  3. x; // 2
  4. x.length; // undefined

数字通常没有length属性可用。x.length = 4设置试图添加一个新属性,但它会自动失败(或根据您的观点被忽略/丢弃);x 继续保持简单的基本数字2

但js允许x.length = 4语句运行看起来很麻烦,如果不是因为其他原因,读者可能会感到困惑。好消息是,如果使用strict模式("use strict";),这样的语句将抛出错误。

如果您尝试改变这样一个值的显式装箱对象表示,会怎么样?

  1. var x = new Number( 2 );
  2. // 运行正常
  3. x.length = 4;

这个代码段中的x保存了对对象的引用,因此可以添加和更改自定义属性而不存在任何问题。

number这样的简单原语的不可变性可能看起来相当明显。但是string值呢?JS开发人员有一个非常普遍的误解,认为字符串就像数组,因此可以更改。JS语法甚至暗示他们是“数组一样”与`[ ]访问操作符。然而,字符串也是不可变的:

  1. var s = "hello";
  2. s[1]; // "e"
  3. s[1] = "E";
  4. s.length = 10;
  5. s; // "hello"

尽管能够像访问数组一样访问s[1],但JS字符串并不是真正的数组。设置 s[1] = "E"s.length = 10和之前做过x.length = 4一样,都自动失败了。在严格模式下,这些赋值将失败,因为1属性和length属性在这个基本string值上都是只读的。

有趣的是,即使是被框起来的String对象值也会(基本上)不可变,因为如果您更改现有属性,它会在严格模式下抛出错误:

  1. "use strict";
  2. var s = new String( "hello" );
  3. s[1] = "E"; // error
  4. s.length = 10; // error
  5. s[42] = "?"; // OK
  6. s; // "hello"

此值到彼值

我们将在本章中进一步解释这个概念,但是首先要有一个清晰的理解:值的不变性并不意味着我们不能在程序的过程中改变值。一个没有改变状态的程序不是一个很有趣的程序!这也不意味着我们的变量不能有不同的值。这些都是关于价值不变性的普遍误解。

值不变性意味着我们需要更改程序中的状态时,我们必须创建并跟踪一个新值,而不是更改一个现有值。

例如:

  1. function addValue(arr) {
  2. var newArr = [ ...arr, 4 ];
  3. return newArr;
  4. }
  5. addValue( [1,2,3] ); // [1,2,3,4]

注意,我们没有更改arr引用的数组,而是创建了一个新数组(newArr),其中包含现有值加上新的4 值。

根据我们在第5章中讨论的关于副作用的原因/影响来分析addValue(..)。它是纯洁的吗?它是否具有参考透明性?给定相同的数组,它总是会产生相同的输出吗?它有没有副作用和副作用?答案为:是的

假设[1,2,3]数组表示来自以前一些操作的数据序列,并且我们存储在某个变量中。这是我们目前的状态。如果我们想计算应用程序的下一个状态,我们调用addValue(..)。但是我们希望下一个状态的计算是直接和明确的。因此,addValue(..)操作接受直接输入,返回直接输出,并避免了修改arr引用的原始数组的副作用。

这意味着我们可以计算[1,2,3,4]的新状态,并完全控制状态的转换。程序的任何其他部分都不能意外地将我们提前转换到那个状态,或者完全转换到另一个状态,比如[1,2,3,5]。通过对我们的值进行约束并将它们视为不可变的,我们大大减少了令人惊讶的表面区域,使我们的程序更易于阅读、推理和最终信任。

arr引用的数组实际上是可变的。我们只是选择不去改变它,所以我们实践了价值不变的精神。

我们也可以对对象使用复制而不是变异的策略。思考下:

  1. function updateLastLogin(user) {
  2. var newUserRecord = Object.assign( {}, user );
  3. newUserRecord.lastLogin = Date.now();
  4. return newUserRecord;
  5. }
  6. var user = {
  7. // ..
  8. };
  9. user = updateLastLogin( user );

非局部

非原始值由引用持有,当作为参数传递时,复制的是引用,而不是值本身。

如果在程序的一个部分中有一个对象或数组,并将其传递给在程序另一个部分中的函数,那么该函数现在可以通过这个引用副本影响值,并以可能意想不到的方式对其进行修改。

换句话说,如果作为参数传递,非原始值将变为非本地值。可能需要考虑整个程序来理解是否会更改这样的值。

思考:

  1. var arr = [1,2,3];
  2. foo( arr );
  3. console.log( arr[0] );

从表面上看,您期望arr[0]仍然是值1。但真的是这样吗?您不知道,因为foo(..) 可能使用传递给它的引用副本更改了数组。

我们在前一章已经看到了一个避免这种意外的技巧:

  1. var arr = [1,2,3];
  2. foo( [...arr] ); // 拷贝一个数组
  3. console.log( arr[0] ); // 1

稍后,我们将看到另一种策略,用于保护不受来自下面的值意外突变的影响。

重赋值

你如何描述“常量”?在你进入下一段之前想一下这个问题。

你们中的一些人可能会这么描述,“一个不能改变的值”,“一个不能改变的变量”,或者类似的东西。意思相近,但不完全正确。我们应该对常量使用的精确定义是:不能重新分配的变量。

这种吹毛求疵是非常重要的,因为它阐明了一个常量实际上与这个值无关,只是说,无论一个常量持有什么值,这个变量都不能被重新分配任何其他值。但它没有说明价值本身的性质。

思考:

  1. var x = 2;

如前所述,值2是一个不可更改(不可变)的原始值。如果我把代码改成:

  1. const x = 2;

const关键字的出现,通常被称为“常量声明”,实际上根本没有改变2的性质;它只是变得不可改变的,而且将永远不变。

这是真的,这后面的一行将报错失败:

  1. // 试着改变`x`看看!
  2. x = 3; // Error!

但是,我们没有改变任何关于值的东西。我们试图重新分配变量x。所涉及的值几乎是偶然的。

要证明const与值的本质无关,思考下:

  1. const x = [ 2 ];

数组是常量吗? x是一个常量,因为它不能被重新分配。但是下面这句话完全可以:

  1. x[0] = 3;

为什么?因为数组仍然是完全可变的,即使x是一个常量。

围绕const和“常量”的混淆只处理赋值而不处理值语义,可以长篇大论了。似乎每一种语言中都有相当多的开发人员遇到了相同类型的混淆。实际上,Java反对使用const,并引入了一个新的关键字final,至少在一定程度上是为了将自己从“常量”语义的混乱中分离出来。

抛开混淆的影响,如果const与创建不可变值没有任何关系,那么它对于FPer有什么重要性呢?

意图

const的使用告诉代码的读者,那个变量不会被重新分配。作为意图的一个信号,const通常被高度赞扬为JavaScript的一个受欢迎的附加功能,并在代码可读性方面得到了普遍的改进。

在我看来,这主要是炒作;这些说法没有多少实质内容。我只看到用这种方式表示你的意图所带来的最轻微的好处。而当你把这个数字与几十年来围绕它的困惑(暗示着值的不变性)进行对比时,我不认为const有什么分量。

为了支持我的断言,让我们考虑作用域。const创建了一个块作用域的变量,这意味着变量只存在于一个本地化的块中:

  1. // 一些代码
  2. {
  3. const x = 2;
  4. // 几行代码
  5. }
  6. // 一些代码

通常,块被认为是最好的设计只有几行。如果您的代码块超过10行,大多数开发人员会建议您重构。所以const x = 2最多只适用于后面的9行代码。

程序的任何其他部分都不能影响x的赋值。

我的主张是,程序的可读性基本上与这个相同:

  1. // 一些代码
  2. {
  3. let x = 2;
  4. // 几行代码
  5. }
  6. // 一些代码

如果您查看let x = 2;后面的几行代码,您将能够很容易地看出x实际上“没有”重新分配。对我来说,这是一个更强的信号——实际上不是重新分配它!——而不是使用一些容易混淆的 const声明来表示“不会重新分配它”。

此外,让我们考虑一下这段代码可能第一眼就传达给读者的信息:

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

难道您的代码的读者(错误地)认为您的意图是永远不修改数组,这至少是可能的(可能的)吗?对我来说,这似乎是一个合理的推论。想象一下他们的疑惑,如果稍后您实际上允许magicNums引用的数组值发生突变。这会让他们感到惊讶吗?

更糟的是,如果您故意以某种方式修改magicNums,结果却不为读者所知,该怎么办?随后,在代码中,他们看到了magicNums的用法,并假设(同样是错误的)它仍然是[1,2,3,4],因为他们将您的意图理解为“不会更改这个”。

我认为您应该使用varlet来声明变量,以保存要进行更改的值。我认为这实际上比使用const更清楚地表达了你的意图。

const的麻烦还不止于此。还记得我们在这一章的开头说过,要将值视为不可变的,就意味着当我们的状态需要更改时,我们必须创建一个新值,而不是对它进行修改吗?一旦你创建了这个新数组,你打算怎么处理它?如果使用const声明对它的引用,则不能重新分配它。

  1. const magicNums = [1,2,3,4];
  2. // 然后:
  3. magicNums = magicNums.concat( 42 ); // 噢, 不能重新分配

那么,下一步怎么做?

从这个角度来看,我认为const实际上使我们更加努力地坚持FP,而不是更加容易。我的结论是:const并没有那么有用。它制造了不必要的混乱,并以不方便的方式限制我们。我只对一些简单的常量使用const,比如:

  1. const PI = 3.141592;

3.141592已经是不可变的,我明确地表示,这个PI将始终用作这个文字值的替代占位符。对我来说,这就是const的好处。坦白地说,在我的典型代码中,我没有使用很多这样的声明。

我写过很多JavaScript代码,也见过很多JavaScript代码,我只是认为这是一个想象中的问题,我们的很多bug都来自于意外的重新分配。

FPers如此青睐const而避免重新分配的原因之一是由于等式推理。尽管这个主题与其他语言的关系比与JS的关系更密切,而且超出了我们将在这里讨论的范围,但它是一个有效的观点。然而,我更喜欢务实的观点,而不是更学术性的观点。

例如,我发现对变量重新分配的度量使用对于简化计算中间状态的描述非常有用。当一个值经过多次类型强制转换或其他转换时,我通常不希望为每种表示形式都提供新的变量名:

  1. var a = "420";
  2. // 然后
  3. a = Number( a );
  4. // 然后
  5. a = [ a ];

如果从字符串"420"更改为数字420后,不再需要原来的"420"值了,那么我认为重新分配的a比使用aNum这样的新变量名更易于阅读。

我们真正应该担心的不是变量是否被重新赋值,而是值是否发生了变化。为什么?因为值是可移植的;词汇赋值则不然。您可以将数组传递给函数,并且可以在您没有意识到的情况下更改它。但是,重新分配绝不会意外地由程序的其他部分引起。

冻结值

有一种既便宜又简单的方法可以将一个可变的对象/数组/函数转换成一个“不可变值”(而且有很多方法):

  1. var x = Object.freeze( [2] );

Object.freeze(..)实用程序遍历对象/数组的所有属性/索引,并将它们标记为只读,因此不能重新分配它们。实际上,这有点像用const声明属性!Object.freeze(..)还将属性标记为不可重构,并将对象/数组本身标记为不可扩展(不能添加新属性)。实际上,它使对象的顶层不可变。

不过,只有顶层不能重新分配。小心这种方式!

  1. var x = Object.freeze( [ 2, 3, [4, 5] ] );
  2. // 不允许:
  3. x[0] = 42;
  4. // 允许:
  5. x[2][0] = 42;

Object.freeze(..)提供了浅的、单一的不变性。如果您想要一个非常不可变的值,就必须手动遍历整个对象/数组结构,并对每个子对象/数组应用Object.freeze(..)

但与const不同的是,Object.freeze(..)实际上给了您一个不可变的值,而const`可能会让您误以为自己没有得到值时就得到了一个不可变的值。

回想一下前面的例子:

  1. var arr = Object.freeze( [1,2,3] );
  2. foo( arr );
  3. console.log( arr[0] ); // 1

现在arr[0]是相当可靠的1

这一点非常重要,因为当我们知道可以相信一个值在传递到某个我们看不到或无法控制的地方时不会发生变化时,它可以使我们更容易地对代码进行推理。

性能

每当我们开始创建新值(数组、对象等)而不是修改现有值时,下一个明显的问题是:这对性能意味着什么?

如果每次需要添加新数组时都要重新分配新数组,这不仅会浪费CPU时间,还会消耗额外的内存;旧值(如果不再引用)也将被垃圾收集。这将消耗更多的CPU。

这是一个可以接受的折中方案吗?这取决于在没有上下文的情况下,不应讨论或优化代码性能。

如果在程序的整个生命周期中只发生一次(甚至几次)状态更改,那么丢弃旧的数组/对象来替换新数组/对象几乎肯定不是问题。我们所讨论的搅动是如此之小——最多可能只有几微秒——以至于对应用程序的性能没有实际影响。与不必跟踪和修复与意外值突变相关的错误所节省的几分钟或几个小时相比,甚至是忽略不计的。

然后,如果这样的操作经常发生,或者特别发生在应用程序的“关键路径”中,那么性能——同时考虑性能和内存!担忧是完全合理的。

考虑一个类似数组的专门化数据结构,但是您希望能够对其进行更改,并使每个更改的行为隐式,就像结果是一个新数组一样。如何在不每次都创建一个新数组的情况下实现这一点呢?这样一个特殊的数组数据结构可以存储原始值,然后跟踪作为前一个版本的增量所做的每个更改。

在内部,它可能类似于对象引用的链表树,其中树中的每个节点表示原始值的一个突变。实际上,这在概念上类似于Git版本控制的工作方式。

章节 6: 值的不变性质 - 图1

在这个概念图中,一个原始数组 [3,6,1,0] 首先具有分配给位置 0 的值4的突变(变成[4,6,1,0]),然后1被分配给位置3(现在成了[4,6,1,1]),最后2被分配给位置4(结果:[4,6,1,1,2])。关键的思想是,在每次突变时,只记录与前一个版本的更改,而不是复制整个原始数据结构。通常,这种方法在内存和CPU性能方面都更有效。

假设使用这个假设的专用数组数据结构,如下所示:

  1. var state = specialArray( 4, 6, 1, 1 );
  2. var newState = state.set( 4, 2 );
  3. state === newState; // false
  4. state.get( 2 ); // 1
  5. state.get( 4 ); // undefined
  6. newState.get( 2 ); // 1
  7. newState.get( 4 ); // 2
  8. newState.slice( 2, 5 ); // [1,1,2]

specialArray(..)数据结构将在内部跟踪每个突变操作(如set(..)),将其作为一个差异,因此它不必为原始值(4, 6, 11)重新分配内存,只需将2值添加到列表的末尾。但重要的是,statenewState指向数组值的不同版本(或视图),因此保留了值的不变性语义

创建自己的性能优化的数据结构是一个有趣的挑战。但是从实用的角度来看,您可能应该使用已经做得很好的库。一个很好的选项是Immutable.js,它提供了各种数据结构,包括List(类数组)和Map(类对象)。

考虑前面的specialArray示例,使用Immutable.List操作:

  1. var state = Immutable.List.of( 4, 6, 1, 1 );
  2. var newState = state.set( 4, 2 );
  3. state === newState; // false
  4. state.get( 2 ); // 1
  5. state.get( 4 ); // undefined
  6. newState.get( 2 ); // 1
  7. newState.get( 4 ); // 2
  8. newState.toArray().slice( 2, 5 ); // [1,1,2]

像Immutable.js这样强大的库使用复杂的性能优化。在没有这样一个库的情况下手工处理所有的细节和基本情况将是非常困难的。

当对值的更改很少或不频繁,性能也不那么重要时,我建议使用较轻量级的解决方案,坚持使用前面讨论过的内置Object.freeze(..)

处理

如果我们收到一个函数的值,但我们不确定它是可变的还是不可变的怎么办?你能不能继续尝试变异它?不。正如我们在本章开始时所断言的,我们应该将所有接收到的值都视为不可变的——以避免副作用并保持纯值——无论它们是或不是。

回想一下前面的例子:

  1. function updateLastLogin(user) {
  2. var newUserRecord = Object.assign( {}, user );
  3. newUserRecord.lastLogin = Date.now();
  4. return newUserRecord;
  5. }

此实现将user视为不应发生突变的值;它是否是不可变的与读取这部分代码无关。与此实现相比:

  1. function updateLastLogin(user) {
  2. user.lastLogin = Date.now();
  3. return user;
  4. }

这个版本编写起来容易得多,甚至性能更好。但是,这种方法不仅使updateLastLogin(..)变得不纯,而且它还以某种方式修改了一个值,使得读取这段代码以及使用它的位置变得更加复杂。

我们应该始终将user视为不可变的,因为在阅读代码时,我们不知道该值来自何处,或者如果对其进行修改,可能会导致什么潜在问题。

这种方法的好例子可以在JS数组的各种内置方法中看到,如concat(..)slice(..):

  1. var arr = [1,2,3,4,5];
  2. var arr2 = arr.concat( 6 );
  3. arr; // [1,2,3,4,5]
  4. arr2; // [1,2,3,4,5,6]
  5. var arr3 = arr2.slice( 1 );
  6. arr2; // [1,2,3,4,5,6]
  7. arr3; // [2,3,4,5,6]

其他数组原型方法将值实例视为不可变的,并返回一个新数组,而不是进行修改:map(..)filter(..)filter(..). The reduce(..)/reduceRight(..)实用程序也避免对实例进行修改,不过它们在默认情况下不会返回一个新数组。

不幸的是,由于历史原因,相当多的其他数组方法是其实例的非纯变异:splice(..), pop(..), push(..), shift(..), unshift(..), reverse(..), sort(..), 和 fill(..),这些都更改了原有数据值。

回顾其中一个章节4 compose(..) 的实现 :

  1. function compose(...fns) {
  2. return function composed(result){
  3. // 复制函数数组
  4. var list = [...fns];
  5. while (list.length > 0) {
  6. // 把列表末尾的最后一个函数去掉
  7. // 并执行它
  8. result = list.pop()( result );
  9. }
  10. return result;
  11. };
  12. }

...fns的gather参数从传入的参数中创建一个新的本地数组,所以我们不能在这个数组上创建外部副作用。那么我们就有理由假设在局部进行变异是安全的。但这里的微妙问题是,在fns上关闭的内部composed(..)在这个意义上不是“局部”的。

考虑一下这个不复制的不同版本:

  1. function compose(...fns) {
  2. return function composed(result){
  3. while (fns.length > 0) {
  4. // 把列表末尾的最后一个函数去掉
  5. // 并执行它
  6. result = fns.pop()( result );
  7. }
  8. return result;
  9. };
  10. }
  11. var f = compose( x => x / 3, x => x + 1, x => x * 2 );
  12. f( 4 ); // 3
  13. f( 4 ); // 4 <-- 变成这样了!

这里第二次使用f(..)是不正确的,因为我们在第一次调用时修改了fns,这影响了后续的使用。根据具体情况,复制一个数组,如list = [...fns]可能有必要,也可能没有必要。但我认为,假设您需要它是最安全的——即使只是为了可读性!除非你能证明你没有,而不是相反。

严格遵守规则,始终将接收到的值视为不可变的,不管它们是或不是。这将提高代码的可读性和可靠性。

总结

值的不变性不是关于不变的值。它是在程序状态更改时创建和跟踪新值,而不是修改现有值。这种方法使我们在阅读代码时更有信心,因为我们限制了状态可以以我们不容易看到或期望的方式更改的地方。

const声明(常量)通常会被误认为是它们发出意图信号和执行不变性的能力。实际上,const基本上与价值的不变性无关,它的使用可能会造成比它解决的更多的混乱。相反,Object.freeze(..)提供了一种很好的内置方法,可以在数组或对象上设置浅值不变性。在许多情况下,这就足够了。

对于程序的性能敏感部分,或者在经常发生更改的情况下,创建一个新的数组或对象(特别是当它包含大量数据时)对于处理和内存问题都是不可取的。在这些情况下,使用像Immutable.js库中的不可变数据结构。可能是最好的主意。

值不变性对代码可读性的重要性不在于不能更改值,而在于将值视为不可变的原则。