第 3 章 控制流(Control flow)
现在你已经熟悉了 Elixir 的基础构建块,是时候看看这门语言的一些典型低层惯用法了。本章会处理条件和循环。你会看到,它们的工作方式不同于许多命令式语言。
经典条件结构,例如 if 和 case,经常会被多子句函数替代;而且 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}
这个表达式假定右侧项是一个包含两个元素的元组。表达式求值时,变量 name 和 age 会绑定到元组中对应的元素。现在可以验证这些变量已正确绑定:
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。匹配之后,元组的其余元素会绑定到变量 name 和 age,这可以轻松验证:
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")
在这一行代码中,会发生三件不同的事:
- 尝试打开并读取
my_app.config文件。 - 如果尝试成功,文件内容会被提取到变量
contents。 - 如果尝试失败,就会抛出错误。这是因为
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
变量 b1、b2 和 b3 保存所匹配字符串中的对应字节。这并不是特别有用,尤其是当你处理 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
拆开来看,这里发生了以下事情:
- 右侧表达式被求值。
- 得到的值与左侧模式匹配。
- 模式中的变量被绑定。
- 匹配表达式的结果就是右侧项的结果。
一个重要后果是,匹配表达式可以串联:
iex(3)> a = (b = 1 + 3)
4
在这个并不太有用的例子中,发生了下面的事情:
- 表达式
1 + 3被求值。 - 结果(
4)与模式b匹配。 - 内部匹配的结果(仍然是
4)与模式a匹配。
因此,a 和 b 的值都是 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
参数声明符 arg1 和 arg2 都是模式,因此可以使用标准匹配技巧。
看看实际例子。正如第 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
这里发生了什么?调用函数时,你提供的参数会与函数定义中指定的模式匹配。该函数期望一个二元素元组,并把元组元素绑定到变量 a 和 b。
调用函数时,被匹配的项是函数调用中提供的参数。你匹配的模式是参数声明符,在这里就是 {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
可在守卫中调用的操作符和函数集合非常有限。特别是,你不能调用自己的函数,大多数其他函数也不能工作。下面是守卫中允许使用的一些操作符和函数示例:
- 比较操作符(
==、!=、===、!==、>、<、<=和>=) - 布尔操作符(
and和or)以及否定操作符(not和!) - 算术操作符(
+、-、*和/) Kernel模块中的类型检查函数,例如is_number/1、is_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_3 和 pattern_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 提供了一些标准条件分支方式,例如 if 和 case 表达式。多子句函数也可以用于这个目的。本节会覆盖所有分支技巧,从多子句函数开始。
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"
多子句的强大在递归中会变得明显。得到的代码看起来是声明式的,并且没有多余的 if 和 return。下面是基于多子句的阶乘递归实现:
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 提供了 if、unless、cond 和 case 表达式。它们大致按你预期的方式工作,不过有一些转折。逐个看看。
if 和 unless
if 表达式具有熟悉的语法:
if condition do
...
else
...
end
它会根据条件的真值性执行其中一个分支。如果条件是除 false 或 nil 之外的任何东西,就进入主分支;否则调用 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 表达式的结果。如果没有子句匹配,就会抛出错误。
基于 case 的 max 函数版本如下:
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 中有不同方式可以实现条件逻辑。多子句让分支更具声明式感觉,但它要求定义函数并传入所有必要参数。if 和 case 这样的经典表达式看起来更命令式,但通常会比多子句方法更简单。选择合适方案取决于具体情况以及个人偏好。
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" => "...",
...
}
你的任务是把这个映射规范化为只包含 login、email 和 password 字段的映射。通常,如果字段集合定义良好并且预先已知,可以把键表示为原子。因此,对于给定输入,可以返回下面的结构:
%{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 中的循环与主流语言非常不同。它不提供 while 和 do...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
这段代码看起来非常声明式:
- 空列表中所有元素之和为 0。
- 非空列表中所有元素之和等于列表头部加列表尾部之和。
看看它如何运行:
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_fun。original_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_sum 的 current_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 的调用是尾调用,因为它是该函数做的最后一件事。同样规则也适用于 unless、cond、case 和 with 表达式。
但下面的代码不是尾调用:
def fun(...) do
1 + another_fun(...)
end
这是因为对 another_fun 的调用并不是 fun 函数做的最后一件事。another_fun 完成之后,还必须把它的结果加 1,才能计算出 fun 的最终结果。
练习
所有这些可能看起来很复杂,但并不太难。如果你来自命令式语言,它可能不是你习惯的方式,而且需要一些时间来适应递归思维和模式匹配设施的组合。你可能想花点时间自己实验递归。下面是几个可以练习编写的函数:
list_len/1函数,计算列表长度range/2函数,接收两个整数from和to,并返回给定范围内所有整数的列表positive/1函数,接收一个列表并返回另一个列表,其中只包含输入列表中的正数
先尝试用非尾递归形式编写这些函数,再把它们转换为尾递归版本。如果卡住了,解答位于 recursion_practice.ex 和 recursion_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 转换为任何东西。如果你来自支持一等函数的语言,可能已经以 inject 或 fold 的名字认识 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 块返回的所有结果。在这种基本形式下,for 与 Enum.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} 元组。这样做是因为映射“知道”如何解释它。第一个元素会作为键,第二个元素会作为对应值。
推导式的另一个有趣特性是可以指定过滤器。这让你能够跳过输入中的某些元素。下面的例子为数字 x 和 y 计算一个非对称乘法表,其中 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 模块,包含 map、filter 和 take 之类的函数。这些函数接收任何 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 函数,例如 each、map 或 filter。也可以使用 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.map 或 Stream.filter 等函数转换现有集合来产生流。Stream 模块中的一些函数允许你从零开始创建流。
其中一个函数是 Stream.iterate/2,可用于产生一个无限集合,其中每个元素都基于前一个元素计算。例如,下面的片段构建了一个自然数无限流:
iex(1)> natural_numbers = Stream.iterate(
...> 1,
...> fn previous -> previous + 1 end
...> )
可以把这个无限集合传给其他 Enum 和 Stream 函数,以产生一个有限序列。例如,要取前 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/2 或 Stream.resource/3。更多细节可以查看前面提到的官方文档。
实践练习
这种编码风格需要一些时间适应。本书后续会一直使用这里介绍的技巧,但你应该尝试自己编写几个这样的迭代。下面是一些练习想法,可以帮助你进入状态。
以 large_lines!/1 为模型,编写下面的函数:
lines_lengths!/1,接收文件路径并返回一个数字列表,每个数字表示文件中对应行的长度。longest_line_length!/1,返回文件中最长行的长度。longest_line!/1,返回文件中最长行的内容。words_per_line!/1,返回一个数字列表,每个数字表示文件中一行的词数。提示:要找到一行中的词数,可以使用length(String.split(line))。
解答位于 enum_streams_practice.ex 文件中,但我强烈建议你花些时间先自己尝试解决这些问题。
总结
- 模式匹配是一种尝试把右侧项匹配到左侧模式的机制。在此过程中,模式中的变量会绑定到项中的对应子项。如果项不匹配模式,就会抛出错误。
- 函数参数是模式。调用函数的目标是把提供的值匹配到函数定义中指定的模式。
- 函数可以有多个子句。第一个匹配所有参数的子句会被执行。
- 对于条件分支,可以使用多子句函数,以及
if、unless、cond、case和with等表达式。 - 递归是实现循环的主要工具。当需要运行任意长循环时,会使用尾递归。
- 高阶函数让编写循环容易得多。
Enum模块中有许多有用的通用迭代函数。此外,Stream模块使惰性且可组合的迭代成为可能。 - 推导式也可以用于迭代、转换、过滤和连接各种 enumerable。