Do you want to pick up from where you left of?
Take me there

Спецификации и типы

В этом уроке мы узнаем о директивах @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 есть три директивы для определения типов:

Запишем определение нашего типа:

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!