今天我们将一起学习一些真正的Swift功能:协议、扩展和协议扩展。
协议扩展允许我们摒弃庞大、复杂的继承层次结构,取而代之的是更小、更简单的协议,这些协议可以组合在一起。
从你的第一个 SwiftUI 项目开始,你就会用到协议,它们在你的整个 Swift 编码生涯中都将是无价之宝,值得你花时间去熟悉它们。
本章节有四个部分,让你了解协议、扩展等内容。现在就让我们开始吧!
如何创建和使用协议
协议有点像 Swift 中的合约:它让我们定义我们期望数据类型支持哪些功能,而 Swift 则确保我们代码的其余部分遵循这些规则。
想想我们如何编写代码来模拟一个人从家到办公室的通勤过程。我们可以创建一个小的 Car 结构,然后编写这样一个函数:
func commute(distance: Int, using vehicle: Car) {
// lots of code here
}
当然,他们也可能坐火车上下班,所以我们也会这样写:
func commute(distance: Int, using vehicle: Train) {
// lots of code here
}
他们也可能乘坐公共汽车:
func commute(distance: Int, using vehicle: Bus) {
// lots of code here
}
或者,他们可能会使用自行车、电动摩托车、共享单车或其他交通工具。
事实上,在这个层面上,我们并不关心基本的出行方式。我们关心的是更广泛的问题:用户使用每种选择通勤可能需要多长时间,以及如何执行移动到新地点的实际行动。
这就是协议的作用所在:协议让我们定义了一系列我们想要使用的属性和方法。它们并不实现这些属性和方法——实际上并不在其背后添加任何代码——它们只是说这些属性和方法必须存在,有点像蓝图。
例如,我们可以这样定义一个新的车辆协议:
protocol Vehicle {
func estimateTime(for distance: Int) -> Int
func travel(distance: Int)
}
让我们来分析一下:
- 要创建一个新协议,我们要写 protocol,然后是协议名称。这是一个新类型,因此我们需要使用大写字母开头的驼峰式大小写。
- 在协议中,我们要列出所有需要使用的方法,以使该协议按照我们期望的方式运行。
- 这些方法内部不包含任何代码——这里不提供函数体。相反,我们指定了方法名称、参数和返回类型。如果需要,还可以将方法标记为抛出(throwing)或突变(mutating)。
那么,我们已经制定了协议——这对我们有什么帮助呢?
现在,我们可以设计与该协议配合使用的类型。这意味着要创建新的结构体、类或枚举来实现协议的要求,我们把这个过程称为 “采用协议(adopting)”或 “符合协议(conforming)”。
协议并没有规定必须存在的全部功能,只规定了最基本的功能。这意味着当你创建符合协议的新类型时,你可以根据需要添加各种其他属性和方法。
例如,我们可以创建一个符合车辆协议的 Car 结构,就像下面这样:
struct Car: Vehicle {
func estimateTime(for distance: Int) -> Int {
distance / 50
}
func travel(distance: Int) {
print("I'm driving \(distance)km.")
}
func openSunroof() {
print("It's a nice day!")
}
}
在这段代码中,我有几处需要特别注意:
- 我们在 Car 名称后面使用冒号来告诉 Swift:Car 与 Vehicle 相符,就像我们标记子类一样。
- 我们在 Vehicle 中列出的所有方法必须完全存在于 Car 中。如果它们的名称略有不同、接受的参数不同、返回类型不同等,那么 Swift 就会说我们没有遵守协议。
- Car 中的方法提供了我们在协议中定义的方法的实际实现。在本例中,这意味着我们的结构体提供了一个粗略估计,即行驶一定距离需要多少分钟,并在调用 travel() 时打印一条信息。
- openSunroof()方法并非来自车辆协议,而且在该协议中也没有实际意义,因为许多车辆类型都没有天窗。不过没关系,因为协议只描述了符合要求的类型必须具备的最低功能,它们可以根据需要添加自己的功能。
现在,我们创建了一个协议,并制作了一个符合协议的汽车结构体。
最后,让我们更新之前的 commute() 函数,使其使用我们为 Car 添加的新方法:
func commute(distance: Int, using vehicle: Car) {
if vehicle.estimateTime(for: distance) > 100 {
print("That's too slow! I'll try a different vehicle.")
} else {
vehicle.travel(distance: distance)
}
}
let car = Car()
commute(distance: 100, using: car)
这些代码都能正常工作,但在这里,协议实际上并没有增加任何价值。是的,它让我们在 Car 内部实现了两个非常特殊的方法,但我们本可以在不添加协议的情况下做到这一点,那又何必呢?
聪明的地方来了: Swift 知道,任何符合 Vehicle 的类型都必须实现 estimateTime() 和 travel() 方法,因此它允许我们使用 Vehicle 作为参数类型,而不是 Car。我们可以这样重写函数:
func commute(distance: Int, using vehicle: Vehicle) {
现在我们是说,只要数据类型符合车辆协议,就可以使用任何类型的数据调用此函数。函数的主体无需更改,因为 Swift 可以确定 estimateTime() 和 travel() 方法已经存在。
如果你还想知道为什么这很有用,请参考下面的结构:
struct Bicycle: Vehicle {
func estimateTime(for distance: Int) -> Int {
distance / 10
}
func travel(distance: Int) {
print("I'm cycling \(distance)km.")
}
}
let bike = Bicycle()
commute(distance: 50, using: bike)
现在我们有了第二个同样符合 Vehicle 标准的结构体,这就是协议的威力所在:我们现在既可以向 commute() 函数传递汽车,也可以传递自行车。在内部,该函数可以拥有它想要的所有逻辑,当它调用 estimateTime() 或 travel() 时,Swift 会自动使用相应的逻辑——如果我们传入一辆汽车,它就会说 “我在开车”,但如果我们传入一辆自行车,它就会说 “我在骑自行车”。
因此,协议让我们讨论的是我们想要使用的功能类型,而不是确切的类型。与其说 “这个参数必须是一辆汽车”,我们倒不如说 “这个参数可以是任何东西,只要它能估算旅行时间并移动到新的位置”。
除了方法外,你还可以编写协议来描述符合要求的类型中必须存在的属性。要做到这一点,先写入 var,然后写入一个属性名称,再列出它是否应该可读和/或可写。
例如,我们可以规定所有符合 Vehicle 的类型都必须指定其名称和当前拥有的乘客数量,就像这样:
protocol Vehicle {
var name: String { get }
var currentPassengers: Int { get set }
func estimateTime(for distance: Int) -> Int
func travel(distance: Int)
}
这就增加了两个属性:
- 一个名为 name 的字符串,它必须是可读的。这可能意味着它是一个常量,但也可能是一个带有 getter 的计算属性。
- 一个名为 currentPassengers 的整数,必须是可读写的。这可能意味着它是一个变量,但也可能是一个带有 getter 和 setter 的计算属性。
这两个属性都需要类型注解,因为我们不能在协议中提供默认值,就像协议不能为方法提供实现一样。
有了这两个额外的要求,Swift 就会警告我们,Car 和 Bicycle 都不再符合协议,因为它们缺少了属性。为了解决这个问题,我们可以为 Car 添加以下属性:
let name = "Car"
var currentPassengers = 1
这些给自行车:
let name = "Bicycle"
var currentPassengers = 1
同样,只要你遵守规则,你也可以用计算属性来代替它们——如果你使用 { get set },那么你就不能使用常量属性来遵守协议。
因此,现在我们的协议需要两个方法和两个属性,这意味着所有符合协议的类型都必须实现这四点,我们的代码才能正常工作。这反过来又意味着 Swift 可以确定这些功能是存在的,因此我们可以编写依赖于这些功能的代码。
例如,我们可以编写一个接受车辆数组的方法,并使用它来计算一系列选项的估算值:
func getTravelEstimates(using vehicles: [Vehicle], distance: Int) {
for vehicle in vehicles {
let estimate = vehicle.estimateTime(for: distance)
print("\(vehicle.name): \(estimate) hours to travel \(distance)km")
}
}
希望这能向你展示协议的真正威力——我们接受一整套 “车辆 ”协议,这意味着我们可以传入一辆汽车、一辆自行车或任何其他符合 “车辆 ”协议的结构,它就会自动运行:
getTravelEstimates(using: [car, bike], distance: 150)
除了接受协议作为参数外,你还可以根据需要从函数中返回协议。
提示:您可以根据需要遵从任意多个协议,只需用逗号将它们一个一个地分隔开来即可。如果你需要对某事物进行子类化并符合某个协议,您应该先列出父类的名称,然后再编写您的协议。
Swift 为什么需要协议?
协议可以让我们定义结构体、类和枚举应该如何工作:它们应该具有哪些方法和属性。Swift 会为我们强制执行这些规则,因此当我们说某个类型符合某个协议时,Swift 会确保它拥有该协议要求的所有方法和属性。
在实践中,协议允许我们以更通用的方式处理数据。因此,与其说 “这个 buy() 方法必须接受一个 Book 对象”,我们可以说 “这个方法可以接受任何符合 Purchaseable 协议的对象。这可能是一本书,但也可能是一部电影、一辆汽车、一些咖啡等等——这让我们的简单方法更加灵活,同时确保 Swift 为我们执行规则。
在代码中,我们只适用于书籍的简单 buy() 方法将如下所示:
struct Book {
var name: String
}
func buy(_ book: Book) {
print("I'm buying \(book.name)")
}
要创建一种更灵活、基于协议的方法,我们首先要创建一个协议,声明我们需要的基本功能。这可能包括许多方法和属性,但在这里我们只需要一个名称字符串:
protocol Purchaseable {
var name: String { get set }
}
现在,我们可以根据需要定义任意多个结构体,每个结构体都有一个名称字符串,以符合该协议:
struct Book: Purchaseable {
var name: String
var author: String
}
struct Movie: Purchaseable {
var name: String
var actors: [String]
}
struct Car: Purchaseable {
var name: String
var manufacturer: String
}
struct Coffee: Purchaseable {
var name: String
var strength: Int
}
你会发现这些类型中的每一种都有一个不同的属性,而协议中并没有声明这些属性,没关系——协议决定了所需的最低功能,但我们可以随时添加更多的功能。
最后,我们可以重写 buy() 函数,使它可以接受任何类型的 Purchaseable 项目:
func buy(_ item: Purchaseable) {
print("I'm buying \(item.name)")
}
在该方法中,我们可以安全地使用项目的 name 属性,因为 Swift 会保证每个 Purchaseable 项目都有 name 属性。但它并不保证我们定义的任何其他属性都会存在,只保证协议中特别声明的属性。
因此,协议可以让我们创建类型共享功能的蓝图,然后在函数中使用这些蓝图,让它们在更广泛的数据上运行。
如何使用不透明返回类型
Swift 提供了一个非常晦涩、复杂但又非常重要的功能,叫做不透明返回类型(opaque return types),它可以让我们消除代码中的复杂性。老实说,如果不是因为一个非常重要的事实,我们是不会在初学者课程中介绍它的:当你创建第一个 SwiftUI 项目时,你就会立即看到它。
重要提示:你不需要详细了解不透明返回类型的工作原理,只需要知道它们的存在并执行一项非常特殊的工作。在学习的过程中,你可能会开始怀疑这个功能为什么有用,但请相信我:它很重要,也很有用,所以请努力学习!
让我们实现两个简单的功能:
func getRandomNumber() -> Int {
Int.random(in: 1...6)
}
func getRandomBool() -> Bool {
Bool.random()
}
提示:Bool.random() 返回 true 或 false。与随机整数和小数不同,我们不需要指定任何参数,因为没有自定义选项。
因此,getRandomNumber() 返回一个随机整数,而 getRandomBool() 返回一个随机布尔值。
Int 和 Bool 都符合 Swift 的通用协议 Equatable,意思是 “可以进行等价比较”。通过 Equatable 协议,我们可以像下面这样使用 ==:
print(getRandomNumber() == getRandomNumber())
因为这两种类型都符合 Equatable,所以我们可以尝试修改函数,返回一个 Equatable 值,就像这样:
func getRandomNumber() -> Equatable {
Int.random(in: 1...6)
}
func getRandomBool() -> Equatable {
Bool.random()
}
但是,这段代码将无法工作,Swift 会弹出一条错误信息,在你的 Swift 职业生涯中,这条错误信息不太可能有什么帮助:“protocol ‘Equatable’ can only be used as a generic constraint because it has Self or associated type requirements(协议’Equatable’只能用作通用约束,因为它有 Self 或相关类型要求)”。Swift 的错误意味着返回 Equatable 没有意义,而理解为什么没有意义是理解不透明返回类型的关键。
首先:是的,你可以从函数中返回协议,而且通常这样做非常有用。例如,你可能有一个为用户寻找租车服务的函数:它接受需要承载的乘客人数,以及他们需要多少行李,但它可能会返回几个结构体中的一个: 紧凑型车、SUV、小型货车等。
我们可以通过返回一个被所有这些结构体采用的车辆协议来处理这个问题,这样无论谁调用函数,都会得到与他们的请求相匹配的某种汽车,而不必编写 10 个不同的函数来处理所有的汽车类型。每种汽车类型都将实现 Vehicle 的所有方法和属性,这意味着它们是可以互换的——从编码的角度来看,我们并不关心返回的是哪种选项。
现在想想发送回 Int 或 Bool。是的,两者都符合 Equatable 协议,但它们不能互换——我们不能使用 == 来比较 Int 和 Bool,因为无论它们符合什么协议,Swift 都不会允许我们这样做。
从函数中返回协议非常有用,因为它可以让我们隐藏信息:我们不必说明返回的确切类型,而是专注于返回的功能。就车辆协议而言,这可能意味着返回座位数、大约的燃料使用量和价格。这意味着我们可以在以后修改代码,而不会造成任何破坏:我们可以返回 RaceCar 或 PickUpTruck 等,只要它们实现了 Vehicle 所需的属性和方法即可。
以这种方式隐藏信息非常有用,但 Equatable 却无法做到这一点,因为无法比较两个不同的 Equatable 事物。即使我们调用 getRandomNumber() 两次得到两个整数,我们也无法对它们进行比较,因为我们隐藏了它们的确切数据类型——我们隐藏了它们是两个可以比较的整数这一事实。
这就是不透明返回类型的作用所在:它允许我们在代码中隐藏信息,但不会被 Swift 编译器发现。这意味着我们保留了在内部灵活运用代码的权利,以便将来可以返回不同的内容,但 Swift 总是能理解实际返回的数据类型,并进行适当的检查。
要将我们的两个函数升级为不透明返回类型,请在其返回类型前添加关键字 some,如下所示:
func getRandomNumber() -> some Equatable {
Int.random(in: 1...6)
}
func getRandomBool() -> some Equatable {
Bool.random()
}
现在我们可以调用 getRandomNumber() 两次,并使用 == 比较结果。 从我们的角度来看,我们仍然只有一些 Equatable 数据,但 Swift 知道它们在幕后实际上是两个整数。
返回不透明的返回类型意味着我们仍然可以专注于我们想要返回的功能,而不是具体的类型,这反过来又允许我们在将来改变主意,而不会破坏代码的其余部分。例如,getRandomNumber() 可以改用 Double.random(in:),代码仍然可以正常运行。
但这样做的好处是,Swift 总是知道真正的底层数据类型。这是一个微妙的区别,但返回 Vehicle 意味着 “任何类型的车辆,但我们不知道是什么”,而返回某种 Vehicle 意味着 “特定类型的车辆,但我们不想说是哪一种”。
说到这里,我估计你已经头晕目眩了,让我举个真实的例子来说明为什么这在 SwiftUI 中很重要。SwiftUI 需要确切知道你想在屏幕上显示什么样的布局,因此我们要编写代码来描述它。
我们可以这样说 “有一个屏幕,顶部是工具栏,底部是标签栏,中间是彩色图标的滚动网格,每个图标下面都有一个标签,用粗体字写明图标的含义,当你点击一个图标时,就会出现一条信息”。
当 SwiftUI 询问我们的布局时,整个描述就会成为布局的返回类型。我们需要明确说明我们希望在屏幕上显示的每一件事物,包括位置、颜色、字体大小等等。你能想象把这些作为返回类型输入吗?这会有一公里长!而且每次更改代码来生成布局时,都需要更改返回类型来匹配。
这时,不透明的返回类型就派上用场了:我们可以返回某个 View 类型,这意味着将返回某种视图屏幕,但我们不必写出它的长达一公里的类型。同时,Swift 知道真正的底层类型,因为这就是不透明返回类型的工作原理: Swift 总是知道返回数据的确切类型,SwiftUI 将使用它来创建布局。
正如在开头所说,不透明返回类型是一个非常晦涩、复杂但又非常重要的功能,如果不是因为它们在 SwiftUI 中被广泛使用,我们也不会在初学者课程中学习它们。
因此,当你在 SwiftUI 代码中看到一些 View 时,这实际上是我们在告诉 Swift:”这将发送回某种视图来布局,但我不想写出具体的内容——你自己去想吧!”
如何创建和使用扩展
扩展可以让我们为任何类型添加功能,不管是我们创建的还是别人创建的,甚至是苹果自己的类型。
为了演示这一点,我想向大家介绍一种有用的字符串方法,名为 trimmingCharacters(in:)。它可以从字符串的开头或结尾删除某些类型的字符,例如字母数字、小数位,或者最常见的空白和新行。
空白是空格字符、制表符以及这两种字符的其他变体的总称。新行是文本中的换行符,听起来似乎很简单,但实际上并没有单一的换行方式,所以当我们要求修剪新行时,它会自动为我们处理所有的变体。
例如,下面是一个两边都有空白的字符串:
var quote = " The truth is rarely pure and never simple "
如果我们想修剪两边的空白和换行符,可以这样做:
let trimmed = quote.trimmingCharacters(in: .whitespacesAndNewlines)
.whitespacesAndNewlines 值来自 Apple 的 Foundation API,实际上 trimmingCharacters(in:) 也是如此——正如我们在本课程开始时所学的,Foundation 确实包含了很多有用的代码!
每次都要调用 trimmingCharacters(in:)有点啰嗦,所以让我们编写一个扩展来缩短它:
extension String {
func trimmed() -> String {
self.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
让我们来分析一下…
- 我们从扩展关键字(extension)开始,它告诉 Swift 我们要为现有类型添加功能。
- 哪种类型?接下来就知道了:我们要为字符串(String)添加功能。
- 现在我们打开一个括号,在最后的收尾括号之前的所有代码都将添加到字符串中。
- 我们要添加一个名为 trimmed() 的新方法,它将返回一个新的字符串。
- 在该方法中,我们调用与之前相同的方法:trimmingCharacters(in:),并将结果发送回去。
- 请注意我们如何在这里使用 self——它自动指向当前字符串。这是因为我们当前处于字符串扩展中。
现在,只要我们想删除空白和换行符,就可以写出下面的代码:
let trimmed = quote.trimmed()
简单多了!
虽然省了一些键入,但它比普通函数好得多吗?
事实上,我们完全可以写一个这样的函数:
func trim(_ string: String) -> String {
string.trimmingCharacters(in: .whitespacesAndNewlines)
}
然后这样使用:
let trimmed2 = trim(quote)
从函数的制作和使用两方面来看,这都比使用扩展程序代码少。这种函数被称为全局函数,因为它在我们的项目中随处可用。
不过,与全局函数相比,扩展功能有很多优点,包括:
- 当你键入 quote 时。Xcode 会弹出字符串上的方法列表,包括我们在扩展中添加的所有方法。这使得我们的额外功能很容易被找到。
- 编写全局函数会让你的代码变得相当凌乱——它们很难组织,也很难跟踪。另一方面,扩展会自然地按其扩展的数据类型分组。
- 由于扩展方法是原始类型的完整组成部分,因此它们可以完全访问该类型的内部数据。例如,这意味着它们可以使用标有私有访问控制的属性和方法。
此外,扩展还可以更方便地就地修改值,即直接修改值,而不是返回一个新值。
例如,我们之前编写了一个 trimmed() 方法,该方法会返回一个去除了空白和换行符的新字符串,但如果我们想直接修改字符串,可以将其添加到扩展中:
mutating func trim() {
self = self.trimmed()
}
因为引号字符串是作为变量创建的,所以我们可以这样修剪它:
quote.trim()
请注意该方法现在的命名略有不同:返回新值时,我们使用 trimmed(),而直接修改字符串时,我们使用 trim()。这是有意为之,也是 Swift 设计指南的一部分:如果要返回一个新值,而不是就地修改,那么就应该使用 ed 或 ing 这样的词尾,就像 reversed()。
小提示:之前我们学习了数组的 sorted() 方法。现在你知道了这条规则,就会意识到如果创建了一个变量数组,就可以使用 sort() 方法对数组进行就地排序,而不是返回一个新的副本。
你也可以使用扩展为类型添加属性,但有一条规则:这些属性必须是计算属性,而不是存储属性。这样做的原因是,添加新的存储属性会影响数据类型的实际大小——如果我们给一个整数添加了大量存储属性,那么每个整数都需要占用更多内存空间,这会带来各种问题。
幸运的是,我们仍然可以使用计算属性完成很多工作。例如,我喜欢为字符串添加一个名为 lines 的属性,它可以将字符串拆分成一个个单独的行数组。它封装了另一个名为 components(separatedBy:) 的字符串方法,该方法通过在我们选择的边界上分割字符串,将字符串分割成一个字符串数组。在本例中,我们希望这个边界是新的行,因此我们要在字符串扩展中添加这个方法:
var lines: [String] {
self.components(separatedBy: .newlines)
}
这样,我们就可以读取任何字符串的行属性了:
let lyrics = """
But I keep cruising
Can't stop, won't stop moving
It's like I got this music in my mind
Saying it's gonna be alright
"""
print(lyrics.lines.count)
无论是单行代码还是复杂的功能片段,扩展的目标始终是一致的:使代码更易于编写、阅读和长期维护。
在我们结束之前,我想向大家展示一个在使用扩展时非常有用的技巧。你之前已经看到 Swift 如何为结构体自动生成一个成员初始化器,就像这样:
struct Book {
let title: String
let pageCount: Int
let readingHours: Int
}
let lotr = Book(title: "Lord of the Rings", pageCount: 1178, readingHours: 24)
我还提到,创建自己的初始化器意味着 Swift 不再为我们提供成员式初始化器。这是故意的,因为自定义初始化器意味着我们要根据一些自定义逻辑来分配数据,就像这样:
struct Book {
let title: String
let pageCount: Int
let readingHours: Int
init(title: String, pageCount: Int) {
self.title = title
self.pageCount = pageCount
self.readingHours = pageCount / 50
}
}
如果 Swift 在这种情况下保留成员初始化器,就会跳过我们计算大致读取时间的逻辑。
不过,有时你会希望两者兼得——既能使用自定义初始化器,又能保留 Swift 的自动成员初始化器。在这种情况下,了解 Swift 的具体做法很有必要:如果我们在结构体内部实现了自定义初始化器,那么 Swift 就会禁用自动成员初始化器。
这个额外的小细节可能会给你接下来的提示:如果我们在扩展中实现了自定义初始化程序,那么 Swift 不会禁用自动成员初始化程序。仔细想想,这就说得通了:如果在扩展中添加一个新的初始化器,同时也禁用默认初始化器,那么我们的一个小改动就可能会破坏其他各种 Swift 代码。
因此,如果我们希望我们的 Book 结构既有默认的成员初始化器,又有自定义的初始化器,我们就会将自定义的初始化器放在扩展中,就像这样:
extension Book {
init(title: String, pageCount: Int) {
self.title = title
self.pageCount = pageCount
self.readingHours = pageCount / 50
}
}
什么时候应该在 Swift 中使用扩展?
扩展可以让我们为类、结构体等添加功能,这对于修改我们不拥有的类型(例如 Apple 或其他人编写的类型)很有帮助。使用扩展添加的方法与原本属于该类型的方法没有区别,但在属性方面有所不同:扩展不能添加新的存储属性,只能添加计算属性。
扩展对于组织我们自己的代码也很有用,虽然有多种方法可以做到这一点,但我想在这里重点介绍两种:一致性分组和目的分组。
一致性分组是指将协议一致性作为扩展添加到类型中,并在扩展中添加所有需要的方法。这样,开发人员在阅读扩展时就更容易理解需要在脑中保留多少代码——如果当前扩展增加了对打印的支持,他们就不会发现打印方法与其他无关协议的方法混在一起。
另一方面,目的分组意味着创建扩展来完成特定任务,这使得处理大型类型变得更容易。例如,你可以创建一个扩展来专门处理该类型的加载和保存。
这里值得补充的是,许多人意识到他们有一个大类,并试图通过将其拆分成扩展来使其变小。需要明确的是:该类型的大小与以前完全一样,只是被整齐地分割开来。这确实意味着它可能更容易理解,但并不意味着类变小了。
如何创建和使用协议扩展
协议可以让我们定义符合要求的类型必须遵守的契约,而扩展可以让我们为现有类型添加功能。但是,如果我们可以在协议上编写扩展会怎样呢?
不用再担心了,Swift 支持协议扩展:我们可以扩展整个协议来添加方法实现,这意味着任何符合该协议的类型都能获得这些方法。
让我们从一个小的例子开始。写一个条件来检查数组中是否有任何值是很常见的,就像下面这样:
let guests = ["Mario", "Luigi", "Peach"]
if guests.isEmpty == false {
print("Guest count: \(guests.count)")
}
有些人喜欢使用布尔 ! 运算符,就像这样:
if !guests.isEmpty {
print("Guest count: \(guests.count)")
}
我并不太喜欢这两种方法,因为对我来说,“如果某个数组不是空的 ”读起来并不自然。
我们可以通过一个非常简单的 Array 扩展来解决这个问题,就像下面这样:
extension Array {
var isNotEmpty: Bool {
isEmpty == false
}
}
提示:Xcode 的 playgrounds 从上到下运行代码,因此请确保将扩展名放在用到它的地方之前。
现在,我们可以编写我认为更容易理解的代码了:
if guests.isNotEmpty {
print("Guest count: \(guests.count)")
}
但我们可以做得更好。我们刚刚为数组添加了 isNotEmpty,但集合和字典呢?当然,我们可以重复自己的工作,将代码复制到这些扩展中,但有一个更好的解决方案: 数组、集合和字典都符合名为 Collection 的内置协议,通过该协议,它们可以获得 contains()、sorted()、reversed() 等功能。
这一点很重要,因为 Collection 也是 isEmpty 属性存在的必要条件。因此,如果我们在 Collection 上编写扩展,我们仍然可以访问 isEmpty,因为它是必需的。这意味着我们可以在代码中将 Array 更改为 Collection,从而获得此属性:
extension Collection {
var isNotEmpty: Bool {
isEmpty == false
}
}
有了这一个词的改动,我们现在就可以在数组、集合和字典以及任何其他符合 Collection 的类型上使用 isNotEmpty。信不信由你,这个小小的扩展存在于成千上万的 Swift 项目中,因为很多人觉得它更容易阅读。
更重要的是,通过扩展协议,我们添加了原本需要在单个结构体内部完成的功能。这真的很强大,它带来了 Apple 称为面向协议编程的技术——我们可以在协议中列出一些必要的方法,然后在协议扩展中添加这些方法的默认实现。然后,所有符合要求的类型都可以使用这些默认实现,或根据需要提供自己的实现。
例如,如果我们有这样一个协议:
protocol Person {
var name: String { get }
func sayHello()
}
这意味着所有符合要求的类型都必须添加 sayHello() 方法,但我们也可以像这样添加一个默认实现作为扩展:
extension Person {
func sayHello() {
print("Hi, I'm \(name)")
}
}
现在,符合要求的类型可以添加自己的 sayHello() 方法(如果它们愿意),但它们并不需要这样做——它们始终可以依赖我们协议扩展中提供的方法。
因此,我们可以创建一个没有 sayHello() 方法的雇员:
struct Employee: Person {
let name: String
}
但由于它符合 Person 标准,我们可以使用扩展中提供的默认实现:
let taylor = Employee(name: "Taylor Swift")
taylor.sayHello()
Swift 经常使用协议扩展,但老实说,你还不需要非常详细地了解它们——你完全可以在不使用协议扩展的情况下构建出色的应用程序。现在你知道它们的存在就足够了!
协议扩展在 Swift 中什么时候有用?
协议扩展在 Swift 中随处可见,因此你经常会看到它被描述为 “面向协议的编程语言”。我们使用它们直接为协议添加功能,这意味着我们不需要在许多结构体和类中复制该功能。
例如,Swift 的数组有一个 allSatisfy() 方法,如果数组中的所有项都通过了测试,该方法就会返回 true。因此,我们可以创建一个数字数组,然后检查它们是否都是偶数:
let numbers = [4, 8, 15, 16]
let allEven = numbers.allSatisfy { $0.isMultiple(of: 2) }
这确实很有用,但如果它也能在Sets中使用,是不是会更有用?当然有用,这也是它能发挥作用的原因:
let numbers2 = Set([4, 8, 15, 16])
let allEven2 = numbers2.allSatisfy { $0.isMultiple(of: 2) }
其基本原理是相同的:将数组或集合中的每个项通过你提供的测试,如果所有项都返回 true,那么方法的结果就是 true。
字典也能使用这种方法吗?当然可以,而且工作原理完全相同:每个键/值对都会传入闭包,而你需要返回 true 或 false。看起来是这样的:
let numbers3 = ["four": 4, "eight": 8, "fifteen": 15, "sixteen": 16]
let allEven3 = numbers3.allSatisfy { $0.value.isMultiple(of: 2) }
当然,Swift 开发人员并不想重复编写相同的代码,因此他们使用了协议扩展:他们编写了单个 allSatisfy() 方法,该方法在名为 Sequence 的协议上运行,所有数组、集合和字典都符合该协议。这意味着 allSatisfy() 方法可以立即用于所有这些类型,共享完全相同的代码。
总结
在这几章中,我们学习了 Swift 一些复杂但强大的功能,但如果你感到有些吃力,也不要难过——这些功能一开始确实很难掌握,只有当你有时间在自己的代码中进行尝试后,才能真正理解它们。
让我们回顾一下所学到的知识:
- 协议就像代码的合同:我们指定了所需的函数和方法,符合协议的类型必须实现这些函数和方法。
- 不透明返回类型可以让我们在代码中隐藏一些信息。这可能意味着我们希望保留将来更改的灵活性,但也意味着我们不需要写出庞大的返回类型。
- 扩展可以让我们为自己的自定义类型或 Swift 内置类型添加功能。这可能意味着添加一个方法,但我们也可以添加计算属性。
- 协议扩展让我们可以同时为多种类型添加功能——我们可以为协议添加属性和方法,所有符合协议的类型都可以访问它们。
归纳起来,这些功能看似简单,其实不然。你需要了解它们,知道它们的存在,但你只需要在表面上使用它们,以便继续你的学习之旅。