CodeGym /课程 /Swift SELF /通过提取值的 switch

通过提取值的 switch

Swift SELF
第 26 级, 课程 1
可用

1. 在 switch 中提取 associated values

当你第一次看到带有 associated values 的 enum 时,会很想像访问结构体字段那样访问数据: command.title、 command.index 等等。但 enum 的工作方式更巧妙:它的并非所有字段都会一直存在,因为在任何时刻只有一个 case 处于激活状态。 所以 Swift 会先要求我们回答一个问题:“当前是哪一个 case?”——只有在这之后,才会把该 case 的数据交给我们。

这时 switch 就不只是一个分支语句了,而是真正的“解包器”: 它会同时检查变体,并把关联值稳稳地提取到本地常量/变量中。 在 Swift 的官方示例里,这种写法非常自然: switch self { case let .load(key): ... } ——也就是“如果是 .load,就把 key 取出来”。

基本形式:case let .someCase(value)

通常在练习题里,一开始都会有一个小小的 enum,表示“程序命令”。 我们来做一个这样的模型:面向迷你图书馆的命令(我们会逐步过渡到 CLI 应用,也就是用户通过文本输入命令)。 不同命令携带的数据不同:add 有书名,remove 有索引,而 list 则没有数据。

import Foundation

enum LibraryCommand {
    case add(title: String)
    case remove(index: Int)
    case list
}

let cmd: LibraryCommand = .add(title: "Clean Code")
print(cmd) // add(title: "Clean Code")

现在我们想把这个命令“描述”为字符串。这时经典写法就出现了: case let .add(title):

import Foundation

enum LibraryCommand {
    case add(title: String)
    case remove(index: Int)
    case list
}

func describe(_ command: LibraryCommand) -> String {
    switch command {
    case let .add(title):
        return "ADD: \(title)"
    case let .remove(index):
        return "REMOVE: #\(index)"
    case .list:
        return "LIST"
    }
}

print(describe(.remove(index: 2))) // REMOVE: #2

注意这里那种“读起来像一句话”的感觉: case let .remove(index) ——“当它是 remove 时,把 index 取出来”。你会在标准库示例和 Swift Evolution 提案中看到同样的风格。

两种等价写法

当你搜索示例时,会看到两种提取风格。它们做的是同一件事,差别只是语法形式——看谁读起来更顺手。 认识这两种写法很有用,这样你就不会想“这是不是另一种 Swift”。

写法 A(Swift 代码里最常见): case let .add(title):

写法 B(更“显式”): case .add(let title):

import Foundation

enum LibraryCommand { case add(title: String) }

func extractTitle(_ command: LibraryCommand) -> String {
    switch command {
    case let .add(title):
        return title
    }
}

func extractTitle2(_ command: LibraryCommand) -> String {
    switch command {
    case .add(let title):
        return title
    }
}

print(extractTitle(.add(title: "Dune")))   // Dune
print(extractTitle2(.add(title: "Dune")))  // Dune

实践建议:选一种风格,并在项目里保持一致。 在后面的课程里,我们通常会写 case let ...,因为它更短,也更常见。

一次提取多个关联值

一旦某个 case 带有多个参数(例如“把书从 from 位置移动到 to 位置”), 提取方式依然很简单:在模式中按相同顺序列出这些值。

重要的是:标签(labels)能帮助人避免把参数搞混。 这不只是好看,更是在预防“代码能编译,但行为不对”这一类 bug。

import Foundation

enum LibraryCommand {
    case move(from: Int, to: Int)
}

func describe(_ command: LibraryCommand) -> String {
    switch command {
    case let .move(from, to):
        return "MOVE: \(from) → \(to)"
    }
}

print(describe(.move(from: 1, to: 3))) // MOVE: 1 → 3

这里我们写了 case let .move(from, to) 而没有显式标签——Swift 允许这样做,因为匹配是按位置进行的。 不过有时显式标签会让代码更稳妥,尤其是当你不确定自己会不会把值写反(或者读代码的人不容易看懂)时。

import Foundation

enum LibraryCommand {
    case move(from: Int, to: Int)
}

func describe(_ command: LibraryCommand) -> String {
    switch command {
    case let .move(from: fromIndex, to: toIndex):
        return "MOVE: \(fromIndex) → \(toIndex)"
    }
}

print(describe(.move(from: 2, to: 0))) // MOVE: 2 → 0

Swift Evolution 里专门讨论过:如果在模式中对名称/标签要求太死,会降低 ergonomics, 所以语言会努力保持平衡:既可以写得更短,也可以写得更“自说明”。

_:忽略一部分 payload

在真实代码里,你经常会遇到这样的情况:一个 case 有两个值,但你只需要其中一个。 为了不用的变量去创建一个局部绑定,是很奇怪的做法(编译器也可能会提醒你)。 所以模式里有 _:表示“我知道这里有东西,但我不需要它”。

假设有一个“按索引替换书籍”的命令:索引需要,但旧书名(假设它也被传进来)并不需要。

import Foundation

enum LibraryCommand {
    case replace(index: Int, oldTitle: String, newTitle: String)
}

func newTitleOnly(_ command: LibraryCommand) -> String {
    switch command {
    case let .replace(_, _, newTitle):
        return newTitle
    }
}

print(newTitleOnly(.replace(index: 1, oldTitle: "A", newTitle: "B"))) // B

注意这里三个 _ 位置:我们实际上是在说“第一个和第二个值忽略掉,只取第三个”。 这比为了不用而去写 let unused = ... 然后假装没人会问“那它干嘛用”要诚实得多。

没有 default 的 switch:为什么它是你的朋友

当针对 enum 的 switch 是穷尽式的(也就是列出了所有 case)时, Swift 就会变成一个严格但公平的审查者:如果你新增了一个 case,编译器会强迫你更新所有处理这个 enum 的地方。 这是 Swift 里最令人愉快的“防 bug”机制之一。

在 Swift 的官方资料(以及语言演进提案)中,针对 enum 的 switch 通常都会写成穷尽式,而不是塞一个 default “兜底”,这样才能保住这个保证。

import Foundation

enum LibraryCommand {
    case list
    case help
}

func handle(_ command: LibraryCommand) {
    switch command {
    case .list:
        print("Listing...") // Listing...
    case .help:
        print("Help...")    // Help...
    }
}

handle(.help)

如果明天你增加了 case add(title: String) ——编译器会诚实地告诉你:“嘿,这里的 switch 已经不穷尽了。”这正是好事。

where in case:无需嵌套 if 的特殊情况

有时只知道“是哪一个 case”还不够:你还需要看它的数据。 比如命令 .remove(index: -1) 形式上是对的(它仍然是 .remove),但语义上却是无效的。 你可以在分支内部用 if 处理,也可以通过 where 直接写进模式里。

where 的核心想法很简单:这个分支只有在 case 匹配并且条件为真时才会执行。 这样一来,switch 仍然保持可读:上面写特殊情况,下面写通用情况。

import Foundation

enum LibraryCommand {
    case remove(index: Int)
}

func validate(_ command: LibraryCommand) -> String {
    switch command {
    case let .remove(index) where index < 0:
        return "Index must be >= 0"
    case let .remove(index):
        return "Removing at #\(index)"
    }
}

print(validate(.remove(index: -2))) // Index must be >= 0
print(validate(.remove(index: 5)))  // Removing at #5

这里要讲究分寸:where 不是写“检查五个字段、两个数组,再顺便看看月亮是不是在天蝎座”的地方。 如果条件变得很长,最好把分支内的逻辑写清楚,或者把检查提取到单独的函数里。

3. 速查表和实战示例

关于提取的速查表

在我们组装迷你命令处理器之前,先在脑子里放一张简洁的“语法地图”。 编程里的表格就像注释:有时能救你于愚蠢错误之中。

我们想做什么 在 switch 里怎么写 会得到什么
只按不带数据的 case 匹配
case .list:
只是进入该分支
提取一个值
case let .add(title):
title
在分支内可用
另一种写法
case .add(let title):
结果相同
提取两个值
case let .move(from, to):
from
、
to
可用
忽略部分数据
case let .by(_, dy):
dy
可用,
dx
被忽略
按数据处理特殊情况
case let .remove(i) where i < 0:
只有在条件满足时才会进入分支

迷你示例:通过 switch 处理命令

现在我们来做一个小而真实感更强的片段:这里有一个书籍数组(先简单地用 [String] 表示), 还有一个 LibraryCommand。我们希望有一个 apply 函数,它接收命令并修改状态(数组), 同时返回一段可以展示给用户的文本。

请注意 switch 在这里多么合适:命令本身就是一组可选情况中的一个,而每个情况都需要不同的逻辑。

import Foundation

enum LibraryCommand {
    case add(title: String)
    case remove(index: Int)
    case list
}

func apply(_ command: LibraryCommand, books: inout [String]) -> String {
    switch command {
    case let .add(title) where title.isEmpty:
        return "Title can't be empty"

    case let .add(title):
        books.append(title)
        return "Added: \(title)"

    case .list where books.isEmpty:
        return "Library is empty"

    case .list:
        return "Books: \(books.joined(separator: ", "))"

    case let .remove(index) where index < 0 || index >= books.count:
        return "Invalid index: \(index)"

    case let .remove(index):
        let removed = books.remove(at: index)
        return "Removed: \(removed)"
    }
}

var books: [String] = []
print(apply(.list, books: &books))                  // Library is empty
print(apply(.add(title: "Dune"), books: &books))    // Added: Dune
print(apply(.list, books: &books))                  // Books: Dune

这里把本讲的“主角”都汇聚到了一起。

  • 我们通过 case let ... 提取 title 和 index
  • 我们避免使用 default,从而得到一个穷尽式的 switch(如果新增命令,编译器会提醒我们补上处理)
  • 我们使用 where 来突出特殊情况(空标题、空书库、非法索引),而不需要再套额外的 if

而且,这些依然只是小函数。我们不是在写一个 200 行的“上帝函数”,因为那样不管是现在还是一周后,读起来都会很痛苦。

4. 常见错误

错误 1:把 associated values 当成属性来访问。
在经过 struct 式思维训练之后,人们总想写 command.title 或者 command.index。 但 enum 并不保证每一种状态下都有这个字段。 所以在 Swift 中,associated values 是通过模式匹配提取的:通过 switch (或者通过点式的 if case/guard case, 不过那会留到下一讲)。 如果你发现自己想“把字段做成 optional 然后把所有东西都塞进一个 case”,这通常意味着模型该重构了。

错误 2:为了“能编译”而写 default,然后忘记更新处理逻辑。
default 很诱人:新增一个 case,代码也“不报错”了。 但这就像拿胶带把 “Check Engine” 指示灯粘住:声音小了,问题却还在。 在我们的命令处理器里,最好让编译器逼你把逻辑补全;否则就会出现“新命令悄悄什么都不做”,然后你还得花很久去找原因。

错误 3:提取了值,却没有使用它。
如果你写了 case let .add(title): 而后面又不用 title,编译器可能会警告,读者也会怀疑这里是不是少写了什么逻辑。 这种情况下,最好老老实实使用 _,或者只提取真正需要的内容。 在模式里使用 _ 是正常工具,不是“能力不足”的标志。

错误 4:在没有标签的 case 中把值顺序搞混。
当你写 case let .move(a, b) 时, 你必须记住 a 是 from,而 b 是 to。 在小例子里这没问题,但在真实项目里,“把参数顺序写反”是经典的逻辑错误。 如果你觉得有风险,就在 case 声明中使用标签,并在提取时使用更清晰的名字。 语言之所以支持多种形式,就是因为可读性比省下三个字符更重要。

错误 5:把 where 写成一个 5 行的小小说。
当条件很短,并且是在解释“为什么这是特殊情况”时,where 很好用。 如果条件变得很长,switch 就会变成一团复杂谜题。 这种情况下,最好把检查提取到一个命名清晰的独立函数中,然后在分支内部或直接在 where 中使用它 (但前提是它看起来像 where isValidIndex(index, books),而不是一篇数学论文)。

1
任务
Swift SELF, 第 26 级, 课程 1
已锁定
通知中心
通知中心
1
任务
Swift SELF, 第 26 级, 课程 1
已锁定
光标命令
光标命令
1
任务
Swift SELF, 第 26 级, 课程 1
已锁定
钱包操作
钱包操作
1
任务
Swift SELF, 第 26 级, 课程 1
已锁定
配送事件流
配送事件流
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION