Грешки и Вход-Изход
Видяхме какво представлява Elixir вътре в процесите. Имаме някаква идея какво представлява Elixir като множество комуникиращи си процеси (ще разберем повече в следващата публикация). Тъй като всяка програма си комуникира по някакъв начин с външния свят ще обърнем внимание на Elixir и в контекста на външния свят.
Ще разгледаме какво представляват грешките в Elixir. Най-често те биха възникнали когато програмата ни няма контрол над даден проблем. В повечето случаи такъв проблем възниква именно извън нея.
Ще разгледаме и модулите свързани с писане и четене на файлове както и принтирането и четенето от терминала.
Грешки
Грешките в Elixir са предназначени за ситуации, които никога не би трябвало да се случат при нормални обстоятелства. Когато програмата зависи нещо външно (service-и, файлова система), а то не е достижимо, когато имаме проблем с ресурсите и конфигурацията, от които нашата програма зависи.
Да речем грешен input от потребител не е такава ситуация.
‘Вдигане’ на грешка
Грешка се ‘вдига’ с raise
:
raise "Ужаст!"
#=> (RuntimeError) Ужаст!
По подразбиране, ако не подадем тип на грешката на raise
, тя е RuntimeError
.
Можем да ‘вдигнем’ грешка и с тип, без съобщение:
raise RuntimeError
#=> (RuntimeError) runtime error
Както и с тип на грешката и съобщение:
raise ArgumentError, message: "Грешка, брато!"
#=> (ArgumentError) Грешка, брато!
Elixir
идва с набор от грешки за различни случаи, които можете да видите в
документацията под EXCEPTIONS.
Грешките в Elixir не са препоръчителни за употреба. Имат славата на GOTO/спагети програмиране и наистина е хубаво да помислим дали има нужда от тях в дадена ситуация.
Прието е функции, при които има проблем, да връщат {:error, <причина>}
, а ако се изпълняват с успех и
имат резултат - {:ok, <резултат>}
.
Имената на функции, които биха могли да ‘вдигнат’ грешка, обикновено завършват на !.
Ако имаме SomeModule.some_function/1
, която връща {:ok, result}
или {:error, reason}
и искаме да дефинираме същата, но при успех връщаща result
, а при неуспех, ‘вдигаща’ грешка,
ще я именоваме SomeModule.some_function!/1
.
В секцията за вход-изход ще видим, че функциите идващи с езика следват тази конвенция.
Прихващане на грешка
Можем да ‘прихванем’ грешка, използвайки try/rescue
блок:
try do
1 / 0
rescue
[RuntimeError, ArgumentError] ->
IO.puts("Няма да стигнем до тук.")
error in [ArithmeticError] ->
IO.puts("На нула не се дели, #{error.message}")
any_other_error ->
IO.puts("Лошаво... #{any_other_error.message}")
else
IO.puts("Няма грешка.")
after
IO.puts("Finally!")
end
Примерът по горе показва няколко вида прихващане. Прихващането е pattern match-ване.
Първият пример е чрез списък от тип грешки, докато във втория, виждаме как от този списък
да си вземем грешката в променлива.
Накрая хващаме всички типове, които не сме описали досега в променливата any_other_error
.
Имаме и after
клауза, която винаги ще се изпълни след като try
функцията завърши, няма значение,
дали е имало грешка или не. Другата интересна клауза в примера е else
. Тялото ѝ се изпълнява само
ако не е възникнала грешка в тялото на try
.
Създаване на нови типове грешки
Можем да създадем и нови типове грешки. Подобно на структурите, нова грешка се дефинира като част от модул:
defmodule VeryBadError do
defexception message: "Лошо!!!"
end
Сега можем да я ‘вдигнем’:
try do
raise VeryBadError
rescue
error in VeryBadError ->
IO.puts(inspect(error, structs: false))
end
#=> %{__exception__: true, __struct__: VeryBadError, message: "Лошо!!!"}
Както виждате, грешките са структури с още едно тайно поле - __exception__
.
То има стойност true
.
Throw/Catch
Тези две конструкции НЕ ТРЯБВА да се ползват. Има библиотеки, които поради някакво стечение на обстоятелствата е възможно да ги ползват, но ги избягвайте. Ние ви ги показваме за да ви кажем: не ги ползвайте!
С throw
‘подхвърляме’ стойност, която може да се ‘хване’ по-късно:
try do
throw 5
catch
x -> IO.puts(x)
end
#=> 5
Сега - забравете за тях и не ги ползвайте.
В заключение
Тази секция е доста кратка. Идеята ѝ е да ни запознае с грешките в Elixir и да ни каже - не ги ползвайте.
Два факта:
- В кода на mix няма прихващане на грешки.
- В кода на компилатора на Elixir има точно пет прихващания. Все пак това е компилатор и там се случват доста магически неща.
Ако започнете да създавате нов тип за грешки се замислете.
В Erlang/Elixir кодът върви в изолирани процеси. Идеологията е - ‘остави го да се счупи’.
Това е така защото тези процеси са наистина изолирани и не споделят състояние. Ако един падне, друг ще бъде вдигнат на негово място и така ако е имало проблем, възникнал runtime, той ще се изчисти. Няма защо ние да правим това.
Важно е да запомните, че писането на нови типове грешки е нещо, което Elixir програмистите НЕ правят. Същото се отнася и за ‘прихващането’ им.
Вход-изход
Ще разгледаме няколко модула от стандартната библиотека, като 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
, можем да избегнем конкатенация на
низове, което е винаги хубаво нещо. Така няма копиране в паметта, данните се изпращат направо където е нужно.
Като цяло това е едно от чудесата на 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.
Функцията връща прочетеното:
IO.read(:line)
Хей, Хей<enter>
#=> "Хей, Хей\n"
Много подобна е и функцията IO.gets/2
. Тя приема prompt като втори аргумент и чете до нов ред:
IO.gets("Кажи нещо!\n")
Кажи нещо!
Нещо!<enter>
#=> "Нещо!\n"
Двете getn
функции прочитат брой байтове или unicode codepoint-и, в зависимост от типа на device
-а.
Когато говорим за файлове ще разгледаме как можем да отворим файл в различни mode-ове.
iodata
Подобно на chardata, iodata може да се дефинира като списък. За разлика от chardata, iodata списъкът е от цели числа които представляват байтове (0 - 255), binary с елементи със size, кратен на 8 (могат да превъртат) и такива списъци.
Има функции, които боравят с iodata - IO.binwrite
и IO.binread
.
Тези функции са по-бързи от не-bin*
вариантите им.
Не трансформират това което получават в utf8.
В IO
има две функции за боравене с iodata.
Функцията IO.iodata_length/1
връща дължината на iodata в байтове:
IO.iodata_length([1, 2 | <<3, 4>>])
#=> 4
Функцията IO.iodata_to_binary/1
трансформира iodata в binary:
IO.iodata_to_binary([1, << 2 >>, [[3], 4]])
#=> <<1, 2, 3, 4>>
Файлове
Модулът 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!")
#=> :ok
File.close(file)
#=> :ok
Функцията 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/2
има и IO.binstream/2
версия.
Ако искаме по бързо четене/писане, без преминаване през комуникация между процеси, ползваме
File.stream!/2
:
File.stream!(filename, read_ahead: 10_000)
По подразбиране, когато използваме File.stream!/2
, файловете се отварят в 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!"}}
Както виждаме в паметта се държат два низа - един за вход, един за изход. Можем да четем от изхода, докато стане празен и да пишем във входа.
Това не е точно поведението при един истински файл, за който нямаме две пространства, а само едно.
Ако искаме псевдо-файл в паметта, който се държи като истински файл, можем да го направим така:
{:ok, file} = 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)
#=> {:ok, 0}
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
.
Те се справят с различията в операционните системи - знаят на какво вървят.
Заключение
Повечето програми използват външни ресурси. Обикновено нямаме пълен контрол над тези ресурси, затова могат да възникнат грешки при работа с тях,
които не зависят от логиката на нашата програма.
Най-добре е да използваме функции, коит връщат {:ok, result}
или {:error, reason}
, когато работим с такива ресурси.
Видяхме, че функциите за IO имат такива версии и точно те се ползват най-често.
Ако сме убедени, че процес трябва да crash-не, ако даден ресурс не е наличен, можем да използваме ! версиите на тези функции.
Научихме как да ‘вдигаме’ грешки, но най-добре е, когато пишем напи функции да не го правим.
По-добре е да връщаме {:error, reason}
. По-лесно се чете и поддържа такъв код.