Управляющие конструкции и функции Elixir

В 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, если необходимо создать список модулей, которые могут быть восстановлены.

Встроенные протоколы

Существует некоторое количество встроенных протоколов, которые могут оказаться крайне полезными в практических задачах. На данный момент это такие протоколы, как CollectableEnumerableInspectList.Charsи String.Chars. Каждый из них имеет только одну функцию, за исключением Enumerable, у которого их целых три. Стоит отметить, что лучше делать протоколы настолько «лёгкими», насколько это возможно.

К списку выше можно было бы добавить и модуль Access, который из протокола превратился в поведение.

Когда их использовать

Одно из значительных преимуществ протоколов заключается в том, что реализация протокола может находиться вне библиотеки/приложения, где он определён. Это пригодится разработчикам библиотек: они смогут создавать точки расширения для пользовательских типов. Вот несколько наглядных примеров из реальных проектов:

  • Plug.Exception — протокол, позволяющий исключениям получать код состояния
  • Phoenix.Para — протокол, конвертирующий структуры данных в параметры URL
  • Scrivener.Paginater — протокол, нумерующий типы
  • Poison.Encoder — протокол, кодирующий типы в формат JSON
  • Joken.Claims — протокол, превращающий данные в запросы.

Производительность

Согласно документации, существует потенциальная угроза снижения производительности при использовании протоколов:

Поскольку протокол может работать с любым типом данных, необходимо при каждом вызове проверять, существует ли реализация для данного типа. Производительность при этом может упасть.

В связи с этим консолидация протоколов (как часть компиляции) по умолчанию включена. Консолидация протоколов — это процесс оптимизации диспетчеризации путём поиска всех реализаций в проекте или приложении. Чтобы проверить, включена ли эта опция, запустите следующую команду:

iex> Mix.Project.config[:consolidate_protocols]
true

Заключение

Протоколы позволяют с лёгкостью добавлять в код точки расширения, что особенно важно для создателей библиотек. Разумеется, не стоит этим увлекаться. Не используйте протоколы, когда к ним обращаются функции, проводящие сопоставление с образцом. То, что встроенных протоколов всего шесть, только подтверждает эту мысль.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *