在本章中,我们将学习 Elixir 中最常用的两种集合数据类型:列表和元组。
(链接)列表
Elixir 使用方括号来指定值列表。值可以是任何类型:
iex>[1, 2, true, 3]
[1, 2, true, 3]
iex>length([1, 2, 3])
3
可以分别使用 ++/2 和 --/2 运算符连接或减去两个列表:
iex>[1, 2, 3] ++ [4, 5, 6]
[1, 2, 3, 4, 5, 6]
iex>[1, true, 2, false, 3, true] -- [true, false]
[1, 2, 3, true]
列表运算符永远不会修改现有列表。连接或从列表中删除元素会返回一个新列表。我们说 Elixir 数据结构是不可变的。不变性的一个优点是它可以使代码更清晰。您可以自由地传递数据,并保证没有人会在内存中改变它 - 只会对其进行转换。
在整个教程中,我们将大量讨论列表的头部和尾部。头部是列表的第一个元素,尾部是列表的其余部分。可以使用函数 hd/1 和 tl/1 检索它们。让我们将列表分配给变量并检索其头部和尾部:
iex>list = [1, 2, 3]
iex>hd(list)
1
iex>tl(list)
[2, 3]
获取空列表的头部或尾部会引发错误:
iex>hd([])
** (ArgumentError) 参数错误
有时您会创建一个列表,它会返回一个以 ~c 开头的带引号的值。例如:
iex>[11, 12, 13]
~c"\v\f\r"
iex>[104, 101, 108, 108, 111]
~c"hello"
当 Elixir 看到可打印的 ASCII 数字列表时,Elixir 会将其打印为字符列表(实际上是字符列表)。字符列表在与现有 Erlang 代码交互时非常常见。每当您在 IEx 中看到一个值并且不太确定它是什么时,您可以使用 i/1 来检索有关它的信息:
iex>i ~c"hello"
Term
i ~c"hello"
Data type
List
Description
...
Raw representation
[104, 101, 108, 108, 111]
Reference modules
List
Implemented protocols
...
我们将在“二进制文件、字符串和字符列表”一章中更多地讨论字符列表。
单引号字符串
在 Elixir 中,您还可以使用“hello”来构建字符列表,但此表示法在 Elixir v1.15 中已被软弃用,并将在未来版本中发出警告。最好改写为 ~c"hello"。
元组
Elixir 使用花括号来定义元组。与列表一样,元组可以保存任何值:
iex>{:ok, "hello"}
{:ok, "hello"}
iex>tuple_size({:ok, "hello"})
2
元组将元素连续存储在内存中。这意味着通过索引访问元组元素或获取元组大小是一项快速操作。索引从零开始:
iex>tuple = {:ok, "hello"}
{:ok, "hello"}
iex>put_elem(tuple, 1, "world")
{:ok, "world"}
iex>tuple
{:ok, "hello"}
也可以使用 put_elem/3 将元素放在元组中的特定索引处:
tuple = {:ok, "hello"}
{:ok, "hello"}
put_elem(tuple, 1, "world")
{:ok, "world"}
tuple
{:ok, "hello"}
请注意,put_elem/3 返回了一个新元组。存储在 tuple 变量中的原始元组未被修改。与列表一样,元组也是不可变的。对元组的每个操作都会返回一个新元组,它永远不会更改给定的元组。
列表还是元组?
列表和元组有什么区别?
列表以链接列表的形式存储在内存中,这意味着列表中的每个元素都保存其值并指向下一个元素,直到到达列表末尾。这意味着访问列表的长度是一个线性操作:我们需要遍历整个列表才能确定其大小。
类似地,列表连接的性能取决于左侧列表的长度:
iex>list = [1, 2, 3]
[1, 2, 3]
# 这很快,因为我们只需要遍历 `[0]` 即可将其添加到 `列表` 的前面
iex>[0] ++ list
[0, 1, 2, 3]
# 这很慢,因为我们需要遍历 `列表` 以附加 4
iex>list++ [4]
[1, 2, 3, 4]
另一方面,元组在内存中连续存储。这意味着获取元组大小或通过索引访问元素的速度很快。另一方面,更新或向元组添加元素的成本很高,因为它需要在内存中创建新的元组:
iex>tuple = {:a, :b, :c, :d}
{:a, :b, :c, :d}
iex>put_elem(tuple, 2, :e)
{:a, :b, :e, :d}
但请注意,元素本身不会被复制。更新元组时,除已替换的条目外,所有条目都在旧元组和新元组之间共享。此规则适用于 Elixir 中的大多数数据结构。这减少了语言需要执行的内存分配量,并且只有由于语言的不可变语义才有可能。
这些性能特征决定了这些数据结构的使用。简而言之,当返回的元素数量可能有所不同时,使用列表。元组具有固定大小。让我们看两个来自 String 模块的例子:
iex>String.split("hello world")
["hello", "world"]
iex>String.split("hello beautiful world")
["hello", "beautiful", "world"]
String.split/2 函数将字符串拆分为每个空格字符上的字符串列表。由于返回的元素数量取决于输入,因此我们使用列表。
另一方面,String.split_at/2 在给定位置将字符串拆分为两部分。由于它总是返回两个条目,而不管输入大小如何,因此它返回元组:
iex>String.split_at("hello world", 3)
{"hel", "lo world"}
iex>String.split_at("hello world", -4)
{"hello w", "orld"}
使用元组和原子创建“标记元组”也很常见,当操作可能成功或失败时,这是一个方便的返回值。例如,File.read/1 读取给定路径下文件的内容,该路径可能存在也可能不存在。它返回带标签的元组:
iex>File.read("path/to/existing/file")
{:ok, "... content ..."}
iex>File.read("path/to/unknown/file")
{:error, :enoent}
如果 File.read/1 给出的路径存在,它将返回一个元组,其中原子 :ok 作为第一个元素,文件内容作为第二个元素。否则,它将返回一个元组,其中包含 :error 和错误描述。我们很快就会了解到,Elixir 允许我们在带标签的元组上进行模式匹配,并轻松处理成功和失败的情况。
鉴于 Elixir 始终遵循这些规则,随着您学习和使用该语言,列表和元组之间的选择会变得更加清晰。Elixir 通常会指导您做正确的事情。例如,有一个 elem/2 函数用于访问元组项:
iex>tuple = {:ok, "hello"}
{:ok, "hello"}
iex>elem(tuple, 1)
"hello"
但是,由于您通常不知道列表中元素的数量,因此除了列表的头部之外,没有内置等效函数用于访问列表中的任意条目。
大小还是长度?
在计算数据结构中的元素时,Elixir 还遵循一个简单的规则:如果操作是恒定时间(值是预先计算的),则函数名为 size;如果操作是线性的(计算长度的速度随着输入的增长而变慢),则函数名为 length。为了便于记忆,“length”和“linear”都以“l”开头。
例如,到目前为止,我们已经使用了 4 个计数函数:byte_size/1(用于字符串中的字节数)、tuple_size/1(用于元组大小)、length/1(用于列表长度)和 String.length/1(用于字符串中的字素数)。我们使用 byte_size 来获取字符串中的字节数,这是一种廉价的操作。另一方面,检索 Unicode 字素的数量使用 String.length/1,并且可能很昂贵,因为它依赖于对整个字符串的遍历。
现在我们已经熟悉了语言中的基本数据类型,在讨论更复杂的数据结构之前,让我们先学习编写代码的重要结构。