檢視原始碼 清單和元組

在本章節中,我們將學習 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

元組會將元素連續儲存在記憶體中。這表示透過索引存取元組元素或取得元組大小是快速的操作。索引從 0 開始

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,而且可能會很昂貴,因為它依賴於整個字串的遍歷。

現在我們已經熟悉了這門語言中的基本資料類型,在討論更複雜的資料結構之前,讓我們學習用於撰寫程式碼的重要結構。