查看源代码 列表和元组

本章将学习 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/1tl/1 来检索。让我们将一个列表分配给一个变量并检索它的头和尾

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

获取空列表的头或尾会引发错误

iex> hd([])
** (ArgumentError) argument error

有时您会创建一个列表,它会返回一个以 ~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> elem(tuple, 1)
"hello"
iex> tuple_size(tuple)
2

还可以使用 put_elem/3 在元组中的特定索引处放置一个元素

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> put_elem(tuple, 1, "world")
{:ok, "world"}
iex> tuple
{:ok, "hello"}

请注意,put_elem/3 返回了一个新的元组。存储在 tuple 变量中的原始元组没有被修改。与列表一样,元组也是不可变的。元组上的每个操作都会返回一个新的元组,它永远不会改变给定的元组。

列表还是元组?

列表和元组有什么区别?

列表在内存中以链表的形式存储,这意味着列表中的每个元素都包含它的值,并指向下一个元素,直到到达列表的末尾。这意味着获取列表的长度是一个线性操作:我们需要遍历整个列表才能确定它的大小。

类似地,列表连接的性能取决于左手边的列表长度

iex> list = [1, 2, 3]
[1, 2, 3]

# This is fast as we only need to traverse `[0]` to prepend to `list`
iex> [0] ++ list
[0, 1, 2, 3]

# This is slow as we need to traverse `list` to append 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, "... contents ..."}
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,并且可能很昂贵,因为它依赖于遍历整个字符串。

现在我们已经熟悉了语言中的基本数据类型,让我们学习编写代码的重要结构,然后我们将讨论更复杂的数据结构。