【iOS】objc.io – Swift 进阶 – 整理 – (六)枚举

结构体和类都是记录类型 (record type)。一个记录由零个或多个具有类型的字段 (属性) 组成。元组也属于记录类型:实际上它是一个功能较少的轻量级的匿名结构体。

Swift 的枚举属于一个完全不同的类别,有时称它为标签联合或变体类型 (variant type)。尽管它和记录一样强大,但在主流编程语言中对其支持却不那么普遍。然而在函数式语言中却是司空见惯的,并已在 Rust 这样较新的语言中流行起来。

概述

一个枚举由零个或多个成员 (case) 组成,每个成员都可以有一个元组样式的关联值 (associated value) 列表。一个成员可以有多个关联值,可以视为为单个元组。

// 一个简单的没有关联值的枚举,它表示段落的对齐方式
enum TextAlignment { 
    case left
    case center
    case right
}
复制代码

Optional 是一个具有 none 和 some 这两个成员的泛型枚举。 some 有一个关联值用来表示当前的装箱值 (boxed value):

@_frozen enum Optional<Wrapped> { 
    /// 没有值。
    case none
    /// 存在一个值,保存为 `Wrapped`。 
    case some(Wrapped)
}
复制代码

Result 类型则用来表示一个操作的成功或失败,它的结构和可选值类似,区别是它增加了一个泛型参数,并把这个参数设置为 failure 的关联值,以便可以获得详细的错误信息:

enum Result<Success, Failure: Error> {
    /// 成功, 保存一个 `Success` 值。 
    case success(Success)
    /// 失败, 保存一个 `Failure` 值。
    case failure(Failure) 
}
复制代码

可以通过指定一个成员以及它的关联值 (如果有的话) 来创建一个枚举值:

let alignment = TextAlignment.left
let download: Result<String, NetworkError> = .success("<p>Hello world!</p>")
复制代码

注意上面第二行,必须提供完整的类型标注,包括所有的泛型参数。除非编译器可以从上下文中推断出另一个 Failure 泛型参数的具体类型,否则像 Result.success(htmlText) 这样的表达式就会产生错误。一旦指定了完整类型,我们就可以依靠着类型推断来使用前导点 (leading-dot) 语法了 (这里忽略了 NetworkError 的定义)。

枚举是值类型

就像结构体一样,枚举也是值类型。它的能力几乎和结构体相同:

  • 枚举可以有方法,计算属性和下标操作。
  • 方法可以被声明为可变或不可变。
  • 可以为枚举实现扩展。
  • 枚举可以实现各种协议。

但枚举不能拥有存储属性。一个枚举的状态完全由它的成员和成员的关联值组合起来表示。对于某个特定的成员,可以将关联值视为其存储属性。

枚举和结构体中的可变方法的工作方式是一样的。在一个可变方法中,因为 self 是 inout 参数,所以它是可变的。由于枚举没有存储属性,并且没有办法直接改变一个成员的关联值,所以修改一个枚举的方式就是直接分配一个新的值给 self。

因为通常会用一个枚举的成员来初始化枚举类型的变量,所以枚举不太需要一个显式的初始化方法。但也有可能会需要在类型定义或扩展中添加另外的简便初始化方法 (convenience initializer)。例如,使用 Foundation 的 Locale API 时,我们可以为 TextAlignment 枚举添加一个初始化方法,这方法根据传入的 locale 参数来设置一个默认的文本对齐方式:

extension TextAlignment {
    init(defaultFor locale: Locale) {
        guard let language = locale.languageCode else {
            // 以下是没能获得当前语言时的默认值。
            self = .left
            return
        }
        switch Locale.characterDirection(forLanguage: language) {
        case .rightToLeft:
            self = .right
        // Left 是对于其他情况的默认值。
        case .leftToRight, .topToBottom, .bottomToTop, .unknown:
            self = .left
        @unknown default:
                self = .left
        }
    }
}
let english = Locale(identifier: "en_AU")
TextAlignment(defaultFor: english) // left 
let arabic = Locale(identifier: "ar_EG")
TextAlignment(defaultFor: arabic) // right
复制代码

总和类型和乘积类型

一个枚举值只会包含一个枚举成员 (如果这个成员有关联值的话,则再加上关联值)。具体来说,一个 Result 类型变量包含的值要么是 success 要么是 failure,但不会两者都包含 (也不会什么都不包含)。相对的,一个记录类型的实例包含了它所有字段的值:一个 (String, Int) 类型的元组包含一个字符串和一个整型。

这种实现 “或” 关系的能力是相当特别的,它使得枚举变得非常有用。通常在无法用结构体,元组或类来清晰地表达逻辑的情况下,枚举允许我们充分利用强类型来写出更安全,更清晰的代码。

之所以说是 “相当特别的”,是因为尽管权衡和应用会有所不同,但也可以用协议和子类达到同样的目的。一个协议类型的变量 (也被称为existential) 可以是实现这个协议的任意类型之一。类似地,在iOS里,一个 UIView 类型的对象也可以指向任意一个 UIView 的直接或间接子类,例如 UILabel 或 UIButton。当操作这样一个对象时,我们要么使用定义在基类的公共接口 (等同于调用定义在枚举上的方法),要么为了访问某个具体子类中特有的数据,而尝试把实例向下转换成这个子类 (等同于转换一个枚举值)。

这两种方法 (通过协议和类的公共接口做动态派发,或是使用枚举) 的区别在于哪种更通用,以及两种结构所特有的功能和限制。比如,一个枚举中所有的成员是固定的,无法在除了声明之外的地方扩展它,但你总是可以让多个类型满足同一个协议,或添加另外一个子类 (除非你声明一个类为 open,否则跨模块的子类化是禁止的)。这种自由是否可取,甚至于说是否需要,则取决于要解决的问题。作为值类型,枚举通常是更轻量,更适合于实现 POD (plain old values) 的。

这两种类型 (“或”,“和”) 和数学概念中加法与乘法之间,有着一一对应的关系。当在设计自定义类型时,它提供了一个有用的思路。

术语 “类型” 的定义可能有多种。这里给出一个定义:一个类型,是它的实例所能表示的所有可能的值的集合,值也被称为居民 (inhabitant)。Bool 有两个居民,false 和 true。UInt8 有 2^8, 也就是 256 个居民。

通常来说,一个元组 (或者结构体,类) 的居民数量,等于其成员的居民数量的乘积。因此,结构体,类和元组也被称为乘积类型 (Product Types)。

枚举和这种类型形成了鲜明的对比。以下是一个有三个成员的枚举:

enum PrimaryColor { 
    case red
    case yellow
    case blue
}
复制代码

这个类型有三个居民,每个对应一个枚举的成员。除了 .red,.yellow 或 .blue 之外,不可能用其他值来构建它。那如果我们往这个类型中增加一个带关联值的成员,居民数量会发生什么变化呢?让我们在其中添加第四个成员,这个成员允许我们在 0 (黑色) 到 255 (白色) 之间,指定一个灰度值:

enum ExtendedColor {
    case red
    case yellow
    case blue
    case gray(brightness: UInt8)
}
复制代码

单就 .gray 来说,可能的值的数量是 256,所以整个枚举的居民数量就为 3 + 256,259 个。一般而言,一个枚举的居民数量,等于它所有成员的居民数量的总和。这也是为什么称枚举为总和类型 (Sum Types) 的原因。

向结构体增加一个字段会增加可能状态的数量,而且这个数量通常会非常大。而往枚举中添加一个成员却只会另外增加一个居民 (如果这个成员是有关联值的话,增加的数量就是关联值的居民数量)。对于实现安全的代码,这是一个非常有用的特性。

模式匹配

为了用枚举值来做点有用的事,通常我们必须检查枚举成员并提取它的关联值。

检查一个枚举值最常用方法就是使用 switch 语句,它允许我们在单个语句中,将值与多个候选值 (candidates) 进行比较。使用 switch 语句还有一个额外的好处,就是它有一个方便的语法, 可以一口气做完值与成员的比较和提取关联值这两件事。这个机制被称为模式匹配。模式匹配不是 switch 所独有的,但却是最明显的用例。

模式匹配是有用的,因为它让我们可以通过结构而不是内容,来解构一个数据结构。
一个 switch 语句的每个分支都由一个或多个和输入值相匹配的模式组成。

一个模式描述了值 的结构。例如,在下面的例子中,模式 .success(42, _) 匹配枚举中带有一对关联值的 success 成员,并且关联值的第一个元素等于 42。这个模式中的下划线是一个通配符模式,它表示第二 个元素可以是任意值。除了这种普通匹配,我们还能提取关联值的某个部分,并把它们绑定到 一个变量。还是下面这个例子,模式 .failure(let error) 匹配 failure 成员,并且把关联值绑定到 一个新的局部常量 error 中:

let result: Result<(Int, String), Error> = ... 
switch result {
case .success(42, _):
    print("Found the magic number!") 
case .success(_):
    print("Found another number") 
case .failure(let error):
    print("Error: \(error)") 
}
复制代码

看一下Swift 支持的模式类型:

  1. 通配符模式 – 符号为下划线:_。它匹配任意值并忽略这个值。当匹配到关联值的一部分,并想忽略另部分的时候,就可以使用它了。在上面的代码中,我们已经在 .success(42, _) 模式中 看到了用通配符的例子。在 switch 语句中,case _ 是等同于 default 关键字的:两者都匹配任意值,并且把它们作为最后一个分支才是合理的。
  2. 元组模式 – 使用一个用逗号分割的子模式 (subpattern) 列表来匹配元组。例如,(let x, 0, _) 匹配的是一个含有三个元素的元组,其中第二个元素为 0,然后把第一个元素与 x 做绑定。元组模式本身只匹配元组的结构,也就是说,只匹配在括号内用逗号分割的元素数量。对于元组内容的匹配则是通过各个子模式来做的。(let x, 0, _) 中就有三个子模式:值绑定模式 (value-binding pattern),表达式模式 (expression pattern) 和通配符模式。当需要在单个 switch 语句中转换多个值时,这个模式是非常有用的。
  3. 枚举成员模式 – 匹配指定的枚举成员。它可以包含子模式来处理关联值,像是等式检查 (.success(42)) 或值绑定 (.failure(let error)) 这种。为了忽略一个关联值,可以在子模式中使用下划线,或删除整个子模式来达到目的,例如,.success(_) 和 .success 就是等价的。如果你想提取一个成员的关联值,或是只想匹配成员而忽略掉关联值的话,这个模式是唯一的方法。为了与某个含有特定关联值的特定成员做比较,只要这个枚举实现了 Equatable 协议, 你就可以在 if 语句中使用 == 操作符来做比较。
  4. 值绑定模式 – 把一个匹配值的部分或全部,绑定到一个新的常量或变量上。语法是let someIdentifiervar someIdentifier 这样。绑定变量的作用域就在其声明的那个 case 语句块中。

作为在单个模式中绑定多个值的一种简写方式,你不需要在每个绑定变量前重复的写 let,只需 要在模式前加一个 let 前缀就可以了。所以模式 let (x, y) (let x, let y) 是一样的。请注意在单个模式中同时使用值绑定和等式匹配时,两者的细微差别:例如,模式 (let x, y) 中把元组的第一个元素和一个新的常量做绑定,但对于第二个元素,模式只是拿它与一个现有的变量 y 做比较。

为了把值绑定和其他一些绑定值所必须满足的条件相结合,你可以使用 where 子句来扩展一个值绑定模式。例如,模式 .success(let httpStatus) where 200..<300 ~= httpStatus 只会匹配 success 且关联值在指定范围内的值。非常重要的一点是,因为 where 子句是在值绑定之后执行的,所以我们才能在子句中使用绑定的值.

如果你在单个分支中包含了多个模式,那么各个模式中值绑定模式的数量,以及每个值绑定所用的变量名和类型都必须相同。

enum Shape {
case line(from: Point, to: Point)
case rectangle(origin: Point, width: Double, height: Double) 
case circle(center: Point, radius: Double)
}

switch shape {
case .line(let origin, _), 
     .rectangle(let origin, _, _), 
     .circle(let origin, _):
    print("Origin point:", origin) 
}
复制代码
  1. 可选值模式 – 通过使用我们所熟悉的问号语法,为匹配及解包可选值这两个操作,提供了一个语法糖。模式 let value? 等价于 .some(let value),也就是说,它匹配一个不为 nil 的可选值, 并把解包出来的值和一个常量绑定。

我们也能使用 nil 来匹配一个可选值的 none 成员。这个简写方式并 没有使用任何编译器的黑魔法,标准库为了和 nil 做比较而重载了 ~= 操作符,让其作为一个普 通的表达式模式

  1. 类型转换模式 – 模式 is SomeType 匹配成功的条件是,一个值的运行时类型必须是 SomeType 或是其子类。模式 let value as SomeType 会执行同样的检查,并且另外还会将匹配的值转换为指定的类型,而 is 只会检查类型:
let input: Any = ... 
switch input {
case let integer as Int: ... // integer 的类型是 Int。
case let string as String: ... // string 的类型是 String。
default: fatalError("Unexpected runtime type: \(type(of: input)")
}
复制代码
  1. 表达式模式 – 通过把输入值和模式作为参数传递给定义在标准库中的模式匹配操作符(~=)来匹配表达式。对于实现 Equatable 协议的类型,~= 的默认实现就是转发到==中,这也是在模式 中简单的等式检查的工作方式。

标准库还为范围 (ranges) 提供了 ~= 的重载。这提供了一个非常漂亮的语法来检查一个值否在 在一个范围内,特别是和单边范围 (one-sided ranges) 相结合的时候。下面的 switch 语句会 检查一个数字是否是正数,负数或是零:

let randomNumber = Int8.random(in: .min...(.max)) 
switch randomNumber {
case ..<0: print("\(randomNumber) is negative") 
case 0: print("\(randomNumber) is zero")
case 1...: print("\(randomNumber) is positive") 
default: fatalError("Can never happen")
}
复制代码

需要注意的是,因为编译器无法确定这三个具体的分支是否能覆盖所有可能的输入 (即使它们确实是可以覆盖的),所以它还是会强制我们添加了一个 default 分支。switch 语句必须始终是完备的 (exhaustive)。

在其他上下文中的模式匹配

虽然模式匹配是从枚举中提取关联值的唯一方式,但它不是专属于枚举或 switch 语句的。事实上,即使像 let x = 1 这样简单的一条赋值语句,也能被视为是一个值绑定模式:用赋值操作 左边的变量来匹配右边的表达式。另外一些模式匹配的例子包括:

  1. 在赋值时解构元组,例如,let (word, pi) = ("Hello", 3.1415) 或迭代时的for (key, value) in dictionary { ... }。请注意在 for 循环的例子中,我们没有使用 let 来指明这是一个值绑定。因为在这种情况下,默认所有标识符都是值绑定。for 循环也支持 where 子句, 比如,for n in 1...10 where n.isMultiple(of: 3) { ... } 只会在 n 为 3, 6, 及 9 的情况下才会执行循环体。

  2. 使用通配符来忽略我们不感兴趣的值,例如,for _ in 1…3 会执行 3 次循环而不为循环计数创 建一个变量,或当我们要执行一个有副作用的函数时,_ = someFunction() 会避免编译器产生 “未使用结果” 的警告。

  3. 在一个 catch 子句中捕获错误:例如,do { ... } catch let error as NetworkError { ... } 这样。

  4. if case 和 guard case 语句类似于只有单个分支的 switch 语句。尽管在许多情况下,我们为了利用编译器的完备性检查而更喜欢用 switch 语句,但因为这两个语句所需的行数要少于 switch,所以它们偶尔还是有用的。

对于 Swift 的新手来说,if/guard case [let] 的语法经常是一个很大的阻碍。我们认为会造成这种情况的原因是,它使用赋值操作符来进行基本的比较操作,并且可以不包含值绑定。例如,以下的代码用来测试一个枚举值是否等于一个特定的成员,但同时又忽略其关联值:

let color: ExtendedColor = ...
if case .gray = color { 
    print("Some shade of gray")
}
复制代码

可以把这里的赋值操作符视为 “在操作符右边的值和左边的模式之间做一个模式匹配”。当引入值绑定时,这个语法会变得更清晰,语法是相同的你只需要在之前的语句中加上 let 或 var 就可以了:


if case .gray(let brightness) = color { 
    print("Gray with brightness \(brightness)")
}
复制代码
  1. for case 和 while case 循环的工作方式类似于 if case。它们允许你仅在模式匹配成功时才执行循环体。

最后,有时闭包表达式的参数列表看起来像是模式,因为它们也支持一种元组解构。例如,对于字典的 map 方法,即使传入的参数被指定为单个 Element,但对字典执行 map 操作时,我 们可以在执行转换的闭包中使用一个 (key, value) 参数列表 (Dictionary.Element 的类型是一个 (Key, Value) 元组)。

dictionary.map { (key, value) in ...}

这里的 (key, value) 看上去像一个元组,但实际上它只是一个拥有两个元素的参数列表。我们之所以能在这里把元组解包成参数列表,是由于编译器对此有特殊处理的缘故,和模式匹配没有关系。没有这个特性的话,我们将不得不使用类似 { element in … } 这样的单项参数列表,然后在闭包中用两行代码把 element (现在它真正是一个元组了) 解构成 key 和 value。

使用枚举进行设计

因为相比结构体和类,枚举属于不同的类别,所以适用于它的设计模式也是不同的。又由于在主流的编程语言中,真正的总和类型是一种相对不常见的 (如果语言快速发展的话) 特性,因此相比传统的面向对象方法,你可能会不习惯于使用它。
那么让我们看一下在代码中,可以使用的一些充分利用了枚举各种特性的模式。我们将它们分为六个主要方面:

  1. Switch 语句的完备性
  2. 不可能产生非法的状态
  3. 使用枚举来实现状态
  4. 在枚举和结构体之间做选择
  5. 枚举和协议之间的相似之处
  6. 使用枚举实现递归数据结构

Switch 语句的完备性

多数情况下,switch 只是对于带有多个 else if case 条件的 if case 语句的一种更方便的语法而已。除了语法差异之外,它们之间还有一个重要的区别:一个 switch 语句必须是完备的,也就是说,它的分支必须覆盖所有可能的输入值。编译器也会强制执行这个完备性。

对于实现安全的代码及在程序修改时保持代码正确来说,完备性检查是一种重要工具。每次你增加一个成员到一个现有的枚举时,编译器会在所有对这个枚举使用 switch 语句的地方发出警告,提醒你需要处理这个新加的成员。if 语句并不会执行完备性检查,它也不会作用于一个包含 default 分支的 switch 语句 – 因为 default 可以匹配任意值,所以这样一种 switch 语句是永远不会是完备的。

因此建议尽可能避免在 switch 语句中使用 default 分支。当然了是无法完全避免的,因为编译器有时并不足够聪明,无法确定一组分支是否真的是完备的。编译器只会在安全性方面出错,也就是说,它永远 不会将一组非完备的模式报告为完备的。

然而当 switch 枚举时,是不会发生漏判 (False negative) 的。对于以下的类型,完备性检查是 完全可以信赖的

  • 布尔值
  • 枚举,只要任何关联值可以被检测出是完备的,或者你用某个模式来匹配任意的关联值

(例如,通配符或值绑定)

  • 元组,只要它的成员类型可以被检测出是完备的

另外,编译器还会验证一个 switch 语句中各个模式的权重。如果一个模式之前的所有模式已经可以覆盖所有情况的话,编译器就能证明这个模式永远不会被匹配到,从而针对这个情况发出一个警告。

完备性检查的最大好处体现在如果你想让枚举和使用它的代码是同步演进的时候,也就是说,每次给枚举增加一个新的成员时,所有 switch 这个枚举代码都可以被同时更新。如果你可以访问程序的依赖项的源代码,并且程序和依赖项是一起编译的话,确实可以利用到完备性检查的各个好处。但当一个库是以二进制形式发布,并且使用这个库的程序必须做好编译之后会使用更新版本的准备,情况就会变得更复杂了。在这种情况下,即使分支已经覆盖了现有的所有情况,但还是需要始终包含一个 default 分支的。

不可能产生非法的状态

为什么要使用像是 Swift 这种静态类型语言?性能是其中之一:编译器对于程序中变量的类型知道的越多,通常就越能产生更快的代码。

另一个同样重要的理由是,类型系统可以指导开发人员应该如何使用 API。如果把一个错误的类型传递给函数,编译器马上就会报错。可以将这种技术称为编译器驱动开发 – 把编译器作为一种工具,通过使用类型信息,找到正确地解决方案:

  • 精心选择一个函数的输入和输出类型的话,可以减少误用这个函数的机会,因为类型为函数的行为建立了一个 “上限”。当要精确定义允许 值的范围时,枚举就经常是一个完美的工具。

  • 静态类型检查可以完全防止某些类别的错误;如果代码违反了类型系统设置的约束的 话,这些代码就不会被编译成功,也永远不会在运行时才去处理这些违反约束的情况。

  • 类型就像是永远不会失效的文档。在这点上它和注释是不一样的,随着代码的更新,人们有可能会忘记更新相应的注释,但因为类型是程序的一个组成部分,所以它永远都是最新的。

以下是我们设计自定义类型时,为了从编译器获得最大程度帮助的建议:使用类型使其无法表示非法状态。在之前总和类型和乘积类型这一节中已经看到过,在为一个枚举增加一个成员后,这个类型就只会增加一个可能的值。除此之外你的粒度不会更细了,对于此目的来说枚举是非常有用的。

典型的例子就是 Optional,通过 none 成员以及添加一个泛型参数作为封装类型,就在不依靠哨岗值的情况下,已经可以精确地表达出缺少一个值这件事了。

让我们看一个 API 的例子,因为不符合上述的准则,所以它比想象中还要难用。在 Apple 的 iOS SDK 中,异步操作 (比如执行一个网络请求) 的常见模式是将一个完成时处理的方法 (回调函数) 传递给你调用的异步操作。当任务完成时,此方法会用任务的结果为参数来调用之前传入的回调函数。因为大多数异步操作都存在失败的可能,所以通常情况下,任务的结果要么是某个表示成功的值 (例如,服务器的响应),要么就是一个错误。

看一下在 Apple 的 Core Location 框架中的地理解码 API。你把一个表示某个地址的字符串和一个回调函数传递给这个 API。这个 API 会去请求服务器返回匹配这个地址的所有地标对象 (placemark object)。然后它要么用得到的地标对象列表,要么用一个错误来调用传入的回调函数:

class CLGeocoder {
    func geocodeAddressString(_ addressString: String,
    completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void) 
    // ...
}
复制代码

观察一下回调函数的类型,([CLPlacemark]?, Error?) -> Void。它的两个参数都是可选值。这意味着此函数可以呈现给调用者四种可能的状态:(.some, .none), (.none, .some),
(.some, .some), 或 (.none, .none) (这是一个简化的视角;因为实际上 some 的状态是有无限多个可能的值,但这里我们只关心它们是不是非空的)。四个合法的状态所造成的问题在于,在实践中只有前两个状态是有意义的。如果开发人员同时接收到一个地标的列表和一个错误的话,他们应该做什么?更糟糕的是,如果两个值返回的都是 nil 的话,又该怎么办?因为类型不够精确,所以在这里编译器无法帮助到你。

目前为止,因为 Apple 可能都在小心翼翼地实现这个方法,让其永远不会返回这些无效状态中的任何一个,所以在实践中前面说的问题永远不会发生。

如果把这两个可选值替换为一个 Result<[CLPlacemark], Error> 类型的话,这个地理解码的 API 将会变得对开发者更友好:

extension CLGeocoder {
    func geocodeAddressString(_ addressString: String,
    completionHandler: @escaping (Result<[CLPlacemark], Error>) -> Void) {
    // ...
    } 
}
复制代码

Result 类型表示操作要么成功要么失败,但这两个状态不可能同时存在,也不可能都不存在。 通过使用一个无法表达无效状态的类型,API 变得更易使用,并且因为编译器禁止了很多情况,所以一系列潜在的错误也就自然不会发生了。由于许多 Apple 的 iOS API 都是用 Objective-C 实现的,所以它们也就无法充分利用 Swift 的类型系统,毕竟 Objective-C 的枚举是没有像关联值这种概念的东西的。但这并不意味着我们不能用 Swift 做得更好。

使用枚举来实现状态

如何在我们的程序中实现状态,并且不让状态出现非法情况,是程序设计的另一个主要方面。在一个给定的时间点上,程序的状态包括所有变量的内容加上 (隐式地) 其当前的执行状态,即哪些线程正在运行以及它们正在执行哪条指令。一个状态需要 “记住” 很多事,像是一个程序所处的模式,正在显示的数据,当前正在处理的用户交互等。除了最最简单的程序之外,所有程序都是有状态的:一个特定的指令被执行时,接下来会发生什么是取决于系统所处的当前状态 (HTTP 是一个无状态协议的例子,这意味着对于同一个客户端,服务端必须在处理当前请求时不考虑先前的请求。在多个请求之间,Web 开发者必须使用像是 cookies 之类的功能来记住状态。即使 HTTP 是无状态的,但一个用来处理 HTTP 请求的程序仍然是有状态的,因为它需要维护内部的状态)。

当程序运行时,它会改变状态以响应像是用户交互或从网络传入数据等这种外部事件。这可以是隐式地发生的,而开发者不用过多的考虑它 – 毕竟,状态改变是始终发生的。但随着程序变得越来越复杂,最好有意识地定义程序 (或其某个子程序) 可能存在的状态,以及状态之间的合法转换。一个系统可以存在的状态集合也称为其状态空间。

试使你程序的状态空间尽可能的小。状态空间越小,你作为开发者所要做的工作就变得越简单 – 一个较小的状态空间减少了代码所需要处理的情况的数量。因为枚举的状态数量是有限的,所以非常适用于实现状态以及状态之间的转换。并且因为每个枚举状态,或者说每个成员,都带有自己的数据 (以关联值的形式),所以很容易禁止表达非法状态的组合。

假设我们正在实现一个聊天的程序。当用户打开一个聊天频道后,程序应该在从网络请求消息列表的期间显示一个 spinner。当网络请求完成时,UI 要么就变成显示接收到的消息列表,要么如果网络失败的话就显示一个错误。让我们先考虑一下如何在没有枚举的情况下,以传统的方式来实现程序的状态 (技术上说,因为我们会用可选值,所以仍然使用的是枚举,但你明白我们这里的意思)。我们可以用三个变量,其中一个为布尔值,我们把这个值设为 true 来表示当前正处于网络请求的过程中,另外两个都为可选值,分别用于消息列表和错误:

struct StateStruct {
    var isLoading: Bool
    var messages: [Message]? var error: Error?
}
// 设置初始状态。
var structState = StateStruct(isLoading: true, messages: nil, error: nil)
复制代码

当加载消息列表时,messages 和 error 这两个变量都该为 nil,然后当网络请求完成时,它们中的一个应该被赋值。在同一时刻这两个变量应该永远不可能都是非 nil 的,当它们中的任何一个不为 nil 时,isLoading 的值也不应该是 true。

回想一下在总和类型和乘积类型中,关于如何确定一个类型所能拥有居民数量的讨论。StateStruct 结构体是一个拥有 2 × 2 × 2 = 8 个可能状态的乘积类型:布尔的 true 或 false 以及两个可选值中任意一个的 none 或 some 所组成的任意组合。这其实就是一个问题,因为我们的程序只需要处理这八种状态中的三种:加载,显示一个消息列表或显示一个错误。如果我们正确实现了程序的话,另外五个状态都应该是不会发生的无效组合,但我们无法指望编译器提供任何帮助来避免创建一个无效的状态。

现在,让我们用一个自定义枚举来实现我们的状态,这个枚举有三个值,loading, loaded 和 failed:

enum StateEnum {
    case loading
    case loaded([Message]) case failed(Error)
}
// 设置初始状态
var enumState = StateEnum.loading
复制代码

因为不必再关心和初始状态无关的属性,所以设置初始状态的代码变得更清晰了。此外,我全消除了转换到一个无效状态的可能性。因为每个状态都带有自己的关联数据,所以 loaded 和 failed 的关联值的类型也就不必是可选值了。因此,除非在代码中确实是有一个 Error 值,否则就不可能转换到 failed 状态 (对于 loaded 状态,因为你总是可以给它赋值一个空数组,所以情况会有点不太清晰,但这不是你会不小心做的事)。当程序处 于某个特定状态时,我们可以确信该状态所需要的数据也已经是可用的了。StateEnum 枚举可以作为一个状态机 (state machine) 的基础。

使用的结构体和之后替换它的枚举都不是实现这个状态的唯一方式。事实上,StateStruct.isLoading 属性是多余的,因为在我们的设计中,isLoading 应该只在 messages 和 error 都为 nil 的时候才为 true。我们可以在不损失任何东西的前提下 让 isLoading 是一个计算属性:

struct StateStruct2 {
    var messages: [Message]? var error: Error?
    var isLoading: Bool {
        get { return messages == nil && error == nil } 
        set {
            messages = nil
            error = nil 
        }
    } 
}
复制代码

这使得可能的状态的数量由八个减少为四个,从而只剩下了一个无效状态 (当 messages 和 error 都为非 nil 时) – 这并不完美,但要比我们一开始的那个结构体版本要好。

这种具有两个互斥的可选值的模式,在前一节中使用 Result<[CLPlacemark], Error>) 替换 ([CLPlacemark]?, Error?) 的例子。对我们现在这个例子 使用相同方式的话就会变成 Result<[Message], Error>,但请注意这两种情况并不完全相同; 聊天程序需要第三个状态 – “加载”,这种状态下,messages 和 error 都为 nil。把 Result 变成 一个可选值就可以实现这一点 (回想一下,把一个类型封装为一个可选值的话,总是会为这个类型增加一个居民),所以用以下另一种方式来表示我们的状态:

/// nil 意味着状态为 "加载"。
typealias State2 = Result<[Message], Error>?
复制代码

这和我们所自定义的枚举是等价的,也就是说,两者在状态的数量和每个状态的有效负载上是一样的 (Result<[Message]?, Error> 是另一个等价的版本)。但从语义上讲,这可以说是一个较差的解决方案,因为它不能立即清晰地表明出,当值为 nil 时就表示 “加载” 状态这件事。

总而言之,枚举是实现状态的绝佳选择。它可以在很大程度上防止无效状态,并将子系统 (或者 甚至是整个程序) 的整个状态都放在一个变量中,从而使状态转换更不容易出错。此外,switch 语句的完备性允许编译器能在你添加了一个新的状态,或改变了现有状态的关联值时,指出需 要更新的代码路径。

在枚举和结构体之间做选择

一个枚举值精确地表示所有成员中的一个 (加上它的关联值),但一个结构体的值表示的是它所有属性的值。

用枚举和结构体各实 现一个用来分析事件的数据类型。以下是枚举的版本:

enum AnalyticsEvent {
    case loginFailed(reason: LoginFailureReason) 
    case loginSucceeded
    ... // 更多的枚举值。
}
复制代码

通过增加数个计算属性来扩展这个枚举,在这些计算属性中,switch 枚举并返回用户所需的数据,即实际上应发送到服务器的字符串和字典:

extension AnalyticsEvent { 
var name: String {
        switch self {
        case .loginSucceeded:
            return "loginSucceeded" 
        case .loginFailed:
            return "loginFailed" 
            // ... more cases.
        }
    }
var metadata: [String: String] {
        switch self {
        // ...
        } 
    }
}
复制代码

另一种选择是我们可以用结构体来实现相同的功能,将其名字和元数据 (metadata) 保存在两个属性中。我们提供一些静态方法 (分别对应于上面各个枚举值) 来为特定的事件创建实例:

struct AnalyticsEvent {
    let name: String
    let metadata: [String : String]
    private init(name: String, metadata: [String: String] = [:]) { 
        self.name = name
        self.metadata = metadata
    }
    static func loginFailed(reason: LoginFailureReason) -> AnalyticsEvent { 
        return AnalyticsEvent(
            name: "loginFailed"
            metadata: ["reason" : String(describing: reason)] )
    }
    static let loginSucceeded = AnalyticsEvent(name: "loginSucceeded") 
        // ...
    }
复制代码

由于我们把初始化方法声明为私有了,所以暴露出去的公有接口是和枚举版本相同的:枚举暴露了一些成员,像是 .loginFailed(reason:) 或 .loginSucceeded 这种,而结构体暴露的则是静态方法和属性。name 和 metadata 在两个版本中都可用的,只是在枚举中是计算属性而在结构体中是存储属性。

但是,每个版本的 AnalyticsEvent 类型都有其独特的特性,这些特性可以成为优点也可以成为 缺点,具体取决于你的需求是什么:

  • 如果我们让结构体的初始化方法的访问级别为 internal 或 public 的话,则可以在其他文件或者甚至其他模块中通过添加静态方法或属性来扩展这个结构体,从而添加新的分 析事件到 API 中。枚举的版本是无法实现这一点的:你不能在其他地方添加新的成员到枚举中。
  • 枚举可以更精确地实现数据类型;它只能表示预定义成员中的一个,但结构体因为这两个属性而可能表示无限多的值。如果你想对事件做进一步的处理 (例如,合并事件序列),则枚举的精确性和安全性会派上用场。
  • 结构体可以有私有 “成员” (也就是说,对所有使用者都不可见的静态方法或静态属性),而枚举中成员的可见性始终和枚举本身保持一致。
  • 你可以对枚举使用 switch 语句,并利用语句的完备性来确保不会错过任何一个事件的类型。但由于这种严格性,所以向枚举添加一个新的事件类型就可能会破坏使用这个 API 用户的源代码,但你可以为新的事件类型往结构体中添加静态方法,而不用担心会影响其他代码。

枚举和协议之间的相似之处

在总和类型和乘积类型这一节中,提到过枚举不是唯一可以表示 “之一” 关系的结构; 协议也可用于此目的。

enum Shape {
    case line(from: Point, to: Point)
    case rectangle(origin: Point, width: Double, height: Double) 
    case circle(center: Point, radius: Double)
}
复制代码

一个形状可以是线段,矩形或圆形。我们在其扩展中添加一个渲染方法来把这些形状渲染到 Core Graphics 的上下文中。

extension Shape {
    func render(into context: CGContext) {
        switch self {
        case let .line(from, to): // ...
        case let .rectangle(origin, width, height): // ...
        case let .circle(center, radius): // ...
        } 
    }
}
复制代码

另一种方式就是可以定义一个名为 Shape 的协议,任何实现这个协议的类型都可以把自身渲染到一个 Core Graphics 上下文中:

protocol Shape {
    func render(into context: CGContext)
}
复制代码

之前用成员表示的各个形状类型,现在都变成了实现这个 Shape 协议的具体类型。每个类型都实现了属于自己的 render(into:) 方法:

struct Line: Shape { 
    var from: Point 
    var to: Point
    func render(into context: CGContext) { /* ... */ } 
}
struct Rectangle: Shape { 
    var origin: Point
    var width: Double
    var height: Double
    func render(into context: CGContext) { /* ... */ } 
}
复制代码

虽然功能上是等价的,但考虑以下两件事就会觉得很有趣:枚举和协议这两种方式是如何组织代码的,以及如何为了新功能来扩展它们。基于枚举的实现是按方法来分组的:所有形状类型的 CGContext 渲染代码都在 render(into:) 方法中的单个 switch 语句中。另一方面,基于协议的实现是按 “成员” 来分组的:每个具体的类型都实现自己的 render(into:) 方法,该方法中包含了每个形状特定的渲染代码。

这在扩展性方面具有重要的影响:在枚举的版本中,我们可以在之后的 Shape 扩展中轻松地添加新的渲染方法 – 例如,渲染成一个 SVG 文件,就是在不同模块中也可以如此。然而,除非我们可以控制含有枚举声明的源代码,否则我们就无法在枚举中添加新的形状。并且即使我们可以修改枚举的定义,添加一个新的成员这件事,是对所有在实现中 switch 这个枚举的方法的一种破坏源代码的修改。

另一方面,在协议的版本中我们可以轻松地添加新的形状:只需创建一个新的结构体,并让其实现 Shape 协议即可。但是,如果不修改现有的 Shape 协议,我们就无法添加新的渲染方法,因为我们不能在协议声明之外添加新的协议要求 (我们是可以在协议的扩展中添加新的方法的,但正如会在协议这一章中看到的一样,扩展方法通常不适合于向协议添加新功能的这个需求,因为这些方法不是动态派发的)。

事实证明,在这种情况下,枚举和协议具有互补的优势和劣势。每个解决方案在一个维度上是可扩展的,而在另一个维度上就缺乏灵活性。如果 API 的声明和使用都发生在同一个模块中的话,枚举和协议之间这些扩展性的差异就不那么重要了。但如果你在实现库代码,那么你应该考虑哪个维度上的扩展性更重要:是添加新的成员重要,还是添加新的方法更重要。

使用枚举实现递归数据结构

枚举非常适合用来实现递归数据结构,即 “包含” 自身的数据结构。想象一下树结构:一个树有多个分支,每个分支其实又是另一个分成多个子树的树,以此类推,直到到达树叶。许多常见的数据格式都是树结构,例如,HTML, XML, 和 JSON。

作为递归数据结构的一个例子,让我们实现一个比树更简单的数据结构:单向链表 (singly linked list)。一个链表的节点可以是两种情况中的一种:节点含有值和指向下一个节点的引用,或节点表示链表的结尾。这种二选一的关系强烈暗示了,一个总和类型 (即枚举) 是非常适合用来定义该数据结构的。以下是一个 List 类型的定义,它的元素类型是一个泛型参数:

enum List<Element> {
case end
indirect case node(Element, next: List<Element>) }
复制代码

请注意 indirect 关键字,这是使代码能编译通过所必需的。indirect 告诉编译器把 node 成员表 示为一个引用,从而使递归起作用。

为了理解其中的原因,回想一下枚举是值类型这件事。值类型是不能包含自身的,因为如果允许这样的话,在计算类型大小的时候,就会创建一个无限递归。编译器必须能够为每种类型确定一个固定且有限的尺寸。将需要递归的成员作为一个引用是可以解决这个问题的,因为引用类型在其中增加了一个间接层;并且编译器知道任何引用的存储大小总是为 8 个字节 (在一个 64 位的系统上)。

indirect 语法仅适用于枚举。如果它不可用或想自己实现一个类似的递归结构的话,可以通过把递归值封装在一个类中来实现相同的行为,因为这样等于手动创建了一个间接层。以下是一个通用类,这个类可以把任何值都封装为一个引用:

final class Box<A> {
    var unbox: A
    init(_ value: A) { self.unbox = value }
}
// 使用这个类,我们就可以不用 indirect 而实现之前的 List 枚举:
enum BoxedList<Element> {
    case end
    case node(Element, next: Box<BoxedList<Element>>)
}
复制代码

这个枚举用起来不太方便,因为我们必须一直手动执行装箱和解箱操作,但它几乎等同于 List 类型。我们之所以会说 “几乎”,是因为间接层从整个 node 成员转移到了关联值的 next 元素,
而一个完全相同的解决方案是把整个关联值封装到 Box 中,像这样: case node(Box<(Element, next: BoxedList<Element>)>)

也可以把 indirect 添加到枚举声明本身上,例如,indirect enum List { … } 这样。这是一个把枚举中含有关联值的成员全部变成引用的简便语法 (indirect 仅适用于关联值,而永远不会作用于枚举用来区分成员的标记位上)。对于我们的 List 类型,这两个版本是等价的,因为里面没有出现,拥有关联值的成员不应该被间接保存的情况。

讨论下如何使用 List 枚举。通过创建一个新的节点来将一个元素添加到链表中,并将新节点的 next 值设置为当前节点:

let emptyList = List<Int>.end
let oneElementList = List.node(1, next: emptyList)
// node(1, next: List<Swift.Int>.end)

extension List {
/// 把一个含有值 `x` 的节点添加到链表的头部。 
/// 然后返回整个链表。
    func cons(_ x: Element) -> List { 
    return .node(x, next: self)
    } 
}

// 一个含有 (3 2 1) 三个元素的链表。
let list = List<Int>.end.cons(1).cons(2).cons(3) 
/*
node(3, next: List<Swift.Int>.node(2, 
next: List<Swift.Int>.node(1, 
next: List<Swift.Int>.end)))
*/
复制代码

链式语法 (chaining syntax) 可以清楚的表明一个链表是如何构造的,但它有点难看。我们可以让链表实现 ExpressibleByArrayLiteral 协议,使其能够使用数组字面量来初始化一个链表。在 具体的实现中,首先反转作为输入的数组 (因为链表是从结尾开始构建的),然后从 .end 节点开 始,使用 reduce 将元素逐个添加到链表中:


extension List: ExpressibleByArrayLiteral { 
    public init(arrayLiteral elements: Element...) {
        self = elements.reversed().reduce(.end) { partialList, element
            in partialList.cons(element)
        } 
    }
}
let list2: List = [3,2,1]
/*
node(3, next: List<Swift.Int>.node(2, 
next: List<Swift.Int>.node(1,
next: List<Swift.Int>.end))) */
复制代码

这个链表类型还有一个有趣的特性:它的可持久化。节点都是不可变的 – 一旦创建,你就无法修改它了。添加一个元素到链表中时并不会复制链表;它只是给你一个新的节点,这个节点会链接到现有列表的头部。

这意味着两个链表可以共享同一个尾部.如果你可以修改链表 (例如,移除最后一个节点,或更新保 存在节点中的元素),那么这个共享就会有问题 – x 可能会改变链表,并且这个改变会影响到 y。

不过,可以在 List 中定义 mutating 方法来推入和弹出元素:

extension List {
    mutating func push(_ x: Element) {
        self = self.cons(x) 
    }
    mutating func pop() -> Element? { 
        switch self {
        case .end: return nil
        case let .node(x, next: tail):
            self = tail
            return x 
        }
    } 
}
复制代码

其实这些可变方法并没有改变链表本身。相反,它们只是把变量所指向的列表部分修改成了另外的值:

var stack: List<Int> = [3, 2, 1] 
var a = stack
var b = stack
a.pop() // Optional(3) 
stack.pop() // Optional(3) 
stack.push(4)
b.pop() // Optional(3) 
b.pop() // Optional(2) 
stack.pop() // Optional(4) 
stack.pop() // Optional(2)
复制代码

可变方法允许我们修改 self 所指向的值,但值本身 (链表中的节点) 是不可变的。从这个意义上讲,通过使用 indirect,变量已然成为链表中的迭代器.

在实际中,这些节点都放在内存 中相互指向的位置上,并且都占有了一定的空间,如果不再需要它们了我们就会想要收回这些 空间。Swift 使用自动引用计数 (ARC) 来管理这件事,并释放那些不再使用的节点的内存.

原始值 (Raw Value)

有时需要将枚举的每个成员同一个数字或其他某个类型的值相关联。C 或 Objective-C 中枚举的工作方式默认就是如此 – 实际上在底层它们就只是一些整数。Swift 的枚举不能与任意整数互换,但我们可以选择性地在枚举的成员和所谓的原始值之间声明一个 1 对 1 的映射。这对于 与 C API 进行相互操作,或把一个枚举值编码成像是 JSON 这种数据格式来说都是非常有用的.

给枚举指定一个原始值需要在枚举名字后面加上原始值的类型并用冒号分隔开。然后使用赋值语法给每个成员赋值一个原始值。以下是一个原始值类型为 Int 的枚举的例子,用这个枚举来 表示 HTTP 状态:

enum HTTPStatus: Int { case ok = 200
    case created = 201 // ...
    case movedPermanently = 301 // ...
    case notFound = 404 // ...
}
复制代码

每个成员的原始值必须唯一。如果不为一个或多个成员提供原始值的话,编译器会尝试选择合理的默认值。在这个例子中,我们其实可以不用为 created 成员显式地分配一个原始值; 编译器会通过递增前一个成员的原始值来给 created 选择一个和现在相同的值 – 201。

RawRepresentable 协议

一个实现 RawRepresentable 协议的类型会获得两个新的 API:一个 rawValue 属性和一个可失败的初始化方法 (init?(rawValue:))。这两个 APi 都被声明在 RawRepresentable 协议中 (编译器自动为具有原始值的枚举实现这个协议):

/// 一个可以同相关原始值做转换的类型。 
protocol RawRepresentable {
    /// 原始值的类型, 例如 Int 或 String。 
    associatedtype RawValue
    init?(rawValue: RawValue)
    var rawValue: RawValue { get } 
}
复制代码

因为对于每个 RawValue 类型的值,有可能会存在对于实现这个协议的类型来说无效的值,所以初始化方法是可失败的。例如,只有一些整数是有效的 HTTP 状态码;对于其他所有的输入, HTTPStatus.init?(rawValue:) 必须返回 nil:

HTTPStatus(rawValue: 404) // Optional(HTTPStatus.notFound) 
HTTPStatus(rawValue: 1000) // nil 
HTTPStatus.created.rawValue // 201
复制代码

手动实现 RawRepresentable

上面那种把原始值赋值给一个枚举的语法,只作用于有限的一组类型:类型可以是 String, Character, 任意整数或浮点类型。这覆盖了一些用例,但并不意味着类型只能是这些。因为上面的语法只是一个实现 RawRepresentable 的语法糖,所以如果你需要更多的灵活性的话,总是可以选择手动实现这个协议。

以下的例子中定义了一个枚举,它表示在一个逻辑坐标系统中的一些点,每个点的 x 和 y 坐标都在 -1 (左/下) 和 1 (右/上) 之间。这个坐标系统有点类似于 Apple 的 Core Animation 框架中 CALayer 的 anchorPoint 属性。我们使用一对整数来作为原始值的类型,并且由于自动合成 RawRepresentable 的语法糖并不支持元组类型,所以我们需手动实现 RawRepresentable:

enum AnchorPoint { 
    case center
    case topLeft 
    case topRight 
    case bottomLeft 
    case bottomRight
}
extension AnchorPoint: RawRepresentable {
    typealias RawValue = (x: Int, y: Int)
    var rawValue: (x: Int, y: Int) { 
        switch self {
        case .center: return (0, 0)
        case .topLeft: return (-1, 1)
        case .topRight: return (1, 1) 
        case .bottomLeft: return (-1, -1) 
        case .bottomRight: return (1, -1) }
    }
    init?(rawValue: (x: Int, y: Int)) { 
        switch rawValue {
        case (0, 0): self = .center
        case (-1, 1): self = .topLeft
        case (1, 1): self = .topRight 
        case (-1, -1): self = .bottomLeft 
        case (1, -1): self = .bottomRight 
        default: return nil
        }
    }
}

/// 这些代码正是编译器在自动合成 RawRepresentable 时为我们 生成的代码
/// 对于使用这个枚举的用户来说,在这两种情况下行为都是相同的:

AnchorPoint.topLeft.rawValue // (x: -1, y: 1) 
AnchorPoint(rawValue: (x: 0, y: 0)) // Optional(AnchorPoint.center) 
AnchorPoint(rawValue: (x: 2, y: 1)) // nil
复制代码

在手动实现 RawRepresentable 时要注意的一件事是用重复的原始值来赋值。自动合成的语法 求原始值是唯一的 – 重复的话会引发一个编译错误。但在手动实现中,编译器不会阻止你从多个成员中返回相同的原始值。可能有充分的理由来使用重复的原始值 (例如,当多个成员互相是同义时,也可能为了向后兼容),但它应该是例外情况。Switch 一个枚举时,总是与成员而不是原始值来进行匹配的。换句话说,即使两个成员具有相同的原始值,你也不能用一个来匹配另外一个。

让结构体和类来实现 RawRepresentable

另外,RawRepresentable 不仅限于枚举;你同样可以让一个结构体或类来实现这个协议。对于为了保护类型安全而引入的简单的封装类型而言,实现 RawRepresentable 协议通常是一个不错的选择。例如,一个程序可能在内部使用字符串来表示用户的 ID。不要直接使用 String 类型,而最好定义一个新的 UserID 类型来防止不小心和其他字符串变量混淆了。还有可能会需要用一个字符串来初始化一个 UserId 实例,以及提取它的字符串值;而 RawRepresentable 非常适合这些需求:

struct UserID: RawRepresentable { 
    var rawValue: String
}
复制代码

这里的 rawValue 属性满足了实现 RawRepresentable 协议的两个要求之一,但是第二个要求 (初始化方法) 的实现在哪里呢?它由 Swift 结构体自动生成的成员初始化方法这个特性所提供。编译器足够聪明,让其把一个不会失败的 init(rawValue:) 方法的实现视作协议所需的那个可失败的初始化方法的实现。这有一个很好的副作用,在用字符串创建一个 UserID 实例时,我们不必处理可选值了。如果我们想对输入的字符串进行验证的话 (也许不是所有的字符串都是有效的用户 ID),就必须为 init?(rawValue:) 提供我们自己的实现。

原始值的内部表示

除了添加 RawRepresentable API 和自动 Codable 的合成之外,实际上具有原始值的枚举与所有其他枚举并没有什么不同。特别是,具有原始值的枚举保持了其完整的类型标识。和 C 语言中可以将任意整数值赋值给一个枚举类型的变量所不同,一个具有 Int 原始值的 Swift 枚举是不会 “成为” 一个整数的。一个枚举类型的实例所能拥有的值只能是其成员中的一个。获取原始值的唯一方法就是通过调用 rawValue 和 init?(rawValue:) 这两个 API。

拥有原始值也不会改变枚举在内存中的表示方式。可以定义一个具有 String 类型原始值的枚举,并通过查看其类型尺寸来验证这一点:

enum MenuItem: String { 
    case undo = "Undo" 
    case cut = "Cut"
    case copy = "Copy"
    case paste = "Paste" 
}
MemoryLayout<MenuItem>.size // 1
复制代码

MenuItem 类型的大小只有 1 个字节。这就告诉了我们一个 MenuItem 实例没有在内部存储原始值 – 如果它这样做的话,其大小肯定至少有 16 个字节 (在 64 位平台上 String 的大小)。编译器生成的 rawValue 实现就像一个计算属性一样,类似于我们在上面展示的 AnchorPoint 的实现。

列举枚举值

已经讨论过什么是一个类型的居民:一个类型的实例可以拥有的所有可能的值的集合。把这些值作为一个集合进行操作的这个需求通常是很有用的,例如迭代或计数。CaseIterable 协议通过添加一个静态属性 allCases 来实现这个 能 (也就是说,不是在实例上,而是在类型上调用此属性):

/// 一个提供其所有值集合的类型 
protocol CaseIterable {
associatedtype AllCases: Collection where AllCases.Element == Self
    static var allCases: AllCases { get } 
}
复制代码

对于没有关联值的枚举,编译器会自动生成实现 CaseIterable 的代码;我们所要做的就只是在 声明的时候把协议加上就可以了。

enum MenuItem: String, CaseIterable { 
    case undo = "Undo"
    case cut = "Cut"
    case copy = "Copy"
    case paste = "Paste" 
}
复制代码

因为 allCases 属性的类型是 Collection,所以它具有你从数组和其他集合类型中,所知的所有常用属性和功能。在下面的示例中,我们使用 allCases 来得到所有菜单项的数量,并把它们转换为适合在用户界面中显示的字符串:

MenuItem.allCases
// [MenuItem.undo, MenuItem.cut, MenuItem.copy, MenuItem.paste]
MenuItem.allCases.count // 4
MenuItem.allCases.map { $0.rawValue } // ["Undo", "Cut", "Copy", "Paste"]
复制代码

和其他像是 Equatable 和 Hashable 这一类,编译器会自动合成实现的协议类似,CaseIterable 自动合成的代码的最大好处,并不是代码本身的难度有多高 (手动实现该协议是很简单的),而是编译器生成的代码始终会是最新的 – 手动实现的话,每次添加或删除成员时都必须手动更新你的实现,这是很容易忘记的。

CaseIterable 协议没有规定 allCases 返回的集合中的值的特定顺序,但 CaseIterable 的文档 中则保证集合中的值的顺序是和它们在声明时的顺序所一致的。

手动实现 CaseIterable

对于没有关联值的普通枚举来说,CaseIterable 是特别有用的,并且自动编译器合成也只支持这一种类型。这是合理的,因为向一个枚举添加关联值可能会使这个枚举的居民数量变成无限。但只要我们能实现一个方法来生成一个所有居民的集合,那么我们总是可以手动实现这个协议的。事实上,这个协议并不只限于枚举。虽然 CaseIterable 和 allCases 这两个名字都暗示了此功能主要用于枚举 (没有其他类型有成员这个概念),但编译器对一个实现了此协议的结构体或类也是没有意见的。

以下的代码中,在最简单的类型之一 Bool 上手动实现 CaseIterable:

extension Bool: CaseIterable {
    public static var allCases: [Bool] {
        return [false, true] 
    }
}
Bool.allCases // [false, true]
复制代码

一些整数类型同样也是好的选择。请注意,allCases 的返回类型不必一定是数组 – 它可以是任何一个实现了 Collection 的类型。当一个范围可以用更少的内存来表示相同的集合时,生成一个包含所有可能的整数的数组就显得非常浪费了:

extension UInt8: CaseIterable {
    public static var allCases: ClosedRange<UInt8> {
        return .min ... .max 
    }
}
UInt8.allCases.count // 256
UInt8.allCases.prefix(3) + UInt8.allCases.suffix(3) // [0, 1, 2, 253, 254, 255]
复制代码

同理,如果你想为一个居民数量很多的类型实现 CaseIterable,或者生成一个类型的值是非常昂贵的话,请考虑返回一个 LazyCollection,以便不用提前执行一些不必要的操作。

固定和非固定枚举

枚举的最佳优点之一就是在 switch 它的时候所展现出来的完备性。 显而易见的是只有在编译期间,编译器知道了一个枚举所可能拥有的全部成员的情况下,才能执行完备性检查。当枚举的声明和 switch 它的语句都是在同一个模块时,这点是很容易做到的。如果枚举的声明是在另一个库,但这个库是和我们的代码一起编译的话 (每次添加或删除一个成员时,都会重新编译枚举的声明和我们自己的代码,这允许编译器重新检查所有相关的 switch 语句),这点也是容易做到的。

然而在某些情况下,我们用到的枚举是在一个以二进制形式链接到我们程序的库中。标准库就是最明显的例子:尽管标准库的源代码已经开源了,但我们通常使用的都是由 Swift 发行版或操作系统所附带的二进制文件。Swift 所附带的一些其他库也是如此,包括 Foundation 和 Dispatch。最后,Apple 和其他公司都希望以二进制形式来发布 Swift 的库。

假设在代码中我们要处理一个 DecodingError 实例。它是一个枚举,从 Swift 5.0 开始它有四个成员来表示不同的错误条件:


let error: DecodingError = ...
// 在编译时做完备性检查,而可能不在运行时做检查。
switch error {
case .typeMismatch: ... 
case .valueNotFound: ... 
case .keyNotFound: ... 
case .dataCorrupted: ... 
}
复制代码

随着 Codable 系统的扩展,未来的 Swift 版本中很有可能会增加另外的成员到这个枚举。但如果我们构建的 app 包含了上述的代码,并且把 app 发给了用户,那么那些用户最终可能会在一个较新的操作系统上运行发送给他们的可执行文件,并且系统附带的是包含了一个新的 DecodingError 成员的较新的 Swift 版本。在这种情况下,我们的程序会崩溃,因为它遇到了一个无法处理的错误条件。

可能会在未来添加新成员的枚举,称之为非固定。为了让程序能够防范这种非固定枚举的修改,在一个模块中 switch 另一个模块中的非固定枚举的话,必须始终包含一个 default 子句,以便能够处理将来会发生的这种情况。在 Swift 5.0 中,如果你忽略了 default 分支的话,编译器只会发出警告 (而不是错误),但这只是为了让你能方便地迁移现有代码所做的权宜之计。在未来的版本中警告会变成错误。

如果你让编译器帮你修复这个警告的话,你会注意到它帮你在 default 分支之前加了一个 @unknown 属性:

switch error { ...
case .dataCorrupted: ... 
@unknown default:
// Handle unknown cases.
... 
}
复制代码

在运行时,@unknown default 的行为就像一个普通的 default 子句,但它也是对于编译器的一个信号,用来告诉编译器这个 default 分支只是为了处理在编译时无法知道的成员的情况。如果 default 分支匹配了一个在编译时就知道的成员的话,我们还是会得到一个相应的警告。 这意味着针对未来一个新的库的接口,当重新编译程序时我们还是可以从完备性检查中获益。如果自上次更新后有新的成员被添加到库的 API 中的话,就会得到一个警告,让我们更新所有相关的 switch 语句来显式地处理新的成员。@unknown default 为你提供了两全其美的方案: 编译时的完备性检查和运行时的安全性。

在 Swift 5.0 中,固定和非固定枚举之间的区别仅适用于标准库。标准库和重叠的部分都用一种特殊的弹性模式 (resilience mode) 来编译,这个模式由 -enable-resilience 编译器标志所触发。在弹性库 (即旨在维护版本之间的二进制兼容性的库) 中的枚举默认情况下是非固定的。这里还有一个没有 在文档中出现的属性 @_frozen,它用于将一个特定的枚举声明为固定的。通过使用此属性,库的开发者就等于做出一个永远不会向被标记的枚举中添加新的成员的承诺,否则就会破坏二进制兼容性。

提示和窍门

  • 尽量避免使用嵌套 switch 语句。可以使用元组一次性匹配多个值。例如,假设你要根据两个布尔类型的值来设置一个变量的话,一个接一个的匹配就会需要一个嵌入的 switch 语句,这很快会让代码变得难看.

  • 避免用 none 或 some 来命名成员。在模式匹配的上下文中,可能与 Optional 的成员发生冲突。

  • 对那些用保留的关键字来命名的成员使用反引号。如果你使用某些关键字来作为成员名字的话 (例如,default),类型检查器会因为无法解析代码而产生错误。你可以用反引号把名字括起来使用它:

enum Strategy {
    case custom
    case `default` // 需要反引号。
}

// 这样做的好处是,在类型检查器可以消除歧义的地方,都不需要反引号了。以下代码是完全有效的:
let strategy = Strategy.default
复制代码
  • 可以像工厂方法一样使用成员。如果一个成员拥有关联值的话,这个枚举值就单独地形成了一个签名为 (AssocValue) -> Enum 的函数。以下枚举用来表示在两个颜色空间之一 (RGB 或灰阶) 的一个颜色:
enum OpaqueColor {
    case rgb(red: Float, green: Float, blue: Float) 
    case gray(intensity: Float)
}
OpaqueColor.rgb 是一个有着三个 Float 类型的参数和返回类型是 OpaqueColor 的函数: OpaqueColor.rgb // (Float, Float, Float) -> OpaqueColor

// 我们也可以将这些函数传递给例如 map 这样的高阶函数。
// 以下的代码中,我们把成员作为一个 工厂方法传递给 map,然后创建从黑到白的渐变灰度颜色:

let gradient = stride(from: 0.0, through: 1.0, by: 0.25).map(OpaqueColor.gray)

/*
[OpaqueColor.gray(intensity: 0.0), 
OpaqueColor.gray(intensity: 0.25), 
OpaqueColor.gray(intensity: 0.5), 
OpaqueColor.gray(intensity: 0.75),
OpaqueColor.gray(intensity: 1.0)]
*/
复制代码
  • 不要使用关联值来模拟存储属性。请改用结构体。枚举不能拥有存储属性。这听起来像是一个重大的限制,但事实并非如此。请你思考一下,实际上添加一个类型为 T 的存储属性与为每个成员添加相同类型的关联值是没有什么不同的。例如,让我们为上面那个 OpaqueColor 类型添加一个透明通道,在每个成员中都添加一个对应的关联值:
enum AlphaColor {
    case rgba(red: Float, green: Float, blue: Float, alpha: Float) 
    case gray(intensity: Float, alpha: Float)
}
复制代码

这是可以工作的,但从一个 AlphaColor 实例中提取 alpha 组件不是很方便 – 即使知道每个 AlphaColor 实例都有一个 alpha 组件,但还是必须 switch 实例并在每个分支中提取这个值。虽然可以将这个逻辑封装到一个计算属性中,但更好的解决方案可能是一开始就避免这个问题 – 把之前的 OpaqueColor 枚举封装到一个结构体中,并把 alpha 作为结构体的一个存储属性:

struct Color {
    var color: OpaqueColor
    var alpha: Float
}
复制代码

这是一个通用模式:当你发现一个枚举中每个成员的关联值都有一部分是相同的,请考虑把这个枚举封装到一个结构体中,并把公共部分提取出来。这会改变结果类型的样子,但不会改变其基本性质。这和在数学等式中提取公因子是一样的:a × b + a × c = a × (b + c)。

  • 不要过度使用关联值组件。在本章中我们大量使用了多个元组式组件来表示关联值,像是 OpaqueColor.rgb(red:green:blue:) 这种。这对简短的例子来说很方便,但在生产环境的代码

中,通常来说为每个成员实现一个自定义的结构体是更好的选择。

  • 把空枚举作为命名空间。

把空枚举作为命名空间。除了由模块形成的隐式命名空间之外,Swift 没有内置的命名空间。但可以用枚举来 “模拟” 命名空间。由于类型定义是可以嵌套的,因此外部类型可以充当其包含的所有声明的命名空间。像是 Never 这样的空枚举是不能被实例化的,这使得空枚举是定义自定义命名空间的最佳选择。标准库也是这么做的,例如 Unicode 命名空间:

/// 一个含有 Unicode 实用方法的命名空间。 
public enum Unicode {
    public struct Scalar { 
        internal var _value: UInt32 // ...
    }
// ...
}
复制代码

不幸的是,空枚举并不是对于缺乏适当的命名空间的完美解决方法:协议不能被嵌入到其他声明中,这就是为什么相关的标准库协议被命名为 UnicodeCodec 而不是 Unicode.Codec 的原因。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享