檢視原始碼 IO 和檔案系統

本章節介紹輸入/輸出機制、檔案系統相關任務,以及相關模組,例如 IOFilePath。IO 系統提供了絕佳機會,可以深入探討 Elixir 和 Erlang VM 的一些哲學和好奇之處。

IO 模組

IO 模組是 Elixir 中用於讀取和寫入標準輸入/輸出 (:stdio)、標準錯誤 (:stderr)、檔案和其他 IO 裝置的主要機制。這個模組的使用相當簡單明瞭

iex> IO.puts("hello world")
hello world
:ok
iex> IO.gets("yes or no? ")
yes or no? yes
"yes\n"

預設情況下,IO 模組中的函式會從標準輸入讀取並寫入標準輸出。我們可以透過傳遞參數來變更,例如傳遞 :stderr(用於寫入標準錯誤裝置)

iex> IO.puts(:stderr, "hello world")
hello world
:ok

File 模組

File 模組包含允許我們開啟檔案作為 IO 裝置的函式。預設情況下,檔案會以二進位模式開啟,這需要開發人員使用 IO 模組中的特定 IO.binread/2IO.binwrite/2 函式

iex> {:ok, file} = File.open("path/to/file/hello", [:write])
{:ok, #PID<0.47.0>}
iex> IO.binwrite(file, "world")
:ok
iex> File.close(file)
:ok
iex> File.read("path/to/file/hello")
{:ok, "world"}

檔案也可以使用 :utf8 編碼開啟,這會指示 File 模組將從檔案讀取的位元組解譯為 UTF-8 編碼的位元組。

除了用於開啟、讀取和寫入檔案的功能外,File 模組還有許多用於處理檔案系統的功能。這些功能以其 UNIX 等效項命名。例如,File.rm/1 可用於移除檔案,File.mkdir/1 可用於建立目錄,File.mkdir_p/1 可用於建立目錄及其所有父鏈。甚至還有 File.cp_r/2File.rm_rf/1 分別用於遞迴複製和移除檔案和目錄(也就是說,同時複製和移除目錄的內容)。

您還會注意到 File 模組中的功能有兩個變體:一個「常規」變體和另一個帶有尾隨驚嘆號 (!) 的變體。例如,當我們在上面的範例中讀取 "hello" 檔案時,我們使用 File.read/1。或者,我們可以使用 File.read!/1

iex> File.read("path/to/file/hello")
{:ok, "world"}
iex> File.read!("path/to/file/hello")
"world"
iex> File.read("path/to/file/unknown")
{:error, :enoent}
iex> File.read!("path/to/file/unknown")
** (File.Error) could not read file "path/to/file/unknown": no such file or directory

請注意,帶有 ! 的版本會傳回檔案內容,而不是元組,而且如果發生任何問題,函式會引發錯誤。

當您想要使用模式比對處理不同的結果時,建議使用不帶 ! 的版本

case File.read("path/to/file/hello") do
  {:ok, body} -> # do something with the `body`
  {:error, reason} -> # handle the error caused by `reason`
end

但是,如果您預期檔案存在,驚嘆號變體會更實用,因為它會引發有意義的錯誤訊息。避免撰寫

{:ok, body} = File.read("path/to/file/unknown")

因為如果發生錯誤,File.read/1 會傳回 {:error, reason},而且模式比對會失敗。您仍然會得到預期的結果(引發錯誤),但訊息會與不匹配的模式有關(因此對於錯誤實際涉及的內容來說是難以理解的)。

因此,如果您不想處理錯誤結果,請優先使用以驚嘆號結尾的函式,例如 File.read!/1

Path 模組

File 模組中的大多數函式都將路徑視為引數。最常見的情況是,這些路徑將是常規二進位檔。Path 模組提供處理此類路徑的工具

iex> Path.join("foo", "bar")
"foo/bar"
iex> Path.expand("~/hello")
"/Users/jose/hello"

使用 Path 模組中的函式,而不是直接操作字串,是比較好的選擇,因為 Path 模組會透明地處理不同的作業系統。最後,請記住,在 Windows 上執行檔案操作時,Elixir 會自動將斜線 (/) 轉換為反斜線 (\)。

有了這些,我們已經涵蓋了 Elixir 提供用於處理 IO 和與檔案系統互動的主要模組。在下一節中,我們將深入了解一下,並學習 IO 系統如何在 VM 中實作。

程序

你可能已經注意到 File.open/2 會傳回一個像 {:ok, pid}

iex> {:ok, file} = File.open("hello", [:write])
{:ok, #PID<0.47.0>}

這是因為 IO 模組實際上會使用程序(請參閱 前一章節)。假設一個檔案是一個程序,當你寫入一個已經關閉的檔案時,你實際上是向一個已經終止的程序傳送訊息

iex> File.close(file)
:ok
iex> IO.write(file, "is anybody out there")
** (ErlangError) Erlang error: :terminated:

  * 1st argument: the device has terminated

    (stdlib 5.0) io.erl:94: :io.put_chars(#PID<0.114.0>, "is anybody out there")
    iex:4: (file)

讓我們更詳細地了解當你要求 IO.write(pid, binary) 時會發生什麼事。 IO 模組會向由 pid 識別的程序傳送訊息,並附上所需的作業。一個小型特設程序可以幫助我們看到它

iex> pid = spawn(fn ->
...>  receive do: (msg -> IO.inspect(msg))
...> end)
#PID<0.57.0>
iex> IO.write(pid, "hello")
{:io_request, #PID<0.41.0>, #Reference<0.0.8.91>,
 {:put_chars, :unicode, "hello"}}
** (ErlangError) erlang error: :terminated

IO.write/2 之後,我們可以看到 IO 模組傳送的請求已列印出來(一個四元素元組)。在那之後不久,我們會看到它失敗,因為 IO 模組預期會有一些結果,但我們沒有提供。

透過使用程序對 IO 裝置進行建模,Erlang VM 甚至允許我們跨節點讀寫檔案。太棒了!

iodatachardata

在以上所有範例中,我們在寫入檔案時都使用了二進位資料。但是,Elixir 中的大部分 IO 函式也接受「iodata」或「chardata」。

使用「iodata」和「chardata」的主要原因之一是為了效能。例如,假設你需要在你的應用程式中向某人打招呼

name = "Mary"
IO.puts("Hello " <> name <> "!")

由於 Elixir 中的字串是不可變的,就像大多數資料結構一樣,以上的範例會將字串「Mary」複製到新的「Hello Mary!」字串中。雖然這不太可能對像以上那樣的短字串造成影響,但複製對於大型字串來說可能會非常昂貴!因此,Elixir 中的 IO 函式允許你傳遞字串清單

name = "Mary"
IO.puts(["Hello ", name, "!"])

在以上的範例中,沒有複製。相反地,我們建立一個包含原始名稱的清單。我們將這樣的清單稱為「iodata」或「chardata」,我們很快就會了解它們之間的精確差異。

這些清單非常有用,因為它實際上可以簡化在各種情況下處理字串。例如,假設你有一個值清單,例如 ["apple", "banana", "lemon"],你想要寫入磁碟並以逗號分隔。你如何達成這一點?

一個選項是使用 Enum.join/2 並將值轉換為字串

iex> Enum.join(["apple", "banana", "lemon"], ",")
"apple,banana,lemon"

上述會透過將每個值複製到新字串中,傳回一個新字串。然而,透過本節中的知識,我們知道可以將字串清單傳遞給 IO/File 函式。因此,我們可以執行

iex> Enum.intersperse(["apple", "banana", "lemon"], ",")
["apple", ",", "banana", ",", "lemon"]

"iodata" 和 "chardata" 不僅包含字串,它們也可能包含任意巢狀的字串清單

iex> IO.puts(["apple", [",", "banana", [",", "lemon"]]])

"iodata" 和 "chardata" 也可能包含整數。例如,我們可以使用 ?, 作為分隔符號來列印逗號分隔的值清單,這是表示逗號的整數 (44)

iex> IO.puts(["apple", ?,, "banana", ?,, "lemon"])

"iodata" 和 "chardata" 之間的差異正是該整數所表示的內容。對於 iodata,整數表示位元組。對於 chardata,整數表示 Unicode 碼點。對於 ASCII 字元,位元組表示法與碼點表示法相同,因此符合這兩種分類。然而,預設 IO 裝置會使用 chardata,這表示我們可以執行

iex> IO.puts([?O, ?l, , ?\s, "Mary", ?!])

總之,清單中的整數可能表示一堆位元組或一堆字元,而使用哪一個取決於 IO 裝置的編碼。如果檔案是在沒有編碼的情況下開啟,則預期檔案處於原始模式,且必須使用 IO 模組中以 bin* 開頭的函式。這些函式預期 iodata 作為引數,其中清單中的整數將表示位元組。

另一方面,預設 IO 裝置 (:stdio) 和使用 :utf8 編碼開啟的檔案會使用 IO 模組中其餘的函式。這些函式預期 chardata 作為引數,其中整數表示碼點。

雖然這是一個細微的差異,但只有當你打算將包含整數的清單傳遞給這些函式時,才需要擔心這些細節。如果你傳遞二進位檔案,或二進位檔案清單,則沒有歧義。

最後,有一個稱為 charlist 的最後建構,我們在前面章節中討論過。Charlist 是 chardata 的特殊情況,其中所有值都是代表 Unicode 編碼點的整數。它們可以用 ~c 標記建立

iex> ~c"hello"
~c"hello"

Charlist 大多出現在與 Erlang 介面時,因為一些 Erlang API 使用 charlist 作為其字串表示。因此,任何包含可列印 ASCII 編碼點的清單將會列印為 charlist

iex> [?a, ?b, ?c]
~c"abc"

我們將許多內容濃縮到這個小節,讓我們來分解它

  • iodata 和 chardata 是二進制和整數的清單。這些二進制和整數可以在清單中任意巢狀。它們的目標是在使用 IO 裝置和檔案時提供彈性和效能;

  • iodata 和 chardata 之間的選擇取決於 IO 裝置的編碼。如果檔案是在沒有編碼的情況下開啟的,則檔案會預期 iodata,而且必須使用 IO 模組中以 bin* 開頭的函數。預設的 IO 裝置 (:stdio) 和使用 :utf8 編碼開啟的檔案會預期 chardata,並使用 IO 模組中其餘的函數;

  • charlist 是 chardata 的特殊情況,其中它專門使用 Unicode 編碼點的整數清單。它們可以用 ~c 標記建立。如果清單中的所有整數都代表可列印的 ASCII 編碼點,則會使用 ~c 標記自動列印整數清單。

這結束了我們對 IO 裝置和 IO 相關功能的介紹。我們已經瞭解了三個 Elixir 模組 - IOFilePath - 以及 VM 如何使用程序來執行底層 IO 機制,以及如何在 IO 操作中使用 chardataiodata