Control Flow

В тази статия ще си говорим за неща, които избягвахме (нарочно) досега.
Целият код в предишните статии е без разделяне на логиката от if
-ове.
Това може би е странно за хора, които за първи път се сблъскват с функционален
език, но в Elixir
такива конструкции често са ненужни.
Ако програмираме, използвайки множество от малки функции, комбинирани с гардове
и pattern matching
, рядко ще имаме нужда от if-else-then
код.
Конструкциите if
и unless
Наше мнение е, че if
е напълно ненужна конструкция в Elixir
. Не стига това,
но има и още една, още по-ненужна - unless
.
Нека ги разгледаме:
defmodule Person do
defstruct [:name, :age, :sex, :location, hobbies: []]
end
pesho = %Person{
name: "Пешо",
age: 45,
sex: :male,
location: 'Елин Пелин',
hobbies: ~w(eurofootbal drink xxx турбо-фолк)
}
Ще използваме чичо Пешо за нашите експерименти.
Ето го и първият ни if
:
# Ще видим "Чичо Пешо"
if pesho.age > 40, do: "Чичо #{pesho.name}", else: pesho.name
Тази конструкция може да се напише и в друг вид:
defmodule Questionnaire do
def drinks?(person) do
if Enum.member?(person.hobbies, "drink") do
"Това момче е пиянка!"
else
"Дааа беее..."
end
end
end
# Познайте:
Questionnaire.drinks?(pesho)
Този пример доста прилича на if
-овете в императивните езици.
И в този случай pattern matching
-ът е по-сложен за ползване, защото проверяваме
дали списък съдържа елемент.
Може да се направи с pattern matching
все пак:
defmodule Questionnaire do
def drinks?(%Person{hobbies: hobbies}) do
check_drinks(hobbies)
end
defp check_drinks([]), do: "Дааа беее"
defp check_drinks(["drink" | _]), do: "Това момче е пиянка!"
defp check_drinks([_ | rest]), do: check_drinks(rest)
end
milena = %Person{
age: 25,
name: "Миленката",
sex: :female,
location: 'Ямбол',
hobbies: ~w(шопинг солариум моренце поп-фолкче)
}
# Прави се, но ако я черпят шотчета, коктейлчета, едно-друго и се раздава.
Questionnaire.drinks?(milena)
В повечето случаи можем да не ползваме if
-подобни конструкции.
Освен if
, имаме unless
, какво споменахме, което е същото като if not <expression>
:
unless 1 + 1 == 2, do: "Аз съм БСП!", else: "Защо въобще проверяваме??"
Стойността на if
или на unless
е стойността на израза, който се оценява.
Конструкцията cond
В тази конструкция дефинираме списък от условия със свързан с тях код.
Всяко от тях се оценява, докато стигнем до някое, което се оцени като true
.
Когато това стане, се изпълнява асоциираният с него код и това е стойността на cond
-a.
Да речем можем да разпишем FizzBuzz
проблема от статията Модули, функции и рекурсия с cond
, така:
defmodule FizzBuzz do
def of(n), do: Enum.map(1..n, &number_value/1)
defp number_value(n) do
cond do
rem(n, 3) == 0 and rem(n, 5) == 0 -> "FizzBuzz"
rem(n, 3) == 0 -> "Fizz"
rem(n, 5) == 0 -> "Buzz"
true -> n
end
end
end
FizzBuzz.of(17)
# [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz", 16, 17]
Обикновено последното условие на cond
е true
, за да има код, който да се изпълни,
когато никое от условията не се оценява като истина. Ако не го сложим и нищо не се оцени като истина
ще има грешка - (CondClauseError) no cond clause evaluated to a true value
.
Както if
, cond
в много случаи е абсолютно ненужен. Ако заменим императивния код, с който сме
свикнали да боравим от други езици, с декларативен, cond
може да бъде премахнат.
Да си припомним FizzBuzz
без cond
:
defmodule FizzBuzz do
def of(n), do: Enum.map(1..n, &number_value/1)
defp number_value(n) when rem(n, 3) == 0 and rem(n, 5) == 0, do: "Fizz Buzz"
defp number_value(n) when rem(n, 3) == 0, do: "Fizz"
defp number_value(n) when rem(n, 5) == 0, do: "Buzz"
defp number_value(n), do: n
end
Когато можем да използваме guard
-ове и pattern matching
, е по-добре да използваме тях.
За разлика от if
, cond
е ‘специална форма’. Специалните форми са най-базовите градивни единици
в Elixir
, които не могат да се пренапишат от нас.
Те са специални macro
-си (за тях в бъдещи статии), които не са написани на езика.
От това следва, че ако толкова много ни трябва control flow
конструкция, е по добре да не ползваме
if
и unless
, а някоя на която са базирани - cond
или case
.
Конструкциите if
и unless
са добавени за да се справи един императивен програмист с навлизането в Elixir
,
но според нас са повече вредни, отколкото помагат. Забравете че ги има.
Всъщност забравете и за cond
. Единственото което ви трябва, когато guard
-овете и pattern matching
-а не ви стигат е case
.
Конструкцията case
Тази конструкция приема израз и списък от стойности и свързан с тях код. Ако изразът се match
-не
с някоя от тези стойности, кодът към нея се изпълнява и неговата стойност е стойността на case
.
pesho = %Person{
name: "Пешо",
age: 45,
sex: :male,
location: 'Елин Пелин',
hobbies: ~w(eurofootbal drink xxx турбо-фолк)
}
milena = %Person{
age: 25,
name: "Миленката",
sex: :female,
location: 'Ямбол',
hobbies: ~w(шопинг солариум моренце поп-фолкче)
}
nikodim = %Person{
age: 15,
sex: :male,
name: 'Никичко',
location: 'Софията, бате',
hobbies: ~w(xxx gaming хип-хоп)
}
defmodule Questionnaire do
def asl(person) do
case person do
%{sex: :male} ->
"#{person.age}, М, #{person.location}"
%{sex: :female} ->
"#{person.age}, F, #{person.location}"
_ ->
"#{person.age}, LGBT, #{person.location}"
end
end
end
Questionnaire.asl(milena) # "25, F, Ямбол"
Questionnaire.asl(nikodim) # "15, М, Софията, бате"
Questionnaire.asl(pesho) # "45, М, Елин Пелин"
Както можете да видите, имаме последна клауза, която match-ва всякакви стойности.
Ако нищо не се match-не се изпълнява тази последна клауза. Ако я няма, ще
имаме грешка - (CaseClauseError) no case clause matching
.
В case
изразите, можем да ползваме и guard
-ове:
defmodule Questionnaire do
def able_to_buy_cigarettes(person) do
case person do
%{age: age} when is_number(age) and age > 17 -> true
_ -> false
end
end
end
Важно е да знаем, че case
, също като cond
е специална форма. И отново да
повторим, ползваме го само ако кодът ни стане прекалено разхвърлян или труден
за разбиране с pattern matching
. Отново - забравете за if
и unless
, показахме ви ги
за да ви кажем да не ги ползвате. По добре използвайте case
.
Защо case
е по-добър избор от cond
?
Проверките които правим в case
, са свързани с data
-та която му даваме.
Това ще рече, че лесно можем да видим какво и защо проверяваме, че е по-трудно да вкараме странични ефекти.
Проверките в cond
могат да са всякакви и да са свързани с всякакви данни - много по-лесно е да напишем код,
в който се чудим кое, от къде идва. Много по-лесно е да имаме странични ефекти.
Всичко това не значи, че в case
не можем да ползваме нещо дефинирано извън него.
Именно затова е най-добре да избягваме и case
ако ни е възможно.
Разбира се има случаи в които това ще доведе до много странен код.
В заключение - нека никога не ползваме if
, unless
и cond
. Никога.
Кога да ползваме case
, тогава? Отговорът е прост, когато няма как да ползваме
guard
клаузи и pattern matching
лесно. Когато трябва да правим функции, само и само
да не ползваме case
.
В една от предишните статии - Низове, създадохме функция, която брои срещанията на буквата ‘a’ в низ:
defmodule ACounter do
def count_it_with_next_grapheme(str) do
count_with_next_grapheme(str, 0)
end
defp count_with_next_grapheme("", n), do: n
defp count_with_next_grapheme(str, n) do
{next_grapheme, rest} = String.next_grapheme(str)
count_with_next_grapheme(rest, next_n(next_grapheme == "a", n))
end
defp next_n(true, n), do: n + 1
defp next_n(false, n), do: n
end
За да не ползваме control flow
конструкция,
ние написахме проста функция next_n
, която взима true
или false
.
Това е случай, в който без да ни е съвестно можем да използваме case
:
defmodule ACounter do
def count_it_with_next_grapheme(str) do
count_with_next_grapheme(str, 0)
end
defp count_with_next_grapheme("", n), do: n
defp count_with_next_grapheme(str, n) do
{next_grapheme, rest} = String.next_grapheme(str)
case next_grapheme do
"a" -> count_with_next_grapheme(rest, n + 1)
_ -> count_with_next_grapheme(rest, n)
end
end
end
Case
VS Pattern Matching
Каква е разликата между case
и използването на pattern matching
и guard
-ове с функции?
Кой от тези два подхода е по-бърз?
За да разберем, ще направим една разходка през компилацията на Elixir
, а от там и на Erlang
.
Първо, какво представлява компилацията на Elixir
?
1. Elixir Source Code =>
2. Elixir Macro Expansion =>
3. Erlang Abstract Format =>
4. Core Erlang =>
5. BEAM VM Bytecode
Идеята е да видим кода си в някаква четима форма малко преди да е станал bytecode.
Така ще сравним инструкциите които се ползват за case
и за pattern matching
с функции.
При компилация, Еликсир се трансформира в Abstract Syntax Tree (AST) код, за който ще си говорим повечко в следващи статии. Нещо подобно:
quote do: 1 + 1
{:+, [context: Elixir, import: Kernel], [1, 1]}
На следващата стъпка, този AST
код, се трансформира в Erlang-ския абстрактен формат:
{op,1,'+',{integer,1,1},{integer,1,1}}
На следващата стъпка този абстрактен формат се преобразува в core erlang
.
Този core erlang
си е семантично верен erlang
.
Обикновено езици написани за BEAM
се трансформират до core erlang
.
На това ниво можем да видим до какви точно извиквания се преобразуват case
и pattern matching + functions
и
да ги сравним.
До тук добре, но не е лесно да преобразуваме Elixir
код до core erlang
, поне ние не откриваме
лесен начин. Хубавото, обаче е, че има лесен начин да преобразуваме Erlang
до core erlang
.
Нека да разгледаме компилацията на Erlang
:
1. Erlang Source Code =>
2. Erlang Abstract Format =>
3. Core Erlang =>
4. BEAM VM Bytecode
Интересно - можем да видим къде се включва Elixir
при компилация до beam
- преди Erlang Abstract Format
фазата.
Както казахме, в Erlang
има много лесен начин за преобразуване на erlang
код до core erlang
.
От това следва, че е нужно нашият elixir
да се преобразува до erlang
.
Можем да го направим ръчно, но за упражнението ще напишем функция която де-компилира какъвто и да е beam
файл до erlang source
:
defmodule BeamToErl do
def transform(beam_file_name, erl_file_name) do
case :beam_lib.chunks(to_charlist(beam_file_name), [:abstract_code]) do
{:ok, {_, [{:abstract_code, {:raw_abstract_v1, abstract_code}}]}} ->
src = :erl_prettypr.format(:erl_syntax.form_list(tl(abstract_code)))
{:ok, file} = File.open(erl_file_name, [:write])
IO.binwrite(file, src)
File.close(file)
error -> error
end
end
end
Има много нови неща, но основото е следното: BeamToErl.transform/2
приема път до beam
файл, който
съществува и път до erl
файл, който все още не съществува.
Функцията генерира erlang
код от beam
байт кода.
От документацията на :erl_prettypr.format/2
(pretty printer
за erlang код), видяхме следното:
{ok,{_,[{abstract_code,{_,AC}}]}} =
beam_lib:chunks("myfile.beam",[abstract_code]),
io:put_chars(erl_prettypr:format(erl_syntax:form_list(AC)))
Това горе-долу ни показва как да вземем абстрактен erlang
от bytecode
и да го трансформираме в Erlang
код.
Това именно ползваме.
Всички erlang
функции, които работят с низове, ползват charlist
, затова си преобразуваме аргумента - път до beam
файл до charlist
.
Ако успешно вземем абстрактния код, го преобразуваме в erlang
код и го записваме във файл.
Нека започнем. Ето и примера ни:
defmodule Cases do
def use_case(data) do
case data do
1 -> "one"
2 -> "two"
_ -> "many"
end
end
def use_func(1), do: "one"
def use_func(2), do: "two"
def use_func(_), do: "many"
end
Просто разписваме една и съща проста логика по двата начина. Нека я компилираме до beam
:
elixirc cases.ex
Получаваме Elixir.Cases.beam
. Сега този файл можем да го преобразуваме до erlang
код с BeamToErl.transform/2
:
BeamToErl.transform("Elixir.Cases.beam", "cases.erl")
Сега имаме следния erlang
код:
-compile(no_auto_import).
-file("cases.ex", 1).
-module('Elixir.Cases').
-export(['__info__'/1, use_case/1, use_func/1]).
-spec '__info__'(attributes | compile | exports |
functions | macros | md5 | module |
native_addresses) -> atom() |
[{atom(), any()} |
{atom(), byte(), integer()}].
'__info__'(functions) -> [{use_case, 1}, {use_func, 1}];
'__info__'(macros) -> [];
'__info__'(info) ->
erlang:get_module_info('Elixir.Cases', info).
use_case(data@1) ->
case data@1 of
1 -> <<"one">>;
2 -> <<"two">>;
_ -> <<"many">>
end.
use_func(1) -> <<"one">>;
use_func(2) -> <<"two">>;
use_func(_) -> <<"many">>.
Малко е подробен, нека изтрием ненужните неща:
-module('cases').
-export([use_case/1, use_func/1]).
use_case(Data) ->
case Data of
1 -> <<"one">>;
2 -> <<"two">>;
_ -> <<"many">>
end.
use_func(1) -> <<"one">>;
use_func(2) -> <<"two">>;
use_func(_) -> <<"many">>.
Точно това можехме да направим ръчно, но за упражнението го направихме автоматично.
Нека да го трансформираме в core erlang
:
erl> c("cases.erl", to_core).
Сега имаме cases.core
файл, който съдържа:
module 'cases' ['module_info'/0,
'module_info'/1,
'use_case'/1,
'use_func'/1]
attributes []
'use_case'/1 =
%% Line 4
fun (_cor0) ->
%% Line 5
case _cor0 of
%% Line 6
<1> when 'true' ->
#{#<111>(8,1,'integer',['unsigned'|['big']]),
#<110>(8,1,'integer',['unsigned'|['big']]),
#<101>(8,1,'integer',['unsigned'|['big']])}#
%% Line 7
<2> when 'true' ->
#{#<116>(8,1,'integer',['unsigned'|['big']]),
#<119>(8,1,'integer',['unsigned'|['big']]),
#<111>(8,1,'integer',['unsigned'|['big']])}#
%% Line 8
<_cor3> when 'true' ->
#{#<109>(8,1,'integer',['unsigned'|['big']]),
#<97>(8,1,'integer',['unsigned'|['big']]),
#<110>(8,1,'integer',['unsigned'|['big']]),
#<121>(8,1,'integer',['unsigned'|['big']])}#
end
'use_func'/1 =
%% Line 11
fun (_cor0) ->
case _cor0 of
<1> when 'true' ->
#{#<111>(8,1,'integer',['unsigned'|['big']]),
#<110>(8,1,'integer',['unsigned'|['big']]),
#<101>(8,1,'integer',['unsigned'|['big']])}#
%% Line 12
<2> when 'true' ->
#{#<116>(8,1,'integer',['unsigned'|['big']]),
#<119>(8,1,'integer',['unsigned'|['big']]),
#<111>(8,1,'integer',['unsigned'|['big']])}#
%% Line 13
<_cor2> when 'true' ->
#{#<109>(8,1,'integer',['unsigned'|['big']]),
#<97>(8,1,'integer',['unsigned'|['big']]),
#<110>(8,1,'integer',['unsigned'|['big']]),
#<121>(8,1,'integer',['unsigned'|['big']])}#
end
'module_info'/0 =
fun () ->
call 'erlang':'get_module_info'
('cases')
'module_info'/1 =
fun (_cor0) ->
call 'erlang':'get_module_info'
('cases', _cor0)
end
Изненада! Кодът и за case
и за функции с pattern matching
е абсолютно еднакъв.
От тук следва, че изборът е философски. Ние ви представихме нашата философия по-горе.
Вие избирате.