Модули и функции


Организацията на кода в Elixir става чрез модули. Модулите групират множество функции. Често тези функции са логически свързани и името на модула отразява това. Например функциите в модула List работят със списъци, а тези дефинирани в модула String, оперират над низове.

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

Дефиниране на модул

Нека да видим един прост пример за модул с една функция:

defmodule Math do
  def square(n) do
    n * n
  end
end

Нека запишем горният код във файл math.exs, да пуснем iex, да компилираме и заредим модула:

% iex
Erlang/OTP 20 [erts-9.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false]

Interactive Elixir (1.6.2) - press Ctrl+C to exit (type h() ENTER for help)

iex(1)> c "math.exs"
[Math]
iex(2)> Math.square(5)
25

Както виждате, вече можем да изпълняваме функциите от този модул като следваме синтаксиса <име на модул>.<име на функция>.

Възможно е да имаме функции с едно и също име, но с различен брой параметри. Конвенцията е да се пише броя параметри на една функция след името, за да се знае за коя точно функция става въпрос. Например горната функция е Math.square/1. Ако имаме функция pow с 2 параметъра бихме я реферирали като Math.pow/2. Тази конвенция се използва широко в Elixir света и ако четете документацията в https://hexdocs.pm ще я срещнете.

Този вид именуване се използва и в самият език, за да се вземе референция към съществуваща функция и да се използва за композиция или параметър. В този случай указването на броя аргументи е задължително. Всяка функция в Elixir определя уникално от нейното име и броя аргументи, които приема. Максималният брой аргументи е 255. Пример:

square = &Math.square/1
#=> &Times.double/1
square.(4)
#=> 16

Можем да имаме 2 функции с еднакво име и различен брой параметри, които да правят две коренно различни неща. За езика това са две абсолютно различни функции, но на практика не трябва да правим това, защото може да е много объркващо за хората, които използват и четат кода ни.

Пример за функция с различен брой параметри би била String.split. Имаме String.split/1, която разделя низ по интервалите и връща като резултат списък от поднизове. Имаме и String.split/2, която разделя низа, използвайки някакъв шаблон. String.split/3 позволява да се подадат допълнителни опции като трети параметър. Пример:

String.split("Elixir is awesome. It totally kicks bum")
#=> ["Elixir", "is", "awesome.", "It", "totally", "kicks", "bum"]
String.split("Elixir is awesome. It totally kicks bum", ".", parts: 2)
#=> ["Elixir is awesome", " It totally kicks bum"]

do..end блокове с код

При дефинирането на модули и функции имаме така наречените do..end блокове. Тези блокове групират кода между тях и го подават като параметър на съответната функция (defmodule и def). Всъщност defmodule и def не са ключови думи в езика, а макроси (мислете за тях като вид функции). Дори можете да прочетете тяхната документация: https://hexdocs.pm/elixir/Kernel.html#def/2 и https://hexdocs.pm/elixir/Kernel.html#defmodule/2.

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

https://hexdocs.pm/elixir/Kernel.html#if/2 https://hexdocs.pm/elixir/Kernel.SpecialForms.html#case/2 https://hexdocs.pm/elixir/Kernel.SpecialForms.html#cond/1

Всъщност do..end блоковете са улеснен синтаксис на това кода да се подава като параметър на макроса, чрез do: (което също не е част от езика, а keyword list). Ето един пример как би изглеждала функцията square написана чрез do:

def square(n), do: n * n

Ако имаме функция от няколко реда можем да я напишем така, но трябва да използваме скоби:

def greeter(greeting, name), do: (
  IO.puts(greeting)
  IO.puts("How are you doing, #{name}?")
)

Тъй като горният синктаксис не е много удобен, много по-добре е да използваме do...end:

def greeter(greeting, name) do
  IO.puts(greeting)
  IO.puts("How are you doing, #{name}?")
end

Реално можем да пишем всичко на един ред, но това не е препоръчително, тъй като кода ще е много труден за четене:

defmodule Math, do: def square(n), do: n * n

Съкратеният синтаксис се използва само ако имаме кратка функция на един ред. Можете и да не го използвате, това е въпрос на избор или договорен стил.

Дефиниране на функции и pattern matching

Нещо много интересно, което се използва много често в Elixir е да се напишат няколко имплементации на една функция, като те се разделят чрез pattern matching (повече за него в следващата публикация). Така много елегантно се избягва писането на управляващи конструкции (if, unless, case, cond, with). Например да видим как би изглеждала функцията за факториел:

defmodule Factorial do
  def of(0), do: 1
  def of(n), do: n * of(n - 1)
end

Ето как работи:

Factorial.of(0)
#=> 1
Factorial.of(5)
#=> 120

Когато се извика функцията Factorial.of/1 с някакви параметри, Elixir ще се опита да pattern match-не параметрите към дефинициите на функцията в реда, в който са дефинирани. Когато има съвпадение ще извика тази функция. Затова и реда на дефиниране на функциите е важен. Например така написано:

defmodule Factorial do
  def of(n), do: n * of(n - 1)
  def of(0), do: 1
end

Elixir ще ни даде предупреждение:

warning: this clause cannot match because a previous clause at line 7 always matches

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

Този вид описание на логиката е доста приятен. Дава ни възможност да я дефинираме, започвайки от простите случаи и вървейки към сложните, като използваме изолирани парчета логика. Ето как би изглеждала дефиницията на редицата на Фибоначи:

defmodule Fibonachi do
  def of(1), do: 1
  def of(2), do: 1
  def of(n), do: of(n - 1) + of(n - 2)
end

Така написан кодът е доста четим. Лесно виждаме тривиалните случаи, а най-отдолу е основната логика. Ще се учудите колко много неща, за който сте писали if клаузи до сега, могат да се опишат по този начин с малки функции.

Hint: Не искате да смятате числата на Фибоначи в реален проект по този начин. По-скоро искате някой от бързите алгоритми тук

Опашкова рекурсия

Колко от вас са срещали страшните думи Stack Overflow Exception? Нашата имплементация на факториел има един недостатък - последното нещо, което се случва във функцията не е изикването на функцията рекурсивно, ами умножението на резултата от извикването с n. Това означава, че всички рекурсивни изиквания трябва да приключат преди Elixir да може да умножи всички числа:

Factorial.of(5)
5 * Factorial.of(4)
5 * (4 * Factorial.of(3))
5 * (4 * (3 * Factorial.of(2)))
5 * (4 * (3 * (2 * Factorial.of(1))))
5 * (4 * (3 * (2 * 1)))
120

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

defmodule Factorial do
  def of(n), do: of(n, 1)

  defp of(0, result), do: result
  defp of(n, result), do: of(n - 1, result * n)
end

Ако изпълним в iex следното:

:observer.start()

то можем да наблюдаваме използваната памет за пресмятане. При локален тест първият вариант при пресмятането на факториел от 100,000 използва ~10GB RAM памет. При вторият вариант (с опашкова рекурсия) използваната памет не се променя по време на изпълнение на функцията. Как се случи това? Ако последното нещо, което правим във функция е рекурсивното извикване, то компилаторът може да направи някои оптимизациии. Тъй като няма нищо останало за вършене във функцията (умножение на резултата от рекурсивното извикване с n), то текущата стекова рамка не е нужна и не се запзва. Това значи, че нашата рекурсия не изразходва памет за десетки и стотици хиляди стакови рамки.

Интересен факт: Elixir няма проблем да сметне факториел от 50,000 за 3-4 секунди. Това е число с 213,237 цифри!

Въпреки казаното за опашковата рекурсия, то тя не е решение на всички проблеми

Guard клаузи при дефиниране на функции

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

defmodule Guard do
  def what_is(x) when is_number(x) do
    IO.puts("#{x} is a number")
  end

  def what_is(x) when is_list(x) do
    IO.puts("#{inspect(x)} is a list")
  end

  def what_is(x) when is_atom(x) do
    IO.puts("#{x} is an atom")
  end
end

Горната логика работи така:

Guard.what_is(42)
# 42 is a number
#=> :ok

Guard.what_is(:cat)
# cat is an atom
#=> :ok

Guard.what_is([1,2,3])
# [1, 2, 3] is a list
#=> :ok

Guard клаузите могат да проверяват и за други свойства на аргументите. Например можем да проверяваме дали числата подавани на редицата на Фибоначи са по-големи от нула:

defmodule Fibonachi do
  def of(1), do: 1
  def of(2), do: 1
  def of(n) when is_number(n) and n > 0, do: of(n - 1) + of(n - 2)
end

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

Fibonachi.of(-5)
#=> ** (FunctionClauseError) no function clause matching in Fibonachi.of/1

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

Fibonachi.of("5")
#=> ** (FunctionClauseError) no function clause matching in Fibonachi.of/1

Този вид проверки ни позволяват да правим хитрини - например функции, които обработват различни видове данни. Можем да имаме функция, която приема както едно число, така и списък от числа:

defmodule Accumulator do
  def sum(n) when is_number(n), do: sum([n])
  def sum(list) when is_list(list), do: Enum.reduce(list, 0, &Kernel.+/2)
end

Имаме обща имплементация за списък и частна имплементация за едно число, която се свежда до викане на същата функция със списък от числа:

Accumulator.sum(42)
#=> 42
Accumulator.sum([1,2,3,4,5,6])
#=> 21

Видовете проверки, които могат да се извършват в guard клаузи са ограничени. Списък от тях можете да видите тук: http://elixir-lang.org/getting-started/case-cond-and-if.html#expressions-in-guard-clauses

Параметри със стойности по подразбиране

Elixir поддържа дефиниране на стойности по подразбиране на аргументите на функциите. Това става чрез синтаксиса param \\ value. Попълването на аргументите става от ляво на дясно, като първо се попълват стойностите на параметрите без стойности по подразбиране:

defmodule Example do
  def func(p1, p2 \\ 2, p3 \\ 3, p4) do
    IO.inspect [p1, p2, p3, p4]
  end
end

Example.func("a", "b")
#=> ["a", 2, 3, "b"]

Example.func("a", "b", "c")
#=> ["a", "b", 3, "c"]

Example.func("a", "b", "c", "d")
#=> ["a", "b", "c", "d"]

Горният пример е доста изкуствен. На практика параметрите със стойности по подразбиране са много удобни за аргументи, които държат някакъв вид допълнителни опции. Например функцията String.split/3: https://hexdocs.pm/elixir/String.html#split/3, която може да се викне с допълнителни опции:

String.split("mississippi", "i")
#=> ["m", "ss", "ss", "pp", ""]

String.split("mississippi", "i", parts: 3)
#=> ["m", "ss", "ssippi"]

Private функции

До тук винаги, когато дефинирахме функции в модул, тези функции бяха достъпни за ползване от външния свят. Понякога, обаче имаме нужда да дефинираме помощни функции, които да не са достъпни извън модула и ни трябват, за да имплементираме някаква по-сложна функционалност. Това става като дефинираме функциите с defp, вместо def. Например ако искаме да решим fizzbuzz проблема:

defmodule FizzBuzz do
  def of(n), do: Enum.map(1..n, &number_value/1)

  defp number_value(n) when rem(n, 15) == 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

FizzBuzz.of(20)
#=> [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "Fizz Buzz", 16, 17, "Fizz", 19, "Buzz"]

Pipe оператора - |>

Тук стигаме до един доста интересен оператор, който отначало ще ви се стори странен, но след като свикнете с него ще ви липсва в другите програмни езици: pipe оператора или |>. Pipe операторът взема стойността от ляво и я вмъква като първи параметър на функцията от дясно.

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

Enum.join(String.split(String.upcase("name,sex,location"), ","), " ")
#=> "NAME SEX LOCATION"

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

"name,sex,location" |> String.upcase |> String.split(",") |> Enum.join(" ")
#=> "NAME SEX LOCATION"

А сега си представете, че горната логика трябва да поддържа низове с интервали в тях, т.е. "name, sex, location" трябва да стане на "NAME SEX LOCATION".

"name, sex, location" |> String.upcase |> String.split(",") |> Enum.join(" ")
#=> "NAME  SEX  LOCATION"

Ако използваме имплементацията без pipe оператора би било доста трудно да преценим къде точно трябва да вмъкнем един String.strip, но като имаме кода написан като няколко трансформации, не е трудно да се види, че това трябва да е преди join-а:

"name, sex, location" |> String.upcase |> String.split(",") |> Enum.map(&String.strip/1) |> Enum.join(" ")
#=> "NAME SEX LOCATION"

Когато пишем подобни конструкции обикновено е доста удобно да разположим отделните стадии на отделен ред:

defmodule CsvUtils do
  def upcase_space_transform(csv_line) do
    csv_line
    |> String.upcase
    |> String.split(",")
    |> Enum.map(&String.strip/1)
    |> Enum.join(" ")
  end
end

CsvUtils.upcase_space_transform("name, sex, location")
#=> "NAME SEX LOCATION"

При писането на Elixir код е препоръчително използването на pipe оператора пред влагането на функции. Ще видите, че в много случаи можете да имплементирате идеите си като трансформации на няколко фази и това ще направи кода ви съставен от множество малки функции, които изпълняват конкретна и добре дефинирана цел. Ако разгледате и някои известни Elixir библиотеки, като Ecto ще видите, че там pipe оператора се използва за много неща, включително и за дефиниране на SQL транзакции: https://hexdocs.pm/ecto/Ecto.Multi.html#module-example .

Връзки между модули

Когато започнете да разделяте приложенията си на отделни модули, много често се налага тези модули да викат функции един на друг. Понякога е удачно да влагате модулите един в друг (нещо наричано в други езици namespaces). Mожете да влагате модулите колко си искате:

defmodule Outer do
  defmodule Inner do
    def inner_func do
      "hello world"
    end
  end

  def outer_func do
    "Greeting from the inner func: #{Outer.Inner.inner_func}"
  end
end

Outer.outer_func
#=> "Greeting from the inner func: hello world"

Outer.Inner.inner_func
#=> "hello world"

Влагането на модули единствено добавя името на родителския модул към името на модула. Горният код е еквивалентен на:

defmodule Outer.Inner do
  def inner_func do
    "hello world"
  end
end

defmodule Outer do
  def outer_func do
    "Greeting from the inner func: #{Outer.Inner.inner_func}"
  end
end

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

Elixir предоставя някои улеснения, когато работим с модули и използваме функции от един модул в друг модул.

import

import прави всички функции от подадения модул достъпни за ползване в текущият модул без да има нужда да се указва името на модула, в който живеят. Например:

defmodule CsvUtils do
  import String

  def upcase_space_transform(csv_line) do
    csv_line
    |> upcase
    |> split(",")
    |> Enum.map(&strip/1)
    |> Enum.join(" ")
  end
end

Когато import-нем модула String в нашия модул, всички функции от него са достъпни без да има нужда да се prefix-ват със String.. Функцията import позволява да се зададе и списък от функции, които да се import-нат в модула:

defmodule CsvUtils do
  import String, only: [upcase: 1, split: 2, strip: 1]

  def upcase_space_transform(csv_line) do
    csv_line
    |> upcase
    |> split(",")
    |> Enum.map(&strip/1)
    |> Enum.join(" ")
  end
end

Числото към имената на функциите е броя на аргументите им, тъй може да имаме различни функции с различен брой аргументи с едно и също име. Възможно е да се import-не всичко с изключение на списък от функции:

defmodule CsvUtils do
  import String, except: [capitalize: 1]

  def upcase_space_transform(csv_line) do
    csv_line
    |> upcase
    |> split(",")
    |> Enum.map(&strip/1)
    |> Enum.join(" ")
  end
end

Можете да прочетете повече за опциите на import в документацията: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#import/2

alias

alias позволява да се ‘преименува’ модул, за да се пише по-малко когато се използва в рамките на текущия модул. Пример:

defmodule Outer.Inner do
  def inner_func do
    "hello world"
  end
end

defmodule Outer do
  alias Outer.Inner, as: Inner

  def outer_func do
    "Greeting from the inner func: #{Inner.inner_func}"
  end
end

С горния alias можем да използваме модула Outer.Inner само чрез Inner. В примера можем да не пишем as: Inner, тъй като по подразбиране alias ще използва името след последната точка.

require

require указва, че имаме нужда от даден модул и той трябва да бъде компилиран и зареден. Обикновено не е нужно да използваме require. Единственото изключение е ако използваме макроси от някой модул. Няма да задълбаваме сега какво са макросите. Достатъчно е да знаете, че ако има нужда да използвате require, компилаторът ще ви уведоми за това.

Модулни атрибути

Когато дефинираме модули понякога е удобно да използваме константи за стойности, които се използват многократно в кода ни. За целта можем да използваме модулни атрибути. Това са стойности, които се изчисляват по време на компилация и не могат да бъдат променяни (подобни на константите в други езици). Пример:

defmodule Greeter do
  @standard_greeting "Hello, stranger!"

  def greet(nil) do
    IO.puts(@standard_greeting)
  end

  def greet(name) do
    IO.puts("Hello, #{name}!")
  end
end

Важно е да се знае, че стойностите на модулните атрибути се смятат по време на компилация, т.е. този код:

defmodule Greeter do
  @standard_time Time.utc_now

  def greet(name) do
    IO.puts("Hello, #{name}! The time is #{@standard_time}")
  end
end

винаги ще принтира едно и също време - времето когато кодът е бил компилиран.

Имена на модули и използване на Erlang модули

Тъй като Elixir работи във виртуалната машина на Erlang можем да използваме модули от Erlang. Това става като реферираме модулите като атоми. Например в Erlang има модул за генериране на случайни числа rand, който можем да използваме ето така:

:rand.uniform(100)
#=> 98
:rand.uniform(100)
#=> 41

Можете да разгледате документацията на rand тук: http://erlang.org/doc/man/rand.html. На същия сайт можете да разгледате и какви други стандартни модули има в Erlang.

Друг доста полезен модул позволява да се тества бързината на код:

defmodule Fibonachi do
  def of(1), do: 1
  def of(2), do: 1
  def of(n), do: of(n - 1) + of(n - 2)
end

{time, value} = :timer.tc(&Fibonachi.of/1, [40])
IO.puts("Function took #{time} milliseconds to run")
# Function took 3687607 milliseconds to run

Повече информация можете да прочетете на http://erlang.org/doc/man/timer.html

Нещо, което може да ви изненада е, че в Elixir няма стандартен модул Math. В началото на публикацията дефинирахме такъв модул и компилатора не се оплака от това. По принцип, компилаторът няма да компилира код, в който дефинираме модул, който вече съществува. Причината да няма модул Math в Elixir, е че такъв има в Erlang - :math:

:math.pow(4, 2)
#=> 16.0

Можем да направим и това:

square = &(:math.pow(&1, 2))
square.(5)
#=> 25.0

За този синтаксис си поговорихме по-подробно в публикацията за типове.

Модулите от Erlang реферираме с атоми. Това не е нещо ново. Модулите от Elixir, нашите собствени модули, както и модулите от някоя библиотека също реферираме с атоми. Fibonachi е атом. Думи започващи с главна буква в Elixir са атоми, които всъщност представляват: :"Elixir.Дума":

Bla == :"Elixir.Bla"
#=> true

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