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可变服务“一次完美运行场景”