Elixir in Action, Third Edition(4)

9次阅读
没有评论

https://www.manning.com/books/elixir-in-action-third-edition

第2章 构建模块

2.4 理解类型系统

Elixir 的核心是 Erlang 类型系统。因此,与 Erlang 库的集成通常很简单。类型系统本身也相当简单,但如果您来自传统的面向对象语言,您会发现它与您习惯的语言有很大的不同。本节介绍 Elixir 的基本类型,并讨论一些不可变性的影响。首先,我们来看数字。

2.4.1 数字

数字可以是整数或浮点数,它们的行为大多符合您的预期:

iex(1)> 3 # 整数
3
iex(2)> 0xFF 以十六进制表示的整数
255
iex(3)> 3.14 # 浮点数
3.14
iex(4)> 1.0e-2 # 浮点数,指数表示法
0.01

支持标准算术运算符:

iex(5)> 1 + 2 * 3
7

除法运算符 / 的行为可能与您预期的不同。它始终返回一个浮点值:

iex(6)> 4/2
2.0
iex(7)> 3/2
1.5

要执行整数除法或计算余数,可以使用自动导入的内核函数:

iex(8)> div(5,2)
2
iex(9)> rem(5,2)
1

要添加语法糖,您可以使用下划线字符作为视觉分隔符:

iex(10)> 1_000_000
1000000

整数的大小没有上限,您可以使用任意大的数字:

iex(11)> 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999

如果您担心内存大小,最好参考 Erlang 官方内存指南,网址为 https://www.erlang.org/doc/system/memory.html 。整数占用的空间取决于数字本身的大小,而浮点数则占用 32 位或 64 位,具体取决于虚拟机的构建架构。浮点数在内部以 IEEE 754-1985(二进制精度)格式表示。

2.4.2 原子

原子本质上是命名常量。它们类似于 Ruby 中的符号或 C/C++ 中的枚举。原子常量以冒号开头,后跟字母数字和/或下划线的组合:

:an_atom
:another_atom

可以在原子名称中使用空格,语法如下:

:"an atom with spaces"

原子由两部分组成:文本和值。原子文本是冒号后面的内容。运行时,此文本保存在原子表中。值是变量中存储的数据,它只是对原子表的引用。这正是原子最适合用作命名常量的原因。它们在内存和性能方面都很高效。

当你写

variable = :some_atom

时,变量并不包含完整的文本,而只是对原子表的引用。因此,内存消耗低,比较速度快,代码仍然可读性好。

别名

原子常量还有另一种语法。您可以省略开头的冒号,并且以大写字母开头:

AnAtom

这被称为别名,在编译时,它会被转换为“Elixir.AnAtom”。我们可以在 shell 中轻松验证这一点:

iex(1)> AnAtom==="Elixir.AnAtom"
false
iex(2)> AnAtom=="Elixir.AnAtom"
false
iex(3)> AnAtom==:"Elixir.AnAtom"
true
iex(4)> AnAtom===:"Elixir.AnAtom"
true

使用别名时,编译器会隐式地在其文本中添加 Elixir. 前缀并生成原子。但如果别名已经包含 Elixir. 前缀,则不会添加。因此,以下代码也有效:

iex(5)> A==Elixir.A
true

您可能还记得,您还可以使用别名给模块指定备用名称:

iex(3)> alias IO, as: MyIO
iex(4)> MyIO.puts("Hello!")
Hello!

别名一词同时用于这两种用途并非偶然。当你使用别名 IO 时,例如:MyIO,你指示编译器将 MyIO 转换为 IO。进一步解析,最终生成的二进制文件中输出的结果是 :Elixir.IO。因此,设置别名后,以下语句也成立:

iex(6)> alias IO,as: MyIO
IO
iex(7)> MyIO==Elixir.IO
true

这一切看起来可能很奇怪,但它背后有一个重要的目的。别名支持模块的正确解析。我们将在本章末尾讨论这一点,届时我们将重新审视模块,并了解它们在运行时是如何加载的。

原子即布尔值

Elixir 没有专门的布尔类型,这可能会让你感到惊讶。它使用原子 :true:false 来表示布尔值。 Elixir 提供了一种语法糖,允许你直接引用这些原子,而无需使用冒号作为起始字符:

iex(8)> :true==true
true
iex(9)> :false==false
true

在 Elixir 中,布尔值仍然用来表示值为 :true:false 的原子。标准的逻辑运算符可以与布尔原子一起使用:

iex(10)> true and false
false
iex(11)> false or true
true
iex(12)> not false
true
iex(13)>  not :an_atom_other_than_true_or_false
** (ArgumentError) argument error
    :erlang.not(:an_atom_other_than_true_or_false)
    iex:13: (file)

请始终记住,布尔值只是一个值为 truefalse 的原子。

空值nil和真值

另一个特殊的原子是 :nil,它的工作方式与其它语言中的 null 有些类似。你可以不用冒号引用 :nil

iex(1)> nil == :nil
true

原子 :nil 在 Elixir 对真值的额外支持中扮演着重要角色,其工作方式与主流语言(例如 C/C++ 和 Ruby)中的用法类似。原子 :nil:false 被视为假值,而其他所有值都被视为真值。

此特性可以与 Elixir 的短路运算符 ||&&! 一起使用。运算符 || 返回第一个非假表达式:

iex(1)> nil || false || 5 || true
5

因为 :nil:false 都是假表达式,所以返回数字 5。请注意,后续表达式将不会被求值。如果所有表达式的计算结果均为假值,则返回最后一个表达式的结果。

运算符 && 返回第二个表达式,但前提是第一个表达式为真值。否则,它将返回第一个表达式而不计算第二个表达式:

iex(1)> true && 5
5
iex(2)> false && 5
false
iex(3)> nil && 5
nil

短路运算符可用于实现简洁的操作链。例如,如果您需要从缓存、本地磁盘或远程数据库中获取值,您可以这样做:

read_cached() || read_from_disk() || read_from_database()

类似地,您可以使用运算符 && 来确保满足某些条件:

database_value = connection && read_data(connection)

在这两个示例中,短路运算符使得编写简洁的代码成为可能,而无需使用复杂的嵌套条件表达式。

2.4.3 元组

元组类似于无类型结构或记录,它们最常用于将固定数量的元素组合在一起。以下代码片段定义了一个元组,其中包含一个人的姓名和年龄:

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

要从元组中提取元素,可以使用 Kernel.elem/2 函数,该函数接受一个元组和元素的从零开始的索引。请记住,Kernel 模块是自动导入的,因此您可以调用 elem 而不是 Kernel.elem

iex(2)> age = elem(person, 1)
25

要修改元组中的元素,您可以使用 Kernel.put_elem/3 函数,该函数接受一个元组、一个从零开始的索引以及给定位置字段的新值:

iex(3)> put_elem(person, 1, 26)
{"Bob", 26}

put_elem 函数不会修改元组。它返回新版本,并保留旧版本。请记住,Elixir 中的数据是不可变的,因此您无法在内存中修改值。您可以验证之前对 put_elem 的调用是否更改了 person 变量:

iex(4)> person
{"Bob", 25}

那么,如何使用 put_elem 函数呢?您需要将其结果存储到另一个变量中:

iex(5)> older_person = put_elem(person, 1, 26)
{"Bob", 26}
ex(6)> older_person
{"Bob", 26}

请记住,变量可以重新绑定,因此您还可以执行以下操作:

iex(7)> person = put_elem(person, 1, 26)
{"Bob", 26}

通过这样做,您实际上已将 person 变量重新绑定到新的内存位置。旧位置没有被任何其他变量引用,因此它符合垃圾回收的条件。

注意:您可能想知道这种方法是否节省内存。大多数情况下,数据复制很少,两个变量会尽可能共享内存。这一点将在本节稍后讨论不可变性时进行解释。

元组最适合将少量固定数量的元素组合在一起。当需要动态大小的集合时,可以使用列表。

2.4.4 列表

在 Erlang 中,列表用于管理动态的、可变大小的数据集合。其语法乍一看很像其他语言中的数组:

iex(1)> prime_numbers = [2, 3, 5, 7]
[2, 3, 5, 7]

列表看起来像数组,但它们的工作方式类似于单链表。要对列表进行操作,必须遍历它。因此,大多数列表操作的时间复杂度为 O(n),包括 Kernel.length/1 函数,该函数会遍历整个列表以计算其长度

iex(2)> length(prime_numbers)
4

列表实用函数

列表可以执行许多操作,但本节仅提及其中几个最基本的操作。有关详细信息,请参阅List 模块的文档 (https://elixir.hexdocs.pm/List.html )。 Enum 模块(https://elixir.hexdocs.pm/Enum.html )中也提供了许多有用的服务。
Enum 模块处理多种不同的可枚举结构,并不局限于列表。我们将在第 4 章讨论协议时详细解释可枚举的概念。

要获取列表中的一个元素,可以使用 Enum.at/2 函数:

iex(3)> Enum.at(prime_numbers, 3)
7

Enum.at 的操作时间复杂度为 O(n):它从列表的开头遍历到所需的元素。当需要直接访问时,列表并非最佳选择。对于这些目的,元组、映射或更高级的数据结构更为合适。

您可以使用 in 运算符检查列表中是否包含特定元素:

iex(4)> 5 in prime_numbers
true
iex(5)> 4 in prime_numbers
false

要操作列表,您可以使用 List 模块中的函数。例如,List.replace_at/3 会修改指定位置的元素:

iex(6)> List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]

与元组的情况类似,该修饰符不会改变变量本身,而是返回修改后的版本,你需要将其存储到另一个变量中:

iex(7)> new_primes = List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]

或者,你可以将其重新绑定到同一个变量:

iex(8)> prime_numbers = List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]

你可以使用 List.insert_at/3 函数在指定位置插入新元素:

iex(9)> List.insert_at(prime_numbers, 3, 13)
[11, 3, 5, 13, 7]

要将元素添加到列表末尾,可以使用负值作为插入位置:

iex(10)> List.insert_at(prime_numbers, -1, 13)
[11, 3, 5, 7, 13]

与大多数列表操作一样,修改任意元素的复杂度为 O(n)。特别是,添加到列表末尾的开销很大,因为它总是需要 n 步,其中 n
是列表的长度。

此外,还可以使用专用运算符 ++。它连接两个列表:

iex(11)> [1, 2, 3] ++ [4, 5]
[1, 2, 3, 4, 5]

同样,时间复杂度为 O(n),其中 n 是左侧列表(即你要添加元素的列表)的长度。一般来说,你应该避免向列表末尾添加元素。列表在向顶部添加新元素或从顶部弹出元素时效率最高。为了理解原因,让我们来看看列表的递归特性。

递归列表定义

另一种看待列表的方式是将其视为递归结构。列表可以用一个二元组 (head, tail) 表示,其中 head 是列表的第一个元素,tail 指向剩余元素的 (head, tail) 二元组,如图 2.1 所示。

图 2.1 列表 [1, 2, 3, 4] 的递归结构

<p>Elixir in Action, Third Edition(4)</p>

如果您熟悉 Lisp,那么您应该知道这个概念,它被称为 cons 单元格。在 Elixir 中,有一种特殊的语法来支持递归列表定义:

a_list = [head | tail]

head 可以是任何类型的数据,而 tail 本身就是一个列表。如果 tail 为空列表,则表示整个列表的结束。

让我们来看一些例子:

iex(1)> [1 | []]
[1]

iex(2)> [1 | [2 | []]]
[1, 2]

iex(3)> [1 | [2]]
[1, 2]

iex(4)> [1 | [2, 3, 4]]
[1, 2, 3, 4]

这只是定义列表的另一种语法方式,但它说明了列表的本质。它是一个包含两个值的二元组:一个头和一个尾,尾本身也是一个列表。

以下代码片段是列表的规范递归定义:

iex(1)> [1 | [2 | [3 | [4 | []]]]]
[1, 2, 3, 4]

当然,没有人想写这样的表达式。但重要的是,你始终要意识到,在内部,列表是由 (头, 尾) 对组成的递归结构。

要获取列表的头,可以使用 hd 函数。可以通过调用 tl 函数来获取尾部:

iex(1)> hd([1, 2, 3, 4])
1
iex(2)> tl([1, 2, 3, 4])
[2, 3, 4]

这两个操作的时间复杂度都是 O(1),因为它们相当于从 (head, tail) 对中读取其中一个值。

注意:为了完整起见,需要说明的是,尾部不一定是列表。它可以是任何类型。当尾部不是列表时,我们称该列表为非正常列表,大多数标准列表操作将无法使用。非正常列表有一些特殊用途,但本书不会讨论它们。

一旦你了解了列表的递归特性,将新元素推入列表顶部就变得简单高效:

iex(1)> a_list = [5, :value, true]
[5, :value, true]
iex(2)> new_list = [:new_element | a_list]
[:new_element, 5, :value, true]

new_list 的构造是一个 O(1) 操作,并且不会发生内存复制——new_list 的尾部就是 a_​​list。为了理解其工作原理,让我们来讨论一下不可变性的内部细节。

2.4.5 不可变性

如前所述,Elixir 数据不可被修改。每个函数都会返回输入数据的修改后版本。您必须将新版本存入另一个变量或将其重新绑定到相同的符号名称。无论哪种方式,结果都驻留在另一个内存位置。修改输入会导致一些数据复制,但通常情况下,大部分内存会在新旧版本之间共享。让我们仔细看看它是如何工作的。

修改元组

我们从元组开始。修改后的元组始终是旧版本的完整浅拷贝。考虑以下代码(参见图 2.2):

a_tuple = {a, b, c}
new_tuple = put_elem(a_tuple, 1, b2)

图 2.2 修改元组会创建一个浅拷贝。

<p>Elixir in Action, Third Edition(4)</p>

变量 new_tuple 将包含 a_tuple 的浅拷贝,两者仅在第二个元素上有所不同。

两个元组都引用了变量 ac,并且这两个变量中的内容在两个元组之间共享(而不是重复)。new_tuple 是原始元组 a_tuple 的浅拷贝。

如果重新绑定一个变量会发生什么?在这种情况下,重新绑定后,变量 a_tuple 将引用另一个内存位置。a_tuple 的旧位置将无法访问,并且可以被垃圾回收。对于旧版本元组引用的变量 b 也是如此,如图 2.3 所示。

图 2.3 重新绑定元组使旧数据可回收

<p>Elixir in Action, Third Edition(4)</p>

请记住,元组总是会被复制,但这种复制是浅复制。然而,列表则具有不同的特性。

修改列表

当您修改列表的第 n 个元素时,新版本将包含前 n – 1 个元素的浅拷贝,以及修改后的元素。之后,列表的尾部完全共享,如图 2.4 所示。

图 2.4 修改列表

<p>Elixir in Action, Third Edition(4)</p>

这正是向列表末尾添加元素开销巨大的原因。要将新元素添加到列表尾部,必须遍历并(浅)复制整个列表。相比之下,将元素推入列表顶部无需复制任何内容,因此它是开销最小的操作,如图 2.5 所示。

图 2.5 将新元素推入列表顶部

<p>Elixir in Action, Third Edition(4)</p>

在这种情况下,新列表的尾部是之前的列表。这在 Elixir 程序中经常用到,用于迭代构建列表。在这种情况下,最好将连续的元素推到顶部,然后在列表构建完成后,一次性反转整个列表。

优点

不可变性可能看起来很奇怪,你可能会好奇它的用途。不可变性有两个重要的优点:无副作用函数和数据一致性。

由于数据不能被修改,你可以将大多数函数视为无副作用的转换。 它们接受一个输入并返回一个结果。更复杂的程序是通过组合更简单的转换来编写的:

def complex_transformation(data) do
data
|> transformation_1(...)
|> transformation_2(...)
...
|> transformation_n(...)
end

这段代码依赖于前面提到的管道运算符,它将两个函数链接在一起,并将前一个调用的结果作为下一个调用的第一个参数。

无副作用函数更容易分析、理解和测试。它们具有定义明确的输入和输出。调用函数时,您可以确保没有任何变量会被隐式改变。无论函数执行什么操作,您都必须获取其结果并对其进行处理。

注意:Elixir 不是纯函数式语言,因此函数仍然可能存在副作用。例如,函数可能会向文件写入数据并发出数据库或网络调用,从而产生副作用。但您可以确定函数不会修改任何变量的值。

不可变数据的隐含结果是能够在程序中保存数据结构的所有版本。这反过来又使得执行原子内存操作成为可能。假设你有一个函数,它执行一系列转换:

def complex_transformation(original_data) do
original_data
|> transformation_1(...)
|> transformation_2(...)
...
end

这段代码从原始数据开始,并对其进行一系列转换,每次转换都返回输入的新修改版本。如果出现错误, complex_transformation 函数可以返回 original_data ,这将有效地回滚函数中执行的所有转换。这是可行的,因为没有任何转换会修改 original_data 占用的内存。

至此,我们对基本不可变性理论的探讨就结束了。如何在更复杂的程序中正确使用不可变数据可能仍然不清楚。我们将在第四章再次探讨这个主题,届时我们将讨论更高层次的数据结构。

2.4.6 映射(Map)

映射是一种键值存储,其中键和值可以是任何内容。映射在 Elixir 中有双重用途。它们用于支持动态大小的键值结构,也用于管理简单的记录——几个定义明确的命名字段组合在一起。让我们分别来看这两种情况。

动态大小的映射

可以使用 %{} 表达式创建一个空映射:

iex(1)> empty_map = %{}

可以使用以下语法创建一个包含一些值的映射:

iex(2)> squares = %{1 => 1, 2 => 4, 3 => 9}

您还可以使用 Map.new/1 函数预先填充映射。该函数接受一个可枚举对象,其中每个元素都是一个大小为 2 的元组(一个二元组):

iex(3)> squares = Map.new([{1, 1}, {2, 4}, {3, 9}])
%{1 => 1, 2 => 4, 3 => 9}

要获取给定键对应的值,可以使用以下方法:

iex(4)> squares[2]
4
iex(5)> squares[4]
nil

在第二个表达式中,由于没有与给定键关联的值,因此返回 nil。

使用 Map.get/3 可以得到类似的结果。表面上看,这个函数的行为类似于 []。但是,Map.get/3 允许您指定默认值,该值在找不到键时返回。如果未提供此默认值,则返回 nil:

iex(6)> Map.get(squares, 2)
4
iex(7)> Map.get(squares, 4)
nil
iex(8)> Map.get(squares, 4, :not_found)
:not_found

请注意,在最后一个表达式中,您无法准确知道给定键下是没有值还是值为 :not_found。如果您想精确区分这些情况,可以使用 Map.fetch/2

iex(9)> Map.fetch(squares, 2)
{:ok, 4}
iex(10)> Map.fetch(squares, 4)
:error

如您所见,成功的情况下,您将获得一个形如 {:ok, value} 的值。这种格式可以精确地检测出键不存在的情况。

有时,您可能只想在键存在于映射中时才继续执行,否则抛出异常。这可以通过 Map.fetch!/2 函数实现:

iex(11)> Map.fetch!(squares, 2)
4
iex(12)> Map.fetch!(squares, 4)
** (KeyError) key 4 not found in: %{1 => 1, 2 => 4, 3 => 9}
(stdlib) :maps.get(4, %{1 => 1, 2 => 4, 3 => 9})

要向地图中添加新元素,可以使用 Map.put/3

iex(13)> squares = Map.put(squares, 4, 16)
%{1 => 1, 2 => 4, 3 => 9, 4 => 16}
iex(14)> squares[4]
16

Map 模块中还有许多其他有用的函数,例如 Map.update/4Map.delete/2。您可以访问 https://elixir.hexdocs.pm/Map.html 查看官方模块文档。此外,map 也是可枚举的,这意味着 Enum 模块中的所有函数都可以与 map 一起使用。

结构化数据

映射(Map)是管理任意大小的键值数据结构的首选类型。然而,在 Elixir 中,它们也经常用于将几个字段合并到一个单一的结构中。这种用法与元组(Tuple)有些重叠,但它提供了一个优势:允许您按名称访问字段。

让我们来看一个例子。在以下代码片段中,您将创建一个表示单个人的映射:

iex(1)> bob = %{:name => "Bob", :age => 25, :works_at => "Initech"}

如果键是原子,您可以这样写,使其更简洁:

iex(2)> bob = %{name: "Bob", age: 25, works_at: "Initech"}

要检索字段,您可以使用 [] 运算符:

iex(3)> bob[:works_at]
"Initech"
iex(4)> bob[:non_existent_field]
nil

原子键同样会受到特殊的语法处理。以下代码片段获取存储在 :age 键下的值:

iex(5)> bob.age
25

使用此语法,如果您尝试获取不存在的字段,将会收到错误:

iex(6)> bob.non_existent_field
** (KeyError) key :non_existent_field not found

要更改字段值,可以使用以下语法:

iex(7)> next_years_bob = %{bob | age: 26}
%{age: 26, name: "Bob", works_at: "Initech"}

此语法也可用于更改多个属性:

iex(8)> %{bob | age: 26, works_at: "Initrode"}
%{age: 26, name: "Bob", works_at: "Initrode"}

但是,您只能修改映射中已存在的值。这使得update 语法成为驱动表示结构的映射的完美选择。如果您输错了字段名,将会立即收到运行时错误:

iex(9)> %{bob | works_in: "Initech"}
** (KeyError) 未找到键 :works_in

在 Elixir 中,使用映射(Map)来存储结构化数据是一种常见的模式。常见的模式是在创建映射时提供所有字段,并使用原子(Atom)作为键。如果某个字段的值不可用,可以将其设置为 nil。这样,映射始终包含所有字段。可以使用 update 表达式修改映射,并使用 a_map.some_field 表达式获取所需的字段。

当然,这样的数据仍然是一个映射,因此您也可以使用 Map 模块中的函数,例如 Map.putMap.fetch。但是,这些函数通常适用于映射用于管理动态键值对结构的情况。

2.4.7 二进制数和位串

二进制数是一段字节。你可以通过将字节序列用 << 和 >> 运算符括起来来创建二进制数。以下代码片段创建了一个三字节二进制数:

iex(1)> <<1, 2, 3>>
<<1, 2, 3>>

每个数字代表对应字节的值。如果提供的字节值大于 255,则会被截断为字节大小:

iex(2)> <<256>>
<<0>>
iex(3)> <<257>>
<<1>>
iex(4)> <<512>>
<<0>>

您可以指定每个值的大小,从而告诉编译器该特定值使用多少位:

iex(5)> <<257::16>>
<<1, 1>>

此表达式将数字 257 放入 16 位连续的内存空间中。输出结果显示您使用了 2 个字节,且两个字节的值均为 1。这是因为 257 的二进制表示形式为 00000001 00000001(16 位)。

大小说明符以位为单位,无需是 8 的倍数。以下代码片段通过组合两个 4 位值来创建一个二进制数:

iex(6)> <<1::4, 15::4>>
<<31>>

结果值占用 1 个字节,并在输出中以规范化的形式 31 (0001 1111) 表示。

0001 1111 = 1×2^4 + 1×2^3 + 1×2^2 + 1×2^1 + 1×2^0=31

如果所有值的总长度不是 8 的倍数,则该二进制数据称为位串——一个比特序列:

iex(7)> <<1::1, 0::1, 1::1>>
<<5::size(3)>>

您还可以使用运算符 <> 连接两个二进制数据或位串:

iex(8)> <<1, 2>> <> <<3, 4>>
<<1, 2, 3, 4>>

二进制数据还有很多其他用途,但目前我们先暂且搁置。关于二进制数据,您需要了解的最重要一点是,它们是连续的字节序列。二进制数据在字符串支持中扮演着重要的角色。

2.4.8 字符串

或许你会感到惊讶,Elixir 没有专门的字符串类型。相反,字符串使用二进制类型或列表类型来表示。

二进制字符串

使用字符串最常见的方法是用熟悉的双引号来指定它们。语法:

iex(1)> "This is a string"
"This is a string"

结果以字符串的形式打印出来,但实际上它是一个二进制数据——只不过是一串连续的字节。

Elixir 支持嵌入式字符串表达式。你可以使用 #{} 将 Elixir 表达式放入字符串常量中。表达式会立即求值,并且其字符串表示形式会被放置在字符串中的相应位置:

iex(2)> "嵌入表达式:#{3 + 0.14}"
"嵌入表达式:3.14"

经典的 \ 转义方式与您习惯的方式相同:

iex(3)> "\r \n \" \\"

字符串不必在同一行结束:

iex(4)> "
这是一个
多行字符串
"

Elixir 提供了另一种声明字符串(或称符号)的语法。在这种方法中,您需要将字符串用 ~s() 包裹起来:

iex(5)> ~s(This is also a string)
"This is also a string"

如果您想在字符串中包含引号,~s 符号会很有用:

 ~s("Do... or do not. There is no try." -Master Yoda")
"\"Do... or do not. There is no try.\" -Master Yoda\""

还有一个大写版本 ~S,它不处理插值或转义字符():

iex(15)>  ~S(未插值 #{3 + 0.14})
"未插值 \#{3 + 0.14}"
iex(16)>  ~S(未转义 \n)
"未转义 \\n"

最后,还有一种特殊的 heredocs 语法,它支持更好的多行字符串格式化。Heredocs 字符串以三个双引号开头。结尾的三个双引号必须单独占一行:

iex(17)> """
         Heredoc 必须单独占一行 """
         """
"Heredoc 必须单独占一行 \"\"\"\n"

由于字符串是二进制数据,您可以使用 <> 运算符将它们连接起来:

iex(10)>  "String" <> " " <> "concatenation"
"String concatenation"

有很多辅助函数可用于处理二进制字符串。其中大多数位于 String 模块中 (https://elixir.hexdocs.pm/String.html )。

字符列表

字符列表(也称为 charlist)是一个列表,其中每个元素都是对应字符的整数代码点。例如,字母 ABC 可以表示为列表 [65, 66, 67]:

iex(1)> IO.puts([65, 66, 67])
ABC

您也可以使用 ~c 符号创建列表:

iex(2)> IO.puts(~c"ABC")
ABC

另一种方法是使用单引号:

iex(3)> IO.puts('ABC')
ABC

从 Elixir 1.15 开始,推荐使用 ~c。Elixir 格式化程序会将单引号转换为等效的符号。此外,字符列表在 shell 中以 ~c 语法打印:

iex(4)> ~c"ABC"
~c"ABC"
iex(5)> [65, 66, 67]
~c"ABC"
iex(6)> 'ABC'
~c"ABC"

字符列表与二进制字符串不兼容。String 模块中的大多数操作都无法处理字符列表。通常情况下,您应该优先使用二进制字符串而不是字符列表。

有时,某些函数只能处理字符列表。这种情况大多发生在纯 Erlang 库中。在这种情况下,您可以使用 String.to_charlist/1 函数将二进制字符串转换为字符列表:

iex(7)> String.to_charlist("ABC")
~c"ABC"

要将字符列表转换为二进制字符串,您可以使用 List.to_string/1

iex(8)> List.to_string(~c"ABC")
"ABC"

一般来说,您应该尽可能使用二进制字符串,仅在某些第三方库(通常用纯 Erlang 编写)需要时才使用字符列表。

2.4.9 一等函数

在 Elixir 中,函数是一等公民,这意味着它可以被赋值给一个变量。这里,将函数赋值给变量并非指调用该函数并将其结果存储到变量中。而是赋值给函数定义本身,然后你可以使用该变量来调用函数。

让我们来看一些例子。要创建一个函数变量,可以使用 fn 表达式:

iex(1)> square = fn x ->
x * x
end

变量 square 现在包含一个计算数字平方的函数。由于该函数没有绑定到全局名称,因此它也被称为匿名函数或 lambda 表达式。

请注意,参数列表没有用括号括起来。从技术上讲,你可以在这里使用括号,但普遍的约定(Elixir 格式化程序也强制执行)是省略括号。相反,命名函数的参数列表应该用括号括起来。乍一看,这似乎不一致,但这种约定是有充分理由的,我们将在第 3 章中解释。

您可以通过指定变量名,后跟一个点 (.) 和参数来调用此函数:

iex(2)> square.(5)
25

注意:您可能想知道为什么这里需要点运算符。在这种情况下,它的目的是使代码更明确。当您在源代码中遇到square.(5) 表达式时,您就知道正在调用一个匿名函数。相反,表达式 square(5) 正在调用模块中其他地方定义的命名函数。如果没有点运算符,您需要解析周围的代码才能了解您调用的是命名函数还是匿名函数。

由于函数可以存储在变量中,因此它们可以作为参数传递给其他函数。 这通常用于允许客户端参数化通用逻辑。例如,函数 Enum.each/2 实现了泛型迭代——它可以遍历任何可枚举对象,例如列表。函数 Enum.each/2 接受两个参数:一个可枚举对象和一个单参数 lambda 表达式(一个接受一个参数的匿名函数)。它遍历可枚举对象,并对每个元素调用 lambda 表达式。客户端提供 lambda 表达式来指定他们想要对每个元素执行的操作。

以下代码片段使用 Enum.each 将列表中的每个值打印到屏幕上:

iex(20)>  print_element = fn x -> IO.puts(x) end  # 定义lambda
#Function<42.130099583/1 in :erl_eval.expr/6>
iex(21)> Enum.each(
         [1, 2, 3],
         print_element # 将 lambda传递给 Enum.each
         )
1 # lambda打印输出
2
3
:ok # Enum.each 的返回值

当然,不需要临时变量来将 lambda 表达式传递给 Enum.each

iex(5)> Enum.each(
[1, 2, 3],
fn x -> IO.puts(x) end # 直接传递lambda
)
1
2
3
:ok

注意 lambda 表达式只是简单地将所有参数传递给 IO.puts 的,并没有执行其他任何有意义的操作。对于这种情况,Elixir 允许直接引用函数,从而实现更简洁的 lambda 表达式定义。与其写成 fn x ->IO.puts(x) end,不如写成 &IO.puts/1

& 运算符,也称为捕获运算符,接受完整的函数限定符——模块名、函数名和参数个数——并将该函数转换为可以赋值给变量的 lambda 表达式。

你可以使用捕获运算符来简化对 Enum.each 的调用:

iex(6)> Enum.each(
[1, 2, 3],
&IO.puts/1 # 传递委托给 IO.puts 的 lambda 函数
)
1
2
3
:ok

捕获运算符还可以用于缩短 lambda 表达式定义,从而可以省略显式的参数命名。例如,您可以将以下定义

iex(7)> lambda = fn x, y, z -> x * y + z end

更改为更简洁的形式:

iex(8)> lambda = &(&1 * &2 + &3)

这段代码创建了一个三元 lambda 表达式。每个参数都通过 &n 占位符引用,该占位符标识函数的第 n 个参数。您可以像调用其他 lambda 表达式一样调用它:

iex(9)> lambda.(2, 3, 4)
10

返回值 10 等于 2 × 3 + 4,正如 lambda 表达式定义中所述。

闭包

lambda 表达式可以引用外部作用域中的任何变量:

iex(1)> outside_var = 5
5

iex(2)> my_lambda = fn ->
IO.puts(outside_var) # lambda 表达式引用了外部作用域的变量。
end

iex(3)> my_lambda.()
5

只要你持有对 my_lambda 的引用,就可以访问变量 outside_var。这也被称为闭包;通过持有对 lambda 表达式的引用,你间接地持有了它使用的所有变量的引用,即使这些变量来自外部作用域。

闭包总是捕获一个特定的内存位置。重新绑定变量不会影响之前定义的引用相同符号名称的 lambda 表达式:

iex(1)> outside_var = 5

iex(2)> lambda = fn -> IO.puts(outside_var) end # lambda 表达式捕获了 outside_var 的当前位置

iex(3)> outside_var = 6 # 重新绑定不会影响闭包

iex(4)> lambda.()  # 证明闭包不受影响
5

上面的代码说明了另一个重要点。通常,在将 outside_var 重新绑定到值 6 之后,原始内存位置将符合垃圾回收的条件。但是,由于 lambda 函数捕获了原始位置(即存储数字 5 的位置),并且您仍然引用该 lambda 表达式,因此原始位置无法被垃圾回收。

2.4.10 其他内置类型

还有几种类型我尚未介绍。我们不会深入探讨它们,但为了完整性,值得一提:

■ 引用是 BEAM 实例中几乎唯一的信息。它通过调用 Kernel.make_ref/0(或 make_ref)生成。根据 Elixir 文档,一个引用大约会在 2^82 次调用后重新出现。但是,如果重启 BEAM 实例,引用会从头开始生成,因此其唯一性仅在 BEAM 实例的生命周期内得到保证。

■ 进程标识符 (PID) 用于标识 Erlang 进程。PID 在并发任务之间协作时非常重要,您将在第五章讨论 Erlang 进程时了解它们。

■ 端口标识符在使用端口时非常重要。它是 Erlang 中用于与外部世界通信的一种机制。文件 I/O 和与外部程序的通信都是通过端口完成的。端口不在本书的讨论范围之内。

至此,我们已经介绍了所有基本数据类型。正如你所见,Elixir 的类型系统很简单,只包含少数几种数据类型。

当然,Elixir 也提供了更高级的类型,这些类型建立在这些基本类型之上,提供了额外的功能。让我们来看看 Elixir 自带的一些最重要的类型。

2.4.11 高级类型

上述内置类型继承自 Erlang 世界。毕竟,Elixir 代码在 BEAM 上运行,因此其类型系统深受 Erlang 基础的影响。但除了这些基本类型之外,Elixir 还提供了一些更高级别的抽象。最常用的是 Range、Keyword、MapSet、Date、Time、NaiveDateTime、
和DateTime。让我们逐一检查一下。

Range范围

范围是一种抽象,允许您表示一系列数字。Elixir甚至提供特殊语法来定义范围:

iex(24)> r = 1..2
1..2

您可以使用 in 运算符询问数字是否在范围内:

iex(25)> 2 in r
true
iex(26)> -1 in r
false

范围是可枚举的,因此 Enum 模块中的函数知道如何使用他们。之前,您遇到过 Enum.each/2,它循环访问一个可枚举值。以下示例使用此函数和范围来打印前三个自然数数字:

iex(27)> Enum.each(
         1..4,
         &IO.puts/1
         )
1
2
3
4
:ok

Range 不是一种特殊类型。在内部,它表示为包含范围的 map 边界。因此,Range 内存占用很小且恒定,无论它代表的元素数量。百万数字的 Range 仍然只是一张小 map。有关 Range 的更多信息,请参阅 Range 模块的文档 (https://elixir.hexdocs.pm/Range.html )。

关键词列表

关键字列表是列表的一种特殊情况,其中每个元素都是一个二元素元组,并且每个元组的第一个元素是一个原子。第二个元素可以是任何类型。让我们看一个例子:

iex(1)> days = [{:monday, 1}, {:tuesday, 2}, {:wednesday, 3}]

Elixir 支持一种更简洁的关键字列表定义语法:

iex(2)> days = [monday: 1, tuesday: 2, wednesday: 3]

两种表达式都产生相同的结果:一个键值对列表。可以说,第二种写法更优雅一些。

关键字列表通常用于小型键值结构,其中键是原子。 Keyword 模块(https://elixir.hexdocs.pm/Keyword.html )中提供了许多有用的函数。例如,您可以使用 Keyword.get/2 来获取某个键的值:

iex(3)> Keyword.get(days, :monday)
1

iex(4)> Keyword.get(days, :noday)
nil

就像映射一样,您可以使用运算符 [] 来获取值:

iex(5)> days[:tuesday]
2

不过,别被这些误导了。因为处理的是列表,所以查找操作的复杂度为 O(n)。

关键字列表最常用于允许客户端传递任意数量的可选参数。例如,函数 IO.inspect 的结果(该函数会将术语的字符串表示形式打印到控制台)可以通过以下方式控制:

iex(6)> IO.inspect([100, 200, 300])  # 默认行为
[100, 200, 300]

iex(7)> IO.inspect([100, 200, 300], [width: 3]) # 传递额外选项
[100,
200,
300]

事实上,这种模式非常常见,以至于如果最后一个参数是关键字列表 Elixir 允许您省略方括号:

iex(28)> IO.inspect([100, 200, 300], width: 3, limit: 1)
[100,
 ...]
[100, 200, 300]

请注意,在这个例子中,您仍然向函数传递了两个参数。 IO.inspect/2:一个数字列表和一个包含两个元素的关键字列表。但这段代码演示了如何模拟可选参数。您可以将关键字列表作为函数的最后一个参数,并将该参数的默认值设置为空列表:

def my_fun(arg1, arg2, opts \\ []) do

...
end

您的客户端可以通过最后一个参数传递选项。当然,您需要检查 opts 参数的内容,并根据调用者发送的内容执行一些条件逻辑。

您可能想知道,对于可选参数,使用映射(map)而不是关键字是否更好。关键字列表可以包含同一键的多个值。此外,您可以控制关键字列表元素的顺序——这是映射(map)无法实现的。 最后,Elixir 和 Erlang 标准库中的许多函数都将它们的选项作为关键字列表。最好遵循现有约定,并通过关键字列表接受可选参数。

MapSet

MapSet 是集合(Set)的一种实现——集合是一个存储唯一值的存储,其中值可以是任何类型。让我们来看一些例子:

iex(1)> days = MapSet.new([:monday, :tuesday, :wednesday])   # 创建一个MapSet实例
MapSet.new([:monday, :tuesday, :wednesday])

iex(2)> MapSet.member?(days, :monday)  # 确认元素是否存在
true

iex(3)> MapSet.member?(days, :noday)
false

iex(4)> days = MapSet.put(days, :thursday) # 增加一个新元素
MapSet.new([:monday, :tuesday, :wednesday, :thursday])

如您所见,您可以使用 MapSet 模块中的函数来操作集合。详细参考请参阅官方文档:https://elixir.hexdocs.pm/MapSet.html

MapSet 也是一个可枚举对象,因此您可以将其传递给 Enum 模块中的函数。

例如:

iex(5)> Enum.each(days, &IO.puts/1)
monday
thursday
tuesday
wednesday

从输出结果可以看出,MapSet 不会保留元素的顺序。

时间和日期

Elixir 提供了一些用于处理日期和时间类型的模块:Date、Time、DateTime 和 NaiveDateTime。

可以使用 ~D 符号创建日期。以下示例创建一个表示 2023 年 1 月 31 日的日期:

iex(1)> date = ~D[2023-01-31]
~D[2023-01-31]

创建日期后,您可以检索其各个字段:

iex(2)> date.year
2023

iex(3)> date.month
1

类似地,您可以使用 ~T 符号表示时间,并提供小时、分钟、秒和微秒:

iex(1)> time = ~T[11:59:12.00007]

iex(2)> time.hour
11

iex(3)> time.minute
59

Date 模块(https://elixir.hexdocs.pm/Date.html )和 Time 模块(https://elixir.hexdocs.pm/Time.html )中也提供了一些有用的函数。

除了这两种类型之外,您还可以使用 NaiveDateTime 和 DateTime 模块来处理日期时间。可以使用 ~N 符号创建简单版本的日期时间:

iex(1)> naive_datetime = ~N[2023-01-31 11:59:12.000007]

iex(2)> naive_datetime.year
2023

iex(3)> naive_datetime.hour
11

DateTime 模块可用于处理日期时间,并支持时区。可以使用 ~U 符号创建 UTC 日期时间实例:

iex(1)> datetime = ~U[2023-01-31 11:59:12.000007Z]

iex(2)> datetime.year
2023

iex(3)> datetime.hour
11

iex(4)> datetime.time_zone
"Etc/UTC"

您可以参考以下文档:https://elixir.hexdocs.pm/NaiveDateTime.htmlhttps://elixir.hexdocs.pm/DateTime.html ,了解更多关于使用这些类型的详细信息。

2.4.12 IO 列表

IO 列表是一种特殊的列表,它可用于逐步构建输出,并将这些输出转发到 I/O 设备,例如网络或文件。IO 列表的每个元素必须是以下类型之一:

  • 0 到 255 范围内的整数
  • 二进制数
  • IO 列表

换句话说,IO 列表是一个深度嵌套的结构,其中叶子元素是普通的字节(或二进制数,二进制数也是字节序列)。例如,以下是用复杂的 IO 列表表示的“Hello,world!”:

iex(1)> iolist = [[[~c"He"], "llo,"], "worl", "d!"]

请注意如何将字符列表和二进制字符串组合成一个深度嵌套的列表。

许多 I/O 函数可以直接高效地处理此类数据。例如,您可以将以下结构打印到屏幕上:

iex(2)> IO.puts(iolist)
Hello, world!

实际上,该结构已被扁平化,您可以看到易于阅读的输出。如果您将 IO 列表发送到文件或网络套接字,也会得到相同的效果。

当您需要逐步构建字节流时,IO 列表非常有用。列表通常在这种情况下效率不高,因为向列表追加元素是 O(n) 操作。相比之下,向 IO 列表追加元素是 O(1) 操作,因为您可以使用嵌套。以下是此技术的演示:

iex(3)> iolist = [] # 初始化一个 IO list
iolist = [iolist, "This"] # 向 IO 列表多次追加元素
iolist = [iolist, "is"]
iolist = [iolist, "an"]
iolist = [iolist, "IO list."]
[[[[[], "This"], "is"], "an"], "IO list."] # 最后的 IO list

这里,您可以通过创建一个包含两个元素的新列表来向 IO 列表追加元素:一个是 IO 列表的先前版本,另一个是要追加的后缀。每次这样的操作都是 O(1),因此性能很高。当然,您可以将此数据传递给 IO 函数:

iex(4)> IO.puts(iolist)
This is an IO list.

至此,我们对类型系统的初步了解就结束了。我们已经涵盖了大部分基础知识,接下来,我们会根据需要,在本书后续章节中对这些知识进行扩展。接下来,是时候学习一些关于 Elixir 操作符的知识了。

2.5 运算符

在本章中,您已经使用了各种运算符,在本节中,我们将系统地了解 Elixir 中最常用的运算符。大多数运算符都定义在 Kernel 模块中,您可以参考该模块的文档以获取详细说明。

我们先来看算术运算符。这些运算符包括标准的 +、-、* 和 /。它们的工作方式基本符合您的预期,但除法运算符除外,它总是返回一个浮点数,正如我们在本章前面处理数字时解释的那样。

比较运算符与您之前使用的运算符大同小异。它们列在表 2.1 中。

表 2.1 比较运算符

运算符 类型 说明 示例
=== 严格相等 值与类型都必须完全相同(特别用于区分整数和浮点数) 1 === 1.0 :right_arrow: false
!== 严格不等 值或类型任一不同即为真 1 !== 1.0 :right_arrow: true
== 弱相等 只比较值,不检查数字的类型 1 == 1.0 :right_arrow: true
!= 弱不等 只在值不相同时为真 1 != 1.0 :right_arrow: false
< 小于 比较左侧是否小于右侧 1 < 2 :right_arrow: true
> 大于 比较左侧是否大于右侧 2 > 1 :right_arrow: true
<= 小于等于 比较左侧是否小于或等于右侧 1 <= 1 :right_arrow: true
>= 大于等于 比较左侧是否大于或等于右侧 2 >= 2 :right_arrow: true

这里我们只需要讨论严格相等和弱相等之间的区别。这仅在比较整数和浮点数时相关:

iex(1)> 1 == 1.0
true

iex(2)> 1 === 1.0
false

逻辑运算符作用于布尔原子。您在前面关于原子的讨论中已经见过它们,但我再重复一遍:与 (and)、或 (or) 和非 (not)。

与逻辑运算符不同,短路运算符使用真值的概念:原子 false 和 nil 被视为假值,其余所有值都被视为真值。&& 运算符在第一个表达式为假值时返回它;否则,它返回第二个表达式。|| 运算符在第一个表达式为真值时返回它;否则,它返回第二个表达式。一元运算符 ! 如果值为真值则返回 false;否则返回 true。

这里介绍的运算符并非全部可用运算符(例如,您可能还见过管道运算符 |>)。但这些是最常见的运算符,因此值得一提。您可以在 https://elixir.hexdocs.pm/operators.html 找到有关运算符的详细信息。

许多运算符都是函数

Elixir 中的许多运算符实际上是函数。例如,您可以调用 Kernel.+(a,b) 而不是 a+b。当然,没有人会想编写这样的代码,但将运算符函数转换为匿名函数会很有帮助。例如,您可以通过调用 &Kernel.+/2 或更简洁的 &+/2 来创建一个计算两个数字之和的二元 lambda 表达式。这样的 lambda 表达式可以与各种枚举函数和流函数一起使用,如第 3 章所述。

我们对 Elixir 语言的初步探索即将完成。还剩最后一件事:Elixir 宏。

2.6 宏

宏是 Elixir 最重要的特性之一,它在纯 Erlang 中是无法实现的。 宏允许在编译时执行强大的代码转换,从而减少样板代码并提供优雅的、小型 DSL 表达式。

宏是一个相当复杂的主题,需要一本书才能详尽阐述。由于本书更侧重于运行时和 BEAM,并且宏是一个较为高级的特性,应该谨慎使用,因此我不会提供详细的讲解。但你应该对宏的工作原理有一个大致的了解,因为许多 Elixir 特性都依赖于宏。

宏由 Elixir 代码组成,可以改变输入代码的语义。宏总是在编译时调用;它接收输入 Elixir 代码的解析表示,并且它有机会返回该代码的替代版本。

让我们通过一个例子来阐明这一点。 unless(相当于 if not)是 Elixir 提供的一个简单的宏:

unless some_expression do
    block_1
else
    block_2
end

unless 不是一个特殊的关键字。它是一个宏(也就是一个 Elixir 函数),它将输入代码转换为类似这样的形式:

if some_expression do
    block_2
else
    block_1
end

这种转换在 C 风格的宏中是不可能的,因为表达式的代码可能非常复杂,嵌套也很深。但在 Elixir 宏(深受 Lisp 启发)中,你已经在解析后的源代码表示上进行操作,因此你可以分别在不同的变量中访问表达式和两个代码块。

最终结果是,Elixir 的许多部分都是借助宏编写的。这包括 unlessif 表达式,以及 defmoduledef。其他语言通常使用关键字来实现这些特性,但在 Elixir 中,它们是构建在一个更小的语言核心之上的。

需要记住的关键点是,宏是编译时代码转换器。每当我提到某个东西是宏时,其潜在含义是它会在编译时运行并生成替代代码。

特殊形式

Elixir 编译器以特殊的方式处理某些语言结构。这些结构被称为特殊形式 (https://elixir.hexdocs.pm/Kernel.SpecialForms.html )。一些例子包括捕获语法 &(…)、for 推导式(第 3 章介绍)、receive 表达式(第 5 章)和 try 代码块(第 8 章)。

更多详情,您可以参考官方元编程指南 (https://mng.bz/BAd1 , 备份: https://elixir-lang.jp/getting-started/meta/quote-and-unquote.html )。至此,我们对 Elixir 语言的初步探索就结束了。但在结束本章之前,我们应该讨论一些底层运行时的重要方面。

2.7 理解运行时

如前所述,Elixir 运行时是一个 BEAM 实例。编译完成后,系统启动,Erlang 便会接管控制权。熟悉虚拟机的一些细节至关重要,这样才能理解系统的工作原理。首先,我们来看看运行时中模块的重要性。

2.7.1 运行时中的模块和函数

无论您如何启动运行时,都会启动一个 BEAM 实例的操作系统进程,所有代码都在该进程内运行。即使您使用的是 iex shell,情况也是如此。如果您需要查找此操作系统进程,可以使用名称 beam 进行搜索。

系统启动后,您通常会通过调用模块中的函数来运行一些代码。运行时如何访问代码?虚拟机跟踪所有已加载到内存中的模块。当您调用模块中的函数时,BEAM 首先会检查该模块是否已加载。如果存在,则执行相应函数的代码。否则,虚拟机尝试在磁盘上查找已编译的模块文件(字节码),然后加载并执行该函数。

注意:前面的描述表明,每个已编译的模块都位于一个单独的文件中。已编译的模块文件扩展名为 .beam(代表 Bogdan/Björn 的 Erlang 抽象机)。文件名与模块名称相对应。

正文完
 0
评论(没有评论)