Спецификации сигнатур функций (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
Вопросы, замечания, указания на ошибки? — С удовольствием отвечу и поспорю.
borisxm
Красиво! На счет «реальной жизни» не согласен — у нас имеется куча мелких библиотек, которые хоть и документированы, но рассчитаны на использование программирующими инженерами, а не программистами или кодерами. И вот там дополнительный, и достаточно гибкий, контроль совсем не помешает.
chapuza Автор
Возьмите
contracts.ruby
который я в начале упоминал, или DSL напишите. Я, говоря про реальную жизнь, имел в виду «не нужно демонстрировать коллегам, что вы настолько умеете в метапрограммирование». А вдруг вы потом в лотерею миллион выиграете, а вашим товарищам — этот код поддерживать?С другой стороны, я очень люблю пижонство и вот такая мини-библиотечка у нас уже три года в продакшене :) Но тут я точно знал, что это финальная версия, и дорабатывать ее не потребуется.