Источник Ввод-вывод и файловая система
В этой главе рассматриваются механизмы ввода-вывода, задачи, связанные с файловой системой, и связанные с ними модули, такие как IO, File и Path. Система ввода-вывода предоставляет отличную возможность пролить свет на некоторые философские и любопытные аспекты Elixir и виртуальной машины Erlang.
Модуль IO
Модуль IO — основной механизм в Elixir для чтения и записи в стандартный ввод (:stdio), стандартную ошибку (:stderr), файлы и другие устройства ввода-вывода. Использование модуля довольно простое:
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.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.
Модуль Path
Большинство функций в модуле File принимают пути в качестве аргументов. Чаще всего эти пути будут обычными двоичными строками. Модуль Path предоставляет средства для работы с такими путями:
iex> Path.join("foo", "bar")
"foo/bar"
iex> Path.expand("~/hello")
"/Users/jose/hello"
Использование функций из модуля Path вместо непосредственной обработки строк предпочтительнее, поскольку модуль Path прозрачно учитывает различные операционные системы. Наконец, имейте в виду, что Elixir автоматически преобразует косые черты (/) в обратные косые черты (\) в Windows при выполнении операций с файлами.
Таким образом, мы рассмотрели основные модули, которые предоставляет Elixir для работы с вводом-выводом и взаимодействием с файловой системой. В следующей секции мы немного заглянем под капот и узнаем, как реализована система ввода-вывода в виртуальной машине.
Процессы
Вы могли заметить, что 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 ожидал некоторого результата, который мы не предоставили.
Представляя устройства ввода-вывода как процессы, виртуальная машина Erlang позволяет нам даже читать и записывать в файлы на разных узлах. Прекрасно!
iodata и chardata
Во всех вышеприведённых примерах при записи в файлы использовались двоичные строки. Однако большинство функций ввода-вывода в Elixir также принимают «iodata» или «chardata».
Одной из основных причин использования «iodata» и «chardata» является производительность. Например, представьте, что вам нужно приветствовать кого-то в вашем приложении:
name = "Mary"
IO.puts("Hello " <> name <> "!")
Поскольку строки в Elixir неизменяемы, как и большинство структур данных, в примере выше строка «Mary» будет скопирована в новую строку «Hello Mary!». Хотя это маловероятно, что окажет влияние на короткую строку, как в приведённом примере, копирование может быть довольно затратным для больших строк! По этой причине функции ввода-вывода в Elixir позволяют вместо этого передавать список строк:
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 представление байтов такое же, как представление кодовой точки, поэтому оно подходит для обеих классификаций. Однако стандартное устройство ввода-вывода работает с chardata, что означает, что мы можем сделать:
iex> IO.puts([?O, ?l, ?á, ?\s, "Mary", ?!])
В целом, целые числа в списке могут представлять набор байтов или набор символов, и выбор зависит от кодировки устройства ввода-вывода. Если файл открыт без кодировки, ожидается, что файл будет в сыром режиме, и необходимо использовать функции модуля IO, начинающиеся с bin*. Эти функции ожидают iodata в качестве аргумента, где целые числа в списке будут представлять байты.
С другой стороны, стандартное устройство ввода-вывода (:stdio) и файлы, открытые с кодировкой :utf8, работают с оставшимися функциями модуля IO. Эти функции ожидают chardata в качестве аргумента, где целые числа представляют кодовые точки.
Хотя это тонкое различие, вам нужно беспокоиться об этом только в том случае, если вы планируете передавать списки, содержащие целые числа, в эти функции. Если вы передаёте двоичные строки или список двоичных строк, то никакой неоднозначности нет.
Наконец, существует ещё одна конструкция, называемая списком символов, о которой мы говорили в предыдущих главах. Списки символов — это особый случай chardata, где все значения являются целыми числами, представляющими кодовые точки Unicode. Их можно создать с помощью символа ~c:
iex> ~c"hello" ~c"hello"
Списки символов в основном появляются при взаимодействии с Erlang, поскольку некоторые API Erlang используют список символов в качестве представления строк. По этой причине любой список, содержащий печатные символы ASCII, будет выводиться как список символов:
iex> [?a, ?b, ?c] ~c"abc"
В эту небольшую секцию мы упаковали много информации, давайте разберём её:
iodata и chardata — это списки двоичных строк и целых чисел. Эти двоичные строки и целые числа могут быть произвольно вложены в списки. Их цель — гибкость и производительность при работе с устройствами ввода-вывода и файлами;
выбор между iodata и chardata зависит от кодировки устройства ввода-вывода. Если файл открыт без кодировки, файл ожидает iodata, и необходимо использовать функции модуля
IO, начинающиеся сbin*. Стандартное устройство ввода-вывода (:stdio) и файлы, открытые с кодировкой:utf8, ожидают chardata и работают с оставшимися функциями модуляIO;списки символов — это частный случай chardata, где он исключительно использует список целых чисел Unicode. Их можно создать с помощью символа
~c. Списки целых чисел автоматически выводятся с использованием символа~c, если все целые числа в списке представляют печатные символы ASCII.
На этом завершается наше знакомство с устройствами ввода-вывода и связанными с ними функциями. Мы изучили три модуля Elixir — IO, File и Path — а также то, как виртуальная машина использует процессы для базовых механизмов ввода-вывода и как использовать chardata и iodata для операций ввода-вывода.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.16.3/io-and-the-file-system.html