Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

枚举与模式匹配

在本章中,我们将介绍枚举(也称为 enums),它允许你通过枚举可能的变体来定义类型。首先,我们将定义并使用枚举来展示枚举如何能够同时编码含义和数据。接下来,我们将探索一个特别有用的枚举,称为 Option,它表示一个值可以是某物或什么都不是。然后,我们将研究如何使用 match 表达式根据枚举的值轻松地为不同代码分支执行不同的代码。

定义枚举

在我们看到什么是枚举之前,让我们想一个我们可能需要在代码中表达并看看为什么枚举有用且合适的情况。假设我们需要处理 IP 地址。目前,用于 IP 地址的两个主要标准是第四版和第六版。这些是我们的程序可能遇到的唯一 IP 地址类型:我们可以枚举所有可能的值,这就是枚举得名的地方。

任何 IP 地址要么是第 4 版要么是第 6 版,但不能同时是两者。IP 地址的这个属性使枚举数据结构合适,因为枚举值只能是其变体之一。第 4 版和第 6 版地址仍然是 IP 地址,所以它们应该被视为同一类型,当代码处理适用于任何类型的 IP 地址的情况时。

我们可以通过在代码中定义一个 IpAddrKind 枚举来表达这个概念,并列出可能的 IP 地址类型:V4V6。这些是枚举的变体:

type IpAddrKind = V4 | V6

我们可以如下使用 IpAddrKind 的每个变体:

let four = V4
let six = V6

换句话说,IpAddrKind 的变体在枚举的命名空间下,我们使用竖线语法来分隔它们。现在我们可以定义一个以任何 IpAddrKind 作为参数的函数:

function route(ip_kind: IpAddrKind) {
  // ...
}

我们可以使用任一变体调用此函数:

route(V4)
route(V6)

将数据附加到枚举变体

使用枚举,我们可以通过将数据直接放入每个枚举变体中来更简洁地表达相同的概念,而不是在枚举内部只包含类型:

type IpAddr = V4(String) | V6(String)

我们可以直接将数据附加到枚举的每个变体,而不是让枚举只包含类型,这样我们就不需要额外的结构体。在这里,IpAddr 枚举的名称也成为一个构造函数:V4(String)V6(String) 都是可以接受参数的函数调用,分别创建 IpAddr 类型的实例。

我们可以这样使用这个新的枚举定义:

let home = V4("127.0.0.1")
let loopback = V6("::1")

请注意,我们现在有两个不同类型的值,它们的类型都是 IpAddr。这很好,因为我们现在可以定义一个以任何 IpAddr 作为参数的函数:

function route(ip: IpAddr) {
  // ...
}

我们可以使用任一变体调用此函数:

route(home)
route(loopback)

这种用枚举代替结构体的能力还有另一个好处:每个变体可以有不同类型和数量的关联数据。第 4 版 IP 地址总是有四个介于 0 和 255 之间的数字组成部分。如果我们想将 V4 地址存储为四个 integer 值,但仍然将 V6 地址表示为一个 String,我们将无法使用结构体。枚举轻松处理这种情况:

type IpAddr = V4(integer, integer, integer, integer) | V6(String)
let home = V4(127, 0, 0, 1)
let loopback = V6("::1")

我们已经展示了多种不同的方法来定义数据结构来存储第 4 版和第 6 版 IP 地址。但是,正如你所看到的,我们可以将数据附加到枚举变体,并且每个变体的类型可以不同,这恰好是我们想要的。让我们看看另一个枚举的例子:这个例子有各种各样的类型嵌入在它的变体中。

type Message =
    Quit
  | Move(integer, integer)
  | Write(String)
  | ChangeColor(integer, integer, integer)

这个枚举有四个变体,具有不同的类型:

  • Quit 没有与之关联的数据。
  • Move 包含两个 integer
  • Write 包含一个 String
  • ChangeColor 包含三个 integer

以这种方式定义枚举类似于定义不同类型的结构体定义,只是枚举没有 type 关键字,并且所有变体都被分组在 Message 类型下。以下结构体可以保存与前面的枚举变体相同的数据:

type QuitMessage = { }
type MoveMessage = { x: integer, y: integer }
type WriteMessage = { content: String }
type ChangeColorMessage = { r: integer, g: integer, b: integer }

但是如果我们使用不同的结构体(每个结构体都有自己的类型),我们不能像使用我们在上面定义的 Message 枚举那样轻松地定义一个可以接受任何这些类型消息的函数。

选项枚举及其优于空值的地方

在本节中,我们将讨论 Option 的一个案例研究,Option 是标准库中定义的另一个枚举。Option 类型在许多情况下非常常用,它编码了一个非常常见的场景,其中一个值可以是某物,也可以什么都不是。

例如,如果你请求非空列表中的第一个项目,你会得到一个值。如果你请求空列表中的第一个项目,你什么也得不到。在类型系统中表达这个概念意味着编译器可以检查你是否处理了应该处理的所有情况;这个功能可以防止其他编程语言中非常常见的错误。

编程语言设计经常从包含哪些功能的角度来考虑,但你排除的功能也很重要。X 语言没有许多其他语言有的空值功能。空值是一个表示那里什么都没有的值。在有空值的语言中,变量总是可以处于两种状态之一:空或非空。

空值的问题是,如果你尝试将空值用作非空值,你会得到某种错误。因为这个空或非空属性无处不在,很容易犯这种错误。

然而,空值试图表达的概念仍然有用:空值是一个由于某种原因当前无效或不存在的值。

问题不在于概念本身,而在于特定的实现。因此,X 语言没有空值,但它确实有一个可以编码存在或不存在概念的枚举。这个枚举是 Option<T>,它由标准库定义如下:

type Option<T> = Some(T) | None

Option<T> 枚举非常有用,它甚至包含在 prelude 中;你不需要将其纳入作用域。它的变体也包含在 prelude 中:你可以直接使用 SomeNone,而不需要 Option:: 前缀。Option<T> 仍然只是一个普通的枚举,Some(T)None 仍然是 Option<T> 类型的变体。

<T> 语法是我们尚未讨论的 X 语言特性。它是一个泛型类型参数,我们将在第 8 章更详细地介绍泛型。目前,你需要知道的是,<T> 意味着 Option 枚举的 Some 变体可以容纳一个任何类型的数据,并且 <T> 可以是任何具体类型。这里有一些使用 Option 值来保存数字类型和字符串类型的示例:

let some_number = Some(5)
let some_string = Some("字符串")
let absent_number: Option<integer> = None

如果我们使用 None 而不是 Some,我们需要告诉 X 语言我们想要什么类型的 Option<T>,因为编译器无法仅通过查看 None 值就推断出 Some 变体将持有的类型。

当我们有一个 Some 值时,我们知道存在一个值,并且该值保存在 Some 中。当我们有一个 None 值时,在某种意义上,它意味着与空值相同:我们没有一个有效的值。那么为什么拥有 Option<T> 比拥有空值更好呢?

简而言之,因为 Option<T>T(其中 T 可以是任何类型)是不同的类型,编译器不会让我们使用 Option<T> 值,就好像它绝对是一个有效值一样。例如,这段代码不会编译,因为它试图将一个 Option<integer> 加到一个 integer 上:

let x: integer = 5
let y: Option<integer> = Some(5)
let sum = x + y  // 错误!不能将 integer 和 Option<integer> 相加

实际上,这个错误消息意味着 X 语言不理解如何将 Option<integer>integer 相加,因为它们是不同的类型。当我们在 X 语言中有一个像 integer 这样类型的值时,编译器将确保我们始终有一个有效值。我们可以放心地进行操作,而不必在使用该值之前检查空值。只有当我们有一个 Option<integer>(或我们正在使用的任何类型的 Option)时,我们才必须担心可能没有值,编译器会确保我们在使用该值之前处理这种情况。

换句话说,在你可以对 Option<T> 进行 T 操作之前,你必须将其转换为 T。一般来说,这有助于捕获空值最常见的问题之一:假设某事不是空的,而实际上它是空的。

不必担心错误地假设一个非空值,这有助于你对代码更有信心。为了拥有一个可能为空的值,你必须通过使该值的类型 Option<T> 来明确选择加入。然后,当你使用该值时,你必须明确处理该值为空的情况。每当一个值的类型不是 Option<T> 时,你可以安全地假设该值不为空。这是 X 语言的一个深思熟虑的设计决策,目的是限制空值的普遍存在,并增加 X 语言代码的安全性。

那么,当你有一个 Option<T> 类型的值时,你如何从 Some 变体中获取 T 值以便你可以使用它呢?Option<T> 枚举有大量有用的方法,可用于各种情况;你可以在其文档中查看它们。熟悉 Option<T> 上的方法将对你的 X 语言之旅非常有用!

通常,为了处理 Option<T>,你会有代码来处理每个变体。你想要一些代码,这些代码只在有 Some(T) 值时运行,并且允许该代码使用内部的 T。你想要一些其他代码,这些代码在有 None 值时运行,并且该代码没有可用的 T 值。match 表达式是一个控制流构造,正是这样做的:它允许我们通过枚举的变体来分支代码。

使用 match 进行模式匹配

X 语言有一个极其强大的控制流构造,称为 match,它允许我们将值与一系列模式进行比较,并根据匹配的模式执行代码。模式可以由字面值、变量名、通配符和许多其他东西组成;第 17 章涵盖了所有不同类型的模式以及它们的作用。match 的威力来自于模式的表现力以及编译器验证所有可能的情况都得到处理的事实。

match 表达式想象成一个硬币分拣机:硬币沿着带有不同尺寸孔的滑道滚动,每个硬币都通过它遇到的第一个合适的孔掉落。以同样的方式,值会通过 match 中的每个模式,并且在第一个模式“匹配“该值时,该值会落入要在执行期间使用的关联代码块中。

既然我们已经使用了枚举,让我们使用 match!让我们看一个例子:

type Coin = Penny | Nickel | Dime | Quarter

function value_in_cents(coin: Coin) -> integer =
  match coin {
    Penny => 1,
    Nickel => 5,
    Dime => 10,
    Quarter => 25
  }

让我们分解 value_in_cents 函数中的 match。首先,我们列出 match 关键字,后跟一个值,在这个例子中是 coin。这看起来与 if 表达式的使用非常相似,但有一个很大的区别:对于 if,条件需要评估为一个 boolean,但在这里,它可以是任何类型。这个例子中的 coin 类型是我们之前定义的 Coin 枚举。

接下来是用大括号括起来的模式匹配臂。一个臂有两个部分:一个模式和一些代码。第一个臂的模式是值 Penny,然后是 => 运算符,它将模式和要运行的代码分开。在这种情况下,代码只是值 1。每个臂用逗号与下一个臂分隔。

match 表达式执行时,它会将结果值按顺序与每个臂的模式进行比较。如果模式与值匹配,则执行与该模式关联的代码。如果该模式与值不匹配,执行将继续到下一个臂,就像在硬币分拣机中一样。我们可以根据需要拥有任意数量的臂!

与模式关联的代码是一个表达式,该表达式在匹配臂中的结果值是整个 match 表达式返回的值。

如果匹配臂代码很短,我们通常不使用大括号,就像我们的例子中每个匹配臂只返回一个值一样。如果你想在一个匹配臂中运行多行代码,你可以使用大括号,并且在大括号之后可以选择使用逗号分隔该臂与下一个臂。例如,以下代码在使用 Penny 调用时每次都会打印 “幸运的便士!”,但仍会返回块中的最后一个值 1

function value_in_cents(coin: Coin) -> integer =
  match coin {
    Penny => {
      println("幸运的便士!")
      1
    },
    Nickel => 5,
    Dime => 10,
    Quarter => 25
  }

绑定值的模式

匹配臂的另一个有用特性是它们可以绑定到匹配模式部分的值。这就是我们如何从枚举变体中提取值的方法。

作为一个例子,让我们修改我们的 Quarter 变体,使其内部保存一个状态值。在 1999 年到 2008 年之间,美国为 50 个州中的每一个州都铸造了带有特殊设计的 25 美分硬币。没有其他硬币有州设计,所以只有 Quarter 变体有这个额外的值。我们可以通过将 UsState 类型添加到我们的 Quarter 变体来将此信息添加到我们的枚举中,如下所示:

type UsState =
    Alabama
  | Alaska
  // -- 等等 --

type Coin =
    Penny
  | Nickel
  | Dime
  | Quarter(UsState)

假设一个朋友正在尝试收集所有 50 个州的 25 美分硬币。当我们按硬币类型对零钱进行排序时,我们也可以说出与 25 美分硬币相关的州名称,这样如果我们的朋友没有这个州的硬币,他们可以将其添加到他们的收藏中。

在这段代码的匹配表达式中,我们在匹配 Quarter 变体值的模式中添加了一个名为 state 的变量。当一个 Quarter 匹配时,state 变量将绑定到该 25 美分硬币的州值。然后我们可以在该臂的代码中使用 state,如下所示:

function value_in_cents(coin: Coin) -> integer =
  match coin {
    Penny => 1,
    Nickel => 5,
    Dime => 10,
    Quarter(state) => {
      println("州 25 美分硬币来自 ", state, "!")
      25
    }
  }

如果我们调用 value_in_cents(Quarter(Alaska))coin 将是 Quarter(Alaska)。当我们将该值与每个匹配臂进行比较时,直到我们到达 Quarter(state),此时 state 将是值 Alaska。然后我们可以在 println 中使用该绑定,从而从 Coin 枚举的 Quarter 变体中获取内部州值。

匹配 Option

在上一节中,我们希望在使用 Option<T> 时从 Some 案例内部获取 T 值;我们也可以使用 match 来处理 Option<T>,就像我们对 Coin 枚举所做的那样!我们将比较 Option 的变体,而不是比较硬币,但 match 表达式的工作方式保持不变。

假设我们想编写一个函数,它接受一个 Option<integer>,如果里面有一个值,则将该值加 1。如果里面没有值,该函数应该返回 None 值而不尝试执行任何操作。

由于 match,这个函数很容易编写,看起来会像这样:

function plus_one(x: Option<integer>) -> Option<integer> =
  match x {
    None => None,
    Some(i) => Some(i + 1)
  }

let five = Some(5)
let six = plus_one(five)
let none = plus_one(None)

让我们更仔细地看一下 plus_one 的第一次执行。当我们调用 plus_one(five) 时,plus_one 主体中的变量 x 将具有值 Some(5)。然后我们将其与每个匹配臂进行比较:

None => None,

Some(5) 值不匹配模式 None,所以我们继续下一个臂。

Some(i) => Some(i + 1),

Some(5) 是否匹配 Some(i)?确实匹配!我们有相同的变体。i 绑定到 Some 内部的值,所以 i 具有值 5。然后执行该匹配臂中的代码:我们将 1 加到 i 的值上,并创建一个新的 Some 值,其中包含我们的总和 6

现在让我们看看清单中的第二次调用 plus_one,其中 xNone。我们进入 match 并与第一个臂进行比较:

None => None,

它匹配!没有要添加的值,所以程序停止并在 => 的右侧返回 None。因为第一个臂匹配,所以其他臂都不会被比较。

以这种方式将 match 与枚举结合使用在许多情况下都很有用。你会在 X 语言代码中看到很多这种模式:match,绑定到内部数据的模式,然后基于该模式执行代码。一开始有点棘手,但一旦你习惯了它,你就会希望在所有语言中都有它。它始终是用户的最爱。

匹配是穷尽的

我们需要讨论 match 的另一个方面:臂的模式必须覆盖所有可能性。看看这个版本的 plus_one 函数,它有一个错误,不会编译:

function plus_one(x: Option<integer>) -> Option<integer> =
  match x {
    Some(i) => Some(i + 1)
  }

我们没有处理 None 案例,所以这段代码会导致错误。幸运的是,这是 X 语言编译器知道如何捕获的错误。如果我们尝试编译这段代码,我们会得到这个错误:

error: 模式 `None` 未覆盖

X 语言知道我们没有覆盖所有可能的情况,甚至知道我们忘记了哪个模式!X 语言中的匹配是穷尽的:我们必须穷尽最后一个可能的情况,才能使代码有效。特别是在 Option 的情况下,当 X 语言防止我们忘记显式处理 None 情况时,它可以保护我们免受假设我们有一个值而实际上可能有一个空值的错误,从而使我们之前讨论的数亿美元错误不可能发生。

通配符模式和 where 守卫

X 语言还有一个模式,我们可以在不想列出所有可能值的情况下使用。例如,signed 8bit integer 可以包含 -128 到 127 的有效数字。如果我们只关心 1、3、5 和 7,我们不想列出 -128、-127、…、0、2、4、6、8、9 一直到 127。幸运的是,我们不必这样做:我们可以使用特殊的通配符模式 _ 代替。

我们还可以在模式中使用 where 子句来添加额外的条件,这被称为“守卫“:

let some_value = 5
match some_value {
  1 => println("一"),
  3 => println("三"),
  5 => println("五"),
  7 => println("七"),
  n where n % 2 == 0 => println("偶数: ", n),
  _ => println("其他数字")
}

_ 模式将匹配任何未指定的值。通过在所有其他臂之后添加 _,通配符将匹配所有可能的情况,这些情况在它之前没有列出。

通配符模式对于我们想要忽略除少数情况之外的所有情况的场景非常有用。

总结

在本章中,我们介绍了如何使用枚举来创建可以是一组变体之一的自定义类型。我们展示了标准库的 Option<T> 类型如何帮助你使用类型系统来防止错误。当枚举值在其变体内部包含数据时,你可以使用 match 来处理这些值并决定对每种情况执行什么代码。

现在你已经了解了结构体和枚举的强大功能,让我们转到记录类型,这是一种在 X 语言中对相关数据进行分组的更简单方法。