Типы и спецификации
Эликсир – язык с динамической типизацией, поэтому все типы в Эликсире определяются во время выполнения. В Эликсире есть спецификации типов, которые являются нотациями для:
- определения сигнатур типизированных функции (спецификации);
- определения пользовательских типов данных.
Спецификации функций
По умолчанию в Эликсире есть несколько базовых типов, таких как 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
Обратите внимание, что нет необходимости определять поведение для динамической диспетчеризации в модуль, но обычно эти возможности идут рука об руку.