Съпоставяне на образци и управляващи оператори


Какво означава ‘функционален език за програмиране’? Има много мнения по въпроса, но почти всеки признава Haskell за функционален език.

Истината е, че има множество от, да ги наречем, симптоми на функционалните езици за програмиране. Някои от тях са по-важни, други - не чак толкова, но бихме могли да кажем, че колкото повече такива симптоми има даден език, той е толкова по-функционален.

Elixir върви в един или повече процеси, които като структури и начин на работа не са обвързани с тези симптоми, но това, което се изпълнява в тях е написано на език за програмиране. Във всеки такъв процес върви последователна логика, написана на функционален език. Този език е основата на Elixir и същевременно е Elixir. Също така е функционален език.

В тази публикация ще обсъдим два от горе-споменатите симптоми на функционалните езици : pattern matching и immutability, като споменем и демонстрираме някои от другите. Също така ще разгледаме какви други начини има за разделяне на логиката на програмата ни.

Elixir като функционален език

Нека да изброим някои от симптомите за които говорихме и които присъстват в Elixir:

  • Функциите са first-class стойности. Кодът представлява композирани функции, абстракции се постигат главно чрез функции от по-висок ред.
  • Контролирани странични ефекти. В Elixir IO операциите са имплементирани чрез комуникацията между процеси. Нищо в текущият процес не се променя, той просто изпраща съобщение някъде.
  • Всичко е израз. Всяка конструкция връща стойност. Винаги.
  • Няма цикли - повторението и пазенето на състояние се извършва чрез рекурсия.
  • Всичко е immutable.
  • Pattern matching - данни могат да се конструират и деконструират в зависимост от тяхната структура. Различни варианти на една функция могат да се дефинират в зависимост от данните, които приема.

Има и други подобни симптоми, но тези са достатъчни засега. Нека да разгледаме pattern matching-ът, който Elixir ни предоставя.

Pattern matching

Преводът на pattern matching от Английски на Български език е ‘съпоставяне на образци’. Както сигурно си мислите - звучи странно и някак далечно. За конспекта на курса по Elixir беше нужно различните термини да са на Български, затова от време на време ще го ползваме в заглавия и под-заглавия, но когато говорим за него ще ползваме просто съпоставяне, matching или pattern matching.

Базова употреба

Pattern matching-ът е една от най-важните и основни особености на Elixir. Операторът = се нарича match operator. Можем да го сравним със знака равно в математиката. Използвайки го, превръщаме целия израз в уравнение, в което сравняваме лявата с дясната страна. Ако сравнението е успешно се връща стойността на това уравнение, ако не - има грешка.

От казаното по-горе, следва, че можем да напишем следното:

4 = 4
#=> 4

В този израз, се съпоставят лявата и дясната страна, тъй като са еднакви, съпоставянето е успешно и израза връща общата стойност на двете страни. Ако сега напишем следното:

x = 5
#=> 5

x
#=> 5

Този израз отново съпоставя лявата и дясната страна. Тук имаме променлива и match операторът прави всичко възможно съпоставянето да е успешно. Поради тази причина при съпоставката, променливата x започва да има стойност 5 и имаме match. Стойността на израза е 5 и като допълнителен ефект променливата x има стойност 5.

Сега следната съпоставка ще е сполучлива:

5 = x
#=> 5

Двете страни на израза имат една и съща стойност, както и преди, изразът връща тази стойност. Ако напишем следното:

3 = x
#=> ** (MatchError) no match of right hand side value: 5

Имаме грешка : MatchError или грешка при съпоставяне. Това е така, защото match операторът присвоява стойност на променлива само ако тя е от лявата му страна. В случая x е от дясната страна и се съпоставя неговата стойност (5) с 3, което не е успешен match.

Важно следствие от начина по който работи mattern matching-ът, е че той може да се използва за проверка на очаквани стойности. Пример:

{:ok, content} = File.read("some_file.txt")

Няма защо да се занимаваме да хвърляме грешки, ако нещо не се случи както очакваме в програмата ни. Ще пишем нашите очаквания от левите страни на изразите, съставящи програмата.

Задаване на стойности на променливи

Нека обобщим какво научихме за match оператора до тук:

  • С него се дефинират променливи.
  • С него могат да се правят проверки дали дадена променлива има дадена стойност.

Променливите в Elixir са от типа на стойността си. Те не могат просто да се декларират без да им се зададе стойност. Ако се опитате да “декларирате” променлива без да ѝ зададете стойност, то това се интерпретира от компилатора като извикване на функция:

x
#=> ** (CompileError): undefined function x/0

x = 4
#=> 4
x
#=> 4

Имената на променливите задължително започват с малка латинска буква или подчертавка (_), следвана от букви, цифри или подчертавки. Могат да завършват на ? или !.

Операторът = ще се опита да присвои стойност на всички променливи, които са в лявата страна на израза. Изпълнението на {a, b, c, d} = {1, 2, 3, 4}, ще доведе до това, че а, b, c и d вече ще имат стойности.

В следващата публикация ще говорим по-обстойно за различни структури от данни, идващи с езика. Засега интересното за тези структури е, че те могат да се използват в pattern matching за присвояване на стойности на множество променливи. Пример е tuple-ът, който представлява множество от стойности, подредени една след друга в паметта:

{one, tWo, t3, f_our, five!} = {1, 2, 3, 4, 5}
#=> {1, 2, 3, 4, 5}

one
#=> 1
tWo
#=> 2
t3
#=> 3
f_our
#=> 4
five!
#=> 5

Или пък списъкът, който е всъщност свързан списък, чиито елементи могат да са навсякъде в паметта:

[head | tail] = [1, 2, 4, 5]
#=> [1, 2, 4, 5]

head
#=> 1
tail
#=> [2, 4, 5]

[a, b | tail] = [1, 2, 4, 5]
#=> [1, 2, 4, 5]

a
#=> 1
b
#=> 2
tail
#=> [4, 5]

Ще научим повече за тези структури в следващата публикация. Ще има достатъчно примери за pattern matching с тях.

Проверка на текущите стойности на променливи

В Elixir е възможно да ‘променим’ стойността на променлива. В Erlang това не е възможно. Всъщност ако напишем:

x = 5
#=> 5

x = 6
#=> 6

Вътрешно имаме нещо като:

x0 = 5
#=> 5

x1 = 6
#=> 6

А x просто прикрива това. Второто x, което е 6 просто скрива първото, което е 5 за остатъка на кода.

Ако искаме една променлива, която вече съществува да не ‘промени’ стойността си по този начин при съпоставяне, а да се направи съпоставка с текущата ѝ стойност и да се хвърли MatchError ако не е успешна, можем да използваме pin оператора - ^:

x = 5
#=> 5

^x = 6 # Променливата се съпоставя с текущата си стойност и не приема новата преди съпоставката => грешка
#=> ** (MatchError) no match of right hand side value: 6

Интересно свойство е следното: Ако искаме да променим y само ако x е точно определена стойност, можем да го направим така:

{y, ^x} = {5, 4} # Тук y ще стане 5 само и единствено ако x е 4, иначе ще имаме MatchError

Ако се опитаме да присвоим стойност на unbound променлива (досега не е съществувала), използвайки pin оператора, ще получим грешка:

^z = 4
#=> ** (CompileError) unbound variable ^z

Параметри на функции

Както знаем от предната публикация, кодът на Elixir е структуриран в колекции от функции, наречени модули. Използвайки само функции, guard изрази и pattern matching можем да опишем всякаква логика. Няма нужда от управляващи оператори като cond или if:

defmodule Example do
  def factorial(0), do: 1
  def factorial(n), do: n * factorial(n - 1)
end

Можем да имаме множество версии на дадена функция спрямо стойностите на параметрите, които ѝ се подават. Ако извикаме Example.factorial(0) ще се изпълни първата дефиниция на функцията, а ако я извикаме с всичко останало - втората.

При анонимни функции синтаксисът е следния:

g = fn
  0 -> 0
  x -> x - 1
end
#=> #Function<6.52032458/1 in :erl_eval.expr/5>

g.(0)
#=> 0
g.(3)
#=> 2

И при двата случая при извикване, функцията съпоставя стойностите, с които е извикана от горе на долу, пробвайки дефинициите. Ако има съвпадение, се извиква то, ако не:

fn
  0 -> "zero"
  1 -> "one"
  2 -> "two"
end.(3)
#=> ** (FunctionClauseError) no function clause matching in...

Грешката FunctionClauseError е подобна на MatchError-а - няма съвпадение при опит за pattern matching. В горния случай можем да направим следното:

fn
  0 -> "zero"
  1 -> "one"
  2 -> "two"
  _ -> "many"
end.(3)
#=> "many"

Подчертавката match-ва всичко.

Нека сега разгледаме някои от управляващите оператори на езика, които както ще разберем действат идентично на pattern matching-а.

Управляващи оператори

В предишното издание на курса говорихме за тези конструкции едва към средата на семестъра. В това имаше смисъл, тъй като можем напишем всичко на Elixir и без тях. Те са просто изразно средство, което е еквивалентно на pattern matching-а и guard клаузите.

Ще започнем с нещо познато от императивните езици:

Конструкциите if и unless

Наше мнение е, че if е напълно ненужна конструкция в Elixir, а даже още по-ненужна е unless. Нека ги разгледаме:

defmodule Questionnaire do
  def how_old_are_you?(age) do
    if age > 30 do
      "Имаме си чичко или леличка"
    else
      if age > 20 do
        "Имаме си милениалче, тригърнато от живота"
      else
        "Дете"
      end
    end
  end
end

Questionnaire.how_old_are_you?(33)
#=> "Имаме си чичко или леличка"

С if можем да проверяваме неща, ако са истина се изпълнява блокът му, ако не се изпълнява блокът на else, ако е наличен. Можем да пропуснем else и тогава при неистина в проверката, изразът ще върне nil:

age = 34
#=> 34

if age < 30 do
  "Младеж"
end
#=> nil

Можем да напишем if и на един ред:

age = 32
#=> 32

name = "Пешо"
#=> "Пешо"

if age > 30, do: "Чичо #{name}", else: name
#=> "Чичо Пешо"

age = 24
#=> 24

name = "Пенка"
#=> "Пенка"

if age > 30, do: "Леля #{name}"
#=> nil

Разбира се горните неща могат да се изразят само с функции, pattern matching и guard-ове така:

defmodule Questionnaire do
  def how_old_are_you?(age) when age > 30 do
    "Имаме си чичко или леличка"
  end
  def how_old_are_you?(age) when age > 20 do
    "Имаме си милениалче, тригърнато от живота"
  end
  def how_old_are_you?(_), do: "Дете"
end

Questionnaire.how_old_are_you?(21)
#=> "Имаме си милениалче, тригърнато от живота"

Освен if, имаме unless, какво споменахме, което е същото като if not <expression>:

unless 1 + 1 == 2, do: "Салам стой си там!", else: "Защо въобще проверяваме??"
#=> "Защо въобще проверяваме??"

Конструкцията cond

В тази конструкция дефинираме списък от условия и код свързан с тях. Всяко от тези условия се оценява отгоре надолу в реда на дефинирането им, докато стигнем до някое, което се оцени като true. Когато това стане, се изпълнява асоциираният с него код и това е стойността на cond израза.

Да речем можем да разпишем FizzBuzz задачката така:

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(13)
#=> [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13]

Обикновено последното условие на 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-си (за тях в бъдещи публикации), които не са написани на Elixir. От това следва, че ако толкова много ни трябва control flow конструкция, е по добре да не ползваме if и unless, а някоя на която са базирани - cond или case.

Конструкциите if и unless са добавени за да се справи един императивен програмист с навлизането в Elixir, но според нас са повече вредни, отколкото помагат. Забравете че ги има докато трае курса. Те са просто ‘синтактичка захар‘. Всъщност забравете и за cond. Единственото което ви трябва, когато guard-овете и pattern matching-а не ви стигат е case.

Конструкцията case

Тази конструкция приема израз и списък от стойности и свързан с тях код. Ако изразът се match-не с някоя от тези стойности, кодът към нея се изпълнява и неговата стойност е стойността на case:

defmodule Questionnaire do
  def asl(age, sex, location) do
    case sex do
      :male ->
        "#{age}, М, #{location}"
      :female ->
        "#{age}, F, #{location}"
      _ ->
        "#{age}, other, #{location}"
    end
  end
end

Questionnaire.asl(25, :female, "Ямбол")
#=> "25, F, Ямбол"
Questionnaire.asl(15, :male, "Софията, братле")
#=> "15, М, Софията, братле"

Както можете да видите, имаме последна клауза, която match-ва всякакви стойности. Ако нищо не се match-не се изпълнява тази последна клауза. Ако я няма, ще имаме грешка - (CaseClauseError) no case clause matching.

В case изразите, можем да ползваме и guard-ове:

defmodule Questionnaire do
  def able_to_buy_cigarettes?(age) do
    case age do
      age when is_number(age) and age > 17 -> true
      _ -> false
    end
  end
end

Конструкцията case, също като cond е специална форма. Отново - забравете за if и unless, показахме ви ги за да ви кажем да не ги ползвате. По добре използвайте case.

Защо case е по-добър избор от cond? Проверките които правим в case, са свързани с data-та която му даваме. Това значи, че лесно можем да видим какво и защо проверяваме и е по-трудно да вкараме странични ефекти. Проверките в cond могат да са всякакви и да са свързани с всякакви данни - много по-лесно е да напишем код, в който се чудим кое, от къде идва. Много по-лесно е да имаме странични ефекти.

Това не значи, че в case не можем да ползваме нещо дефинирано извън него. Именно затова е най-добре да избягваме и case ако ни е възможно. Разбира се има случаи в които това ще доведе до много странен код. Когато сметнете, че добавяте функции с единствената цел да не пишете case, по добре не го правете.

Всъщност case и pattern matching-а са дълбоко свързани помежду си. Нека видим защо.

Конструкцията with

Последната конструкция, която ще разгледаме в тази публикация е with. Още една специална форма, която опростява писането на код и без която е напълно възможно да изразим всичко, което поискаме.

Това, което прави with е да комбинира множество успешни съпоставяния. Да речем, ако искаме няколко различни условия да са изпълнени за да направим нещо и ако само едно от тези условия не е изпълнено, да не го правим.

defmodule HR do
  def work_experience(years) when years > 10, do: :experienced
  def work_experience(years) when years > 5, do: :advanced
  def work_experience(_), do: :not_experienced

  def knows_elixir?([]), do: false
  def knows_elixir?([:elixir, _]), do: true
  def knows_elixir?([_ | rest]), do: knows_elixir?(rest)

  def read_cv(file_path), do: File.read(file_path)
end

years = 11
#=> 11

languages = [:erlang, :elixir, :rust]
#=> [:erlang, :elixir, :rust]

cv_path = "/tmp/cv.txt"

with :experienced <- HR.work_experience(years),
     true <- HR.knows_elixir?(languages),
     {:ok, cv} <- HR.read_cv(cv_path),
     do: cv
#=> "Some CV\n

Такъв код само с pattern matching би изглеждал така:

defmodule CheckCandidate do
  def get_cv(years, languages, cv_path) do
    HR.work_experience(years) == :experienced and
      HR.knows_elixir?(languages) and
        read_cv(HR.read_cv(cv_path))
  end

  defp read_cv({:ok, cv}), do: cv
  defp read_cv(_), do: nil
end

CheckCandidate.get_cv(12, [:elixir, :java], "/tmp/cv.txt")
#=> "Some CV\n

Ако зависиме от множество външни ресурси (подобни на CV файла от примера), кодът би станал още по-сложен без with.

Ако някое от условията не е изпълнено, ще получим стойността му:

with :experienced <- HR.work_experience(3),
     true <- HR.knows_elixir?(languages),
     {:ok, cv} <- HR.read_cv(cv_path),
     do: cv
#=> :not_experienced