Pull to refresh

Dialyzer specs: путь джедая

Reading time3 min
Views1.3K

Есть два типа разработчиков, использующих эрланг и эликсир: те, кто пишет спеки для Dialyzer, и те, кто пока нет. Поначалу кажется, что это все пустая трата времени, особенно тем, кто пришел из языков с нестрогой типизацией. Однако они помогли мне отловить не одну ошибку еще до стадии CI, и — рано или поздно — любой разработчик понимает, что они нужны; не только как инструмент наведения полустрогой типизации, но и как отличное подспорье в документировании кода.


Но, как это всегда бывает в нашем жестоком мире, в любой бочке пользы — без ложки не обойтись. По сути, директивы @spec дублируют код объявления функции. Ниже я расскажу, как двадцать строчек кода помогут объединить спецификацию и объявление функции в конструкцию вида


defs is_forty_two(n: integer) :: boolean do
  n == 42
end

Как известно, в эликсире нет ничего, кроме макросов. Даже Kernel.defmacro/2 — это макрос. Поэтому все, что нам потребуется — определить собственный макрос, который из конструкции выше создаст и спеку, и объявление функции.


Ну что ж, приступим.


Шаг 1. Изучение ситуации.


Начнем с того, что поймем, что за AST наш макрос получит в качестве аргументов.


defmodule CustomSpec do
  defmacro defs(args, do: block) do
    IO.inspect(args)
    :ok
  end
end

defmodule CustomSpec.Test do
  import CustomSpec

  defs is_forty_two(n: integer) :: boolean do
    n == 42
  end
end

Здесь взбунтуется formatter, понарасставит скобок и отформатирует код внутри них так, что из глаз потекут слезы. Отучим его от этого. Изменим файл конфигурации .formatter.exs вот таким образом:


[
  inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
  export: [locals_without_parens: [defs: 2]]
]

Вернемся к нашим баранам и посмотрим, что там получает на вход defs/2. Надо заметить, что наш IO.inspect/2 отработает на стадии компиляции (если вы не понимаете, почему, не нужно пока играться с макросами, лучге почитать блистательную книжку «Metaprogramming Elixir» Криса Маккорда). Чтобы компилятор не заругался, мы возвращаем :ok (макросы обязаны возвращать корректный AST). Итак:


{:"::", [line: 7],
 [
   {:is_forty_two, [line: 7], [[n: {:integer, [line: 7], nil}]]},
   {:boolean, [line: 7], nil}
 ]}

Угу. Парсер считает, что главный тут — оператор ::, склеивающий определение функции и возвращаемый тип. Определение функции также содержит список параметров в виде Keyword, «имя параметра → тип».


Шаг 2. Fail fast.


Поскольку мы пока решили поддерживать только такой синтаксис вызова, нужно переписать определение макроса defs таким образом, чтобы если, например, возвращаемый тип не указан — компилятор ругался сразу.


defmacro defs({:"::", _, [{fun, _, [args_spec]}, {ret_spec, _, nil}]}, do: block) do

Ну что ж, пора и к реализации приступать.


Шаг 3. Генерация спеки и объявления функции.


defmodule CustomSpec do
  defmacro defs({:"::", _, [{fun, _, [args_spec]}, {ret_spec, _, nil}]}, do: block) do
    # аргументы для вызова функции
    args = for {arg, _spec} <- args_spec, do: Macro.var(arg, nil)
    # аргументы для спеки
    args_spec = for {_arg, spec} <- args_spec, do: Macro.var(spec, nil)

    quote do
      @spec unquote(fun)(unquote_splicing(args_spec)) :: unquote(ret_spec)
      def unquote(fun)(unquote_splicing(args)) do
        unquote(block)
      end
    end
  end
end

Здесь все настолько прозрачно, что даже и комментировать нечего.


Пора посмотреть, к чему приведет вызов CustomSpec.Test.is_forty_two(42):


iex> CustomSpec.Test.is_forty_two 42
#⇒ true
iex> CustomSpec.Test.is_forty_two 43
#⇒ false

Ну что ж, оно работает.


Шаг 4. И все?


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


В принципе, можно еще удивить коллег при помощи чего-нибудь вот такого:


defmodule CustomSpec do
  defmacro __using__(_) do
    import Kernel, except: [def: 2]
    import CustomSpec

    defmacro def(args, do: block) do
      defs(args, do: block)
    end
  end

  ...
end

(Там еще defs/2 надо будет подправить, генерируя Kernel.def вместо def), но вот этого я бы делать настоятельно не рекомендовал.


Спасибо за внимание, макросите на здоровье!

Tags:
Hubs:
Total votes 6: ↑6 and ↓0+6
Comments0

Articles