Erlang: proměnné a funkce

11. 8. 2014
Doba čtení: 12 minut

Sdílet

Funkcionální programovací jazyk Erlang je určený k vytváření distribuovaných systémů pro zpracování velkého množství paralelních úloh. Používá ho například populární IM aplikace WhatsApp. V dnešním díle si ukážeme práci s proměnnými, mocné porovnávání vzorů, funkce a struktury na větvení programu.

Proměnné

Pro uchovávání hodnot se stejně jako jinde v Erlangu používají proměnné. Proměnné se nemusejí deklarovat, datový typ je určen hodnotou, která je v ní uložena. Proměnné začínají velkým písmenem a dále mohou obsahovat libovolná písmena, číslice a podtržítko.

Flag15, Result_reason, Count

Všechny proměnné jsou lokální a platí jen uvnitř funkce, kde jsou použity.

Na rozdíl od většiny běžných jazyků lze hodnotu do proměnné uložit jen jednou. Jakmile se do proměnné uloží hodnota, nelze ji dodatečně změnit. Např. inkrementovat hodnotu v čítači, jak je zvykem jinde, v Erlangu nelze. Pokus o změnu hodnoty proměnné způsobí chybu.

1> I = 6.
6
2> I = I + 1.
** exception error: no match of right hand side value 2

Pokud je třeba z hodnoty proměnné vypočítat novou hodnotu, tak je třeba výsledek uložit do nové proměnné.

3> I = 6.
6
4> NewI = I + 1.

Tato vlastnost Erlangu (tzv. single assigment, jinde se s ním lze setkat např. v transformačním jazyku XSLT) vypadá na první pohled jako nepříjemná zbytečná komplikace. Jedná se o důsledek toho, jak Erlang pracuje s pamětí. Tj. co se jednou zapíše, nelze měnit, což je zase důsledkem snahy vyřešit problémy související s paralelním programováním. Trochu více smyslu a logiky v nemožnosti měnit hodnoty v proměnných bude vidět o něco dále v porovnávání vzorů.

Dobře napsaný program v Erlangu se skládá z funkcí, co mají maximálně několik desítek řádků. V taková situaci se s tímto omezením dá žít. Nemožnost měnit proměnné tak činí obtížnější vytvářet nekonečné „write only“ špagety, jejichž fungování nelze prakticky bez debugeru pochopit. Další výhodou je, pokud zjistíte (z logu, debugováním, ze stack trace po výjimce a pod.), že v nějaké proměnné je hodnota, co by tam neměla být, existuje vždy jen jedno místo, kde se tam mohla dostat.

Datové typy proměnných jsou v Erlangu dynamické. Tj. není třeba proměnné předem deklarovat a určovat jejich typ. Ten je dán hodnotou, která je v proměnné uložena. Stejně tak není třeba deklarovat datové typy parametrů funkcí. Na jednou stranu je to příjemné, neboť odpadá práce s deklaracemi. Na druhou stranu určením datových typů předem je možno odhalit případné chyby již při kompilaci.

Podle poznámky v knize „Erlang the programming“ je důvodem existence dynamického typování proměnných to, že se autorům Erlangu nepodařilo vymyslet, jak určovat typy v době kompilace tak, aby vše fungovalo dle jejich představ za všech okolností při výměně kódu za běhu programu.

Pokud se např. do sčítání dostane třeba atom, tak se na to přijde až tím, že dojde za běhu programu k výjimce.

5> A = jedna.
jedna
9> B = 1 + A.
** exception error: an error occurred when evaluating an arithmetic expression

Kontrolu datových typů v době kompilace analýzou zdrojových kódů lze provádět pomocí tzv. specifikací funkcí. U každé funkce lze jistými značkami v komentářích popsat, jaké jsou přípustné hodnoty v parametrech a co může být výstupem. Nemusí to být jen popisy datových typů, ale lze být více konkrétní (výčty, vzory struktur a pod.). To lze využít pro dokumentaci generovanou ze zdrojových kódů, ale hlavně se jedná o vstup pro program Dyalizer (DIscrepancy AnalYZer for ERlang programs), který umí mimo jiné vyhodnotit, zda jsou funkce volány s parametry, které odpovídají specifikacím. O specifikacích podrobněji opět někdy příště.

Porovnávání vzorů

Porovnávání vzorů je silný nástroj, pomocí kterého lze stručně a přehledně zapisovat složité operace nebo vybírat směry pro další výpočet v rozhodovacích strukturách. V jazyce Erlang se s ním lze setkat na více místech. Nejjednodušším způsobem je použití operátoru =. Ten funguje tak, že na pravé straně je nějaký výraz (konstanta, n-tice, list, struktura …) a na levé straně je popis tohoto výrazu. To může být opět nějaká konstanta nebo složitější struktura, ale s tím, že může obsahovat dosud nepoužité (nové) proměnné. Tyto proměnné fungují jako zástupný znak pro cokoliv a to, co zastupují, je do nich uloženo. Princip stejný jako např. u regulárních výrazů (ale syntaxe je zcela jiná). Někdy se porovnávání vzorů používá k přiřazení hodnot do proměnných (rovnou nebo vytažením ze struktury), jindy je záměrem otestovat, zda nějaká proměnná (se strukturou) odpovídá nějakému vzoru.

Příklady:

% n-tice o třech prvcích se rozebere na jednotlivé kusy a ty
% se uloží do proměnných A, B a C
{A, B, C} = {1, 2, 3}

% přiřazení hodnoty je speciální případ - co je nalevo se uloží do proměnné
X = 143

% list se dá rozdělit na počátek a zbytek tj. v Head je 10 a v Tail
% je list [20, 30]
[Head | Tail] = [10, 20, 30]

% není třeba se omezovat jen na jeden prvek z listu, pokud víme že jich tam
% je více. Zde se do Head1 uloží 50, do Head2 60 a v Tail2 zůstane list [70]
[Head1, Head2 | Tail2] = [50, 60, 70]

% popisované struktury mohou být složitější (do sebe zanořené)
% Do proměnné Key se uloží foo, do Value 100 a zbytek listu [{bar, 200}, {quix, 300}]]
% do proměnné Tail3.
[{Key, Value} | Tail3] = [{foo, 100}, {bar, 200}, {quix, 300}]

Pokud si výraz na levé straně a vzor na pravé straně neodpovídají (nejde je na sebe napasovat), tak se vyvolá výjimka.

1> {A, B, C} = {1, 2, 3, 4}.
** exception error: no match of right hand side value {1,2,3,4}

Příklad. Při volání funkcí bývá někdy záměrem autora napsat kód tak, aby se operace buď povedla, nebo aby se vyvolala výjimka, pokud se operace nezdařila. Např. funkce pro otevření souboru může vrátit buď n-tici {ok, IoDevice} nebo {error, Reason} (viz. dokumentace). Pak zápis

{ok, IoDevice} = file:open("soubor.txt", [read])

V případě úspěšného volání vytvoří proměnnou IoDevice a do ní uloží handler, přes který lze číst soubor, nebo se vyvolá výjimka.

1> {ok, IoDevice} = file:open("soubor_co_neexistuje.txt", [read]).
** exception error: no match of right hand side value {error,enoent}

Tento způsob zápisu bývá často vidět, pokud se volá funkce, u které se nepředpokládá, že by za daných okolností měla dopadnout neúspěchem. A pokud by neúspěchem skončila, tak by se stejně nedalo nic dělat. V jiných programovacích jazycích na podobných místech bývá kód „co se nikdy nevolá“ a zajišťuje nějaké ukončení nebo vyvolání výjimky.

Při porovnávání vzorů se občas stane, že v popisované struktuře je něco, co není důležité a nedá se to popsat konstantou. Např. pokud v n-tici o třech prvcích je zajímavý jen první. Dalo by se na takové místo vložit proměnnou, která by se dále nepoužívala. Na takovou nepoužitou proměnnou ale kompilátor upozorňuje varováním. Tomu lze zabránit tak, že se před proměnnou dá znak podtržítko.

{A, _B, _C} = {1, 2, 3}

Druhou možností je použít samotný znak podtržítko, který má funkci zastupovat cokoliv.

{A, _, _} = {1, 2, 3}

Záleží na okolnostech a vkusu, kterou z variant použít. První má význam zejména pro lepší čitelnost kódu, kdy název proměnné s podtržítkem může napovědět, co ve struktuře je a záměrně se nepoužívá.

Funkce

Funkce je v Erlangu určena názvem a počtem parametrů (tzv. arity). Název funkce je atom, takže pro něj platí stejná pravidla, ale bývá zvykem používat malá písmena, čísla a podtržítko. Za názvem následují parametry a tělo funkce. Funkce obsahuje jeden nebo více příkazů oddělených čárkou a za posledním následuje tečka. Výsledek posledního příkazu je návratová hodnota funkce.

scitej (A, B)  ->
  Soucet = A + B,  % vznikne Soucet
  {ok, Soucet}.    % vznikne n-tice a ta se vrati jako výsledek funkce

Jedna funkce může mít více variant podle parametrů, s jakými se volá. Na popis parametrů se využívá porovnávání vzorů. Jednotlivé varianty se oddělují středníkem, za poslední následuje tečka.

eval_expr (plus, A, B)  -> A + B;
eval_expr (minus, A, B) -> A - B;
eval_expr (krat, A, B)  -> A * B.

Při volání funkce, co má více variant, se postupuje odshora dolů a první varianta, která pasuje na vzory parametrů, se zvolí a začne se vykonávat. Pokud se funkce zavolá s parametry, které neodpovídají žádnému vzoru, vyvolá se výjimka.

Je vidět, že pro ukončení řádku se používají tři různé znaky. Čárka znamená konec příkazu v těle funkce, tečka znamená ukončení funkce a středník ukončení funkce, za kterým následuje ještě jedna varianta.

Ne vždy si lze při specifikaci parametrů vystačit s porovnáváním vzorů. Parametry lze testovat pomocí vstupních podmínek (guards), které se zapisují za klíčové slovo when hned za parametry funkce.

% urceni znamenka cisla
sign (N) when N > 0  ->  1;
sign (N) when N == 0 ->  0;
sign (N) when N < 0  -> -1.

Ve vstupních podmínkách mohou být porovnávací výrazy, logické operátory, závorky a několik málo vestavěných funkcí pro testování datových typů (is_number, is_integer, is_atom …)

sign (N) when is_number(N) and N > 0  ->  1;
sign (N) when is_number(N) and N == 0 ->  0;
sign (N) when is_number(N) and N < 0  -> -1.

Jiné ani vlastní funkce povolené nejsou.

Vstupní podmínky a porovnávání vzorů lze pochopitelně kombinovat. Lze si např. ze struktury vytáhnout nějakou hodnotu a tu pak porovnat v podmínce.

Příklad. Ze zadaného listu je třeba vyfiltrovat čísla (přeskočit nečísla) a uložit je do nového listu.

get_numbers ([]) -> [];
get_numbers ([N | Tail]) when is_number(N) -> [N | get_numbers(Tail)];
get_numbers ([_ | Tail]) -> get_numbers(Tail).

Co se zde děje. První varianta říká, že výsledkem prázdného listu je opět prázdný list (zakončení rekurze). Druhá varianta říká, že pokud je na začátku prvek splňující podmínku, má se vzít a spojit s výsledkem rekurzivního volání na zbytek listu (N se vytáhne ze struktury a pak se testuje v podmínce). Třetí varianta říká, že pokud to došlo až sem, tak prvek na začátku listu je nezajímavý a výsledek je rekurzivní volání na zbytek listu.

Rozhodovací struktury

Každý program je potřeba občas větvit podle různých podmínek. V Erlangu k tomu slouží syntaktické konstrukce caseif. Case je postaveno na porovnávání vzorů. Tj. získá se nějak hodnota (zavolá se funkce, vezme se vstupní parametr nebo proměnná …) a pod ni se napíší různé vzory kterým by mohla odpovídat. U každého vzoru jsou napsané příkazy a pokud nějaký vzor odpovídá, tak se začnou vykonávat.

case file:open("soubor.txt", [read]) of
    {ok, IoDevice} ->
        do_action_read (IoDevice),
        file:close(IoDevice);
    {error, enoent} ->
        do_action_file_not_exists ();
    {error, Reason} ->
        do_action_error (Reason)
end

Co se zde děje. Zavolá se funkce file:open. Pokud vrátí n-tici {ok, IoDevice}, zavolá se funkce do_action_read a pak file:close. Přičemž je na těchto dvou řádcích možnou používat proměnnou IoDevice, která vypadla z příslušného porovnání vzorů. Druhý vzor je popsán konstantou {error, enoent} (soubor neexistuje) a poslední vzor řeší případ ostatních chyb. Pokud by funkce file:open vrátila něco, co sem nepasuje, tak vrátila hodnotu, co není v dokumentaci a vyvolá se výjimka.

Jednotlivé vzory jsou oddělené středníkem (za posledním příkazem, který ke vzoru patří) a za posledním je klíčové slovo end. Pokud má nějaký vzor více příkazů, jsou oddělené čárkou. I zde lze porovnávací vzory doplnit podmínkou za klíčovým slovem when.

case file:open("soubor.txt", [read]) of
    {ok, IoDevice} ->
        do_action_read (IoDevice),
        file:close(IoDevice);
    {error, Reason} when ((Reason == enoent) or (Reason == eacces)) ->
        do_action_file_not_exists ();
    {error, Reason} ->
        do_action_error (Reason)
end

Příkaz if funguje podobně, ale nemá na začátku výraz a místo porovnávání vzorů obsahuje logické podmínky které se postupně vyhodnocují a až je některá splněna, zavolají se příkazy u ní uvedené. Pokud žádná splněná není, vyvolá se výjimka.

if
    A > B -> do_action (A, B);
    true ->  do_default_action ()
end

Oba příkazy caseif vracejí hodnotu, kterou lze případně nějak zpracovat (přiřadit do proměnné pod.). Tou hodnotou je výsledek posledního výrazu v sekci, která se vybrala. Příklad – funkce vracející znaménko čísla.

sign (Number) when is_number(Number) ->
  Sign = if
    Number > 0  ->  1;
    Number < 0  -> -1;
    Number == 0 ->  0
  end,
  {ok, Sign}.

Alternativně by se tato funkce dala napsat

sign (Number) when is_number(Number) ->
  if
    Number > 0  -> {ok, 1};
    Number < 0  -> {ok, -1};
    Number == 0 -> {ok, 0}
  end.

nebo

sign (Number) when is_number(Number) ->
  case Number of
    N when N > 0  -> {ok, 1};
    N when N < 0  -> {ok, -1};
    N when N == 0 -> {ok, 0}
  end.

V těchto dvou případech je návratovou hodnotou funkce návratová hodnota rozhodovací struktury, a to je poslední příkaz ve větvi, co se vybrala. A tím může být případně další zanořený rozhodovací blok. To se často používá.

Cykly

Cykly v Erlangu nejsou. Jak bylo již několikrát naznačeno, pro procházení listů se používá rekurze. Pro tyto operace existují dva základní vzory. Procházení s tzv. akumulátorem a bez akumulátoru.

Příklad – součet čísel z listu čísel. Nejprve přímá rekurze bez akumulátoru.

sum ([]) -> 0;
sum ([N | Tail]) -> N + sum(Tail).

Pokud se použije tzv. akumulátor, tak přibude pomocný parametr, do kterého se bude postupně hromadit výsledek.

sum (List) -> sum (List, 0).

sum ([], Acc) -> Acc;
sum ([N | Tail], Acc) ->
  NewAcc = Acc + N,
  sum (Tail, NewAcc).

Co se tu děje. Funkce jsou tu dvě. První funkce s jedním parametrem je určena pro uživatele. Vloží do ní list a ta pak zavolá interní funkci sum se dvěma parametry, přičemž nastaví do akumulátoru výchozí hodnotu (v tomto případě nulu). Varianta s prázdným listem vrátí hodnotu akumulátoru (co se tam nasbíralo). Varianta s neprázdným listem vezme první prvek, zapracuje ho do akumulátoru (přičte ho) a zavolá sama sebe s novým akumulátorem a zbytkem listu.

Výhodou akumulátorů je, že při jednom průchodu listem se dá sbírat více dat, pokud je potřeba. Příklad – výpočet průměru z listu čísel.

avg (List) -> avg (List, 0, 0).

avg ([], _, 0) -> undefined;
avg ([], SumAcc, CountAcc) -> SumAcc / CountAcc;
sum ([N | Tail], SumAcc, CountAcc) ->
  NewSumAcc = SumAcc + N,
  NewCountAcc = CountAcc + 1,
  sum (Tail, NewSumAcc, NewCountAcc).

Až se dostaneme k predikátům (anonymní funkce, co je lze zadávat jako parametry do jiných funkcí), tak bude vidět, že rekurzi není třeba psát pokaždé znova, ale jsou na ni hotové funkce v knihovnách. Pouze se napíše, jak má být akumulátor inicializován, a jak se do něj má zapracovat prvek ze seznamu.

bitcoin_skoleni

Použití akumulátoru bývalo v dřívějších verzích Erlangu rychlejší. Dnes by to mělo být jedno a záleží na konkrétním případě, kdy je jeden z přístupů vhodnější (pokud jde o rychlost, je třeba měřit).

Ze sekvenčního programování je toho prozatím dost a příště už začneme zkoušet pouštět procesy, komunikovat mezi nimi a ukazovat si, jak pro ně organizovat kód.

Autor článku