Управляющие конструкции и функции 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

Заключение

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

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

Ваш адрес email не будет опубликован.

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.