Swift 如何正确的实现 Equatable
从 NSObject 的判等说起
相信所有的 Swift 开发者都碰到过(或将要碰到)实现 NSObject 子类的判等的坑。
历程大概是这样的,先凭纯熟的 Swift 直觉写出一个 == 实现:
class Object: NSObject {
let value: Int
init(value: Int) {
self.value = value
}
static func ==(lhs: Object, rhs: Object) -> Bool {
// 这里一般是由于业务需要,故意跳过了 NSObject 父类的判等
lhs.value == rhs.value
}
}
简单跑起来也非常正确,子类的 == 被正确调用了。
let a = Object(value: 1)
let b = Object(value: 1)
a == b // true
多测试一些 Case,发现在 Object 一旦在其他容器( 如 Optional, 数组)里结果就不对了:
let a = Object(value: 1)
let b: Object? = Object(value: 1)
let isOptionalEqual = a == b
很显然,我们实现的 Object 的== 并没有被调用。通过调用堆栈可以发现被调用的是 Object 的 isEqual 方法。翻阅 Swift 源码中 NSObject 中 Equatable 的实现:
// ObjectiveC.swift
extension NSObject : Equatable, Hashable {
public static func == (lhs: NSObject, rhs: NSObject) -> Bool {
return lhs.isEqual(rhs)
}
// ...
}
那把判等逻辑移动到 isEqual 就万事大吉了!
class Object: NSObject {
//...
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Object else {
return false
}
return value == object.value
}
}
简单测试,这个实现结果基本正确。但为什么 Object 的 == 没有被调用呢?
再看看调用堆栈:

注意图中的 in conformance NSObject
从上图,可以看出容器中的 Object 调用的是声明了遵循 Equatable 的 NSObject 的 ==,而不是 Object 中的 ==(实际上,这两个 == 的方法签名也不同)。这个“问题”看来和 NSObject 并没有关系,只是 NSObject 继承类是最容易发生这个 “问题” 场景。那这个现象的原因是什么呢?
继承与 Protocol 方法调度
在苹果官方的 SIL 文档中,介绍了继承与 Protocol 的关系:
If a derived class conforms to a protocol through inheritance from its base class, this is represented by an inherited protocol conformance, which simply references the protocol conformance for the base class.
[…]
Witness tables are only directly associated with normal conformances. Inherited and specialized conformances indirectly reference the witness table of the underlying normal conformance.
Protocol 中定义的方法通过 PWT(Protocol Witness Table,与 V-Table 类似)进行调度。从文档可知,只有显式声明了遵循某个协议的类得到对应的 PWT,它的子类并不会得到一个独立的 PWT,而是直接使用父类的。
通过查看一段简单的代码生成的 SIL(Swift Intermediate Language,Swift 编译器生成 IR 之前的一个中间语言),可以验证这个逻辑:
class Parent: Equatable {
/* final */static func ==(lhs: Parent, rhs: Parent) -> Bool {
return true
}
}
class Son: Parent {
static func ==(lhs: Son, rhs: Son) -> Bool {
return false
}
}
生成 SIL:
swiftc -emit-sil -Onone main.swift > main.swift.sil
可以看到整个生成的 SIL 文件中,唯一一个 sil_witness_table 就是 Parent 的,Son 并没有独立的 PWT。
// ...
sil_witness_table hidden Parent: Equatable module main {
method #Equatable."==": <Self where Self : Equatable> (Self.Type) -> (Self, Self) -> Bool : @$s4main6ParentCSQAASQ2eeoiySbx_xtFZTW // protocol witness for static Equatable.== infix(_:_:) in conformance Parent
}
//...
那么,是否可以通过 override 的方式覆盖掉父类的 == 方法,很遗憾,并不行,static 方法默认是 final 的。
如何正确的实现 Equatable
判等作为日常编程最简单基础的一项行为,也需要满足很多的特性,包括:
- 反射性:对于
x,x == x应该是true。 - 对称性:对于
x和y,y == x与x == y应返回相同的值。 - 传递性:对于
xyz,当x == y,y == z都为true时,x == z也应为true。 - 一致性:对于
xy,当不修改x和y的属性时,x == y的结果无论多少次执行都应一致。 - 对于非空的
x,x == nil的结果应为false。
上述几条特性看起来平平无奇,但一旦涉及到父类/子类混合判等,实际要全部满足有相当的难度。除非你是 struct 的忠实爱好者,否则在代码库中总能找到几个不完全符合上述特性的 == 实现。 一位发表了 2007 篇论文的作者也曾断论:几乎所有 Java equals 方法的实现都是错误的。1
回到 == 的实现,既然不能 override,我们可以把 == 中判等实现剥离出一个独立方法,这样子类可以继承。这也正是 NSObject 的实现方式。于是乎,我们有了一个比较通用的实现:
class Parent: Equatable {
let a: Int
init(a: Int) {
self.a = a
}
/* final */static func ==(lhs: Parent, rhs: Parent) -> Bool {
return lhs.isEqual(rhs)
}
func isEqual(_ object: Parent) -> Bool {
a == object.a
}
}
class Son: Parent {
let b: Int
init(a: Int, b: Int) {
self.b = b
super.init(a: a)
}
override func isEqual(_ object: Parent) -> Bool {
guard let son = object as? Son else { return false }
return super.isEqual(object) && b == son.b
}
}
感谢 Swift 良好的类型系统,大部分判等的特性都自动满足了,但很快就发现其实这并不满足传递性:
a == s // true
a == s2 // true
s == s2 // false
为此需要再加上一些类型检查:
class Parent: Equatable {
let a: Int
init(a: Int) {
self.a = a
}
/* final */static func ==(lhs: Parent, rhs: Parent) -> Bool {
return lhs.isEqual(rhs)
}
func isEqual(_ object: Parent) -> Bool {
object.canEqual(self) && a == object.a
}
func canEqual(_ object: Parent) -> Bool {
true // object is Parent
}
}
class Son: Parent {
let b: Int
init(a: Int, b: Int) {
self.b = b
super.init(a: a)
}
override func isEqual(_ object: Parent) -> Bool {
guard let son = object as? Son else { return false }
return object.canEqual(self) && super.isEqual(object) && b == son.b
}
override func canEqual(_ object: Parent) -> Bool {
object is Son
}
}
备注:如果有用到
Hashable,子类需要 overridehash(into hasher:,否则在 Dictionary 与 Set 中可能会冲突。
这个实现来自 Scala 语言的作者 Martin Odersky,精巧之处在于 canEqual 的实现使得父类与子类的判等结果必定是 false,保证了传递性。在这个实现下,我们设定了一个隐式的“约定”,即子类在实现判等时候需要实现 canEqual,这类约定在大型项目中往往难以保证必定得到满足,也常常是一些 Bug 的源头。因此,struct 和减少继承层级其实是更为推荐的做法,这样一个简单的 == 就能满足所有的需求。
Takeaway
NSObject子类判等调用isEqual的现象并不是一个 Bug,而是 Protocol 与继承组合起来的奇怪结果。- 对于 Swift 语言特性的探究,SIL 是一个很好的工具。
- 多层继承下实现一个正确的
Equatable费事费力,优先考虑struct或减少继承层级。
全文完
参考
Tags: Swift, Equatable