查看源代码 IO 和文件系统
本章介绍输入/输出机制、与文件系统相关的任务以及相关的模块,例如 IO
、File
和 Path
。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/2
和 IO.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/2
和 File.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 允许我们甚至在节点之间读写文件。太棒了!
iodata
和 chardata
在上面所有示例中,我们在写入文件时使用二进制文件。但是,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 模块 - IO
,File
,以及 Path
- 以及 VM 如何使用进程进行底层 IO 机制,以及如何使用 chardata
和 iodata
进行 IO 操作。