Вход-изход


В тази статия ще се запознаем с функциите, свързани с четене и писане. Ще разгледаме няколко модула от стандартната библиотека, като IO и File.

Изход с IO.puts/2 и IO.write/2

Досега ползвахме IO.puts/2 за да извеждаме текст на стандартния изход. Може би се чудите защо написахме функцията като функция на два аргумента. Това е защото тя е такава, просто първият, device има стойност по подразбиране. Засега няма да разглеждаме тази стойност. Тя е свързана с комуникацията между процеси. Това което ще направим е да подадем други стойности. Примери:

IO.puts("По подразбиране пишем на стандартния изход.")
IO.puts(:stdio, "Можем да го направим и така.")
IO.puts(:stderr, "Или да пишем в стандартния изход за грешки.")

Всъщност puts се държи по същия начин като друга функция в IO - write. Разликата е, че puts слага нов ред след текста, който му е подаден.

IO.write(:stderr, "Това е грешка!")

Какво е chardata

Както казахме, първият аргумент на write и puts е device. Вторият е нещо от тип chardata. Какво е chardata? В статията Списъци и потоци споменахме за iolist, по-познат като iodata в Elixir. Този тип е доста подобен.

Chardata е:

  • Низ, да речем "далия".
  • Списък от codepoint-и, да речем [83, 79, 0x53] или [?S, ?O, ?S] или 'SOS'.
  • Списък от codepoint-и и низове - [83, 79, 83, "mayday!"].
  • Списък от chardata, тоест списък от нещата в горните три точки : [[83], [79, ["dir", 78]]].

Подавайки chardata на функции като IO.puts/2 и IO.write/2, можем да избегнем конкатенация на низове, което е винаги хубаво нещо. Така няма копиране в паметта, data-та се изпраща направо в целта си. Показахме ви как да изграждате HTML темплейти с този тип данни. Като цяло това е едно от чудесата на Erlang/Elixir, ползвайте го вместо конкатенирани или интерполирани низове, когато можете.

В IO има функция, която трансформира chardata в низ.

IO.chardata_to_string([1049, [1086, 1091], "!"])
# "Йоу!"

Вход с IO.read/2, IO.gets/2, IO.getn/2 и IO.getn/3

Функцията read също взима device като първи аргумент (което е или атом, да речем :stdio или PID на процес). Вторият аргумент може да бъде:

  • Атомът :all - значи да се изчете всичко идващо от device-а, докато не се достигне EOF, тогава се връща празен низ.
  • Атомът :line - прочита се всичко до нов ред или EOF. При EOF, функцията връща :eof.
  • Цяло число, по голямо от нула - прочита толкова символа от device или колкото може преди да достигне EOF.

Функцията връща прочетеното:

iex> IO.read(:line)
Хей, Хей<enter>
# "Хей, Хей\n"

Много подобна е и функцията IO.gets/2. Тя приема prompt като втори аргумент и чете до нов ред:

iex> IO.gets("Кажи нещо!\n")
Кажи нещо!
Нещо!<enter>
# "Нещо!\n"

Двете getn функции прочитат брой байтове или unicode codepoint-и, в зависимост от типа на device-а. Когато говорим за файлове ще разгледаме как можем да отворим файл в различни mode-ове.

Какво е iodata

Подобно на chardata, iodata може да се дефинира като списък от data. За разлика от chardata, iodata списъкът е от цели числа които представляват байтове (0 - 255), binary с елементи със size, кратен на 8 (могат да превъртат) и такива списъци.

Има функции, които боравят с iodata - IO.binwrite и IO.binread. Тези функции са по-бързи от не-bin* вариантите им. Не трансформират това което получават в utf8.

В IO има две функции за боравене с iodata:

IO.iodata_length([1, 2|<<3, 4>>])
# 4

Връща дължината в байтове на iodata-та.

IO.iodata_to_binary([1, << 2 >>, [[3], 4]])
# <<1, 2, 3, 4>>

Трансформира iodata в binary.

Файлове

Модулът File съдържа функции за работа с файлове. Някои от тях ни позволяват да отваряме файловете за писане и четене. По подразбиране всички файлове се отварят в binary mode и функциите IO.binwrite/2 и IO.binread/2 трябва да се използват за работа с тях.

Разбира се файл може да бъде отворен и в utf8 mode. По този начин байтовете, записани или прочетени, ще се интерпретират като UTF8 codepoint-и.

{:ok, file} = File.open("test.txt", [:write])
# {:ok, #PID<0.855.0>}
IO.binwrite(file, "some text!")
File.close(file)

Както можем да видим, File.open/2 връща наредена двойка - {:ok, device}. Ако имаше някаква грешка щяхме да получим {:error, reason}. Това е нормално при повечето функции свързани с файлове. Разбира се има и функции, които хвърлят грешка при проблем и връщат резултата направо. Те имат същите имена, но завършващи на !. Да речем File.open!/2.

В модула има много функции за създаване и триене на файлове и директории, за проверки дали съществуват, за промяна и показване на съдържанието на директория. Прочетете за тях в документацията.

Процеси и файлове

Така нареченият device всъщност е PID на процес или атом, който сочи към PID на процес. По принцип, когато отваряме файл се създава нов процес, който знае file descriptor-а на файла и управлява писането и четенето към и от него.

Това е много хубаво нещо. От една страна това означава, че IO функциите на един node, могат да четат/пишат файл на друг node, или един компютър да управлява файлове на друг. От друга, означава че можем да си създаваме лесно свои device-и, чрез процеси, които знаят какво съобщение да очакват.

Разбира се това означава и, че всяка операция с файла минава през комуникация между процеси. Когато искаме оптимално писане/четене на файл, това не е плюс.

Именно за това има функции, които направо работят с файлове, като File.read/1, File.read!/1, File.write/3, File.write!/3.

Тези функции отварят файла и пишат/четат в/от него като една операция, след това го затварят.

Потоци и файлове

Ако не искаме да прочетем цял файл в паметта, можем да го отворим и да си направим поток към него:

{:ok, file} = File.open("program.txt", [:read])
# {:ok, #PID<0.82.0>}

IO.stream(file, :line)
|> Stream.map(fn line -> line <> "!" end)
|> Stream.each(fn line -> IO.puts(line) end)
|> Stream.run

Това ще прочете файла ред по ред, трансформирайки редовете и ще ги изведе на стандартния изход. Разбира се IO.stream има и IO.binstream версия.

Ако искаме по бързо четене/писане, без преминаване през комуникация между процеси, ползваме File.stream!:

File.stream!(filename, read_ahead: 10_000)

По подразбиране, когато използваме File.stream!, файловете се отварят в raw binary read_ahead mode. Това ще рече, че няма трансформация към UTF8 codepoint-и има буфериране в паметта. В примера по горе, показваме как можем да зададем големина на буфера.

Ако искаме наистина бързо четене от файл на части, трансформиране и записване в друг файл е добре да следваме следния шаблон:

File.stream!(<input_name>, read_ahead: <buffer_size>)
|> Stream.<transform-or-filter>
...
|> Stream.into(File.stream!(<output_name>, [:delayed_write]))
|> Stream.run

По този начин комбинирайки read_ahead и delayed_write се получава буфериране с добра скорост. Повече по темата тук.

Модула IO.ANSI

Този модул съдържа функции които контролират цвета, и форматирането в теминала. Много добре се комбинират в chardata списък с текст:

IO.puts [IO.ANSI.blue(), "text", IO.ANSI.reset()]
# Ще отпечата 'text' в синьо, ако терминалът ви поддържа ANSI цветове

Модула StringIO и файлове в паметта

Използвайки този модул, ние можем да четем/пишем от/в низове в паметта:

{:ok, pid} = StringIO.open("data")
#PID<0.136.0>}

StringIO.contents(pid)
# {"data", ""}

IO.write(pid, "doom!")
#:ok

StringIO.contents(pid)
# {"data", "doom!"}

IO.read(pid, :line)
# "data"

StringIO.contents(pid)
# {"", "doom!"}

StringIO.close(pid)
# {:ok, {"", "doom!"}}

Както виждаме в паметта се държат два низа - един за вход, един за изход. Можем да четем от изхода, докато стане празен и да пишем във входа.

Това не е точно поведението при един истински файл, за който нямаме две пространства, а само едно.

Ако искаме псевдо-файл в паметта, който се държи като истински файл, можем да го направим така:

File.open("data", [:ram])
# {:ok, {:file_descriptor, :ram_file, #Port<0.1578>}}
IO.binread(file, :all)
# "data"

Опцията при отваряне ram, създава файл в паметта със съдържание първия аргумент на функцията open. Ако сега направим:

IO.binread(file, :all)
# ""

Ще получим празен низ. Това е защото сме в края на файла, можем да променим това, с Erlang функцията :file.postion/2.

:file.position(file, :bof)
IO.binread(file, :all)
# "data"

Така отиваме в :bof - beginning of file и четем. В Elixir няма random access функции, но могат да се ползват тези от erlang.

Модула Path

Много от функциите във File изискват пътища. Модулът Path, ни предоставя спомагателни функции за работа с пътища. Примери:

Path.join("some", "path")
# "some/path"
Path.expand("~/development")
# "/home/meddle/development"

По добре е да си строим пътищата с функции от Path. Те се справят с различията в операционните системи - знаят на какво вървят.

Заключение

Това беше всичко от нас за файловете. Тази статия е последната преди навлизането в процесите и по advanced темите.

Научихме за структурите от данни в Elixir, типовете, controw flow конструкциите, грешките, работата с файлове. Време е да разберем по какъв начин работят процесите в които върви кодът ни и как си комуникират те. Това ще направим в следващата статия.