查看源代码 IO 和文件系统

本章介绍输入/输出机制、与文件系统相关的任务以及相关的模块,例如 IOFilePath。IO 系统为阐明 Elixir 和 Erlang VM 的一些理念和奇特性提供了一个绝佳的机会。

The IO 模块

The 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

The File 模块

The File 模块包含允许我们打开文件作为 IO 设备的函数。默认情况下,文件以二进制模式打开,这要求开发人员使用 IO.binread/2IO.binwrite/2 等特定函数来自 IO 模块。

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

The Path 模块

The File 模块中的大多数函数都期望路径作为参数。最常见的是,这些路径将是常规二进制文件。The Path 模块提供了用于处理此类路径的工具。

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

使用来自 Path 模块的函数,而不是直接操作字符串是首选,因为 Path 模块透明地处理了不同的操作系统。最后,请记住,Elixir 将在 Windows 上执行文件操作时自动将斜杠 (/) 转换为反斜杠 (\)。

至此,我们已经涵盖了 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) 时会发生什么。The 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 设备的编码。如果文件在没有编码的情况下打开,则该文件应该处于原始模式,并且必须使用以 bin* 开头的 IO 模块中的函数。这些函数期望 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,并且必须使用以 bin* 开头的 IO 模块中的函数。默认的 IO 设备 (:stdio) 和使用 :utf8 编码打开的文件期望 chardata 并使用 IO 模块中的其余函数;

  • charlist 是 chardata 的一个特例,它专门使用整数 Unicode 代码点的列表。它们可以使用 ~c 符号创建。如果列表中的所有整数都表示可打印的 ASCII 代码点,则整数列表将自动使用 ~c 符号打印。

至此,我们完成了对 IO 设备和 IO 相关功能的介绍。我们学习了三个 Elixir 模块 - IOFile,以及 Path - 以及 VM 如何使用进程进行底层 IO 机制,以及如何使用 chardataiodata 进行 IO 操作。