Спецификации и типы
В этом уроке мы узнаем о директивах @spec
и @type
. Первая — скорее дополнение синтаксиса языка для написания документации, которая может быть проанализирована специальными инструментами. Вторая — помогает нам писать более читаемый и понятный код.
Введение
Иногда вы можете захотеть сделать описание для интерфейса вашей функции. Конечно, вы можете использовать аннотацию @doc, но это информация для других разработчиков, которая не проверяется во время компиляции. В Elixir есть аннотация @spec
, она позволяет описать спецификацию функции, и может использоваться для дополнительной проверки кода.
Такая спецификация может получиться весьма объёмной из-за сложных параметров. Вы можете упростить её, определяя свои типы. В Elixir для этого существует аннотация @type
. С другой стороны, Elixir всё равно остаётся динамическим языком. Это значит, что вся информация о типах будет проигнорирована компилятором, но может быть использована другими инструментами.
Спецификация
Если у вас есть опыт работы с Java, вы можете думать о спецификации как об интерфейсе. Спецификация определяет типы параметров и возвращаемого значения функции.
Чтобы описать принимаемые и возвращаемые функцией типы мы используем директиву @spec
, располагая её прямо перед определением функции. Директива принимает в качестве параметров: имя функции, список типов аргументов функции, и, после знака ::
, тип возвращаемого значения.
Рассмотрим пример:
@spec sum_product(integer) :: integer
def sum_product(a) do
[1, 2, 3]
|> Enum.map(fn el -> el * a end)
|> Enum.sum()
end
Всё выглядит верно и мы получим правильный результат после вызова функции, но Enum.sum
возвращает значение типа number
а не integer
, как мы ожидали в @spec
. Это может быть источником ошибок! Существуют инструменты статического анализа кода, например Dialyzer
, которые помогают нам найти такие ошибки. Мы поговорим о них в других уроках.
Пользовательские типы
Создание спецификации — хорошо, но иногда наши функции работают со сложными структурами данных, вместо чисел или списков. В таком случае определение в @spec
будет очень сложно понять и/или изменить. Иногда функциям необходимо принимать большое число параметров или возвращать сложные структуры данных. Длинный список параметров — это одно из потенциально проблемных мест кода. В объектно-ориентированных языках, таких как Ruby или Java, мы легко можем определить классы, которые помогут нам решить эту проблему. В Elixir нет классов, но благодаря тому, что он легко расширяем, мы можем определять наши собственные типы.
Изначально Elixir уже содержит некоторые простые типы, такие как integer
или pid
. Вы можете найти полный список доступных типов в документации.
Определение пользовательского типа
Изменим нашу функцию sum_times
и добавим несколько дополнительных параметров:
@spec sum_times(integer, %Examples{first: integer, last: integer}) :: integer
def sum_times(a, params) do
for i <- params.first..params.last do
i
end
|> Enum.map(fn el -> el * a end)
|> Enum.sum()
|> round
end
Мы добавили в модуль Examples
структуру, которая содержит два поля: first
и last
. Это упрощенная версия структуры из модуля Range
. Мы поговорим о структурах
в разделе модули. Представьте, что нам надо написать в нашем коде спецификации, включающие структуру Examples
, множество раз. Это будет довольно нудно — писать длинную, сложную спецификацию, и может послужить источником ошибок. Решением этой проблемы будет директива @type
.
У Elixir
есть три директивы для определения типов:
-
@type
— простой публичный тип. Внутренняя структура типа тоже публична. -
@typep
— тип закрытый и может использоваться только в том модуле, в котором описан. -
@opaque
— тип публичный, но его внутренняя структура закрыта.
Запишем определение нашего типа:
defmodule Examples do
defstruct first: nil, last: nil
@type t(first, last) :: %Examples{first: first, last: last}
@type t :: %Examples{first: integer, last: integer}
end
Мы определили тип t(first, last)
, который представляет нашу структуру %Examples{first: first, last: last}
. Теперь мы видим, что типы могут принимать параметры. Но мы также определили и тип t
, в данном случае это представление структуры %Examples{first: integer, last: integer}
.
В чем отличие? Первый — представляет структуру Examples
, в которой оба ключа могут иметь любой тип. Второй — представляет структуру, ключи в которой имеют тип integer
. Это означает, что такой код:
@spec sum_times(integer, Examples.t()) :: integer
def sum_times(a, params) do
for i <- params.first..params.last do
i
end
|> Enum.map(fn el -> el * a end)
|> Enum.sum()
|> round
end
равносилен коду:
@spec sum_times(integer, Examples.t(integer, integer)) :: integer
def sum_times(a, params) do
for i <- params.first..params.last do
i
end
|> Enum.map(fn el -> el * a end)
|> Enum.sum()
|> round
end
Документирование типов
Последний вопрос, который нам надо обсудить: как документировать наши типы. Как известно из урока документирование, для описания функций и модулей у нас есть аннотации @doc
и @moduledoc
. Для описания наших типов мы можем использовать @typedoc
:
defmodule Examples do
@typedoc """
Тип, который представляет структуру Examples с полями :first типа integer и :last типа integer.
"""
@type t :: %Examples{first: integer, last: integer}
end
Директива @typedoc
аналогична директивам @doc
и @moduledoc
.
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!