В Elixir управляющие конструкции if и unless определены как макрос, а не часть языка. Код реализации можно увидеть в модуле Kernel. Стоит заметить что в Elixir единственными ложными значениями являются nil и false.
iex> if String.valid?("Hello") do ...> "Valid string!" ...> else ...> "Invalid string." ...> end "Valid string!" iex> if "a string value" do ...> "Truthy" ...> end "Truthy"
unless/2 похож на if/2, но работает наоборот:
iex> unless is_integer("hello") do ...> "Not an Int" ...> end "Not an Int"
case
Если нужно сопоставить с несколькими образцами, используется оператор case/2:
iex> case {:ok, "Hello World"} do ...> {:ok, result} -> result ...> {:error} -> "Uh oh!" ...> _ -> "Catch all" ...> end "Hello World"
Переменная _ является важной частью конструкции case/2. Без неё в случае отсутствия найденного сопоставления произойдёт ошибка:
iex> case :even do ...> :odd -> "Odd" ...> end ** (CaseClauseError) no case clause matching: :even iex> case :even do ...> :odd -> "Odd" ...> _ -> "Not Odd" ...> end "Not Odd"
Можно рассматривать _ как else, который будет сопоставлен с чем угодно. Так как case/2 основывается на сопоставлении с образцом, то все те же ограничения и особенности продолжают работать. Если нужно сопоставлять со значением переменной вместо ее присвоения, используется уже знакомый оператор ^/1:
iex> pie = 3.14 3.14 iex> case "cherry pie" do ...> ^pie -> "Not so tasty" ...> pie -> "I bet #{pie} is tasty" ...> end "I bet cherry pie is tasty"
Другой интересной возможностью case/2 является поддержка ограничивающих выражений:
Этот пример взят из официальной документации Getting Started.
iex> case {1, 2, 3} do ...> {1, x, 3} when x > 0 -> ...> "Will match" ...> _ -> ...> "Won't match" ...> end "Will match"
Также советуем почитать официальную документацию про выражения, доступные в ограничивающих выражениях.
cond
Когда нужно проверять условия, а не значения, можно использовать cond/1. Это похоже на else if или elsif в других языках:
Этот пример взят из официальной документации Getting Started.
iex> cond do ...> 2 + 2 == 5 -> ...> "This will not be true" ...> 2 * 2 == 3 -> ...> "Nor this" ...> 1 + 1 == 2 -> ...> "But this will" ...> end "But this will"
Также как и case/2, cond/1 вызовет ошибку, если не пройдёт ни одно из выражений. Для решения этой проблемы можно определить условие в true:
iex> cond do ...> 7 + 1 == 0 -> "Incorrect" ...> true -> "Catch all" ...> end "Catch all"
with
Специальная форма with/1 может пригодиться в ситуациях, когда сложно использовать оператор потока, либо когда нужен вложенный вызов case/2. with/1 состоит из ключевых слов, генераторов и выражения в конце.
Мы ещё обсудим генераторы в уроке о списковых включениях, но сейчас нам достаточно знать лишь то, что они используют сопоставление с образцом для сравнения правой части <- с левой.
Начнём с простого примера с with/1:
iex> user = %{first: "Sean", last: "Callan"} %{first: "Sean", last: "Callan"} iex> with {:ok, first} <- Map.fetch(user, :first), ...> {:ok, last} <- Map.fetch(user, :last), ...> do: last <> ", " <> first "Callan, Sean"
В случае, если для выражения не найдётся совпадение, вернётся несовпавшее значение:
iex> user = %{first: "doomspork"} %{first: "doomspork"} iex> with {:ok, first} <- Map.fetch(user, :first), ...> {:ok, last} <- Map.fetch(user, :last), ...> do: last <> ", " <> first :error
Давайте взглянем на пример побольше без использования with/1, а затем узнаем, как мы могли бы его улучшить:
case Repo.insert(changeset) do {:ok, user} -> case Guardian.encode_and_sign(user, :token, claims) do {:ok, token, full_claims} -> important_stuff(token, full_claims) error -> error end error -> error end
А теперь благодаря with/1 мы в итоге получим короткий и простой для понимания код:
with {:ok, user} <- Repo.insert(changeset), {:ok, token, full_claims} <- Guardian.encode_and_sign(user, :token, claims) do important_stuff(token, full_claims) end
Начиная с версии Elixir 1.3, конструкция with/1 также начала поддерживать else:
import Integer m = %{a: 1, c: 3} a = with {:ok, number} <- Map.fetch(m, :a), true <- Integer.is_even(number) do IO.puts "#{number} divided by 2 is #{div(number, 2)}" :even else :error -> IO.puts "We don't have this item in map" :error _ -> IO.puts "It is odd" :odd end
Это помогает структурировать код обработки ошибок с помощью сопоставления с образцом в общем блоке-обработчике. Значение, которое туда передаётся — первое же выражение, которое не сопоставилось в основном теле with.
Анонимные функции
Как и следует из названия, у анонимной функции нет имени. В уроке Enumбыло показано что они часто используются в качестве параметров других функций. Для определения анонимной функции в Elixir используются ключевые слова fn и end. Между ними можно определить любое количество параметров и тел функции (function body), разделённых ->.
Давайте рассмотрим простой пример:
iex> sum = fn (a, b) -> a + b end iex> sum.(2, 3) 5
Краткий синтаксис
Анонимные функции используются в языке очень часто. Потому для них было создано специальное сокращение:
iex> sum = &(&1 + &2) iex> sum.(2, 3) 5
Как вы уже могли догадаться, в сокращенной версии параметры доступны как&1, &2, &3 и так далее.
Сопоставление с образцом
Сопоставление с образцом в Elixir применяется не только для сопоставления переменных. Этот же инструмент используется в объявлении функций.
Elixir использует сопоставление с образцом для определения первого подходящего набора параметров и вызывает соответствующую имплементацию:
iex> handle_result = fn ...> {:ok, result} -> IO.puts "Handling result..." ...> {:error} -> IO.puts "An error has occurred!" ...> end iex> some_result = 1 iex> handle_result.({:ok, some_result}) Handling result... iex> handle_result.({:error}) An error has occurred!
Именованные функции
Можно определять именованные функции для дальнейшего их вызова по этим именам. Эти функции объявляются с помощью ключевого слова def в контексте модуля. Про модули будет подробнее рассказано в следующих уроках, в этом мы сосредоточимся только на именованных функциях.
Функции, определенные в модуле, доступны из других модулей:
defmodule Greeter do def hello(name) do "Hello, " <> name end end iex> Greeter.hello("Sean") "Hello, Sean"
Если функция однострочная, то ее описание можно сократить с использованием do::
defmodule Greeter do def hello(name), do: "Hello, " <> name end
Уже разобравшись в сопоставлении с образцом, давайте рассмотрим пример рекурсии с использованием именованных функций:
defmodule Length do def of([]), do: 0 def of([_ | tail]), do: 1 + of(tail) end iex> Length.of [] 0 iex> Length.of [1, 2, 3] 3
Наименования и арность функций
Ранее мы отмечали, что функции именуются путём сочетания имени и арности (количества аргументов). Это позволяет делать такое:
defmodule Greeter2 do def hello(), do: "Hello, anonymous person!" # hello/0 def hello(name), do: "Hello, " <> name # hello/1 def hello(name1, name2), do: "Hello, #{name1} and #{name2}" # hello/2 end iex> Greeter2.hello() "Hello, anonymous person!" iex> Greeter2.hello("Fred") "Hello, Fred" iex> Greeter2.hello("Fred", "Jane") "Hello, Fred and Jane"
В комментариях к функциям мы привели их наименования. Первая функция не принимает аргументы, потому описывается как hello/0; вторая принимает один параметр, потому описывается как hello/1, и т.д. В отличие от перегрузки функций в некоторых других языках, в нашем случае функции стоит считать разными . (Сопоставление с образцом, описанное ранее, применяется только в случае, когда для функций с одинаковым количеством аргументов предоставлены несколько различных описаний.)
Закрытые функции
Когда мы не хотим давать доступ к функции из других модулей, мы определяем закрытые (private) функции. Они могут быть вызваны только из этого же модуля. Такие функции определяются с помощью defp:
defmodule Greeter do def hello(name), do: phrase <> name defp phrase, do: "Hello, " end iex> Greeter.hello("Sean") "Hello, Sean" iex> Greeter.phrase ** (UndefinedFunctionError) function Greeter.phrase/0 is undefined or private Greeter.phrase()
Ограничители
Мы уже затрагивали ограничители в главе Управляющие конструкции, теперь же рассмотрим их применение в именованных функциях. Ограничители проверяются только после того как Elixir сопоставил функцию.
В следующем примере у нас есть две функции с одинаковыми сигнатурами. Мы используем ограничители для определения какую из них использовать на основе типа аргумента:
defmodule Greeter do def hello(names) when is_list(names) do names |> Enum.join(", ") |> hello end def hello(name) when is_binary(name) do phrase() <> name end defp phrase, do: "Hello, " end iex> Greeter.hello ["Sean", "Steve"] "Hello, Sean, Steve"
Аргументы по умолчанию
Когда мы хотим иметь некое значение по умолчанию у аргумента — используется синтаксис argument \\ value:
defmodule Greeter do def hello(name, language_code \\ "en") do phrase(language_code) <> name end defp phrase("en"), do: "Hello, " defp phrase("es"), do: "Hola, " end iex> Greeter.hello("Sean", "en") "Hello, Sean" iex> Greeter.hello("Sean") "Hello, Sean" iex> Greeter.hello("Sean", "es") "Hola, Sean"
Когда мы используем одновременно ограничители и аргументы по умолчанию, то все перестает работать. Давайте посмотрим как это выглядит:
defmodule Greeter do def hello(names, language_code \\ "en") when is_list(names) do names |> Enum.join(", ") |> hello(language_code) end def hello(name, language_code \\ "en") when is_binary(name) do phrase(language_code) <> name end defp phrase("en"), do: "Hello, " defp phrase("es"), do: "Hola, " end ** (CompileError) iex:31: definitions with multiple clauses and default values require a header. Instead of: def foo(:first_clause, b \\ :default) do ... end def foo(:second_clause, b) do ... end one should write: def foo(a, b \\ :default) def foo(:first_clause, b) do ... end def foo(:second_clause, b) do ... end def hello/2 has multiple clauses and defines defaults in one or more clauses iex:31: (module)
Elixir не поддерживает аргументы по умолчанию при наличии нескольких подходящих функций. Для решения этой проблемы мы добавляем определение функции с аргументами по умолчанию:
defmodule Greeter do
def hello(names, language_code \\ "en")
def hello(names, language_code) when is_list(names) do
names
|> Enum.join(", ")
|> hello(language_code)
end
def hello(name, language_code) when is_binary(name) do
phrase(language_code) <> name
end
defp phrase("en"), do: "Hello, "
defp phrase("es"), do: "Hola, "
end
iex> Greeter.hello ["Sean", "Steve"]
"Hello, Sean, Steve"
iex> Greeter.hello ["Sean", "Steve"], "es"
"Hola, Sean, Steve"
Протоколы
Согласно документации:
Протоколы — это механизм реализации полиморфизма в Elixir. Диспетчеризация возможна для любого типа данных, если тип указан в реализации протокола.
Другими словами, можно создать функцию, поведение которой будет различно в зависимости от типа её первого аргумента. Реализации протоколов могут существовать для одного из встроенных поддерживаемых псевдонимов типов: Atom, BitString, Float, Function, Integer, List, Map, PID, Port, Reference, Tuple, Any, а также для пользовательских структур. Создадим протокол под названием Countable для подсчёта элементов.
Сначала протокол нужно определить:
iex> defprotocol Countable do
...> def count_items(term) ...> end
Заметьте, функция, объявленная в протоколе, не имеет тела. Протоколы поддерживают только заголовки определений; в сущности, они переопределяют макрос def. Заголовки определений имеют больше ограничений. К примеру, определение, содержащее охранное условие, не будет скомпилировано:
iex> defprotocol Countable do
...> def count_items(term) when is_binary(term) ...> end ** (CompileError) iex:4: missing do keyword in def iex:4: (module)
Такая же ошибка возникнет при попытке компиляции функции сопоставления с литералом:
iex> defprotocol Countable do
...> def count_items("") ...> end ** (CompileError) iex:4: can use only variables and \\ as arguments in definition header iex:4: (module)
Заголовок функции в объявлении протокола должен содержать хотя бы один аргумент, именно он впоследствии понадобится для обращения к нужной реализации протокола:
iex> defprotocol Countable do
...> def count_items() ...> end ** (ArgumentError) protocol functions expect at least one argument (elixir) expanding macro: Protocol.def/1 iex:19: Countable (module)
Определим реализации протокола для подсчёта элементов для типов List, Map и пользовательского типа Order.
Реализация для List:
iex> defimpl Countable, for: List do
...> def count_items(list), do: length(list) ...> end
Реализация для Map:
iex> defimpl Countable, for: Map do
...> def count_items(map), do: map_size(map) ...> end
Реализация для Order:
iex> defmodule Order do
...> defstruct number: 0, items: [] ...> ...> defimpl Countable do ...> def count_items(%Order{items: items}) do ...> Countable.count_items(items) ...> end ...> end ...> end
Обратите внимание, что при определении реализации внутри модуля, в котором уже определена структура, аргумент for может быть опущен.
Несмотря на наличие ограничений на сопоставление при объявлении протокола, в его реализации эти ограничения отсутствуют: можно без опаски использовать сопоставление с образцом и охранные условия. Это означает, что для одного и того же количества аргументов могут существовать несколько тел функции.
Вызовем функцию протокола с указанием аргумента, который совпадает с указанными выше типами:
iex> Countable.count_items([:one, :two])
2 iex> Countable.count_items(%{one: 1, two: 2}) 2 iex> Countable.count_items(%Order{number: 1, items: [:apple]}) 1 iex> Countable.count_items({:one, :two}) ** (Protocol.UndefinedError) protocol Countable not implemented for {:one, :two} iex:1: Countable.impl_for!/1 iex:2: Countable.count_items/1
Любопытно, что каждая реализация становится подмодулем модуля протокола, а значит, в реализации протокола будут поддерживаться атрибуты модуля. Чтобы убедиться в этом, посмотрим информацию о модуле:
iex> Countable.Order.__info__(:functions)
[__impl__: 1, count_items: 1] iex> Countable.impl_for(%Order{}) Countable.Order
Атрибут модуля @for
Реализации протокола имеют доступ к атрибуту модуля @for, который представляет собой псевдоним текущего типа, и атрибуту @protocol — псевдониму реализуемого протокола. Это очень удобно во время реализации протокола для различных псевдонимов:
iex> defprotocol ToList do
...> def to_list(term) ...> end iex> defimpl ToList, for: [Map, Tuple] do ...> def to_list(term) do ...> @for.to_list(term) ...> end ...> end iex> ToList.to_list({:ok, "Hurray"}) [:ok, "Hurray"]
В примере выше оба модуля Map и Tuple определяют функцию to_list, и в зависимости от значения атрибута @for будет вызвана правильная реализация.
Резервная реализация для Any
Any — особый псевдоним, который можно использовать в качестве резервной реализации протокола или его реализации по умолчанию. При этом, в объявлении протокола необходимо указать:
iex> defprotocol Countable do
...> @fallback_to_any true ...> def count_items(term) ...> end iex> defimpl Countable, for: Any do ...> def count_items(_), do: :unknown ...> end iex> Countable.count_items({:one, :two}) :unknown
Примечание: можно обратиться к реализации протокола с Any, если необходимо создать список модулей, которые могут быть восстановлены.
Встроенные протоколы
Существует некоторое количество встроенных протоколов, которые могут оказаться крайне полезными в практических задачах. На данный момент это такие протоколы, как Collectable, Enumerable, Inspect, List.Charsи String.Chars. Каждый из них имеет только одну функцию, за исключением Enumerable, у которого их целых три. Стоит отметить, что лучше делать протоколы настолько «лёгкими», насколько это возможно.
К списку выше можно было бы добавить и модуль Access, который из протокола превратился в поведение.
Когда их использовать
Одно из значительных преимуществ протоколов заключается в том, что реализация протокола может находиться вне библиотеки/приложения, где он определён. Это пригодится разработчикам библиотек: они смогут создавать точки расширения для пользовательских типов. Вот несколько наглядных примеров из реальных проектов:
- Plug.Exception — протокол, позволяющий исключениям получать код состояния
- Phoenix.Para — протокол, конвертирующий структуры данных в параметры URL
- Scrivener.Paginater — протокол, нумерующий типы
- Poison.Encoder — протокол, кодирующий типы в формат JSON
- Joken.Claims — протокол, превращающий данные в запросы.
Производительность
Согласно документации, существует потенциальная угроза снижения производительности при использовании протоколов:
Поскольку протокол может работать с любым типом данных, необходимо при каждом вызове проверять, существует ли реализация для данного типа. Производительность при этом может упасть.
В связи с этим консолидация протоколов (как часть компиляции) по умолчанию включена. Консолидация протоколов — это процесс оптимизации диспетчеризации путём поиска всех реализаций в проекте или приложении. Чтобы проверить, включена ли эта опция, запустите следующую команду:
iex> Mix.Project.config[:consolidate_protocols]
true
Заключение
Протоколы позволяют с лёгкостью добавлять в код точки расширения, что особенно важно для создателей библиотек. Разумеется, не стоит этим увлекаться. Не используйте протоколы, когда к ним обращаются функции, проводящие сопоставление с образцом. То, что встроенных протоколов всего шесть, только подтверждает эту мысль.