《深入理解Scala》——第2章,第2.3节优先选择不变性

2.3 优先选择不变性
深入理解Scala
编程中的不变性指对象一旦创建后就不再改变状态。这是函数式编程的基石之一,也是JVM上的面向对象编程的推荐实践之一。Scala也不例外,在设计上优先选择不变性,在很多场景中把不变性作为默认设置。对此,你可能一下子会不适应。本节中,我们将学到不变性对于判等问题和并发编程能提供什么帮助。
Scala里首先要明白的是不变对象和不变引用(immutable referene)的区别。Scala里的所有变量都是指向对象的引用。把变量声明为val意味着它是个不变“引用”。所有的方法参数都是不变引用,类参数默认为不可变引用。创建可变引用的唯一方法是使用var语法。引用的不变性不影响它指向的对象是否是不可变的。你可以创建一个指向不变对象的可变引用,反之亦然。这意味着,重要的是知道对象本身是不变的还是可变的。
对象是否有不变性约束不是那么显然的事。一般来说如果文档指出一个对象是不可变的,那么可以安全地假定它就是不可变的,否则就要小心。Scala标准库里的集合类库把可变还是不变描述得很清楚,它有并列的两个包,一个放不变类,一个放可变类。
Scala里不变性很重要,因为它有助于程序员推理代码。如果一个对象的状态不改变,那程序员找到对象创建的地方就可以确定其状态。这也可以简化那些基于对象状态的方法,这个好处在定义判等或写并发程序时尤其明显。

2.3.1 判等
优先选择不变性的关键原因之一在于简化对象判等。如果一个对象在生命周期中不改变状态,你就能为该类型对象创建一个既深又准的equals实现。在创建对象的散列(hash)函数时这一点也很关键。
散列函数返回对象的简化表现形式,通常是个整数,可以用来快速地确定一个对象。好的散列函数和equals方法一般是成对的,即使不通过代码体现,也会以某种逻辑定义的方式体现。如果一个对象的生命周期中改变了状态,那就会毁掉为该对象生成的散列代码。这又会连带着影响对象的判等测试。我们来看个非常简单的例子:一个二维几何点类。

Point2D类非常简单,它包含x和y值,对应x和y坐标轴上的位置。它还有个move方法,用来在平面上移动点。想象我们要在这个二维平面上的特定点上贴个标签,每个标签就只用一个字符串表示。要实现这功能,我们会考虑定义一个Point2D到字符串的映射。出于性能考虑,我们打算写个散列函数并用HashMap来存放这个映射。我们来试试可行的最简单方法:直接对x和y变量做散列。

一开始代码执行结果看上去完全符合预期。但到我们试图构造一个与点x的值一样的新点对象时就不对了。这个新的点对象的散列值应该对应到map的同一块,然而判等检查却是否定的。这是因为我们没有为之创建自己的判等方法(equals)。默认情况下Scala用对象位置判等法和散列,而我们只覆盖了散列代码(hashCode)方法。对象位置判等法用对象在内存中的位置来作为判等的唯一因素。在我们的Point2例子里,对象位置判等可能是判等的一种便捷方法,但是我们也可以用x和y的位置来判等。
你可能已经注意到Point2类覆盖了hashCode方法,但我对x实例调用的却是##方法。这是Scala的一个规约。为了与Java兼容,Scala同样使用在java.lang.Object里定义的equals和hashCode方法。但是Scala把基础数据类型也抽象成了完整的对象。编译器会在需要的时候为你自动打包和拆包基础数据类型。这些类基础数据类型(primitive-like)的对象都是scala.AnyVal的子类,而那些继承自java.lang.Objec的“标准”对象则都是scala.AnyRef的子类。scala.AnyRef可以看作java.lang.Object的别名。因为hashCode和equals方法只在AnyRef中有定义(AnyVal里没有),所以Scala就提供了可以同时用于AnyRef和AnyVal的##和==方法。
作者注:hashCode和equals应该总是成对实现。
equals和hashCode方法应该实现为如果x == y则x.## == y.##。
我们来实现自己的判等方法,看看结果会怎样。

equals的实现看上去可能有点怪,不过我会在2.5.2节详做解释。当前我们注意看strictEquals辅助方法直接比较x和y的值。意思是如果两个点在同一位置,就认为它们是相等的。现在我们的equals和hashCode方法采用相同标准了,也就是x和y的值。我们再次把点x和点y放入HashMap,只是这次我们准备移动点x,看看与点x绑定的标签会发生什么。

贴在点x上的标签出什么问题了?我们是在x为(1,1)的时候把它放进HashMap的,意味着其散列值为32。然后我们把x移到了(2,2),散列值变成了64。现在我们试图查找x对应的标签时,HashMap里存放的是32,而我们却用64去找。但是为什么我们用新点z去找也找不到呢?z的散列值还是32啊。这是因为根据我们的规则,x和z不相等。你要知道,HashMap在插入值的时候使用散列值,但是当对象状态变化时HashMap并不会更新。这意味着我们无法用基于散列的查找来找到x对应的标签,但是我们在遍历map或者用遍历算法时还是能得到值:

如你所见,这种行为令人困扰,还会在调试的时候造成无尽的争议。因此,在实现判等的时候一般推荐确保如下的约束。
• 如果两个对象相等,它们的散列值应该也相等。
• 一个对象的散列值在对象生命周期中不应该变化。
• 在把对象发送到另一个JVM时,应该用两个JVM里都有的属性来判等。
如你所见,第二个约束意味着用来创建散列值的要素在对象生命周期里不应该变化。最后一个约束则是说,对象的散列和equals方法应该尽量用其内部状态来计算(而不依赖虚拟机里的其他因素)。再跟第一个约束结合起来,你会发现唯一满足这些要求的办法就是使用不变对象。如果对象的状态永远不变,那用状态来计算散列值或判等就是可以接受的。你可以把对象序列化到另个虚拟机,同时仍然保证其散列和判等的一致性。
你或许会奇怪为什么我要关心把对象发送到另一个JVM?我的软件只在一个JVM里跑。甚至我的软件可能是在移动设备上跑的,资源是很紧张的。这种想法的问题在于把一个对象序列化到另一个JVM并非一定要是实时的。我们可能会把一些程序状态保存到磁盘,过会儿再读回来。这跟发送对象到另一个JVM其实没什么区别。尽管你或许没有通过网络传递对象,但你实际上是在通过时间传递对象,从今天这个写数据的JVM传递到明天启动的读数据的JVM。在这种情况下,保持一致的散列值和判等实现是非常关键的。
最后一个约束使不变性成为必要条件了。去掉这个约束的话,其实也只有以下两种较简单的办法来满足前两个约束。
• 在计算散列值时只使用对象的不可变状态(不用可变的状态)。
• 为散列计算和判等使用默认概念。
如你所见,这意味着对象里的某些状态必须是不可变的。把整个对象变成不可变实际上极大简化了整个过程。不变性不仅简化了对象判等,还简化了对数据的并发访问。

2.3.2 并发
不变性能够彻底地简化对数据的并发访问。随着多核处理器的发展,程序越来越变得并行。无论哪种计算形式,在程序里运行并发线程的需求都在增长。传统上,这意味着使用创造性的方式对多线程共享的数据进行保护。通常使用某种形式的锁来保护共享的可变数据。不变性有助于共享状态同时减少对锁的依赖。
加锁必然要承担一定的性能开销。想要读数据的线程必须在拿到锁后才能读。即使使用读写锁(read-write lock)也可能造成问题,因为写线程有可能比较慢,妨碍了读线程去读想要的数据。JVM上的JIT有做一些优化来试图避免不必要的锁。一般来说,你希望你的软件里的锁越少越好,但又必须足够多,以便能够做较多的并发。你设计代码时越能避免锁越好。我们做个案例分析—试试测量加锁对一个算法的影响,然后看我们能不能设计个新的算法,减少加锁的数量。
我们来创建个索引服务,让我们能用键值来查找特定项。这服务同时允许用户把新项加入索引中。我们预期查找值的用户数量很多,加内容的用户数量较少。这里是初始接口:

服务由两个方法构成。lookUp方法根据key的索引查找值,insert方法插入新值。这服务基本上是个键值对的映射。我们用加锁和可变HashMap来实现它。

这个类有三个成员,第一个是currentIndex,指向我们用来存放数据的可变HashMap。lookUp和insert方法都用synchronized块包起来,表明对MutableService自身做同步。你应该注意到了我们对MutableService的所有操作都加了锁。因为案例背景指出应用场景是lookUp方法比insert方法调用频繁得多,在这种场景下读写锁可能有所帮助,但我们来看看怎么能不用读写锁而用不变性来达到目的。
我们把currentIndex改成一个不可变HashMap,每次调用insert方法的时候覆盖原值。然后lookUp方法就可以不加任何锁了。我们来看以下内容。

首先要注意的是currentIndex是个指向不变变量的可变引用。每次insert操作我们都会更新引用。第二个要注意的是我们没把这个服务变成完全不可变的。我们唯一做的就是利用不可变HashMap减少了锁的使用。这个简单的改变能够带来运行时的极大提升。
我为这两个类设置了简单的微型性能测试套件。基本原理很简单:我们构建一组任务向服务写数据,另一组任务从索引读数据。然后我们把两组任务交错提交给两个线程的队列去执行。我们对整个过程的速度做计时并记录结果。下面是一些“最差场景”(worst case)的结果。
如图2.2所示,y轴表示测试的执行时间。x轴对应于提交给线程池的插入/查找任务数。你会注意到(完成同样数量的任务时)可变服务的执行时间增长快于不可变服务的执行时间。这个图明显地表现出额外的加锁对性能有严重影响。然而,有人应该会注意到这种测试的执行时间波动可能会很大。由于并行计算的不确定性,可能另一次运行产生的图上,不可变和可变服务的执行时间轨迹会几乎相同。一般来说,可变服务慢于不变服务,但是我们不该仅凭一张图或一次执行来判断性能。所以图2.3是另一次执行的图,你可以看到,在某一次测试里,可变服务得到上帝垂青,加锁开销极大降低。
图2.2 不可变VS可变服务“最差场景”

在图2.2里你可以看到有一个测试案例执行时所有时机都配合得恰到好处,以至于可变服务在那一瞬间超过了不变服务的性能。尽管存在这种个别案例,一般情况下不变服务的性能好于可变服务。如果我们得出的结论也适用于真实世界的程序的话,就说明不变服务性能一般较优,而且也没有随机争用降速(random contention slowdown)的问题。
最重要的事是要认识到不可变对象可以安全地在多个线程之间传递而不用担心争用。能够消除锁以及锁所带来的各种潜在bug,能极大地提高代码库(codebase)的稳定性。再加上不变性可以提高代码的可推理性,如我们在前文equals方法里所见。我们应该努力在代码库里保持不变性。
Scala通过不变性减少了开发者在与不可变对象交互时必须得采用的保护措施,从而简化了并发程序开发。除了不变性,Scala还提供了Option类,减少了开发者在处理null时需要采用的保护措施。
图2.3 不可变VS可变服务“一次完美运行场景”

时间: 2024-10-03 19:57:40

《深入理解Scala》——第2章,第2.3节优先选择不变性的相关文章

《深入理解Scala》——导读

目 录 第1章 Scala--一种混合式编程语言1.1节Scala一种混合式编程语言 1.2 当函数式编程遇见面向对象 1.3 静态类型和表达力 1.4 与JVM的无缝集成 1.5 总结 第2章 核心规则 2.1 学习使用Scala交互模式(REPL)2.2 优先采用面向表达式编程 2.3 优先选择不变性2.4 用None不用null2.5 多态场景下的判等2.6 总结 第3章 来点样式-编码规范 第4章 面向对象编程 第5章 利用隐式转换写更有表达力 第6章 类型系统 第7章 隐式转换和类型系

《深入理解Scala》——第1章,第1.4节与JVM的无缝集成

1.4 与JVM的无缝集成 深入理解Scala Scala的吸引力之一在于它与Java和JVM的无缝集成.Scala与Java有很强的兼容性,比如说Java类可以直接映射为Scala类.这种紧密联系使Java到Scala的迁移相当简单,但在使用Scala的一些高级特性时还是需要小心的,Scala有些高级特性是Java里没有的.在Scala语言设计时已经小心地考虑了与Java无缝交互的问题,用Java写的库,大部分可以直接照搬(as-is)到Scala里. 1.4.1 Scala调用Java 从S

《深入理解Scala》——第2章,第2.1节学习使用Scala交互模式(REPL)

第2章 核心规则深入理解Scala 本章包括的内容: • 使用Scala交互模式(Read Eval Print Loop 简称REPL) • 面向表达式编程 • 不变性(Immutability) • Option类 本章内容覆盖了每个新Scala开发者都需要知道的几个主题.本章不会深入到每个主题里,但是会讲到可以让你自己去接着探索的程度.你将学会使用REPL,学会如何利用这个工具做软件的快速原型开发.然后我们会学到面向表达式编程,并从另一个视角来看控制结构是怎么回事.在此基础上,我们来研究不

《深入理解Scala》——第1章,第1.3节静态类型和表达力

1.3 静态类型和表达力 深入理解Scala 开发人员中有一个误解,认为静态类型必然导致冗长的代码.之所以如此是因为很多继承自C的语言强制要求程序员必须在代码中多处明确地指定类型.随着软件开发技术和编译器理论的发展,情况已经改变.Scala利用了其中一些技术进步来减少样板(boilerplate)代码,保持代码简洁. Scala做了以下几个简单的设计决策,以提高代码表达力. • 把类型标注(type annotation)换到变量右边. • 类型推断. • 可扩展的语法. • 用户自定义的隐式转

《深入理解Scala》——第1章,第1.5节总结

1.5 总结 深入理解Scala 本章中,你学到了一些Scala的设计理念.设计Scala的初衷在于把不同语言中的多种概念融合起来.Scala融合了函数式和面向对象编程,尽管显然Java也已经这么做了.Scala精选其语法,极大地减少了语言中的繁冗之处,使一些强大的特性可以优雅地表达,比如类型推断.最后,Scala和Java能够紧密集成,而且运行在Java虚拟机上,这或许是让Scala变成一种实用选择的最重要的一点.几乎不花代价就可以把Scala用于我们的日常工作中. 因为Scala融合了多种概

《深入理解Scala》——第1章,第1.1节Scala一种混合式编程语言

第1章 Scala--一种混合式编程语言 Scala是一种将其他编程语言中的多种技巧融合为一的语言.Scala尝试跨越多种不同类型的语言,给开发者提供面向对象编程.函数式编程.富有表达力的语法.静态强类型和丰富的泛型等特性,而且全部架设于Java虚拟机之上.因此开发者使用Scala时可以继续使用原本熟悉的某种编程特性,但要发挥Scala的强大能力则需要结合使用这些有时候相互抵触的概念和特性,建立一种平衡的和谐.Scala对开发者的真正解放之处在于让开发者可以随意使用最适合手头上的问题的编程范式.

《深入理解Scala》——第2章,第2.6节总结

2.6 总结 深入理解Scala 本章中我们了解了Scala编程时的第一个关键组成部分.利用REPL做快速原型是每个成功的Scala开发者必须掌握的关键技术之一.面向表达式编程和不可变性都有助于简化程序和提高代码的可推理性.Option也有助于可推理性,因为它明确声明了是否接受空值.另外,在多态的场景下实现好的判等可能不容易.以上这些实践可以帮助我们成功踏出Scala开发的第一步.要想后面的路也走得顺利,我们就必须来看一下编码规范,以及如何避免掉进Scala解析器的坑.

《深入理解Scala》——第1章,第1.2节当函数式编程遇见面向对象

1.2 当函数式编程遇见面向对象 深入理解Scala 函数式编程和面向对象编程是软件开发的两种不同途径.函数式编程并非什么新概念,在现代开发者的开发工具箱里也绝非是什么天外来客.我们将通过Java生态圈里的例子来展示这一点,主要来看Spring Application framework和Google Collections库.这两个库都在Java的面向对象基础上融合了函数式的概念,而如果我们把它们翻译成Scala,则会优雅得多.在深入之前,我们需要先理解面向对象编程和函数式编程这两个术语的含义

《深入理解Scala》——第2章,第2.2节优先采用面向表达式编程

2.2 优先采用面向表达式编程 深入理解Scala 面向表达式编程是个术语,意思是在代码中使用表达式而不用语句.表达式和语句的区别是什么?语句是可以执行的东西,表达式是可以求值的东西.在实践中这有什么意义呢?表达式返回值,语句执行代码,但是不返回值.本节我们将学习面向表达式编程的全部知识,并理解它对简化程序有什么帮助.我们也会看一下对象的可变性,以及可变性与面向表达式编程的关系. 作者注:语句VS表达式 语句是可以执行的东西,表达式是可以求值的东西. 表达式是运算结果为一个值的代码块.Scala