Спецификации типов и поведения

Типы и спецификации

Эликсир – язык с динамической типизацией, поэтому все типы в Эликсире определяются во время выполнения. В Эликсире есть спецификации типов, которые являются нотациями для:

  1. определения сигнатур типизированных функции (спецификации);
  2. определения пользовательских типов данных.

Спецификации функций

По умолчанию в Эликсире есть несколько базовых типов, таких как integer или pid, но также есть и более сложные типы: например, функция round/1, которая округляет число с плавающей запятой до ближайшего целого, принимает number в качестве аргумента (integer или float) и возвращает integer. Как вы можете увидеть в её документации, сигнатура round/1 выглядит следующим образом:

round(number) :: integer

Обозначение :: означает, что функция слева возвращает значение того типа, который указан справа. Спецификации функций пишутся с помощью директивы @spec, расположенной прямо перед определением функции. Функция round/1 может быть написана так:

@spec round(number) :: integer
def round(number), do: # реализация...

Эликсир также поддерживает составные типы. Например, список целых чисел будет выглядеть как [integer]. Вы можете увидеть все встроенные типы Эликсира в документации по спецификациям типов.

Определение пользовательских типов

Эликсир предоставляет множество удобных встроенных типов, и также удобно в некоторых ситуациях определять пользовательские типы. Это можно сделать при определении модулей с помощью директивы @type.

Допустим, у нас есть модуль LousyCalculator, который умеет выполнять обычные арифметические операции (сложение, умножение и т. д.), но, вместо возврата чисел, он возвращает кортежи с результатом операции первым элементом и случайной цитатой вторым.

defmodule LousyCalculator do
  @spec add(number, number) :: {number, String.t}
  def add(x, y), do: {x + y, "You need a calculator to do that?!"}

  @spec multiply(number, number) :: {number, String.t}
  def multiply(x, y), do: {x * y, "Jeez, come on!"}
end

Как вы можете увидеть в примере, кортежи – составные типы. Каждый кортеж идентифицируется типами внутри него. Чтобы понять, почему String.t не записано как string, взгляните на заметки в документации по спецификациям типов.

Определение спецификации функции таким способом работает, но быстро станет надоедать, если придётся повторять {number, String.t} снова и снова. Мы можем использовать директиву @type для объявления нашего пользовательского типа.

defmodule LousyCalculator do
  @typedoc """
  Просто строка, следующая за числом.
  """
  @type number_with_remark :: {number, String.t}

  @spec add(number, number) :: number_with_remark
  def add(x, y), do: {x + y, "You need a calculator to do that?"}

  @spec multiply(number, number) :: number_with_remark
  def multiply(x, y), do: {x * y, "It is like addition on steroids."}
end

Директива @typedoc используется по аналогии с @doc и @moduledoc, только для определения пользовательских типов.

Типы, определённые через @type экспортируются и становятся доступны снаружи модуля, в котором они определены:

defmodule QuietCalculator do
  @spec add(number, number) :: number
  def add(x, y), do: make_quiet(LousyCalculator.add(x, y))

  @spec make_quiet(LousyCalculator.number_with_remark) :: number
  defp make_quiet({num, _remark}), do: num
end

Если вы хотите оставить пользовательский тип приватным, вы можете использовать директиву @typep вместо @type.

Статический анализ кода

Спецификации типов полезны для разработчиков в качестве дополнительной документации. Например, инструмент из Эрланга под названием Dialyzer использует спецификации типов для статического анализа кода. Именно поэтому в примере QuietCalculator мы писали спецификацию для функции make_quiet/1, хотя она была приватной.

Поведения

Многие модули предоставляют одинаковый публичный API. Взгляните на Plug, который, согласно его описанию, является спецификацией для компонуемых модулей в веб-приложениях. Каждый плаг – это модуль, который должен реализовывать как минимум две публичные функции: init/1 и call/2.

Поведения нужны для того, чтобы:

  • определить набор функций, который модуль должен реализовывать;
  • убедиться, что модуль реализует все функции из набора.

Если нужно, вы можете думать о поведениях как о подобии интерфейсов из объектно-ориентированных языков, вроде Java: набор сигнатур функций, который модуль обязан реализовывать.

Определение поведений

Допустим, мы хотим сделать несколько парсеров. Каждый должен парсить структурированные данные: например, JSON и YAML. Каждый из этих двух парсеров будет вести себя подобным образом: оба будут реализовывать функции parse/1 и extensions/0. Функция parse/1 будет возвращать представление этих данных в Эликсире, тогда как extensions/0 будет возвращать список файловых расширений, которые можно использовать для каждого типа данных (например, .json для JSON файлов).

Мы можем создать поведение Parser:

defmodule Parser do
  @callback parse(String.t) :: any
  @callback extensions() :: [String.t]
end

Модули, реализующие поведение Parser должны будут иметь все функции, заданные директивой @callback. Как вы видите, @callback принимает имя функции, а также её спецификацию, аналогично рассмотренной выше директиве @spec.

Применение поведений

Применить поведение достаточно просто:

defmodule JSONParser do
  @behaviour Parser

  def parse(str), do: # ... парсим JSON
  def extensions, do: ["json"]
end
defmodule YAMLParser do
  @behaviour Parser

  def parse(str), do: # ... парсим YAML
  def extensions, do: ["yml"]
end

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

Динамическая диспетчеризация

Поведения часто используются совместно с динамической диспетчеризацией. Например, мы можем добавить в модуль Parse функцию parse!, которая выполняет диспетчеризацию вызова предоставленной реализации и возвращает результат в случае :ok или вызывает ошибку в случае :error:

defmodule Parser do
  @callback parse(String.t) :: {:ok, term} | {:error, String.t}
  @callback extensions() :: [String.t]

  def parse!(implementation, contents) do
    case implementation.parse(contents) do
      {:ok, data} -> data
      {:error, error} -> raise ArgumentError, "parsing error: #{error}"
    end
  end
end

Обратите внимание, что нет необходимости определять поведение для динамической диспетчеризации в модуль, но обычно эти возможности идут рука об руку.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.