Chapter 30《Object Equality》
Scala中的相等性
-
Scala的相等性比较和Java中的不同,在Java中使用==表示两个对象的引用相等性,使用equals表示自然意义的相等性。在Scala中使用eq表示两个对象的引用相等性,equals是Java的Object类中的equals方法,使用==表示两个对象自然意义的相等性。对于新类的相等性,可以通过重写equals方法来实现==的相等意义。这个equals如果不重写,则还是使用的Object中的equals方法用来比较对象本身引用的相等性。==本身是不能被重写的,因为在Any类中该方法是被定义为final的。
对象相等性比较的设计
-
由于大量的代码都依赖于对象的相等性,因此需要定义准确的相等性比较函数。刚开始定义容易出现的错误有:
-
equals的签名定义错误 - 修改
equals的时候没有修改hashCode - 使用可变字段来定义
equals - 定义的
equals不满足相等性关系
1. equals的签名定义错误
class Point(val x: Int, val y: Int) { ... } def equals(other: Point): Boolean = this.x == other.x && this.y == other.y定义
p1和p2为equals为true的两个点,当定义val p2a: Any = p2的时候,p1和p2a使用equals时就不能返回true,原因在于在Point中定义的比较,使用的是Point类型的参数,而不是Any类型的,并没有Override Any类中的equals方法,而是对其的一个重载,Scala和Java一样也是使用编译器类型选择重载函数的版本,因此比较的使用的是Any中的原生equals,比较的是引用相等性,必然是不同的。HashSet的Contains方法中使用了对象的HashCode,和该对象在内存中的地址有关,所以尽管p1和p2的equals方法返回的是true,p1也的确在set中,但是p1和p2的Hashcode是不一样的。改进版:
override def equals(other: Any) = other match { case that: Point => this.x == that.x && this.y == that.y case _ => false }重写了
equals方法,任何两个对象的equals都会使用这个版本。不要试图去定义==方法。2.修改了equals但是不修改HashCode
以上的
HashSet中的Contains方法结果出错的原因就是没有重新定义HashCode,使用Hash桶存储数据。HashCode的实现依然是AnyRef中的实现,和对象的地址有关。equals相等,但是contains找不到,这样是不合逻辑的。所以一般在修改了equals的类中,也会修改Hashcode使其相等。在Java中,Hashcode和equals的修改总是同步的,hashcode的值依赖于equals比较时使用的类的fields。例如可以这样定义class Point(val x: Int, val y: Int) { override def hashCode = (x, y).## override def equals(other: Any) = other match { case that: Point => this.x == that.x && this.y == that.y case _ => false } }##是计算hash code的简写方法。3.equals依赖于可变的类字段,例如
class Point(var x: Int, var y: Int) { // Problematic override def hashCode = (x, y).## override def equals(other: Any) = other match { case that: Point => this.x == that.x && this.y == that.y case _ => false } }x,y现在都是var,equals和hashCode都依赖于可变字段进行修改,所以当x,y改变的时候,相应的hashcode也会修改,因此,使用hashcode存储的coll会受到影响,因为原来的对象的key发生了变化,coll使用新的key寻找value时,会找不到。 -
4.不能满足相等关系
两个对象相等必须要满足以下的关系:自反性,对称性,传递性,重现性,任意不为null的value和null都是不相等的。目前的设计是满足这些关系的,但如果加入了子类,则情况发生了变化,定义了一个ColoredPoint。
class ColoredPoint(x: Int, y: Int, val color: Color.Value)
extends Point(x, y) { // Problem: equals not symmetric
override def equals(other: Any) = other match {
case that: ColoredPoint =>
this.color == that.color && super.equals(that)
case _ => false
}
}
该子类继承了父类的hashCode并在equals中调用了父类的equals方法。p = new Point(1,2),cp = new ColoredPoint(1,2,Yellow),p equals cp可以返回正确的true,但是cp equals p返回的却是false,因为p并不是一个ColoredPoint,不满足对称性。将equals中的对比条件设置得更宽泛些,因此有了以下的设置:
class ColoredPoint(x: Int, y: Int, val color: Color.Value) extends Point(x, y) { // Problem: equals not transitive
override def equals(other: Any) = other match {
case that: ColoredPoint =>
(this.color == that.color) && super.equals(that)
case that: Point =>
that equals this
case _ =>
false
}
}
这样可以满足对称性,但是不满足传递性!p1 == cp1(RED),p1 == cp2(BLUE),但是cp1(RED)和cp2(BLUE)并不是相等的。如何能够在类的层级上定义相等函数并保持同级相等关系需要满足的条件?使用canEqual函数
def canEqual(other: Any): Boolean
在任何重写了equals的类中都定义一下这个函数,这个函数定义了哪些obj可以被当做该类的一个实例,从而返回true,否则的话返回false。这个方法会在equals中被调用,从而满足子类进行对比的要求。以下版本是特别严格的比较版本:
class Point(val x: Int, val y: Int) {
override def hashCode = (x, y).##
override def equals(other: Any) = other match {
case that: Point => (that canEqual this) && (this.x == that.x) && (this.y == that.y)
case _ => false
}
def canEqual(other: Any) = other.isInstanceOf[Point]
}
canEqual声明了所有的Point的子类都可以被当做是Point进行比较。对于ColoredPoint的实现则可以
class ColoredPoint(x: Int, y: Int, val color: String) extends Point(x, y) {
override def hashCode = (super.hashCode, color).##
override def equals(other: Any) = other match {
case that: ColoredPoint => (that canEqual this) && super.equals(that) && this.color == that.color
case _ => false
}
override def canEqual(other: Any) =
other.isInstanceOf[ColoredPoint]
}
如果在父类中重写了equals并定义了canEqual函数,那么在子类中可以决定要不要定义canEqual函数来决定子类对象是否可以和父类相等。如果定义了canEqual,则子类对象不能和父类对象相等;如果没有定义canEqual,则子类继承父类的canEqual,子类对象可以和父类对象相等,这种情况发生在子类对象和父类对象真的可以相等的时候,因为相等具有自反性,child=father,father=child则是必须的。这种情况则最大给予了子类的自由,如果是一个new Point(1,2) {val y=1}这种,其实就是父类,可以不用重写canEqual,但是如果在Point上添加了色彩属性,就一般需要重新覆盖canEqual了。
为参数化的类型定义相等性
- 之前做
equals的第一步就比较equals的操作数类型是否为含有equals的类的实例,当这个类含有类型参数的时候,情况会稍微复杂一些。
在实现trait Tree[+T] { def elem: T def left: Tree[T] def right: Tree[T] } object EmptyTree extends Tree[Nothing] { def elem = throw new NoSuchElementException("EmptyTree.elem") def left = throw new NoSuchElementException("EmptyTree.left") def right = throw new NoSuchElementException("EmptyTree.right") } class Branch[+T](val elem: T, val left: Tree[T], val right: Tree[T]) extends Tree[T]equals和hashcode时候,在Tree类中并不需要实现,只需在子类中进行实现即可。在EmptyTree对象中,继承自AnyRef的默认实现就可以了。毕竟,一个object就应该等于自己,比较的也就是引用地址的相等性。在Branch中实现的时候,需要当前的元素是相等的,左子树是相等的,右子树也是相等的。如果在equals中采用这样的写法:
如果使用override def equals(other: Any) = other match { case that: Branch[T] => this.elem == that.elem && this.left == that.left && this.right == that.right case _ => false }uncheck选项,会有uncheck的警告,因为泛型信息在编译器被擦除,编译器只能检测出对象是否为Branch,而不能检测出是否为Branch[T],这个T在运行时期是不可见的。class Branch[T](val elem: T, val left: Tree[T], val right: Tree[T]) extends Tree[T] { override def equals(other: Any) = other match { case that: Branch[_] => (that canEqual this) && this.elem == that.elem && this.left == that.left && this.right == that.right case _ => false } def canEqual(other: Any) = other.isInstanceOf[Branch[_]] override def hashCode: Int = (elem, left, right).## }
制定equals函数的步骤:
-
- 在
non-final类中覆盖equals函数,并创建一个canEqual方法,如果是直接继承AnyRef,则在AnyRef中没有canEqual方法,此时创建的canEqual方法就是新的方法,否则的话,会重写继承类中的canEqual方法。唯一的例外是继承自AnyRef的final类,因为没有继承的子类,就算是定义了canEqual,也和没有定义是一样的,能调用canEqual的对象调用该方法后的返回值都为true。因此不需要在这类类中定义canEqual函数,定义的签名如下所示:
def canEqual(other: Any): Boolean = - 在
-
canEqual函数如果是当前类的一个实例,则需要返回true,否则的话返回false
other.isInstanceOf[Rational]
-
- 对
equals的重写,方法签名要写正确的:
override def equals(other: Any): Boolean =
- 对
- 书写
equals的方法体,使用一个match语句
other match { // ... }
- 书写
-
match中的case分为两类,第一类是使用类型匹配,
case that: Rational =>
-
- 接下来的判断逻辑:使用与逻辑表达式将各个单独的判断表达式并联在一起,如果需要使用父类中的
equals方法,需要指明是super.equals而不是直接使用equals,如果定义了canEqual方法,使用它,
(that canEqual this) &&
最后使用类中的每一个域进行比较:
numer == that.numer && denom == that.denom
- 接下来的判断逻辑:使用与逻辑表达式将各个单独的判断表达式并联在一起,如果需要使用父类中的
- 最后一种情况,使用一个通配符进行匹配:
case _ => false
- 最后一种情况,使用一个通配符进行匹配:
制定hashcode的步骤
-
hashcode的制定依赖于判断相等性的字段,使用元组将这些字段组合起来,然后使用##计算其hashcode,如果在equals中调用了super.equals,则在hashcode中也必须算上override def hashCode: Int = (super.hashCode, numer, denom).##
总结
- 实现一个正确的
equals方法必须小心equals的签名,重写hashCode,不能依赖变化的字段,如果不是final class的话,还需要实现canEqual方法。因此一般如果需要比较的类,推荐使用case class。