Спецификации сигнатур функций (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
Voilà. Оно уже как-то работает. В смысле, оно не ругается на синтаксические ошибки и не сбивает с толку парсер и интерпретатор.
Хардкор: логическое 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:bazjust 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
Вопросы, замечания, указания на ошибки? — С удовольствием отвечу и поспорю.
