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 е абсолютно еднакъв. От тук следва, че изборът е философски. Ние ви представихме нашата философия по-горе. Вие избирате.