Skip to main content

第 3 章 控制流(Control flow)

现在你已经熟悉了 Elixir 的基础构建块,是时候看看这门语言的一些典型低层惯用法了。本章会处理条件和循环。你会看到,它们的工作方式不同于许多命令式语言。

经典条件结构,例如 ifcase,经常会被多子句函数替代;而且 Elixir 中没有 while 这样的循环语句。不过,你仍然可以在 Elixir 中解决任意复杂度的问题,得到的代码也不会比典型面向对象方案更复杂。

这些听起来可能有些激进,所以本章会详细讨论条件和循环。不过,在开始讨论分支与循环之前,你需要先学习一个重要的底层机制:模式匹配。

本章涵盖内容:

  • 理解模式匹配
  • 使用多子句函数
  • 使用条件表达式
  • 使用循环

3.1 模式匹配(Pattern matching)

正如第 2 章所提到的,= 操作符并不是赋值。在表达式 a = 1 中,我们把变量 a 绑定到值 1= 操作符称为匹配操作符,而这个看起来像赋值的表达式,就是模式匹配的一个例子。

模式匹配是 Elixir 中的重要概念。它让操作复杂变量(例如元组和列表)变得容易得多。不那么明显的是,它还允许你编写优雅的、类似声明式的条件和循环。本章结束时你会理解这意味着什么;在本节中,我们先看模式匹配的基本机械工作方式。

先从匹配操作符开始。

3.1.1 匹配操作符(The match operator)

到目前为止,你已经看过匹配操作符最基本的用法:

iex(1)> person = {"Bob", 25}

我们曾经把它当成类似赋值的东西,但实际上这里发生了更复杂的事情。运行时,= 操作符左侧会与右侧匹配。左侧称为模式,而右侧是一个会求值为 Elixir 项的表达式。

在这个例子中,你把变量 person 与右侧项 {"Bob", 25} 匹配。变量总是能匹配右侧项,并绑定到该项的值。这听起来可能有点理论化,所以我们看看一个涉及元组的稍复杂匹配操作符用法。

3.1.2 匹配元组(Matching tuples)

下面的例子展示了元组的基本模式匹配:

iex(1)> {name, age} = {"Bob", 25}

这个表达式假定右侧项是一个包含两个元素的元组。表达式求值时,变量 nameage 会绑定到元组中对应的元素。现在可以验证这些变量已正确绑定:

iex(2)> name
"Bob"
iex(3)> age
25

当你调用某个返回元组的函数,并想把该元组中的各个元素绑定到单独变量时,这个特性很有用。下面的例子调用 Erlang 函数 :calendar.local_time/0 来获取当前日期和时间:

iex(4)> {date, time} = :calendar.local_time()

日期和时间也都是元组,可以进一步拆解:

iex(5)> {year, month, day} = date
iex(6)> {hour, minute, second} = time

如果右侧与模式不对应会怎样?匹配会失败,并抛出错误:

iex(7)> {name, age} = "can't match"
** (MatchError) no match of right hand side value: "can't match"

注意 我们还没有讨论错误处理机制;这会在第 8 章讨论。目前只需要知道,抛出错误的工作方式有点像主流语言中的经典异常机制。当错误被抛出时,控制流会立即转移到调用链上方某处捕获该错误的代码(假设存在这样的代码)。

最后,值得注意的是,就像其他表达式一样,匹配表达式也会返回一个值。匹配表达式的结果总是你正在匹配的右侧项:

iex(8)> {name, age} = {"Bob", 25}
{"Bob", 25}

3.1.3 匹配常量(Matching constants)

匹配并不限于把元组元素解构到单独变量中。令人惊讶的是,常量也允许出现在匹配表达式左侧:

iex(1)> 1 = 1
1

回忆一下,匹配操作符 = 会尝试把右侧项匹配到左侧模式。在这个例子中,你尝试把模式 1 与项 1 匹配。显然,这会成功,整个表达式的结果就是右侧项。这个例子没有太多实际价值,但它说明常量可以放在 = 左侧,从而证明 = 不是赋值操作符。

常量在复合匹配中更有用。例如,元组有时用于组合记录的多个字段。下面的片段创建了一个保存人员姓名和年龄的元组:

iex(2)> person = {:person, "Bob", 25}

第一个元素是常量原子 :person,用于表示该元组代表一个人。稍后,你可以依赖这个知识并取出人员的各个属性:

iex(3)> {:person, name, age} = person
{:person, "Bob", 25}

这里,你期望右侧项是一个三元素元组,且第一个元素的值为 :person。匹配之后,元组的其余元素会绑定到变量 nameage,这可以轻松验证:

iex(4)> name
"Bob"
iex(5)> age
25

这是 Elixir 中的常见惯用法。许多来自 Elixir 和 Erlang 的函数会返回 {:ok, result}{:error, reason}。例如,假设你的系统依赖一个配置文件,并期望它始终可用。可以借助 File.read/1 函数读取文件内容:

{:ok, contents} = File.read("my_app.config")

在这一行代码中,会发生三件不同的事:

  1. 尝试打开并读取 my_app.config 文件。
  2. 如果尝试成功,文件内容会被提取到变量 contents
  3. 如果尝试失败,就会抛出错误。这是因为 File.read 的结果是 {:error, reason} 形式的元组,因此它无法匹配 {:ok, contents}

通过在模式中使用常量,你可以收紧匹配,确保右侧的某一部分具有特定值。

3.1.4 模式中的变量(Variables in patterns)

只要变量名存在于左侧模式中,它总是会匹配对应的右侧项。此外,该变量会绑定到它匹配的项。

有时,我们对右侧项中的某个值不感兴趣,但仍然需要匹配它。例如,假设你想获取一天中的当前时间。可以使用函数 :calendar.local_time/0,它返回一个元组:{date, time}。但你对日期不感兴趣,所以不想把它存储到单独变量中。在这种情况下,可以使用匿名变量(_):

iex(1)> {_, time} = :calendar.local_time()
iex(2)> time
{20, 44, 18}

就匹配而言,匿名变量和命名变量一样:它能匹配任意右侧项。但该项的值不会绑定到任何变量。

也可以在下划线后加上描述性名称:

iex(1)> {_date, time} = :calendar.local_time()

_date 会被视为匿名变量,因为它的名字以下划线开头。严格来说,你可以在程序其余部分使用这个变量,但编译器会发出警告。

模式可以任意嵌套。继续这个例子,假设你只想取出一天中的当前小时:

iex(3)> {_, {hour, _, _}} = :calendar.local_time()
iex(4)> hour
20

同一个变量可以在同一个模式中引用多次。下面的表达式中,你期望一个 RGB 三元组中每个分量都是同一个数字:

iex(5)> {amount, amount, amount} = {127, 127, 127}
{127, 127, 127}
iex(6)> {amount, amount, amount} = {127, 127, 1}
** (MatchError) no match of right hand side value: {127, 127, 1}

有时,你需要匹配变量当前持有的内容。为此,Elixir 提供了 pin 操作符(^)。用例子最容易说明:

iex(7)> expected_name = "Bob"
"Bob"
iex(8)> {^expected_name, _} = {"Bob", 25}
{"Bob", 25}
iex(9)> {^expected_name, _} = {"Alice", 30}
** (MatchError) no match of right hand side value: {"Alice", 30}

在模式中使用 ^expected_name 表示你期望变量 expected_name 的值出现在右侧项中的对应位置。在这个例子中,它等同于使用硬编码模式 {"Bob", _} = ...。因此,第一次匹配成功,第二次失败。

注意,pin 操作符不会绑定变量。你期望该变量已经绑定到某个值,并尝试匹配这个值。

3.1.5 匹配列表(Matching lists)

列表匹配的工作方式类似元组。下面的例子拆解了一个三元素列表:

iex(1)> [first, second, third] = [1, 2, 3]
[1, 2, 3]

当然,前面提到的模式技巧同样可用:

[1, second, third] = [1, 2, 3]
[first, first, first] = [1, 1, 1]
[first, second, _] = [1, 2, 3]
[^first, second, _] = [1, 2, 3]

列表匹配更常通过依赖它的递归性质完成。回忆第 2 章:每个非空列表都是递归结构,可以表示为 [head | tail] 的形式。可以用模式匹配把这两个元素分别放入变量:

iex(3)> [head | tail] = [1, 2, 3]
[1, 2, 3]
iex(4)> head
1
iex(5)> tail
[2, 3]

如果你只需要 [head, tail] 对中的一个元素,可以使用匿名变量。下面是一种低效计算列表最小元素的方法:

iex(6)> [min | _] = Enum.sort([3, 2, 1])
iex(7)> min
1

首先,对列表排序,然后用模式 [min | _] 只取出排序后列表的头部。注意,这也可以用第 2 章提到的 hd 函数完成。事实上,在这个例子中,hd 会更优雅。模式 [head | _] 在模式匹配函数参数时更有用,这会在 3.2 节看到。

3.1.6 匹配映射(Matching maps)

可以用下面的语法匹配映射:

iex(1)> %{name: name, age: age} = %{name: "Bob", age: 25}
%{age: 25, name: "Bob"}
iex(2)> name
"Bob"
iex(3)> age
25

匹配映射时,左侧模式不需要包含右侧项中的所有键:

iex(4)> %{age: age} = %{name: "Bob", age: 25}
iex(5)> age
25

你可能会好奇这种部分匹配规则的用途。映射经常用于表示结构化数据。在这种情况下,你通常只关心映射中的某些字段。例如,在前面的片段中,你只想提取 age 字段,忽略其他所有内容。部分匹配规则正好允许你这样做。

当然,如果模式包含被匹配项中没有的键,匹配就会失败:

iex(6)> %{age: age, works_at: works_at} = %{name: "Bob", age: 25}
** (MatchError) no match of right hand side value

3.1.7 匹配 bitstring 和 binary(Matching bitstrings and binaries)

本书不会过多处理 bitstring 和纯 binary,但值得提一下基本匹配语法。回忆一下,bitstring 是一段位,而 binary 是 bitstring 的一种特殊情况,它总是按字节大小对齐。

要匹配 binary,可以使用类似创建 binary 的语法:

iex(1)> binary = <<1, 2, 3>>
<<1, 2, 3>>
iex(2)> <<b1, b2, b3>> = binary
<<1, 2, 3>>
iex(3)> b1
1
iex(4)> b2
2
iex(5)> b3
3

这个例子匹配一个三字节 binary,并把各个字节提取到单独变量中。

下面的例子把 binary 拆成两部分:第一个字节放入一个变量,其余部分放入另一个变量:

iex(6)> <<b1, rest :: binary>> = binary
<<1, 2, 3>>
iex(7)> b1
1
iex(8)> rest
<<2, 3>>

rest :: binary 表示你期望一个任意大小的 binary。甚至可以提取单独的位或位组。下面的例子把单个字节拆成两个四位值:

iex(9)> <<a :: 4, b :: 4>> = <<155>>
<<155>>
iex(10)> a
9
iex(11)> b
11

模式 a :: 4 表示你期望一个四位值。在这个例子中,你把前四位放入变量 a,后四位放入变量 b。由于数字 155 的二进制表示是 10011011,所以得到值 9(二进制 1001)和 11(二进制 1011)。

当你尝试解析来自文件、外部设备或网络的紧凑二进制内容时,匹配 bitstring 和 binary 非常有用。在这些情况下,可以用 binary 匹配优雅地提取各个位和字节。

如前所述,本书示例不需要这个特性。尽管如此,你仍然应该记住 binary 和模式匹配,以备未来需要。

匹配 binary 字符串

回忆一下,字符串就是 binary,因此可以用 binary 匹配从字符串中提取单独位和字节:

iex(13)> <<b1, b2, b3>> = "ABC"
"ABC"
iex(13)> b1
65
iex(14)> b2
66
iex(15)> b3
67

变量 b1b2b3 保存所匹配字符串中的对应字节。这并不是特别有用,尤其是当你处理 Unicode 字符串时。提取单独字符最好使用 String 模块中的函数完成。

更有用的模式是匹配字符串开头:

iex(16)> command = "ping www.example.com"
"ping www.example.com"
iex(17)> "ping " <> url = command
"ping www.example.com"
iex(18)> url
"www.example.com"

在这个例子中,你构造了一个保存 ping 命令的字符串。当写下 "ping " <> url = command 时,你声明期望变量 command 是一个以 "ping " 开头的 binary 字符串。如果匹配成功,字符串剩余部分会绑定到变量 url

3.1.8 复合匹配(Compound matches)

你已经见过这一点,但现在明确说明一下。模式可以任意嵌套,例如下面这个刻意设计的例子:

iex(1)> [_, {name, _}, _] = [{"Bob", 25}, {"Alice", 30}, {"John", 35}]

在这个例子中,被匹配的项是一个三元素列表。每个元素都是一个代表人员的元组,由两个字段组成:姓名和年龄。该匹配提取列表中第二个人的姓名。

另一个有趣特性是匹配链。在看它如何工作之前,先更详细地讨论一下匹配表达式。

匹配表达式的一般形式如下:

pattern = expression

正如你在示例中看到的,右侧可以放任意表达式:

iex(2)> a = 1 + 3
4

拆开来看,这里发生了以下事情:

  1. 右侧表达式被求值。
  2. 得到的值与左侧模式匹配。
  3. 模式中的变量被绑定。
  4. 匹配表达式的结果就是右侧项的结果。

一个重要后果是,匹配表达式可以串联:

iex(3)> a = (b = 1 + 3)
4

在这个并不太有用的例子中,发生了下面的事情:

  1. 表达式 1 + 3 被求值。
  2. 结果(4)与模式 b 匹配。
  3. 内部匹配的结果(仍然是 4)与模式 a 匹配。

因此,ab 的值都是 4。

括号是可选的,许多开发者在这种情况下会省略它们:

iex(4)> a = b = 1 + 3
4

这会产生相同结果,因为 = 操作符是右结合的。

现在看一个更有用的例子。回忆函数 :calendar.local_time/0

iex(5)> :calendar.local_time()
{{2023, 11, 11}, {21, 28, 41}}

假设你想取出该函数的完整结果(datetime),同时取出一天中的当前小时。可以用一个复合匹配完成:

iex(6)> date_time = {_, {hour, _, _}} = :calendar.local_time()

甚至可以交换顺序。它仍然得到相同结果(假设在同一秒调用):

iex(7)> {_, {hour, _, _}} = date_time = :calendar.local_time()

无论哪种方式,都能得到你想要的东西:

iex(8)> date_time
{{2023, 11, 11}, {21, 32, 34}}
iex(9)> hour
21

这之所以可行,是因为模式匹配的结果总是被匹配项的结果,也就是匹配操作符右侧的内容。你可以依次针对该项的结果执行匹配,并提取自己感兴趣的不同部分。

3.1.9 一般行为(General behavior)

我们几乎完成了基本模式匹配机制。前面已经走过许多例子,现在尝试稍微形式化地描述它的行为。

模式匹配表达式由两部分组成:模式(左侧)和项(右侧)。在匹配表达式中,会尝试把项匹配到模式上。

如果匹配成功,模式中的所有变量都会绑定到项中的对应值。整个表达式的结果就是你匹配的整个项。如果匹配失败,就会抛出错误。

因此,在模式匹配表达式中,你会执行两个不同任务:

  • 对右侧项声明期望。如果这些期望不满足,就抛出错误。
  • 把项中的某些部分绑定到模式中的变量。

最后,值得一提的是,这里还没有覆盖所有可能的模式。更详细的参考可以查阅官方文档:https://mng.bz/dd6o。

匹配操作符 = 只是可以使用模式匹配的一个例子。模式匹配支撑了许多其他表达式,在函数中使用时尤其强大。

3.2 使用函数进行匹配(Matching with functions)

模式匹配机制用于函数参数的声明中。回忆一下基本函数定义:

def my_fun(arg1, arg2) do
...
end

参数声明符 arg1arg2 都是模式,因此可以使用标准匹配技巧。

看看实际例子。正如第 2 章所说,元组经常用于组合相关字段。例如,如果进行几何操作,可以用包含矩形两条边的元组 {a, b} 表示矩形。下面的代码展示了一个计算矩形面积的函数。

代码清单 3.1 匹配函数参数(rect.ex)

defmodule Rectangle do
def area({a, b}) do
a * b
end
end

注意你如何对参数进行模式匹配。函数 Rectangle.area/1 期望它的参数是一个二元素元组。然后它把对应元组元素绑定到变量,并返回结果。

可以在 shell 中看看它是否工作。启动 shell,然后加载模块:

$ iex rect.ex

再尝试该函数:

iex(1)> Rectangle.area({2, 3})
6

这里发生了什么?调用函数时,你提供的参数会与函数定义中指定的模式匹配。该函数期望一个二元素元组,并把元组元素绑定到变量 ab

调用函数时,被匹配的项是函数调用中提供的参数。你匹配的模式是参数声明符,在这里就是 {a, b}

当然,如果提供的不是二元素元组,就会抛出错误:

iex(2)> Rectangle.area(2)
** (FunctionClauseError) no function clause matching in Rectangle.area/1

模式匹配函数参数是非常有用的工具。它支撑了 Elixir 中最重要的特性之一:多子句函数。

3.2.1 多子句函数(Multiclause functions)

Elixir 允许通过指定多个子句来重载函数。子句是由 def 表达式指定的函数定义。如果提供了同名且同元数函数的多个定义,就称该函数有多个子句。

来看实际例子。扩展前面的例子,假设你需要开发一个能处理各种形状的 Geometry 模块。你会用元组表示形状,并用每个元组的第一个元素表示它代表哪种形状:

rectangle = {:rectangle, 4, 5}
square = {:square, 5}
circle = {:circle, 4}

给定这些形状表示,可以编写下面的函数来计算形状面积。

代码清单 3.2 多子句函数(geometry.ex)

defmodule Geometry do
def area({:rectangle, a, b}) do
a * b
end

def area({:square, a}) do
a * a
end

def area({:circle, r}) do
r * r * 3.14
end
end

如你所见,你提供了同一个函数的三个子句。根据传入的参数,适当的子句会被调用。从 shell 中试试:

iex(1)> Geometry.area({:rectangle, 4, 5})
20
iex(2)> Geometry.area({:square, 5})
25
iex(3)> Geometry.area({:circle, 4})
50.24

调用函数时,运行时会按照源代码中指定的顺序遍历该函数的每个子句,并尝试匹配所提供的参数。第一个能成功匹配所有参数的子句会被执行。

当然,如果没有子句匹配,就会抛出错误:

iex(4)> Geometry.area({:triangle, 1, 2, 3})
** (FunctionClauseError) no function clause matching in Geometry.area/1

从调用者角度看,多子句函数是一个单一函数。你不能直接引用某个特定子句。相反,你总是操作整个函数。

回忆第 2 章,可以用捕获操作符 & 创建函数值:

&Module.fun/arity

如果捕获 Geometry.area/1,你捕获的是它的所有子句:

iex(4)> fun = &Geometry.area/1
iex(5)> fun.({:circle, 4})
50.24
iex(6)> fun.({:square, 5})
25

这证明函数会被作为整体处理,即使它由多个子句组成。

有时,你希望函数返回一个表示失败的项,而不是抛出错误。可以引入一个总是匹配的默认子句。让我们为 area 函数这样做。下面的代码清单增加了一个最终子句,用于处理无效输入。

代码清单 3.3 多子句函数(geometry_invalid_input.ex)

defmodule Geometry do
def area({:rectangle, a, b}) do
a * b
end

def area({:square, a}) do
a * a
end

def area({:circle, r}) do
r * r * 3.14
end

def area(unknown) do
{:error, {:unknown_shape, unknown}}
end
end

如果前三个子句都不匹配,最后一个子句会被调用。这是因为变量模式总是匹配对应项。在这个例子中,你返回一个二元素元组 {:error, reason},表示出了问题。

在 shell 中试试:

iex(1)> Geometry.area({:square, 5})
25
iex(2)> Geometry.area({:triangle, 1, 2, 3})
{:error, {:unknown_shape, {:triangle, 1, 2, 3}}}

提示 为了让它正确工作,把子句放在合适顺序中很重要。运行时会按照源代码顺序尝试选择子句。如果 area(unknown) 子句定义在最前面,你总会得到错误结果。

注意,area(unknown) 子句只适用于 area/1。如果传入多个参数,该子句不会被调用。回忆第 2 章,函数由名称和元数区分。因为同名不同元数的函数是两个不同函数,所以无法指定一个不管传入多少参数都会执行的 area 子句。

最后一点:同一个函数的子句应该始终放在一起,而不是分散在模块各处。如果多子句函数散落在文件各处,就会越来越难分析该函数的完整行为。编译器甚至会对此发出编译警告。

3.2.2 守卫(Guards)

假设你想写一个函数,它接收一个数字,并根据该数字的值返回原子 :negative:zero:positive。用目前看到的简单模式匹配无法做到这一点。Elixir 以守卫形式为此提供了解决方案。

守卫是基础模式匹配机制的扩展。它们允许你声明必须满足的额外、更广义的期望,只有这些期望也满足,整个模式才算匹配。

可以通过在参数列表后提供 when 子句来指定守卫。用例子最容易说明。下面的代码检测给定数字是正数、负数还是零。

代码清单 3.4 使用守卫(test_num.ex)

defmodule TestNum do
def test(x) when x < 0 do
:negative
end

def test(x) when x == 0 do
:zero
end

def test(x) when x > 0 do
:positive
end
end

守卫是一个逻辑表达式,会给模式添加进一步条件。在这个例子中,我们有三个具有相同模式(x)的子句,正常情况下它们都会匹配。额外的守卫细化了模式,确保只有给定条件满足时才调用该子句,如下面的 shell 会话所示:

iex(1)> TestNum.test(-1)
:negative
iex(2)> TestNum.test(0)
:zero
iex(3)> TestNum.test(1)
:positive

令人惊讶的是,用非数字调用这个函数会产生奇怪结果:

iex(4)> TestNum.test(:not_a_number)
:positive

为什么会这样?解释在于,即使 Elixir 项不是同一类型,也可以用 <> 操作符比较。在这种情况下,类型排序会决定结果:

number < atom < reference < fun < port < pid <
tuple < map < list < bitstring (binary)

数字小于任何其他类型,这就是为什么如果提供非数字,TestNum.test/1 总是返回 :positive。要修复它,必须扩展守卫,测试参数是否为数字。

代码清单 3.5 使用守卫(test_num2.ex)

defmodule TestNum do
def test(x) when is_number(x) and x < 0 do
:negative
end

def test(x) when x == 0 do
:zero
end

def test(x) when is_number(x) and x > 0 do
:positive
end
end

这段代码使用函数 Kernel.is_number/1 测试参数是否为数字。现在,如果给 TestNum.test/1 传入非数字,就会抛出错误:

iex(1)> TestNum.test(-1)
:negative
iex(2)> TestNum.test(:not_a_number)
** (FunctionClauseError) no function clause matching in TestNum.test/1

可在守卫中调用的操作符和函数集合非常有限。特别是,你不能调用自己的函数,大多数其他函数也不能工作。下面是守卫中允许使用的一些操作符和函数示例:

  • 比较操作符(==!====!==><<=>=
  • 布尔操作符(andor)以及否定操作符(not!
  • 算术操作符(+-*/
  • Kernel 模块中的类型检查函数,例如 is_number/1is_atom/1

完整且最新的列表可以在 https://mng.bz/rjVJ 找到。

在某些情况下,守卫中使用的函数可能导致错误。例如,length/1 只对列表有意义。假设你有下面这个函数,用于计算非空列表中的最小元素:

defmodule ListHelper do
def smallest(list) when length(list) > 0 do
Enum.min(list)
end

def smallest(_), do: {:error, :invalid_argument}
end

你可能以为用列表以外的任何东西调用 ListHelper.smallest/1 都会抛出错误,但这不会发生。如果错误从守卫内部抛出,它不会继续传播,守卫表达式会返回 false。对应子句不会匹配,但其他子句可能匹配。

在前面的例子中,如果调用 ListHelper.smallest(123),会得到结果 {:error, :invalid_argument}。这表明守卫表达式中的错误会在内部被处理。

3.2.3 多子句 lambda(Multiclause lambdas)

匿名函数(lambda)也可以由多个子句组成。先回忆定义和使用 lambda 的基本方式:

iex(1)> double = fn x -> x * 2 end
iex(2)> double.(3)
6

lambda 的一般语法具有如下形状:

fn
pattern_1, pattern_2 ->
...
pattern_3, pattern_4 ->
...
...
end

如果 pattern_1 匹配第一个参数,且 pattern_2 匹配第二个参数,就会执行第一个分支;如果 pattern_3pattern_4 匹配,则执行第二个分支。

通过重新实现检测数字为正、负或零的 test/1 函数,看看它的实际用法:

iex(3)> test_num =
fn
x when is_number(x) and x < 0 -> :negative
x when x == 0 -> :zero
x when is_number(x) and x > 0 -> :positive
end

注意,lambda 子句没有特殊的结束终止符。当新子句开始(形式为 pattern ->)或 lambda 定义以 end 结束时,前一个子句就结束了。

注意 因为 lambda 的所有子句都列在同一个 fn 表达式下,所以按惯例会省略每个子句的括号。相比之下,命名函数的每个子句都在单独的 def(或 defp)表达式中指定。因此,建议在命名函数参数周围使用括号。

现在可以测试这个 lambda:

iex(4)> test_num.(-1)
:negative
iex(5)> test_num.(0)
:zero
iex(6)> test_num.(1)
:positive

当使用高阶函数时,多子句 lambda 会很方便,本章稍后会看到。但目前,多子句函数背后的基础理论已经讲完了。它们在运行时条件分支中扮演重要角色,这是下一个主题。

3.3 条件(Conditionals)

Elixir 提供了一些标准条件分支方式,例如 ifcase 表达式。多子句函数也可以用于这个目的。本节会覆盖所有分支技巧,从多子句函数开始。

3.3.1 使用多子句函数进行分支(Branching with multiclause functions)

你已经见过如何用多子句实现条件逻辑,但再看一次:

defmodule TestNum do
def test(x) when x < 0, do: :negative
def test(0), do: :zero
def test(x), do: :positive
end

三个子句构成三个条件分支。在典型命令式语言(例如 JavaScript)中,可以写成类似下面这样:

function test(x) {
if (x < 0) return "negative";
if (x == 0) return "zero";
return "positive";
}

可以说两个版本同样可读。但使用多子句时,可以获得模式匹配的所有好处,例如根据数据形状进行分支。下面的例子使用多子句测试给定列表是否为空:

defmodule TestList do
def empty?([]), do: true
def empty?([_ | _]), do: false
end

第一个子句匹配空列表,第二个子句依赖非空列表的 head | tail 表示。

依赖模式匹配,可以实现多态函数,根据输入类型做不同事情。下面的例子实现了一个让变量加倍的函数。该函数会根据它是以数字还是 binary(字符串)调用而表现不同:

iex(1)> defmodule Polymorphic do
def double(x) when is_number(x), do: 2 * x
def double(x) when is_binary(x), do: x <> x
end
iex(2)> Polymorphic.double(3)
6
iex(3)> Polymorphic.double("Jar")
"JarJar"

多子句的强大在递归中会变得明显。得到的代码看起来是声明式的,并且没有多余的 ifreturn。下面是基于多子句的阶乘递归实现:

iex(4)> defmodule Fact do
def fact(0), do: 1
def fact(n), do: n * fact(n - 1)
end
iex(5)> Fact.fact(1)
1
iex(6)> Fact.fact(3)
6

由多子句驱动的递归也是循环的主要构建块。下一节会彻底解释这一点,但这里先看一个简单例子。下面的函数求列表中所有元素之和:

iex(7)> defmodule ListHelper do
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
iex(8)> ListHelper.sum([])
0
iex(9)> ListHelper.sum([1, 2, 3])
6

这个解决方案依赖列表的递归定义来实现求和。空列表之和始终是 0,而非空列表之和等于头部值加尾部之和。

所有能用经典分支表达式完成的事情,都可以用多子句完成。不过,底层模式匹配机制通常更具表达力,允许你根据函数参数的值、类型和形状进行分支。有些情况下,代码用经典命令式分支风格看起来更好。下面看看 Elixir 中的其他分支表达式。

3.3.2 经典分支表达式(Classical branching expressions)

多子句方案并不总是合适。使用它们需要创建一个单独函数,并传入必要参数。有时,在函数中使用经典分支表达式更简单。对于这种情况,Elixir 提供了 ifunlesscondcase 表达式。它们大致按你预期的方式工作,不过有一些转折。逐个看看。

if 和 unless

if 表达式具有熟悉的语法:

if condition do
...
else
...
end

它会根据条件的真值性执行其中一个分支。如果条件是除 falsenil 之外的任何东西,就进入主分支;否则调用 else 部分。

也可以像 def 表达式一样,把它压缩成单行:

if condition, do: something, else: another_thing

回忆一下,Elixir 中一切都是具有返回值的表达式。if 表达式返回被执行块的结果,也就是该块最后一个表达式的结果。如果条件不满足且没有指定 else 子句,返回值就是原子 nil

iex(1)> if 5 > 3, do: :one
:one
iex(2)> if 5 < 3, do: :one
nil
iex(3)> if 5 < 3, do: :one, else: :two
:two

看一个更具体的例子。下面的代码实现了一个 max 函数,它返回两个元素中较大的那个(根据 > 操作符语义):

def max(a, b) do
if a >= b, do: a, else: b
end

还有 unless 表达式,它等价于 if not ...。考虑下面的 if 表达式:

if result != :error, do: send_notification(...)

也可以表示为:

unless result == :error, do: send_notification(...)

cond

cond 表达式可以视为等价于 if-else-if 模式。它接收一组表达式,并执行第一个求值为真值的表达式对应的块:

cond do
expression_1 ->
...
expression_2 ->
...
...
end

cond 的结果是对应被执行块的结果。如果没有任何条件满足,cond 会抛出错误。

如果分支选择超过两个,cond 很适合:

def call_status(call) do
cond do
call.ended_at != nil -> :ended
call.started_at != nil -> :started
true -> :pending
end
end

在这个例子中,你正在计算一次通话的状态。如果 ended_at 字段已填充,则通话已结束。否则,如果 started_at 字段已填充,则通话已开始。如果这两个字段都没有填充,则通话处于待处理状态。注意最后一个子句:true -> :pending。由于该子句的条件(true)总是满足,它实际上成为 fallback 子句,在 cond 表达式中前面列出的条件都不满足时被调用。

case

case 的一般语法如下:

case expression do
pattern_1 ->
...
pattern_2 ->
...
...
end

这里的 pattern 表明它处理的是模式匹配。在 case 表达式中,提供的表达式会被求值,然后其结果会与给定子句匹配。第一个匹配的子句会执行,对应块(其最后一个表达式)的结果就是整个 case 表达式的结果。如果没有子句匹配,就会抛出错误。

基于 casemax 函数版本如下:

def max(a, b) do
case a >= b do
true -> a
false -> b
end
end

如果不想定义单独的多子句函数,case 表达式最合适。除此之外,case 与多子句函数没有差别。事实上,一般 case 语法可以直接翻译成多子句方法:

defp fun(pattern_1), do: ...
defp fun(pattern_2), do: ...
...

然后必须用 fun(expression) 调用它。

可以通过使用匿名变量匹配任何东西来指定默认子句:

case expression do
pattern_1 -> ...
pattern_2 -> ...
...
_ -> ...
end

如你所见,Elixir 中有不同方式可以实现条件逻辑。多子句让分支更具声明式感觉,但它要求定义函数并传入所有必要参数。ifcase 这样的经典表达式看起来更命令式,但通常会比多子句方法更简单。选择合适方案取决于具体情况以及个人偏好。

3.3.3 with 表达式(The with expression)

最后要讨论的分支表达式是 with 表达式。当你需要串联几个表达式,并返回第一个失败表达式的错误时,它非常有用。看一个简单例子。

假设你需要处理用户提交的注册数据。输入是一个映射,键是字符串("login""email""password")。下面是一个输入映射示例:

%{
"login" => "alice",
"email" => "some_email",
"password" => "password",
"other_field" => "some_value",
"yet_another_field" => "...",
...
}

你的任务是把这个映射规范化为只包含 loginemailpassword 字段的映射。通常,如果字段集合定义良好并且预先已知,可以把键表示为原子。因此,对于给定输入,可以返回下面的结构:

%{login: "alice", email: "some_email", password: "password"}

不过,输入映射中可能缺少某个必填字段。在这种情况下,你想报告错误,所以函数可以有两种不同结果。它可以返回规范化后的用户映射,也可以返回错误。此类情况下的惯用方式是让函数返回 {:ok, some_result}{:error, error_reason}。在这个练习中,成功结果是规范化后的用户映射,而错误原因是描述性文本。

先编写用于提取各个字段的辅助函数:

defp extract_login(%{"login" => login}), do: {:ok, login}
defp extract_login(_), do: {:error, "login missing"}

defp extract_email(%{"email" => email}), do: {:ok, email}
defp extract_email(_), do: {:error, "email missing"}

defp extract_password(%{"password" => password}), do: {:ok, password}
defp extract_password(_), do: {:error, "password missing"}

这里,你依赖模式匹配检测字段是否存在。

现在需要编写顶层 extract_user/1 函数,把这三个函数组合起来。下面是一种使用 case 的写法:

def extract_user(user) do
case extract_login(user) do
{:error, reason} ->
{:error, reason}

{:ok, login} ->
case extract_email(user) do
{:error, reason} ->
{:error, reason}

{:ok, email} ->
case extract_password(user) do
{:error, reason} ->
{:error, reason}

{:ok, password} ->
%{login: login, email: email, password: password}
end
end
end
end

考虑到这段代码只是组合三个函数,它显得相当嘈杂。每次获取某个东西时,都需要根据结果分支,最终得到三个嵌套的 case。在现实中,你通常必须执行更多验证,因此代码很快会变得相当糟糕。

这正是 with 可以提供帮助的地方。with 特殊形式允许使用模式匹配串联多个表达式,验证每个结果都符合期望模式,并返回第一个意外结果。

最简单形式下,with 具有如下形状:

with pattern_1 <- expression_1,
pattern_2 <- expression_2,
...
do
...
end

从顶部开始,对第一个表达式求值,并把结果与对应模式匹配。如果匹配成功,就移动到下一个表达式。如果所有表达式都成功匹配,就进入 do 块,with 表达式的结果就是 do 块中最后一个表达式的结果。

不过,如果任何匹配失败,with 不会继续对后续表达式求值。相反,它会立即返回无法被匹配的结果。

看一个例子:

iex(1)> with {:ok, login} <- {:ok, "alice"},
...> {:ok, email} <- {:ok, "some_email"} do
...> %{login: login, email: email}
...> end
%{email: "some_email", login: "alice"}

这里,你通过两次模式匹配提取 login 和 email。然后 do 块被求值。with 表达式的结果是 do 块中表达式的最后一个结果。表面上,这与下面的代码没有区别:

{:ok, login} = {:ok, "alice"}
{:ok, email} = {:ok, "email"}
%{login: login, email: email}

with 的好处在于,它会返回第一个无法匹配对应模式的项:

iex(2)> with {:ok, login} <- {:error, "login missing"},
...> {:ok, email} <- {:ok, "email"} do
...> %{login: login, email: email}
...> end
{:error, "login missing"}

对你的情况而言,这正是所需。掌握这一点后,可以重构顶层 extract_user 函数。

代码清单 3.6 基于 with 的用户提取(user_extraction.ex)

def extract_user(user) do
with {:ok, login} <- extract_login(user),
{:ok, email} <- extract_email(user),
{:ok, password} <- extract_password(user) do
{:ok, %{login: login, email: email, password: password}}
end
end

如你所见,这段代码短得多,也清晰得多。你提取所需数据片段,只有成功时才前进。如果某件事失败,就返回第一个错误。否则,返回规范化后的结构。完整实现可在 user_extraction.ex 中找到。试试看:

$ iex user_extraction.ex
iex(1)> UserExtraction.extract_user(%{})
{:error, "login missing"}
iex(2)> UserExtraction.extract_user(%{"login" => "some_login"})
{:error, "email missing"}
iex(3)> UserExtraction.extract_user(%{
...> "login" => "some_login",
...> "email" => "some_email"
...> })
{:error, "password missing"}
iex(4)> UserExtraction.extract_user(%{
...> "login" => "some_login",
...> "email" => "some_email",
...> "password" => "some_password"
...> })
{:ok, %{email: "some_email", login: "some_login",
password: "some_password"}}

with 特殊形式还有一些这里未展示的特性。建议在 https://mng.bz/VRxy 更详细地学习它。

这就结束了 Elixir 分支表达式之旅。现在,是时候看看如何执行循环和迭代了。

3.4 循环与迭代(Loops and iterations)

Elixir 中的循环与主流语言非常不同。它不提供 whiledo...while 这样的结构。尽管如此,任何严肃程序都需要做某种动态循环。那么在 Elixir 中应该怎么做呢?Elixir 的主要循环工具是递归,接下来会详细看看如何使用它。

注意 虽然递归是任何循环的基本构建块,但大多数生产 Elixir 代码只是谨慎地使用它。这是因为有许多高层抽象隐藏了递归细节。本书会介绍许多这样的抽象,但理解递归在 Elixir 中如何工作很重要,因为大多数复杂代码都基于这个机制。

注意 本节大多数示例处理的是简单问题,例如计算列表中所有元素之和。这些任务 Elixir 允许你用高效且优雅的一行代码完成。然而,示例的重点是通过简单问题理解基于递归处理的不同方面。

3.4.1 使用递归迭代(Iterating with recursion)

假设你想实现一个函数,用于打印前 n 个自然数(正整数)。因为没有循环,必须依赖递归。基本方法如下所示。

代码清单 3.7 打印前 n 个自然数(natural_nums.ex)

defmodule NaturalNums do
def print(1), do: IO.puts(1)

def print(n) do
print(n - 1)
IO.puts(n)
end
end

这段代码依赖递归、模式匹配和多子句函数。如果 n 等于 1,就打印该数字。否则,打印前 n - 1 个数字,然后打印第 n 个。

在 shell 中尝试会得到令人满意的结果:

iex(1)> NaturalNums.print(3)
1
2
3

你可能注意到,如果提供负整数或浮点数,该函数无法正确工作。这可以通过额外守卫解决,留作练习。

代码清单 3.7 展示了执行条件循环的基本方式。你指定一个多子句函数,先提供停止递归的子句。后面跟着更一般的子句,这些子句生成一部分结果并递归调用函数。

接下来看看如何在循环中计算某些东西并返回结果。处理条件时你已经看过这个例子,但再重复一次。下面的代码实现了一个函数,用于求给定列表中所有元素之和。

代码清单 3.8 计算列表之和(sum_list.ex)

defmodule ListHelper do
def sum([]), do: 0

def sum([head | tail]) do
head + sum(tail)
end
end

这段代码看起来非常声明式:

  1. 空列表中所有元素之和为 0。
  2. 非空列表中所有元素之和等于列表头部加列表尾部之和。

看看它如何运行:

iex(1)> ListHelper.sum([1, 2, 3])
6
iex(2)> ListHelper.sum([])
0

你可能从其他语言中知道,函数调用会导致栈压入,因此会消耗一些内存。非常深的递归可能导致栈溢出并使整个程序崩溃。在 Elixir 中,由于尾调用优化,这未必是问题。

3.4.2 尾函数调用(Tail function calls)

如果一个函数做的最后一件事是调用另一个函数(或自身),你面对的就是尾调用:

def original_fun(...) do
...
another_fun(...)
end

Elixir(更准确地说是 Erlang)通过执行尾调用优化,以特殊方式处理尾调用。在这种情况下,调用函数不会导致通常的栈压入。相反,会发生更像 goto 或跳转语句的事情。调用函数之前不会分配额外栈空间,这反过来意味着尾函数调用不消耗额外内存。

这是怎么可能的?在前面的片段中,original_fun 做的最后一件事是调用 another_funoriginal_fun 的最终结果就是 another_fun 的结果。因此,编译器可以安全地通过跳转到 another_fun 开头来执行操作,而无需额外分配内存。当 another_fun 完成时,会返回到调用 original_fun 的位置。

尾调用在递归函数中特别有用。尾递归函数,也就是在最后调用自身的函数,可以几乎永远运行而不消耗额外内存。

下面的函数是 Elixir 中无限循环的等价物:

def loop_forever(...) do
...
loop_forever(...)
end

因为尾递归不消耗额外内存,所以它是处理任意大迭代的合适方案。

下一个代码清单中,会把 ListHelper.sum/1 函数转换为尾递归版本。

代码清单 3.9 前 n 个自然数之和的尾递归版本(sum_list_tc.ex)

defmodule ListHelper do
def sum(list) do
do_sum(0, list)
end

defp do_sum(current_sum, []) do
current_sum
end

defp do_sum(current_sum, [head | tail]) do
new_sum = head + current_sum
do_sum(new_sum, tail)
end
end

首先注意有两个函数。导出的函数 sum/1 由模块客户端调用,从表面看它和之前一样工作。

递归发生在私有函数 do_sum/2 中,它被实现为尾递归。这是一个双子句函数,我们逐个分析子句。第二个子句更有意思,所以先看它:

defp do_sum(current_sum, [head | tail]) do
new_sum = head + current_sum
do_sum(new_sum, tail)
end

该子句期望两个参数:要操作的非空列表,以及目前已经计算出的和(current_sum)。然后它计算新的和,并用列表剩余部分和新和递归调用自身。因为调用发生在最后,函数是尾递归的,调用不会消耗额外内存。

这里引入变量 new_sum 只是为了让事情更明显。也可以内联该计算:

defp do_sum(current_sum, [head | tail]) do
do_sum(head + current_sum, tail)
end

这个函数仍然是尾递归的,因为它在最后调用自身。

最后看 do_sum/2 的第一个子句:

defp do_sum(current_sum, []) do
current_sum
end

该子句负责停止递归。它匹配空列表,也就是迭代的最后一步。到达这里时,没有其他东西需要求和,所以返回累计结果。

最后还有函数 sum/1

def sum(list) do
do_sum(0, list)
end

这个函数供客户端使用,也负责初始化递归传给 do_sumcurrent_sum 参数值。

可以把尾递归视为命令式语言中经典循环的直接等价物。参数 current_sum 是经典累加器:每个迭代步骤都会把结果增量加到这个值上。do_sum/2 函数实现迭代步骤,并把累加器从一步传到下一步。Elixir 是不可变语言,所以需要用这个技巧在整个循环中维护累计值。do_sum/2 的第一个子句定义迭代终点并返回累加器值。

无论如何,列表求和的尾递归版本现在可以工作了,可以从 shell 中尝试:

iex(1)> ListHelper.sum([1, 2, 3])
6
iex(2)> ListHelper.sum([])
0

如你所见,从调用者角度看,函数工作方式完全一样。内部则依赖尾递归,因此可以处理任意大的列表,而不需要为这个任务使用额外内存。

尾递归与非尾递归

考虑到尾递归的性质,你可能认为它总是循环的首选方式。如果需要运行无限循环,尾递归是唯一能工作的方式。除此之外,应优先选择看起来更可读的版本。另外,非尾递归经常能产生更优雅、更简洁的代码,有时甚至比尾递归版本性能更好。

识别尾调用

尾调用可以有不同形态。你已经看过最明显的情况,但还有几种。尾调用也可以发生在条件表达式中:

def fun(...) do
...
if something do
...
another_fun(...)
end
end

another_fun 的调用是尾调用,因为它是该函数做的最后一件事。同样规则也适用于 unlesscondcasewith 表达式。

但下面的代码不是尾调用:

def fun(...) do
1 + another_fun(...)
end

这是因为对 another_fun 的调用并不是 fun 函数做的最后一件事。another_fun 完成之后,还必须把它的结果加 1,才能计算出 fun 的最终结果。

练习

所有这些可能看起来很复杂,但并不太难。如果你来自命令式语言,它可能不是你习惯的方式,而且需要一些时间来适应递归思维和模式匹配设施的组合。你可能想花点时间自己实验递归。下面是几个可以练习编写的函数:

  • list_len/1 函数,计算列表长度
  • range/2 函数,接收两个整数 fromto,并返回给定范围内所有整数的列表
  • positive/1 函数,接收一个列表并返回另一个列表,其中只包含输入列表中的正数

先尝试用非尾递归形式编写这些函数,再把它们转换为尾递归版本。如果卡住了,解答位于 recursion_practice.exrecursion_practice_tc.ex 文件中(后者用于尾递归版本)。

递归是基础循环技术,没有递归就无法执行循环。尽管如此,你并不经常需要编写显式递归。许多典型任务可以用高阶函数完成。

3.4.3 高阶函数(Higher-order functions)

高阶函数是一类以一个或多个函数作为输入,或返回一个或多个函数,或两者兼具的函数。这里的函数一词指的是函数值。

第 2 章中使用 Enum.each/2 遍历列表并打印所有元素时,你已经第一次接触过高阶函数。回忆一下如何做到:

iex(1)> Enum.each(
...> [1, 2, 3],
...> fn x -> IO.puts(x) end
...> )
1
2
3

函数 Enum.each/2 接收一个 enumerable(这里是列表)和一个 lambda。它遍历 enumerable,并对每个元素调用 lambda。因为 Enum.each/2 接收 lambda 作为输入,所以它称为高阶函数。

可以使用 Enum.each/2 遍历 enumerable 结构,而不用自己编写递归。在幕后,Enum.each/2 由递归驱动;Elixir 中没有其他方式执行循环和迭代。不过,编写递归的复杂性、重复代码和尾递归细节都对你隐藏了。

Enum.each/2 只是由高阶函数驱动的迭代的一个例子。Elixir 标准库在 Enum 模块中提供了许多其他有用的迭代辅助函数。你应该花点时间研究该模块文档(https://hexdocs.pm/elixir/Enum.html)。这里会看看一些最常用的 Enum 函数。

你经常需要的一种操作是把列表一对一转换成另一个列表。因此,Elixir 提供了 Enum.map/2。它接收一个 enumerable 和一个 lambda,后者把每个元素映射为另一个元素。下面的例子把列表中每个元素翻倍:

iex(1)> Enum.map(
...> [1, 2, 3],
...> fn x -> 2 * x end
...> )
[2, 4, 6]

Enumerables

Enum 模块中的大多数函数都作用于 enumerable。你会在第 4 章学习这意味着什么。现在只需要知道,enumerable 是一种实现了特定契约的数据结构,该契约使它适合被 Enum 模块中的函数使用。

enumerable 的一些例子包括列表、范围、映射和 MapSet。也可以把自己的数据结构变成 enumerable,从而利用 Enum 模块的所有特性。

回忆第 2 章,可以使用捕获操作符 & 让 lambda 定义更紧凑:

iex(2)> Enum.map(
...> [1, 2, 3],
...> &(2 * &1)
...> )
[2, 4, 6]

&(...) 表示简化 lambda 定义,其中使用 &n 作为 lambda 第 n 个参数的占位符。

另一个有用函数是 Enum.filter/2,它可用于根据某些条件仅提取列表中的部分元素。下面的片段返回列表中的所有奇数:

iex(3)> Enum.filter(
...> [1, 2, 3],
...> fn x -> rem(x, 2) == 1 end
...> )
[1, 3]

Enum.filter/2 接收一个 enumerable 和一个 lambda。它只返回那些让 lambda 返回 true 的元素。

当然,也可以使用捕获语法:

iex(3)> Enum.filter(
...> [1, 2, 3],
...> &(rem(&1, 2) == 1)
...> )
[1, 3]

再多玩一下 Enum。回忆 3.3.3 节的例子,其中你用 with 验证 login、email 和 password 是否提交。在那个例子中,你返回遇到的第一个错误。掌握新知识之后,可以改进这段代码,一次报告所有缺失字段。

简单回顾一下:输入是一个映射,你需要获取键 "login""email""password",然后把它们转换成键为原子的映射。如果某个必填字段没有提供,需要报告错误。在之前版本中,你只报告第一个缺失字段。更好的用户体验是返回所有缺失字段的列表。

这可以借助 Enum.filter/2 轻松完成。思路是遍历必填字段列表,并只保留映射中不存在的字段。可以借助 Map.has_key?/2 轻松检查键是否存在。解决方案草图如下。

代码清单 3.10 报告所有缺失字段(user_extraction_2.ex)

case Enum.filter(
["login", "email", "password"],
&(not Map.has_key?(user, &1))
) do
[] ->
...

missing_fields ->
...
end

Enum.filter/2 有两种可能结果。如果结果是空列表,说明所有字段都已提供,可以提取数据。否则,某些字段缺失,需要报告错误。为了简洁,这里省略了每个分支中的代码,但完整解决方案可以在 user_extraction_2.ex 中找到。

reduce

Enum 模块中最通用的函数可能是 Enum.reduce/3,它可用于把 enumerable 转换为任何东西。如果你来自支持一等函数的语言,可能已经以 injectfold 的名字认识 reduce。

用例子最容易解释 reduce。我们用 reduce 对列表中所有元素求和。在 Elixir 中做之前,先看看用命令式方式如何完成这个任务。下面是一个命令式 JavaScript 示例:

var sum = 0;
[1, 2, 3].forEach(function(element) {
sum += element;
});

这是标准命令式模式。你初始化一个累加器(变量 sum),然后执行某种循环,在每一步中调整累加器的值。循环结束后,累加器保存最终值。

在函数式语言中,不能改变累加器,但仍然可以通过 Enum.reduce/3 增量计算结果。该函数具有如下形状:

Enum.reduce(
enumerable,
initial_acc,
fn element, acc ->
...
end
)

Enum.reduce/3 第一个参数接收一个 enumerable。第二个参数是累加器初始值,也就是你增量计算的东西。最后一个参数是一个 lambda,它会针对每个元素调用。该 lambda 接收 enumerable 中的元素以及当前累加器值。lambda 的任务是计算并返回新的累加器值。迭代完成后,Enum.reduce/3 返回最终累加器值。

Enum.reduce/3 对列表元素求和:

iex(4)> Enum.reduce(
...> [1, 2, 3],
...> 0,
...> fn element, sum -> sum + element end
...> )
6

就是这样!我自己来自命令式背景,把 lambda 想成每个迭代步骤都会调用的函数会有帮助。它的任务是给结果添加一点信息。

你可能还记得我提到过,许多操作符都是函数,可以通过调用 &+/2&*/2 等把操作符变成 lambda。这与高阶函数结合得很好。例如,求和示例可以写成更紧凑的形式:

iex(5)> Enum.reduce([1, 2, 3], 0, &+/2)
6

值得一提的是,有一个名为 Enum.sum/1 的函数,其工作方式与这个片段完全相同。求和示例的重点是说明如何遍历集合并累计结果。

再继续看 reduce。前面的例子只有在传入的列表完全由数字组成时才工作。如果列表包含其他东西,就会抛出错误(因为 + 操作符只对数字有定义)。下面的例子可以处理任意类型列表,并只对其中的数值元素求和:

iex(6)> Enum.reduce(
...> [1, "not a number", 2, :x, 3],
...> 0,
...> fn
...> element, sum when is_number(element) ->
...> sum + element
...> _, sum ->
...> sum
...> end
...> )
6

这个例子依赖多子句 lambda 得到所需结果。如果元素是数字,就把它的值加到累计和中。否则,返回当前已有的和,实际上把它不变地传递给下一个迭代步骤。

就个人而言,我倾向于避免编写复杂 lambda。如果匿名函数中有更多逻辑,这通常说明它作为独立函数可能更好看。下面的片段把 lambda 代码推到单独的私有函数中:

defmodule NumHelper do
def sum_nums(enumerable) do
Enum.reduce(enumerable, 0, &add_num/2)
end

defp add_num(num, sum) when is_number(num), do: sum + num
defp add_num(_, sum), do: sum
end

这与前面看到的方法或多或少相似。这个例子把迭代步骤移到单独的私有函数 add_num/2 中。调用 Enum.reduce 时,使用捕获操作符 & 传入委托给该函数的 lambda。

注意,捕获函数时没有指定模块名。这是因为 add_num/2 位于同一个模块中,所以可以省略模块前缀。事实上,由于 add_num/2 是私有的,不能使用模块前缀捕获它。

这就结束了对 Enum 模块的基础展示。务必查看其他可用函数,因为你会发现许多有用辅助函数,可以简化循环、迭代和 enumerable 操作。

3.4.4 推导式(Comprehensions)

comprehensions 这个有些神秘的名字表示另一种可以帮助你迭代和转换 enumerable 的表达式。下面的例子使用推导式对列表中每个元素求平方:

iex(1)> for x <- [1, 2, 3] do
...> x * x
...> end
[1, 4, 9]

推导式会遍历每个元素,并运行 do...end 块。结果是一个列表,包含 do...end 块返回的所有结果。在这种基本形式下,forEnum.map/2 没有区别。

推导式还有其他几个特性,这往往让它们相比基于 Enum 的迭代更优雅。例如,可以对多个集合执行嵌套迭代。下面的例子利用这个特性计算一个小乘法表:

iex(2)> for x <- [1, 2, 3], y <- [1, 2, 3], do: {x, y, x * y}
[
{1, 1, 1}, {1, 2, 2}, {1, 3, 3},
{2, 1, 2}, {2, 2, 4}, {2, 3, 6},
{3, 1, 3}, {3, 2, 6}, {3, 3, 9}
]

在这个例子中,推导式执行嵌套迭代,为输入集合的每种组合调用提供的块。

就像 Enum 模块中的函数一样,推导式可以遍历任何 enumerable。例如,可以使用范围计算一位数乘法表:

iex(3)> for x <- 1..9, y <- 1..9, do: {x, y, x * y}

到目前为止的例子中,推导式结果都是列表,但推导式可以返回任何 collectable。collectable 是对一种能够收集值的函数式数据类型的抽象称呼。一些例子包括列表、映射、MapSet 和文件流;甚至可以让自己的自定义类型成为 collectable(第 4 章会详细讨论)。

更一般地说,推导式会遍历 enumerable,为每个值调用提供的块,并把结果存储在某种 collectable 结构中。看看实际例子。

下面的片段创建一个保存乘法表的映射。其键是因子元组 {x, y},值包含乘积:

iex(4)> multiplication_table =
...> for x <- 1..9,
...> y <- 1..9,
...> into: %{} do
...> {{x, y}, x * y}
...> end
iex(5)> Map.get(multiplication_table, {7, 6})
42

注意 into 选项,它指定要收集到什么东西中。在这个例子中,它是一个空映射 %{},会用 do 块返回的值填充。注意你从 do 块返回了一个 {factors, product} 元组。这样做是因为映射“知道”如何解释它。第一个元素会作为键,第二个元素会作为对应值。

推导式的另一个有趣特性是可以指定过滤器。这让你能够跳过输入中的某些元素。下面的例子为数字 xy 计算一个非对称乘法表,其中 x 永远不会大于 y

iex(6)> multiplication_table =
...> for x <- 1..9,
...> y <- 1..9,
...> x <= y,
...> into: %{} do
...> {{x, y}, x * y}
...> end
iex(7)> Map.get(multiplication_table, {6, 7})
42
iex(8)> Map.get(multiplication_table, {7, 6})
nil

推导式过滤器会在执行块之前,针对输入 enumerable 的每个元素求值。如果过滤器返回 true,块会被调用并收集结果。否则,推导式会继续处理下一个元素。

如你所见,推导式是一个有趣特性,允许对输入 enumerable 做一些优雅转换。虽然这可以用 Enum 函数完成,尤其是 Enum.reduce/3,但使用推导式时,得到的代码通常更优雅。当你必须对多个 enumerable 执行笛卡尔积(cross join),或遍历嵌套集合以产生扁平结果时尤其如此。

注意 推导式也可以遍历 binary。语法略有不同,这里不讨论。更多细节最好查看官方 for 文档:https://mng.bz/xj2d。

3.4.5 流(Streams)

流是一种特殊 enumerable,可用于对任何 enumerable 执行惰性且可组合的操作。要理解这意味着什么,先看看标准 Enum 函数的一个短板。

假设你有一个员工列表,需要打印每个员工,并在前面加上他们在列表中的位置:

1. Alice
2. Bob
3. John
...

组合各种 Enum 函数可以相当简单地完成。例如,有一个函数 Enum.with_index/1,它接收一个 enumerable,并返回一个元组列表。元组的第一个元素是输入 enumerable 中的成员,第二个元素是其从零开始的索引:

iex(1)> employees = ["Alice", "Bob", "John"]
["Alice", "Bob", "John"]
iex(2)> Enum.with_index(employees)
[{"Alice", 0}, {"Bob", 1}, {"John", 2}]

现在可以把 Enum.with_index/1 的结果传给 Enum.each/2,得到期望输出:

iex(3)> employees
...> |> Enum.with_index()
...> |> Enum.each(fn {employee, index} ->
...> IO.puts("#{index + 1}. #{employee}")
...> end)
1. Alice
2. Bob
3. John

这里,你依赖管道操作符把多个函数调用串在一起。这避免了使用中间变量,让代码稍微干净一些。

这段代码有什么问题?Enum.with_index/1 会遍历整个列表,产生另一个由元组组成的列表,然后 Enum.each 又会遍历这个新列表。更好的方式是在不构建另一个列表的情况下,一次遍历完成两个操作。这就是流能提供帮助的地方。

流在 Stream 模块中实现(https://hexdocs.pm/elixir/Stream.html)。乍看之下,它类似 Enum 模块,包含 mapfiltertake 之类的函数。这些函数接收任何 enumerable 作为输入,并返回一个流:一个拥有特殊能力的 enumerable。

流是惰性 enumerable,这意味着它按需产生实际结果。看看这意味着什么。

下面的片段使用流把列表中每个元素翻倍:

iex(4)> stream = Stream.map([1, 2, 3], fn x -> 2 * x end)
#Stream<[enum: [1, 2, 3],
funs: [#Function<44.45151713/1 in Stream.map/2>]]>

因为流是惰性 enumerable,对输入列表([1, 2, 3])的迭代和对应转换(乘以 2)尚未发生。相反,你得到的是描述该计算的结构。

要让迭代发生,需要把流传给某个 Enum 函数,例如 eachmapfilter。也可以使用 Enum.to_list/1 函数,它会把任何 enumerable 转换成列表:

iex(5)> Enum.to_list(stream)
[2, 4, 6]

Enum.to_list/1(以及任何其他 Enum 函数)都是急切操作。它会立即开始遍历输入并创建结果。这样做时,Enum.to_list/1 会请求输入 enumerable 开始产生值。这就是为什么把流发送给 Enum 函数时,流的输出才会被创建。

流的惰性不仅限于按需遍历列表。当 Enum.to_list 请求另一个元素时,值会一个接一个产生。例如,可以使用 Enum.take/2 只从流中请求一个元素:

iex(6)> Enum.take(stream, 1)
[2]

因为 Enum.take/2 只会迭代到收集到所需数量的元素为止,所以输入流只会把列表中的一个元素翻倍。其他元素从未被访问。

回到打印员工的例子,使用流可以一次完成员工打印。对原始代码的修改很简单。不要使用 Enum.with_index/1,而是依赖它的惰性等价物 Stream.with_index/1

iex(7)> employees
...> |> Stream.with_index()
...> |> Enum.each(fn {employee, index} ->
...> IO.puts("#{index + 1}. #{employee}")
...> end)
1. Alice
2. Bob
3. John

输出相同,但列表迭代只执行一次。当你需要组合同一个列表上的多个转换时,这会变得越来越有用。下面的例子接收输入列表,只打印表示非负数字的元素的平方根,并在开头添加索引前缀:

iex(1)> [9, -1, "foo", 25, 49]
...> |> Stream.filter(&(is_number(&1) and &1 > 0))
...> |> Stream.map(&{&1, :math.sqrt(&1)})
...> |> Stream.with_index()
...> |> Enum.each(fn {{input, result}, index} ->
...> IO.puts("#{index + 1}. sqrt(#{input}) = #{result}")
...> end)
1. sqrt(9) = 3.0
2. sqrt(25) = 5.0
3. sqrt(49) = 7.0

这段代码很密集,展示了仅依赖函数作为抽象工具时可以多么简洁。你从输入列表开始,只过滤正数。接着,把每个这样的数字转换成 {input_number, square_root} 元组。然后,用 Stream.with_index/1 给结果元组加索引。最后,打印结果。

即使叠加了多个转换,当调用 Enum.each 时,一切仍然在一次遍历中完成。相比之下,如果到处使用 Enum 函数,就需要对每个中间列表运行多次迭代,这会带来内存使用代价。

流的这种惰性属性对于消费缓慢且可能很大的 enumerable 输入非常有用。一个典型情况是需要解析文件的每一行。依赖急切 Enum 函数意味着必须把整个文件读入内存,然后遍历每一行。相反,使用流可以一次读取并立即解析一行。例如,下面的函数接收一个文件名,并返回该文件中所有超过 80 个字符的行的列表:

def large_lines!(path) do
File.stream!(path)
|> Stream.map(&String.trim_trailing(&1, "\n"))
|> Enum.filter(&(String.length(&1) > 80))
end

这里,你依赖 File.stream!/1 函数,它接收文件路径并返回文件行组成的流。因为结果是流,所以只有在请求它时才会遍历文件。File.stream! 返回后,还没有读取文件中的任何字节。然后,以惰性方式移除每一行尾部的换行符。最后,使用 Enum.filter/2 急切地只取长行。这时迭代才会发生。结果是,你永远不会把整个文件读入内存;相反,你会逐行处理。

注意 Elixir 编译器中没有允许这些惰性枚举的特殊技巧。真实实现相当复杂,但流背后的基本思路很简单,并依赖匿名函数。简而言之,要创建惰性计算,需要返回一个执行该计算的 lambda。这会让计算变得惰性,因为你返回的是计算描述而不是计算结果。当计算需要物化时,消费代码可以调用该 lambda。

无限流

到目前为止,你已经通过使用 Stream.mapStream.filter 等函数转换现有集合来产生流。Stream 模块中的一些函数允许你从零开始创建流。

其中一个函数是 Stream.iterate/2,可用于产生一个无限集合,其中每个元素都基于前一个元素计算。例如,下面的片段构建了一个自然数无限流:

iex(1)> natural_numbers = Stream.iterate(
...> 1,
...> fn previous -> previous + 1 end
...> )

可以把这个无限集合传给其他 EnumStream 函数,以产生一个有限序列。例如,要取前 10 个自然数,可以使用 Enum.take/2

iex(2)> Enum.take(natural_numbers, 10)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

另一个例子是函数 Stream.repeatedly/1,它会反复调用提供的 lambda 生成元素。在下面的例子中,我们用它反复从控制台读取用户输入,并在用户提交空输入时停止:

iex(3)> Stream.repeatedly(fn -> IO.gets("> ") end)
...> |> Stream.map(&String.trim_trailing(&1, "\n"))
...> |> Enum.take_while(&(&1 != ""))
> Hello
> World
>
["Hello", "World"]

Stream 模块还包含一些生成无限流的函数,例如 Stream.unfold/2Stream.resource/3。更多细节可以查看前面提到的官方文档。

实践练习

这种编码风格需要一些时间适应。本书后续会一直使用这里介绍的技巧,但你应该尝试自己编写几个这样的迭代。下面是一些练习想法,可以帮助你进入状态。

large_lines!/1 为模型,编写下面的函数:

  1. lines_lengths!/1,接收文件路径并返回一个数字列表,每个数字表示文件中对应行的长度。
  2. longest_line_length!/1,返回文件中最长行的长度。
  3. longest_line!/1,返回文件中最长行的内容。
  4. words_per_line!/1,返回一个数字列表,每个数字表示文件中一行的词数。提示:要找到一行中的词数,可以使用 length(String.split(line))

解答位于 enum_streams_practice.ex 文件中,但我强烈建议你花些时间先自己尝试解决这些问题。

总结

  • 模式匹配是一种尝试把右侧项匹配到左侧模式的机制。在此过程中,模式中的变量会绑定到项中的对应子项。如果项不匹配模式,就会抛出错误。
  • 函数参数是模式。调用函数的目标是把提供的值匹配到函数定义中指定的模式。
  • 函数可以有多个子句。第一个匹配所有参数的子句会被执行。
  • 对于条件分支,可以使用多子句函数,以及 ifunlesscondcasewith 等表达式。
  • 递归是实现循环的主要工具。当需要运行任意长循环时,会使用尾递归。
  • 高阶函数让编写循环容易得多。Enum 模块中有许多有用的通用迭代函数。此外,Stream 模块使惰性且可组合的迭代成为可能。
  • 推导式也可以用于迭代、转换、过滤和连接各种 enumerable。