Вход-изход

В тази статия ще се запознаем с функциите, свързани с четене и писане.
Ще разгледаме няколко модула от стандартната библиотека, като 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 конструкциите, грешките, работата с файлове.
Време е да разберем по какъв начин работят процесите в които върви кодът ни и как си комуникират те.
Това ще направим в следващата статия.