Спецификации сигнатур функций (Typespecs)


Эликсир позаимствовал из Эрланга многое. Например, оба — языки с динамической
типизацией (что прекрасно, что бы вам там категоричные строгие типы в касках
не говорили). При этом, в обоих языках присутствуют расширенные возможности для
проверки типов, когда это надо.


Вот введение в typespecs, а вот здесь можно ознакомиться с ними подробнее.


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


@spec concat(binary(), any()) :: {:ok, binary()} | {:error, any()}
def concat(origin, any), do: origin <> IO.inspect(any)

Эта фунция ожидает на входе строку и любой терм, и возвращает строку (получившуюся конкатенацией первого параметра и приведенного к строковому типу второго).


Когда мы решили поддержать явное указание типов для методов в Dry::Protocols, я поэкспериментировал немного с синтаксом. Мне удалось почти полностью повторить эликсировские typespec:


include Dry::Annotation

@spec[(this), (string | any) :: (string)]
def append(this, any)
  this << any.inspect
end

Вот эта вот инструкция @spec парсится и исполняется стандартным парсером ruby. Хочу рассказать, как она была реализована. Если вы думаете, что умеете в ruby, очень рекомендую не читать дальше, а попробовать добиться такого же результата — это весело.


Да, я в курсе про contracts.ruby, но мне совсем не хотелось тащить такого невнятного монстра в малюсенькую прикладную библиотеку, ну и я уже давно не доверяю коду из интернетов.


Выбор синтаксиса


Ну, чтобы сделать задачу поинтереснее, я намеренно решил добиваться синтаксиса, максимально похожего на оригинальный. Я, разумеется, мог пойти по скучному монорельсовому пути и объявить многословный, занудный и раздражающий DSL, но я не такой деревянный кодер.


Итак, поглядим, что нам позволит стандартный парсер ruby. Одинокая instance-переменная будет просто проигнорирована (ну, для пуристов: значение nil будет возвращено на стадии парсинга и мгновенно позабыто), поэтому у нас остается примерно три варианта: присвоить ей значение типа, или вызвать напрямую. Мне лично больше глянулся вариант с вызовом @spec[...] (который просто делегирует @spec.call под капотом).


- @spec = ...
- @spec.(...)
+ @spec[...]

Теперь параметры. Простейший способ скормить парсеру кучу всего, чего угодно — создать инстанс какого-нибудь всеядного класса-аккумулятора и возвращать self из каждого вызова method_missing. Чтобы по максимуму избежать наложений имен, я унаследуюсь от BasicObject, а не от стандартного отца-родителя Object:


class AnnotationImpl < BasicObject
  def initialize
    @types = {args: [], result: []}
  end

  def ___?(name, *args, &?)
    @types[:args] << [args.empty? ? name : [name, args, ?]]
    self
  end
end

module Annotation
  def self.included(base)
    base.instance_variable_set(:@annotations, AnnotationImpl.new)
    base.instance_variable_set(:@spec, ->(*args) { puts args.inspect })
    base.instance_eval do
      def method_missing(name, *args, &?)
        @annotations.__send__(:___?, name, *args, &?)
      end
    end
  end
end

Я такие дикие имена даю методам этого класса не случайно: не хотелось бы, чтобы наследник случайно его переписал. Да, этот подход довольно опасен в принципе, потому что мы сейчас перепишем method_missing в модуле Annotation, который потом будет включать везде, где нам будут нужны аннотации.


Ну, это же просто демонстрация мощи ruby, так что нормально. И, кстати, для исходной задачи аннотирования методов в Dry::Protocols, это почти безопасно: протоколы в принципе очень изолированы и определяют всего несколько стоящих особняком методов: такой дизайн.


Ну, поехали. У нас уже есть все, чтобы поддержать синтак аннотаций вида @spec[foo, bar, baz]. Пора включить Annotation в какой-нибудь класс и посмотреть, что получится.


class C
  include Annotation
  @spec[foo, bar, baz]
end
#? NoMethodError: undefined method `inspect' for #<AnnotationImpl:0x00564f9d7e0e80>

А, ну да, BasicObject же. Определим его:


def inspect
  @types.inspect
end

Voila?. Оно уже как-то работает. В смысле, оно не ругается на синтаксические ошибки и не сбивает с толку парсер и интерпретатор.


Хардкор: логическое or для типов


Ну, начинается интересное. Хочется не ограничиваться одним типом; надо поддержать булево или, чтобы можно было задавать несколько разрешенных типов! В эликсире это сделано с помощью |, ну и мы сделаем так же. Может показаться, что это не так-то просто, но на самом деле, нет. Классы в Ruby позволяют переопределение метода #|:


def |(_)
  @types[:args].push(@types[:args].pop(2).reduce(:+))
  self # always return self to collect the result
end

Что тут происходит? Достали два элемента из массива (этот и предыдущий,) склеили их,
сохраняя порядок и засунули обратно в массив аргументов:


  • @types[:args] before: [[:foo], [:bar], [:baz]] where the :baz just came in
  • after 2 pops: [[:foo]] and [[:baz], [:bar]].rotate.reduce(&:concat) ? [[:bar, :baz]]
  • @types[:args] after: [[:foo], [:bar, :baz]]

Не сложно. Еще, чтобы сделать код почище и избежать путаницы с порядком вызовов методов,
мы заставим пользователей эксплицитно оборачивать аргументы в скобки @spec[(foo), (bar | baz)].


Уровень nightmare: тип результата


Ну, вот тут я ожидал почти неразрешимых проблем. Разумеется, я мог бы использовать
hashrocket, как сделали бы ленивые неамбициозные рейлисты, но я не таков!
Мне хотелось добиться элегантности синтакса эликсира, с двоеточиями:


- @spec[(foo), (bar, baz) => (boo)]
+ @spec[(foo), (bar, baz) :: (boo)]

Но как? — Да запросто. Как всем известно, ruby позволяет вызывать методы используя
не точку, а двойное двоеточие 42::to_s #? "42", причем не только методы класса.


def call(*)
  @types[:result] << @types[:args].pop
  self # always return self to collect the result
end

Смотрите, как изящненько: двойное двоеточие просто делегирует вызов методу call
инстансу-ресиверу. Наша реализация просто вытащит последний аргумент из входного массива
(вся строка «выполняется» справа налево), и засунет в массив result.
Честно говоря, я думал, это будет сложнее.


Полировка: прикрепление аннотаций к методам


Тут вообще ничего делать не нужно: def, который идет вслед за аннотацией,
возвращает инстанс метода. Который и будет автоматически передан в качестве
аргумента тому, что вернет @spec[]. Поэтому мы просто вернем самое себя, и
полив метод в качествн входного аргумента — прицепим к нему аннотацию. Так просто.


Подводя итоги


Фактически, все. Реализация готова к использованию. Ну, почти. Еще несколько
косметических добавлений, чтобы разрешить несколько разных вызовов @spec
(наподобие того, как desc в определении rake tasks собирает все определения
для дальнейшего использования), и документация.


Хотелось бы предостеречь тех, кто побежал внедрять это прямо сейчас: не нужно
делать это ни дома, ни в школе, ни на работе. Не потому, что этот код сложен
(он прост), и не потому, что его сложно читать (его просто читать).


Просто в реальной жизни это никому не нужно, ruby хорош именно тем, что он
супер, утка, динамичен, и типизировать его — только портить. Ну и засорять
глобальное пространство имен всяким шлаком, как это любят делать рельсы —
так себе практика. Если нужно внезапно найти у себя в простом классе миллиард
ненужных, неизвестно откуда взявшихся методов — ну, у нас уже есть ActiveSupport.
На наш век хватит.


Код я привожу исключительно, как пример почти неограниченных возможностей ruby
выполнить любую прихоть сошедшего с ума разработчика.




Appendix I :: исходник


module Dry
  class AnnotationImpl < BasicObject
    def initialize
      @spec = []
      @specs = []
      @types = {args: [], result: []}
    end

    def ___? &?
      return @spec if ?.nil?
      (yield @spec).tap { @spec.clear }
    end

    def ___??
      @specs
    end

    def ___?
      @types
    end

    def to_s
      @specs.reject do |type|
        %i[args result].all? { |key| type[key].empty? }
      end.map do |type|
        "@spec[" <<
          type.
            values.
            map { |args| args.map { |args| "(#{args.join(' | ')})" }.join(', ') }.
            join(' :: ') << "]"
      end.join(' || ')
    end

    def inspect
      @specs.reject do |type|
        %i[args result].all? { |key| type[key].empty? }
      end.inspect
    end

    def call(*)
      @types[:result] << @types[:args].pop
      self
    end

    def |(_)
      @types[:args].push(
        2.times.map { @types[:args].pop }.rotate.reduce(&:concat)
      )
      self
    end

    def ?___?(name, *args, &?)
      @types[:args] << [args.empty? ? name : [name, args, ?]]
      self
    end
  end

  module Annotation
    def self.included(base)
      annotations = AnnotationImpl.new
      base.instance_variable_set(:@annotations, annotations)
      base.instance_variable_set(:@spec, ->(*args) {
        impl = args.first
        last_spec = impl.___?.map { |k, v| [k, v.dup] }.to_h

        # TODO WARN IF SPEC IS EMPTY
        %i[args result].each do |key|
          last_spec[key] << %i[any] if last_spec[key].empty?
        end

        base.instance_variable_get(:@annotations).___?? << last_spec
        base.instance_variable_get(:@annotations).___?.replace([last_spec])

        impl.___?? << last_spec
        impl.___?.each { |k, v| v.clear }
      })

      base.instance_eval do
        def method_missing(name, *args, &?)
          @annotations.__send__(:?___?, name, *args, &?)
        end
      end
    end
  end
end



Вопросы, замечания, указания на ошибки? — С удовольствием отвечу и поспорю.

Комментарии (2)


  1. borisxm
    30.11.2019 11:17

    Красиво! На счет «реальной жизни» не согласен — у нас имеется куча мелких библиотек, которые хоть и документированы, но рассчитаны на использование программирующими инженерами, а не программистами или кодерами. И вот там дополнительный, и достаточно гибкий, контроль совсем не помешает.


    1. chapuza Автор
      30.11.2019 13:56

      Возьмите contracts.ruby который я в начале упоминал, или DSL напишите. Я, говоря про реальную жизнь, имел в виду «не нужно демонстрировать коллегам, что вы настолько умеете в метапрограммирование». А вдруг вы потом в лотерею миллион выиграете, а вашим товарищам — этот код поддерживать?


      С другой стороны, я очень люблю пижонство и вот такая мини-библиотечка у нас уже три года в продакшене :) Но тут я точно знал, что это финальная версия, и дорабатывать ее не потребуется.