原文:Equality, Identity, and Hashing with Swift Types
编写高效且易于测试的代码,减少错误的发生。
在开始之前,让我们先来回答以下问题。
什么是类型?
这个问题的答案相当简单------**类型是一个相关数据的集合,封装了某些概念和原则。**一个类型可以是抽象的,也可以是具体的,它定义了一组我们可以对其进行的有效操作,最后,一个类型明确划分了内部和外部的界限(封装性的体现)。
类型是一种非常好的文档形式,比指导性的注释好得多。类型的设计可以保证代码的安全和行为的正确性,由于类型的工作是安全的,所以在编译时,你可能会在工作中遇到很多编译器错误。但这是件好事,因为这使我们能够在大多数问题进入生产应用之前就将其修复。我鼓励你把这些当成编译器在我们出错时生成的待办事项清单。
类型也使我们能够编写易于测试的代码。例如,让我们以下面两个函数为例:
swift
func getAddress() -> String
swift
func getAddress() -> Any
以上所给的函数中,哪个更容易测试?第二个函数提供了更多的灵活性,因为它允许我们返回 Any
类型,但是在测试方法的正确性时,同样的灵活性被证明是具有挑战性的。如果我们看一下第一个函数,我们可以确定它将返回一个String
类型。
仅仅通过观察第一个函数,我们就可以看到它每次都会返回一个String
,但是第二个函数就不一样了,这就使得它更难测试。
Equality 和 Identity / 等同性和唯一性
我们先借助下面的例子来探讨 "Equality":
swift
struct EmployeeID {
private(set) var id: Int
init?(_ raw: Int) {
guard raw > 1000 else {
return nil
}
id = raw
}
}
我们有一个 EmployeeID
类型。
我们有一个 EmployeeID
的可失败初始化器,可以防止创建一个小于 4 位数的 EmployeeID
。
为了方便测试 EmployeeID
类型,让我们增加 Equatable
协议一致性,看看编译器对现有代码的反应:
swift
struct EmployeeID : Equatable {
// Same code as above
}
let employeeIDOne = EmployeeID(1001)
let employeeIDTwo = EmployeeID(1001)
print("Both EmployeeID's are \(employeeIDOne == employeeIDTwo ? "equal" : "unequal")")
我们可以看到,编译器并没有抱怨,这是因为 EmployeeID
是一个值类型(ValueType
),id
是一个 Int
类型。编译器隐含地生成了一致性要求,因此,执行上面的代码会在控制台中打印以下内容。
让我们借助于下面的例子来研究引用类型的 Equality 行为:
swift
class Employee {
var id: EmployeeID
var name: String
init(id: EmployeeID, name: String) {
self.id = id
self.name = name
}
}
我们有一个 Employee
类,并注意到它需要一个 EmployeeID
类型。在这种情况下,Employee
是一个依赖类型 ,如果没有一个有效的 EmployeeID
或者至少是一个超过四位数的 EmployeeID
,就不可能创建一个 Employee
。这些都是强有力的保证。
现在,如果我们像对待 EmployeeID
类型那样使 Employee
类型遵循 Equatable
协议,你会注意到编译器会直接开始发出警告,信息如下:"Type 'Employee' does not conform to protocol 'Equatable'"。这其中的一个原因是:
引用类型可能会形成一个对象的引用循环,并创造一个检查等同性的无限循环。Codable,它确实生成了一个自动的
Equatable
,目前也有这个问题,如果有一个循环,它将创建一个无限的循环。
解决上述问题的方法非常简单------只需在代码中实现 "==
",如下所示:
swift
class Employee : Equatable {
static func == (lhs: Employee, rhs: Employee) -> Bool {
return lhs.id == rhs.id &&
lhs.name == rhs.name
}
// Same as above.
}
标准的实现是将左边的所有属性与右边的进行比较。
引用类型除了 Equality 之外还有 Identity 的概念,让我们借助下面的代码例子看看这两个概念有什么不同:
swift
guard let employeeId = EmployeeID(1001) else {
fatalError("Not a valid Employee ID")
}
let firstEmployee = Employee(id: employeeId, name: "EmployeeOne")
let copyOfFirstEmployee = Employee(id: employeeId, name: "EmployeeOne")
print("Both Employees are \(firstEmployee == copyOfFirstEmployee ? "Equal" : "Unequal")")
print("Both Employees are \(firstEmployee === copyOfFirstEmployee ? "Identical" : "Unidentical")")
在这里,我们用相同的 EmployeeId
创建了两个相似的 Employee
副本,然后我们比较了这两个 Employee
对象,看它们是否相等。我们还用 "==
" 和 "===
" 运算符来检查它们是否相同。执行上述代码的结果是在控制台中打印出以下内容:
"===
" 检查两个对象是否指向相同的引用 ,由于两个引用都指向内存中相等但不同的位置,比较这些对象身份的结果是错误的,即 firstEmployee
和 copyOfFirstEmployee
是相等的,但它们在内存中并不指向同一个对象。
如果我们把 firstEmployee
分配给一个新的变量,然后检查这两个变量的 Identity,会发生什么?
swift
let refereceToFirstEmployee = firstEmployee
print("Both References are \(refereceToFirstEmployee === firstEmployee ? "Identical" : "Unidentical")")
执行这段代码的结果是在控制台打印出以下内容:
证明这一次,两个对象现在指的是内存中的同一个位置。
我们可以灵活地定义 "==
",但最好是保持简单,比较所有的属性,只要我们不产生无限循环。
添加 Equatable 一致性使模型更可用,更容易测试。
Hashable
Hashable 是一种比较对象的更强大的方式。当我们需要在 Dictionary
或 Set
中进行快速查找时,它很有用。
Set
中的元素或Dictionary
中的键必须是可哈希的;否则,编译器会开始抱怨。
另外,Hashable 也需要 Equatable 一致性 。让我们看看它是如何工作的。假设我们有一个包含 7 个可哈希对象的 Set
,如下图所示:
Set
中的每个对象都会返回一个看似随机但实际上并非随机的数字,称为 Hash 值。这个 Hash 值然后被用来为底层的 Set
存储生成一个槽号。
槽数 = 集合中单个元素的哈希值 % 集合中元素的总数。
例如,假设元素 0 返回的哈希值是 1234567。这个元素的槽位数是 1234567 乘以 7=5。
不同的实例产生不同的哈希值,从而产生不同的槽号,但有时甚至不同的哈希值可能导致相同的槽号,从而导致碰撞,如图片中的元素 3-5 所示。
碰撞会减慢性能,这就是为自定义类型建立有效的散列机制的原因。在我们上面的例子中,当涉及到查找元素 0 时,如果散列值和槽号生成为唯一的,这可以在恒定时间内发生,即 O (1)。让我们看看 hashable
是如何影响现有代码的。
在上面的代码例子中,为了让 EmployeeID
成为可哈希类型,我们需要做的就是让它符合 Hashable
而不是 Equatable
,Swift 会在后台为我们做必要的调整,因为 EmployeeID
里面的存储值是一个 Int
,Int
本质上是可散列的。
但同样的事情并不直接适用于 Employee
类型,因为它是一个引用类型。正因为如此,当我们试图使其成为可哈希类型的时候,编译器就开始抱怨了,如下图所示。
使 Employee 可哈希
Swift 的最新迭代使得正确实现 Hashing
变得非常直观和清晰。让我们来看看这段代码:
swift
class Employee : Hashable {
// Same as above
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
}
}
我们首先实现 hash(into hasher: inout Hasher)
方法,并利用传入的 hasher
来哈希我们所有的属性。如代码所示,Hasher
使用了名为 combine
的方法,并利用可哈希的子对象来生成哈希值。
一个最佳实践是在
hash(into:)
方法中使用我们在==
中使用的所有属性。
如果两个对象是相等的,那么它们必须返回相同的哈希值。否则,在 Dictionary
或 Set
中的查找将不起作用。而反过来就不对了,因为两个不同的对象可以返回相同的哈希值,我们最终会出现碰撞。散列器试图创建密码学上正确的散列值。
好的哈希值是必须的。否则,想象一下,如果一个恶意的黑客能够通过为字典内的键注入相同的哈希值来造成碰撞,会发生什么?提示:☠️☠️☠️**DENIAL OF SERVICE!!!**☠️☠️☠️
关于其他的更新,你可以在 Twitter 上关注我,我的 twitter@NavRudraSambyal
谢谢你的阅读,如果你觉得有用,请分享给大家 :)