Skip to content

第13天 协议、扩展

今天你将学习一些真正具有 Swift 特色的功能:协议、扩展和协议扩展。

协议扩展使我们能够摒弃庞大、复杂的继承层次结构,取而代之的是更小、更简单且可组合使用的协议。这真正印证了托尼・霍尔多年前说过的一句话:“每个大型程序内部,都有一个小型程序试图脱颖而出。”

从你第一个 SwiftUI 项目开始,你就会用到协议,并且在整个 Swift 编程生涯中,它们都将是非常宝贵的工具 —— 花时间熟悉它们是值得的。

今天你要学习四个教程,从中你会接触到协议、扩展等内容。

提示: 这是少数几天中几乎肯定会花费超过一小时来学习的一天,但这并不会让学习变得更容易 —— 有很多内容需要涵盖!记住,额外阅读是可选的—— 如果你第一次就理解了某个主题,完全可以跳过。

13.1 如何创建和使用协议

作者:Paul Hudson 2024 年 4 月 16 日 已针对 Xcode 16.4 更新

在 Swift 中,协议有点像契约:它们让我们能够定义我们期望某种数据类型支持的功能,而 Swift 会确保我们代码的其余部分遵循这些规则。

想想我们如何编写一些代码来模拟某人从家通勤到办公室的场景。我们可能会创建一个小型的Car结构体,然后编写一个这样的函数:

swift
func commute(distance: Int, using vehicle: Car) {
    // 这里有很多代码
}

当然,他们也可能乘火车通勤,所以我们还会这样写:

swift
func commute(distance: Int, using vehicle: Train) {
    // 这里有很多代码
}

或者他们可能乘坐公共汽车:

swift
func commute(distance: Int, using vehicle: Bus) {
    // 这里有很多代码
}

或者他们可能使用自行车、电动滑板车、拼车或其他多种交通方式。

事实上,在这个层面上,我们并不真正关心底层的行程是如何进行的。我们更关心的是更广泛的事情:用户使用每种方式通勤可能需要多长时间,以及如何执行实际的移动到新地点的操作。

这就是协议的用武之地:它们让我们能够定义一系列我们想要使用的属性和方法。它们并不实现这些属性和方法 —— 它们实际上不会在背后编写任何代码 —— 它们只是表明这些属性和方法必须存在,有点像一个蓝图。

例如,我们可以这样定义一个新的Vehicle协议:

swift
protocol Vehicle {
    func estimateTime(for distance: Int) -> Int
    func travel(distance: Int)
}

让我们来分解一下:

  • 要创建一个新协议,我们先写protocol,然后是协议名称。这是一种新类型,所以我们需要使用以大写字母开头的驼峰式命名法。
  • 在协议内部,我们列出了为了让这个协议按我们期望的方式工作所需要的所有方法。
  • 这些方法内部不包含任何代码 —— 这里没有提供函数体。相反,我们只是指定了方法名称、参数和返回类型。如果需要,你还可以将方法标记为可抛出(throwing)或可修改(mutating)。

那么我们已经创建了一个协议 —— 这对我们有什么帮助呢?

好吧,现在我们可以设计与该协议配合工作的类型了。这意味着创建新的结构体、类或枚举,它们要实现该协议的要求,这个过程我们称之为采用遵循协议。

协议并没有指定必须存在的全部功能,而只是最低限度的功能。这意味着当你创建遵循协议的新类型时,你可以根据需要添加各种其他属性和方法。

例如,我们可以创建一个遵循Vehicle协议的Car结构体,像这样:

swift
struct Car: Vehicle {
    func estimateTime(for distance: Int) -> Int {
        distance / 50
    }

    func travel(distance: Int) {
        print("我正在驾驶\(distance)公里。")
    }

    func openSunroof() {
        print("今天天气真好!")
    }
}

在这段代码中,有几件事我想特别提醒大家注意:

  1. 我们通过在Car名称后面使用冒号,告诉 SwiftCar遵循Vehicle协议,就像我们标记子类那样。
  2. 我们在Vehicle中列出的所有方法都必须精确地存在于Car中。如果它们的名称略有不同、接受的参数不同、返回类型不同等等,那么 Swift 会说我们没有遵循协议。
  3. Car中的方法提供了我们在协议中定义的方法的实际实现。在这种情况下,这意味着我们的结构体提供了驾驶一定距离大约需要多少分钟的估计,并且在调用travel()时会打印一条消息。
  4. openSunroof()方法并非来自Vehicle协议,而且在协议中也没有意义,因为许多交通工具类型没有天窗。但这没关系,因为协议只描述了遵循协议的类型必须具备的最低功能,它们可以根据需要添加自己的功能。

所以,现在我们已经创建了一个协议,并制作了一个遵循该协议的Car结构体。

最后,让我们更新一下前面的commute()函数,以便它使用我们添加到Car中的新方法:

swift
func commute(distance: Int, using vehicle: Car) {
    if vehicle.estimateTime(for: distance) > 100 {
        print("那太慢了!我会试试别的交通工具。")
    } else {
        vehicle.travel(distance: distance)
    }
}

let car = Car()
commute(distance: 100, using: car)

这段代码都能正常工作,但这里的协议实际上并没有增加任何价值。是的,它让我们在Car内部实现了两个非常具体的方法,但我们不添加协议也能做到这一点,那为什么还要费心呢?

精彩之处来了:Swift 知道任何遵循Vehicle协议的类型都必须实现estimateTime()travel()方法,所以它实际上允许我们使用Vehicle作为参数类型,而不是Car。我们可以把函数重写成这样:

swift
func commute(distance: Int, using vehicle: Vehicle) {

现在我们的意思是,这个函数可以用任何类型的数据来调用,只要那种类型遵循Vehicle协议。函数体不需要改变,因为 Swift 肯定知道estimateTime()travel()方法是存在的。

如果你仍然想知道这有什么用,不妨看看下面的结构体:

swift
struct Bicycle: Vehicle {
    func estimateTime(for distance: Int) -> Int {
        distance / 10
    }

    func travel(distance: Int) {
        print("我正在骑行\(distance)公里。")
    }
}

let bike = Bicycle()
commute(distance: 50, using: bike)

现在我们有了第二个也遵循Vehicle协议的结构体,这就是协议的强大之处:我们现在可以向commute()函数传入Car或者Bicycle。函数内部可以有各种逻辑,当它调用estimateTime()travel()时,Swift 会自动使用相应的方法 —— 如果我们传入一辆汽车,它会说 “我正在驾驶”,但如果我们传入一辆自行车,它会说 “我正在骑行”。

所以,协议让我们能够谈论我们想要使用的功能类型,而不是具体的类型。我们可以不说 “这个参数必须是一辆汽车”,而是说 “这个参数可以是任何东西,只要它能够估计旅行时间并移动到新的位置。”

除了方法,你也可以编写协议来描述遵循协议的类型必须存在的属性。要做到这一点,写出var,然后是属性名称,再列出它应该是可读的和 / 或可写的。

例如,我们可以规定所有遵循Vehicle协议的类型都必须指定其名称和当前的乘客数量,像这样:

swift
protocol Vehicle {
    var name: String { get }
    var currentPassengers: Int { get set }
    func estimateTime(for distance: Int) -> Int
    func travel(distance: Int)
}

这增加了两个属性:

  1. 一个名为name的字符串,它必须是可读的。这可能意味着它是一个常量,也可能是一个带有 getter 的计算属性。
  2. 一个名为currentPassengers的整数,它必须是可读写的。这可能意味着它是一个变量,也可能是一个带有 getter 和 setter 的计算属性。

两者都需要类型注解,因为我们不能在协议中提供默认值,就像协议不能为方法提供实现一样。

有了这两个额外的要求,Swift 会警告我们CarBicycle都不再遵循协议,因为它们缺少这些属性。为了修复这个问题,我们可以向Car添加以下属性:

swift
let name = "汽车"
var currentPassengers = 1

并向Bicycle添加这些属性:

swift
let name = "自行车"
var currentPassengers = 1

不过,同样,只要你遵守规则,你可以用计算属性来替换它们 —— 如果你使用{ get set },那么你就不能用常量属性来遵循协议。

现在我们的协议需要两个方法和两个属性,这意味着所有遵循协议的类型都必须实现这四个东西,我们的代码才能工作。这反过来意味着 Swift 肯定知道这些功能是存在的,所以我们可以编写依赖于它们的代码。

例如,我们可以编写一个方法,它接受一个交通工具数组,并使用它来计算一系列选项的估计值:

swift
func getTravelEstimates(using vehicles: [Vehicle], distance: Int) {
    for vehicle in vehicles {
        let estimate = vehicle.estimateTime(for: distance)
        print("\(vehicle.name):行驶\(distance)公里需要\(estimate)小时")
    }
}

我希望这能向你展示协议的真正力量 —— 我们接受一个Vehicle协议的数组,这意味着我们可以传入CarBicycle或任何其他遵循Vehicle协议的结构体,它会自动工作:

swift
getTravelEstimates(using: [car, bike], distance: 150)

除了接受协议作为参数外,如有需要,你也可以从函数中返回协议。

提示: 你可以根据需要遵循任意多个协议,只需一个接一个地列出它们,用逗号分隔即可。如果你需要继承某个类并且遵循某个协议,你应该先写父类的名称,然后再写协议。

【可选阅读】Swift 为什么需要协议?

作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新

协议让我们能够定义结构体、类和枚举应该如何工作:它们应该具有哪些方法,以及应该具有哪些属性。Swift 会为我们强制执行这些规则,因此当我们说某个类型遵循某个协议时,Swift 会确保它拥有该协议所要求的所有方法和属性。

实际上,协议允许我们以更通用的方式处理数据。因此,我们不必说 “这个 buy() 方法必须接受一个 Book 对象”,而是可以说 “这个方法可以接受任何遵循 Purchaseable 协议的东西。这可能是一本书,但也可能是一部电影、一辆汽车、一些咖啡等等 —— 这使我们的简单方法更具灵活性,同时确保 Swift 为我们执行这些规则。

用代码来说,我们这个只适用于书籍的简单 buy() 方法会是这样的:

swift
struct Book {
    var name: String
}

func buy(_ book: Book) {
    print("I'm buying \(book.name)")
}

为了创建一种更灵活的、基于协议的方法,我们首先要创建一个协议,声明我们需要的基本功能。这可能包含很多方法和属性,但在这里我们只说我们需要一个字符串类型的名称:

swift
protocol Purchaseable {
    var name: String { get set }
}

现在我们可以继续定义任意多个结构体,每个结构体都通过拥有一个字符串类型的名称来遵循该协议:

swift
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 项目:

swift
func buy(_ item: Purchaseable) {
    print("I'm buying \(item.name)")
}

在这个方法内部,我们可以安全地使用项目的 name 属性,因为 Swift 会保证每个 Purchaseable 项目都有一个 name 属性。它保证我们定义的其他任何属性都会存在,只保证那些在协议中专门声明的属性存在。

因此,协议让我们能够创建类型如何共享功能的蓝图,然后在我们的函数中使用这些蓝图,让它们能够处理更多种类的数据。

【练习题】协议

问题 1/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Swimmable {
    var depth { get }
}

问题 2/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Purchaseable {
    var price: Double { get set }
    var currency: String { get set }
}

问题 3/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Climbable {
    var height: Double { get }
    var gradient: Int { get }
}

问题 4/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Mailable {
    var width: Double { get, set }
    var height: Double { get, set }
}

问题 5/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Strokeable {
    fluffiness: Int { get }
}

问题 6/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Learnable {
    var difficulty: Int { get }
}

问题 7/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Washable {
    var dirtinessLevel: Int { get set }
}

问题 8/12:这是一个有效的协议吗?—— 是或否?

swift
struct Knittable {
    var needleSizes: [Double] { get set }
}

问题 9/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Singable {
    var lyrics: [String] { get set }
    var notes: [String] { get set }
}

问题 10/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Plantable {
    var requirements: [String] { get set }
}

问题 11/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Buildable {
    var numberOfBricks: Int { set }
    var materials: [String] { set }
}

问题 12/12:这是一个有效的协议吗?—— 是或否?

swift
protocol Liftable {
    var weight: Double get set
}

13.2 如何使用不透明返回类型

作者:Paul Hudson 2022 年 12 月 27 日 已针对 Xcode 16.4 更新

Swift 提供了一个非常晦涩、非常复杂但又非常重要的特性,叫做不透明返回类型,它可以帮助我们简化代码。说实话,如果不是因为一个非常重要的事实 —— 你在创建第一个 SwiftUI 项目时就会立刻看到它 —— 我不会在初学者课程中介绍它。

重要提示: 你不需要详细理解不透明返回类型是如何工作的,只需要知道它们存在并且有特定的用途。在学习的过程中,你可能会开始疑惑这个特性为什么有用,但相信我:它确实很重要,也确实很有用,所以请努力理解下去!

让我们实现两个简单的函数:

swift
func getRandomNumber() -> Int {
    Int.random(in: 1...6)
}

func getRandomBool() -> Bool {
    Bool.random()
}

提示: Bool.random()会返回 true 或 false。与随机整数和小数不同,我们不需要指定任何参数,因为它没有可自定义的选项。

所以,getRandomNumber()返回一个随机整数,getRandomBool()返回一个随机布尔值。

IntBool都遵循 Swift 中的一个通用协议Equatable,这个协议的意思是 “可以进行相等性比较”。Equatable协议让我们能够使用==,就像这样:

swift
print(getRandomNumber() == getRandomNumber())

因为这两种类型都遵循Equatable,我们可能会尝试修改函数,让它们返回Equatable类型的值,就像这样:

swift
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是没有意义的,而理解它为什么没有意义是理解不透明返回类型的关键。

首先:你可以从函数中返回协议,而且这通常是一件非常有用的事情。例如,你可能有一个为用户查找租车的函数:它接受需要承载的乘客数量以及他们想要携带的行李数量,但它可能返回几个结构体中的一个:Compact(紧凑型车)、SUV(运动型多用途车)、Minivan(小型货车)等等。

我们可以通过返回一个所有这些结构体都遵循的Vehicle(交通工具)协议来处理这种情况,这样调用函数的人会得到某种符合他们要求的车,而我们不必编写 10 个不同的函数来处理所有的车型。这些车型中的每一种都会实现Vehicle的所有方法和属性,这意味着它们是可互换的 —— 从编码的角度来说,我们不在乎得到的是哪一种。

现在想想返回IntBool的情况。是的,它们都遵循Equatable,但它们不是可互换的 —— 我们不能用==来比较一个Int和一个Bool,因为无论它们遵循什么协议,Swift 都不允许这样做。

从函数中返回协议很有用,因为它让我们可以隐藏信息:我们不用说明返回的确切类型,而是专注于返回的功能。在Vehicle协议的例子中,这可能意味着返回座位数、大致油耗和价格。这意味着我们以后可以修改代码而不会破坏现有的功能:我们可以返回RaceCar(赛车)或PickUpTruck(皮卡车)等等,只要它们实现了Vehicle所需的属性和方法。

以这种方式隐藏信息非常有用,但对于Equatable来说是不可能的,因为比较两个不同的Equatable类型的对象是行不通的。即使我们两次调用getRandomNumber()得到两个整数,我们也不能比较它们,因为我们隐藏了它们的确切数据类型 —— 我们隐藏了它们是两个实际上可以比较的整数这一事实。

这就是不透明返回类型的用武之地:它们让我们可以在代码中隐藏信息,但不会对 Swift 编译器隐藏。这意味着我们保留了在内部灵活修改代码的权利,以便将来可以返回不同的内容,但 Swift 始终了解返回的实际数据类型,并会进行相应的检查。

要将我们的两个函数升级为不透明返回类型,可以在它们的返回类型前添加关键字some,像这样:

swift
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 类型,但我们不知道是什么”,而返回some Vehicle意味着 “一种特定的Vehicle类型,但我们不想说具体是哪一种”。

我猜看到这里你的头可能已经晕了,所以让我举一个在 SwiftUI 中这一点为什么重要的实际例子。SwiftUI 需要确切地知道你想在屏幕上显示什么样的布局,所以我们编写代码来描述它。

用通俗的话来说,我们可能会这样描述:“有一个屏幕,顶部有一个工具栏,底部有一个标签栏,中间是一个滚动的颜色图标网格,每个图标下面都有一个标签,用粗体字体写着这个图标的含义,当你点击一个图标时,会出现一条消息。”

当 SwiftUI 询问我们的布局时,这个描述 —— 整个内容 —— 就成了布局的返回类型。我们需要明确说明我们想在屏幕上显示的每一个细节,包括位置、颜色、字体大小等等。你能想象把这些作为返回类型来输入吗?那会有一英里长!而且每次你修改生成布局的代码时,你都需要修改返回类型以匹配。

这就是不透明返回类型发挥作用的地方:我们可以返回some View类型,这意味着会返回某种视图屏幕,但我们不想写出它那一英里长的类型。同时,Swift 知道真正的底层类型,因为不透明返回类型就是这样工作的:Swift 始终知道返回的确切数据类型,而 SwiftUI 会利用这一点来创建它的布局。

就像我在开头说的,不透明返回类型是一个非常晦涩、非常复杂但又非常重要的特性,如果不是因为它们在 SwiftUI 中被广泛使用,我不会在初学者课程中介绍它们。

所以,当你在 SwiftUI 代码中看到some View时,实际上我们是在告诉 Swift“这将会返回某种用于布局的视图,但我不想写出确切的内容 —— 你自己去弄清楚吧。”

13.3 如何创建和使用扩展

作者:Paul Hudson 2022 年 5 月 12 日 已针对 Xcode 16.4 更新

扩展允许我们向任何类型添加功能,无论这个类型是我们自己创建的,还是其他人创建的 —— 甚至是苹果自己的类型。

为了说明这一点,我想向你介绍字符串上的一个有用方法,叫做 trimmingCharacters(in:)。它可以从字符串的开头或结尾移除某些类型的字符,比如字母数字、十进制数字,或者最常见的空格和换行符。

空格是空格字符、制表符以及这两种字符的各种变体的统称。换行符是文本中的换行,听起来可能很简单,但实际上当然没有单一的换行方式,所以当我们要求修剪换行符时,它会自动处理所有的变体。

例如,这里有一个两边都有空格的字符串:

swift
var quote = "   The truth is rarely pure and never simple   "

如果我们想修剪两边的空格和换行符,可以这样做:

swift
let trimmed = quote.trimmingCharacters(in: .whitespacesAndNewlines)

.whitespacesAndNewlines 值来自苹果的 Foundation API,实际上 trimmingCharacters(in:) 也是 —— 就像我在本课程一开始说的,Foundation 确实包含了很多有用的代码!

每次都调用 trimmingCharacters(in:) 有点啰嗦,所以让我们编写一个扩展来简化它:

swift
extension String {
    func trimmed() -> String {
        self.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}

让我们来分解一下:

  1. 我们从 extension 关键字开始,它告诉 Swift 我们想要向一个已存在的类型添加功能。
  2. 哪个类型呢?接下来就是:我们想要向 String 添加功能。
  3. 现在我们打开一个大括号,直到最后的闭合大括号之前的所有代码都将被添加到字符串中。
  4. 我们添加了一个名为 trimmed() 的新方法,它返回一个新的字符串。
  5. 在方法内部,我们调用了之前的同一个方法:trimmingCharacters(in:),并返回其结果。
  6. 注意我们在这里可以使用 self—— 它自动指代当前的字符串。这是可能的,因为我们现在处于字符串扩展中。

现在,无论在哪里我们想要移除空格和换行符,都可以这样写:

swift
let trimmed = quote.trimmed()

简单多了!这节省了一些输入,但它真的比一个普通的函数好很多吗?

好吧,事实是我们可以写出这样一个函数:

swift
func trim(_ string: String) -> String {
    string.trimmingCharacters(in: .whitespacesAndNewlines)
}

然后像这样使用它:

swift
let trimmed2 = trim(quote)

无论是创建函数还是使用函数,这都比使用扩展的代码少。这种函数被称为全局函数,因为它在我们的项目中随处可用。

然而,扩展相比全局函数有很多好处,包括:

  1. 当你输入 quote. 时,Xcode 会显示字符串上的方法列表,包括我们在扩展中添加的所有方法。这使得我们的额外功能很容易找到。
  2. 编写全局函数会使你的代码相当混乱 —— 它们难以组织且难以跟踪。另一方面,扩展自然地按照它们所扩展的数据类型进行分组。
  3. 因为你的扩展方法是原始类型的完整部分,它们可以完全访问该类型的内部数据。例如,它们可以使用标记为 private 访问控制的属性和方法。

此外,扩展使就地修改值更容易 —— 也就是说,直接更改一个值,而不是返回一个新值。

例如, earlier 我们编写了一个 trimmed() 方法,它返回一个移除了空格和换行符的新字符串,但如果我们想直接修改字符串,我们可以向扩展中添加这个:

swift
mutating func trim() {
    self = self.trimmed()
}

因为 quote 字符串是作为变量创建的,我们可以像这样就地修剪它:

swift
quote.trim()

注意现在方法的命名略有不同:当我们返回一个新值时,我们使用 trimmed(),但当我们直接修改字符串时,我们使用 trim()。这是有意为之的,并且是 Swift 设计指南的一部分:如果你返回一个新值而不是就地修改它,你应该使用像 eding 这样的词尾,比如 reversed()

提示: 之前我向你介绍了数组上的 sorted() 方法。现在你知道了这个规则,你应该意识到,如果你创建了一个可变数组,你可以使用 sort() 来就地排序数组,而不是返回一个新的副本。

你也可以使用扩展向类型添加属性,但有一个规则:它们只能是计算属性,而不是存储属性。原因是添加新的存储属性会影响数据类型的实际大小 —— 如果我们向整数添加一堆存储属性,那么到处的每个整数都需要在内存中占用更多空间,这会导致各种各样的问题。

幸运的是,我们仍然可以使用计算属性完成很多工作。例如,我喜欢向字符串添加的一个属性叫做 lines,它将字符串分解为单行的数组。这包装了另一个字符串方法 components(separatedBy:),该方法通过在我们选择的边界上拆分字符串,将字符串分解为字符串数组。在这种情况下,我们希望边界是换行符,所以我们会向字符串扩展中添加这个:

swift
var lines: [String] {
    self.components(separatedBy: .newlines)
}

有了这个,我们现在可以读取任何字符串的 lines 属性,如下所示:

swift
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 如何为结构体自动生成一个成员初始化器,像这样:

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 将不再为我们提供成员初始化器。这是有意的,因为自定义初始化器意味着我们希望基于一些自定义逻辑来分配数据,像这样:

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 结构体既有默认的成员初始化器,又有我们的自定义初始化器,我们会把自定义的初始化器放在扩展中,像这样:

swift
extension Book {
    init(title: String, pageCount: Int) {
        self.title = title
        self.pageCount = pageCount
        self.readingHours = pageCount / 50
    }
}

【可选阅读】什么时候应该在 Swift 中使用扩展?

作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新

扩展允许我们向类、结构体等添加功能,这对于修改我们不拥有的类型很有帮助 —— 例如,那些由苹果或其他人编写的类型。使用扩展添加的方法与最初属于该类型的方法难以区分,但对于属性来说有所不同:扩展可能不会添加新的存储属性,只能添加计算属性。

扩展对于组织我们自己的代码也很有用,虽然有几种方法可以做到这一点,但这里我想重点关注两个:一致性分组和目的分组。

一致性分组意味着将协议一致性添加到作为扩展的类型中,在该扩展内部添加所有必需的方法。这使得开发人员在阅读扩展时更容易理解需要记住多少代码 —— 如果当前扩展添加了对 Printable 的支持,他们不会发现打印方法与来自其他不相关协议的方法混合在一起。

另一方面,目的分组意味着创建扩展来执行特定任务,这使得处理大型类型更容易。例如,你可能有一个专门用于处理该类型的加载和保存的扩展。

值得在此补充的是,许多人意识到他们有一个大型类,并试图通过将其拆分为扩展来使其变小。需要明确的是:该类型的大小与以前完全相同,只是被整齐地拆分了。这确实意味着它可能更容易理解,但并不意味着这个类变小了。

【练习题】扩展

问题 1/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Double {
	var isNegative: Bool {
		return self < 0
	}
}

问题 2/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Int {
	var isEven {
		return self.isMultiple(of: 2)
	}
}

问题 3/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension String {
	func append(_ other: String) {
		self += other
	}
}

问题 4/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Int {
	times(_ action: () -> Void) {
		for _ in 0..<self {
			action()
		}
	}
}

问题 5/12:这段代码是一个有效的扩展 —— 对还是错?

swift
ext Array {
	func summarize() {
		print("The array has \(count) items. They are:")
		for item in self {
			print(item)
		}
	}
}

问题 6/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Int {
	var isAnswerToLifeUniverseAndEverything: Bool {
		let target = 42
		self == target
	}
}

问题 7/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Bool {
	func toggled() -> Bool {
		if self = true {
			return false
		} else {
			return true
		}
	}
}

问题 8/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Int {
	func cubed() -> Int {
		return self * self * self
	}
}

问题 9/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension Int {
	func clamped(min: Int, max: Int) -> Int {
		if (self > max) {
			return max
		} else if (self < min) {
			return min
		}
		return self
	}
}

问题 10/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension String {
	var isLong: Bool {
		return count > 25
	}
}

问题 11/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension String {
	func withPrefix(_ prefix: String) -> String {
		if self.hasPrefix(prefix) { return self }
		return "\(prefix)\(self)"
	}
}

问题 12/12:这段代码是一个有效的扩展 —— 对还是错?

swift
extension String {
	func isUppercased() -> Bool {
		return self == self.uppercased()
	}
}

13.4 如何创建和使用协议扩展

作者:Paul Hudson 2021 年 10 月 27 日 已针对 Xcode 16.4 更新

协议让我们能够定义遵循协议的类型必须遵守的契约,而扩展让我们可以向现有类型添加功能。但如果我们能对协议编写扩展,会发生什么呢?

别再好奇了,因为 Swift 恰好支持这种功能,它被恰当地命名为协议扩展:我们可以扩展整个协议来添加方法实现,这意味着任何遵循该协议的类型都能获得这些方法。

让我们从一个简单的例子开始。编写条件来检查数组是否有值是很常见的,就像这样:

swift
let guests = ["马里奥", "路易吉", "碧琪"]

if guests.isEmpty == false {
    print("客人数量:\(guests.count)")
}

有些人喜欢使用布尔值 ! 运算符,像这样:

swift
if !guests.isEmpty {
    print("客人数量:\(guests.count)")
}

我其实不太喜欢这两种方法,因为它们读起来不自然 ——“如果不是某个数组为空”?

我们可以通过一个非常简单的 Array 扩展来解决这个问题,如下所示:

swift
extension Array {
    var isNotEmpty: Bool {
        isEmpty == false
    }
}

提示: Xcode 的 playground 是从上到下运行代码的,所以要确保把这个扩展放在使用它的地方之前。

现在我们可以写出我认为更容易理解的代码:

swift
if guests.isNotEmpty {
    print("客人数量:\(guests.count)")
}

但我们可以做得更好。你看,我们只是给数组添加了 isNotEmpty,但集合(set)和字典(dictionary)呢?当然,我们可以重复劳动,把代码复制到这些类型的扩展中,但有一个更好的解决方案:ArraySetDictionary 都遵循一个内置的协议 Collection,通过这个协议它们获得了诸如 contains()sorted()reversed() 等功能。

这一点很重要,因为 Collection 也要求必须存在 isEmpty 属性。所以,如果我们对 Collection 编写一个扩展,我们仍然可以访问 isEmpty,因为它是必需的。这意味着我们可以在代码中将 Array 改为 Collection,得到这样的结果:

swift
extension Collection {
    var isNotEmpty: Bool {
        isEmpty == false
    }
}

只需这一个单词的改动,我们现在就可以在数组、集合、字典以及任何其他遵循 Collection 协议的类型上使用 isNotEmpty 了。信不信由你,这个小小的扩展存在于成千上万的 Swift 项目中,因为很多人都觉得它更容易阅读。

更重要的是,通过扩展协议,我们添加的功能原本需要在各个结构体内部实现。这非常强大,也催生了苹果称之为面向协议编程的技术 —— 我们可以在协议中列出一些必需的方法,然后在协议扩展中添加这些方法的默认实现。所有遵循该协议的类型都可以使用这些默认实现,或者根据需要提供自己的实现。

例如,如果我们有这样一个协议:

swift
protocol Person {
    var name: String { get }
    func sayHello()
}

这意味着所有遵循该协议的类型都必须添加 sayHello() 方法,但我们也可以像这样通过扩展添加该方法的默认实现:

swift
extension Person {
    func sayHello() {
        print("嗨,我是\(name)")
    }
}

现在,遵循该协议的类型可以根据需要添加自己的 sayHello() 方法,但也不是必须的 —— 它们随时可以依赖我们在协议扩展中提供的那个方法。

因此,我们可以创建一个没有 sayHello() 方法的员工:

swift
struct Employee: Person {
    let name: String
}

但因为它遵循 Person 协议,我们可以使用我们在扩展中提供的默认实现:

swift
let taylor = Employee(name: "泰勒·斯威夫特")
taylor.sayHello()

Swift 大量使用协议扩展,但说实话,你暂时不需要非常深入地理解它们 —— 即使不使用协议扩展,你也能构建出很棒的应用程序。到这里,你知道它们的存在就足够了!

【可选阅读】在 Swift 中协议扩展何时有用?

作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新

协议扩展在 Swift 中随处可见,这就是为什么 Swift 经常被描述为 “面向协议的编程语言”。我们用它们直接向协议添加功能,这意味着我们不需要在许多结构体和类中重复添加这些功能。

例如,Swift 的数组有一个 allSatisfy() 方法,如果数组中的所有元素都通过某个测试,该方法就返回 true。所以,我们可以创建一个数字数组,并检查它们是否都是偶数:

swift
let numbers = [4, 8, 15, 16]
let allEven = numbers.allSatisfy { $0.isMultiple(of: 2) }

这真的很有用,但如果它也能在集合上工作,不是更有用吗?当然是,这也是它能在集合上使用的原因:

swift
let numbers2 = Set([4, 8, 15, 16])
let allEven2 = numbers2.allSatisfy { $0.isMultiple(of: 2) }

其基本原理是相同的:将数组或集合中的每个元素传入你提供的测试中,如果所有元素都返回 true,那么该方法的结果就是 true。

那字典呢 —— 它们也能使用这个方法吗?当然可以,而且工作方式相同:每个键值对都会传入闭包,你需要返回 true 或 false。看起来是这样的:

swift
let numbers3 = ["four": 4, "eight": 8, "fifteen": 15, "sixteen": 16]
let allEven3 = numbers3.allSatisfy { $0.value.isMultiple(of: 2) }

当然,Swift 的开发者们不想一遍又一遍地编写相同的代码,所以他们使用了协议扩展:他们编写了一个单独的 allSatisfy() 方法,该方法适用于一个名为 Sequence 的协议,所有的数组、集合和字典都遵循这个协议。这意味着 allSatisfy() 方法立即就能在所有这些类型上使用,并且共享完全相同的代码。

【练习题】协议扩展

问题 1/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol DogTrainer {
	func train(dog: String) {
		print("我们很快就能让\(dog)听话!")
	}
}

问题 2/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Chef {
	func getRecipes() -> [String]
}
extension Chief {
	func getRecipes() -> [String] {
		return ["通心粉奶酪"]
	}
}

问题 3/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol SmartPhone {
	func makeCall(to name { get set })
}
extension SmartPhone {
	func makeCall(to name: String) {
		print("正在拨打\(name)……")
	}
}

问题 4/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Politician {
	var isDirty: Bool { get set }
	func takeBribe()
}
extension Politician {
	func takeBribe() {
		if isDirty {
			print("非常感谢!")
		} else {
			print("快叫警察!")
		}
	}
}

问题 5/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Anime {
	var availableLanguages: [String] { get set }
	func watch(in language: String)
}
extension Anime {
	func watch(in language: String) {
		if availableLanguages.contains(language) {
			print("现在以\(language)语言播放")
		} else {
			print("无法识别的语言。")
		}
	}
}

问题 6/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Club {
	func organizeMeeting(day: String)
}
extension Club {
	override func organizeMeeting(day: String) {
		print("我们将在\(day)举行会议。")
	}
}

问题 7/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol SuperHeroMovie {
	func writeScript() -> String
}
extension SuperHeroMovie {
	func makeScript() -> String {
		return """
		大量的特效,
		一些蹩脚的笑话,
		以及结尾处对另一部续集的暗示。
		"""
	}
}

问题 8/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Mammal {
	func eat()
}
extension Mammal {
	func eat() {
		print("该吃晚饭了!")
	}
}

问题 9/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Bartender {
	func makeDrink
}
extension Bartender {
	func makeDrink(name: String) {
		print("一杯\(name)马上来。")
	}
}

问题 10/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Hamster {
	var name: String { get set }
	func runInWheel(minutes: Int)
}
extension Hamster {
	func runInWheel(minutes: Int) {
		print("\(name)要去跑步了。")
		for _ in 0..<minutes {
			print("呼呼呼")
		}
	}
}

问题 11/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Starship {
	func transport(number: Int)
}
extension Starship {
	func transport(number: Int) {
		print("\(number)人准备传送——能量启动!")
	}
}

问题 12/12:这个扩展正确地实现了其协议中的一个方法 —— 对还是错?

swift
protocol Fencer {
	func fenceFoil()
}
extension Fencer {
	func fenceFoil() {
		print("准备!")
	}
}

13.5 总结:协议和扩展

作者:Paul Hudson 2021 年 10 月 12 日 已针对 Xcode 16.4 更新

在这些章节中,我们介绍了 Swift 的一些复杂但强大的特性。如果你觉得有些吃力,也不用难过 —— 这些特性一开始确实很难掌握,只有当你有时间在自己的代码中尝试使用它们之后,才能真正理解。

让我们回顾一下所学的内容:

  • 协议就像是代码的契约:我们指定所需的函数和方法,遵循协议的类型必须实现它们。
  • 不透明返回类型让我们可以隐藏代码中的一些信息。这可能意味着我们希望保留未来修改的灵活性,同时也意味着我们不需要写出冗长的返回类型。
  • 扩展让我们可以为自己的自定义类型或 Swift 的内置类型添加功能。这可能包括添加方法,我们也可以添加计算属性。
  • 协议扩展让我们可以一次性为多种类型添加功能 —— 我们可以为协议添加属性和方法,所有遵循该协议的类型都能使用这些功能。

归根结底,这些特性看似简单,实则不然。你需要了解它们,知道它们的存在,但在继续学习的过程中,只需对它们有初步的使用即可。

13.6 检查点 8

作者:Paul Hudson 2021 年 10 月 15 日 已针对 Xcode 16.4 更新

既然你已经理解了协议和扩展的工作原理,现在是时候暂停学习,接受一个挑战,以便将所学知识付诸实践了。

你的挑战是:创建一个描述建筑物的协议,添加各种属性和方法,然后创建两个结构体House(房屋)和Office(办公室)来遵循这个协议。你的协议需要包含以下内容:

  1. 一个存储房间数量的属性。
  2. 一个存储成本的整数属性(例如,500000 表示该建筑物价值 500000 美元)。
  3. 一个存储负责销售该建筑物的房地产经纪人姓名的属性。
  4. 一个打印建筑物销售摘要的方法,描述该建筑物是什么以及它的其他属性。

过一会儿我会提供一些提示,但首先建议你自己先尝试一下。

还在看吗?好吧,这里有一些提示:

  1. 在编写任何结构体之前,先完整地设计协议。
  2. 记住,你不能在协议中提供方法的实现。
  3. 你可以决定你的属性是只读的还是既有 getter 又有 setter。例如,房间数量可能是固定的,但也许你还需要考虑到有人会对其进行扩展。
  4. 你可以使用{ get }{ get set }来控制属性的读写状态。

**补充:**如果你有更多时间,想进一步探索这个主题,我为你准备了一个额外的教程:《如何充分利用协议扩展》。这完全是可选的,而且肯定超出了 Swift 初学者的范围 —— 你不需要为了继续学习而必须学习这个教程。

13.7 扩展阅读:如何充分利用协议扩展

作者:Paul Hudson 2022 年 5 月 12 日 已针对 Xcode 16.4 更新

协议扩展是 Swift 中的一项强大功能,你已经掌握了足够的知识,可以在自己的应用程序中使用它们。不过,如果你有好奇心并且有时间的话,我们可以稍微偏离一下主题,进一步探索它们。

注意: 这绝对已经超出了 Swift 初学者的水平,所以请不要有任何继续学习的压力 —— 你随时都可以跳到下一章!

让我们再尝试一个协议扩展,这次会稍微复杂一点。首先,我们将它作为 Int 的一个简单扩展来编写:

swift
extension Int {
    func squared() -> Int {
        self * self
    }
}

let wholeNumber = 5
print(wholeNumber.squared())

这添加了一个 squared() 方法,该方法将数字自身相乘得到平方,因此上面的代码将打印 25。

如果我们想在 Double 上也有同样的方法,又不想直接复制代码,我们可以遵循处理 Collection 时使用的相同模式:找到 IntDouble 都遵循的协议,然后扩展该协议。

这两种类型都遵循 Numeric 协议,因为它们都是数字,所以我们可以尝试扩展它:

swift
extension Numeric {
    func squared() -> Int {
        self * self
    }
}

然而,这段代码不再有效,因为 self * self 现在可以是任何类型的数字,包括 Double,而如果将一个 Double 乘以另一个 Double,得到的结果肯定不是 Int

为了解决这个问题,我们可以使用 Self 关键字 —— 在讨论引用静态属性和方法时我简要介绍过它,因为它允许我们引用当前的数据类型。它在这里很有用,因为它表示 “调用该方法的具体遵循类型”,代码如下:

swift
extension Numeric {
    func squared() -> Self {
        self * self
    }
}

请记住,selfSelf 含义不同:self 指的是当前值,而 Self 指的是当前类型。因此,如果我们有一个值为 5 的整数,我们的 squared() 函数实际上会像这样工作:

swift
func squared() -> Int {
    5 * 5
}

在这里,Self 实际上是 Int,而 self 实际上是 5。或者,如果我们有一个值为 3.141 的小数,squared() 会像这样工作:

swift
func squared() -> Double {
    3.141 * 3.141
}

如果你还在看,我想可以肯定地说你想更深入地探索协议扩展,我又怎能让你失望呢?不过,这绝对只适合非常好奇的人 —— 要开始使用 SwiftUI 构建应用程序,你真的不需要知道以下内容。

让我们从一个名为 Equatable 的内置协议开始,Swift 用它来使用 ==!= 比较两个对象。

我们可以像这样让我们的 User 结构体遵循 Equatable

swift
struct User: Equatable {
    let name: String
}

现在我们可以比较两个用户:

swift
let user1 = User(name: "Link")
let user2 = User(name: "Zelda")
print(user1 == user2)
print(user1 != user2)

我们不需要做任何特殊的工作,因为 Swift 可以为我们实现 Equatable 遵循 —— 它会将一个对象的所有属性与另一个对象的相同属性进行比较。

我们可以更进一步:Swift 有一个名为 Comparable 的协议,它允许 Swift 确定一个对象是否应该在另一个对象之前排序。Swift 不能自动在我们的自定义类型中实现这一点,但这并不难:你需要编写一个名为 < 的函数,它接受你的结构体的两个实例作为参数,如果第一个实例应该在第二个实例之前排序,则返回 true。

所以,我们可以这样写:

swift
struct User: Equatable, Comparable {
    let name: String
}

func <(lhs: User, rhs: User) -> Bool {
    lhs.name < rhs.name
}

提示: 随着你对 Swift 了解的深入,你会发现 < 函数可以实现为一个静态函数,这有助于使你的代码更有条理。

我们的代码足以让我们创建两个 User 实例,并使用 < 来比较它们,如下所示:

swift
let user1 = User(name: "Taylor")
let user2 = User(name: "Adele")
print(user1 < user2)

这很巧妙,但真正聪明的是,Swift 使用协议扩展使以下代码也能工作:

swift
print(user1 <= user2)
print(user1 > user2)
print(user1 >= user2)

这段代码之所以可行,是因为 Equatable 让我们知道 user1 是否等于 user2,而 Comparable 让我们知道 user1 是否应该在 user2 之前排序,有了这两个信息,Swift 就可以自动计算出其余的比较结果。

更好的是,我们甚至不需要为结构体添加 Equatable 就能使用 ==。这样就足够了:

swift
struct User: Comparable {
    let name: String
}

在幕后,Swift 使用协议继承,因此 Comparable 自动也意味着遵循 Equatable。这与类继承类似,所以当 Comparable 继承自 Equatable 时,它也继承了其所有要求。

无论如何,这已经是一个很大的题外话了 —— 如果这让你有点头晕,别担心,这只是出于好奇,当你在 Swift 学习之路上走得更远时,你会更明白的!

本站使用 VitePress 制作