Проблема с keyword arguments в Ruby 3.0
После недавнего выхода Ruby 3.0 я решил проверить, что gem Dynamoid нормально на нем заводится. И в процессе столкнулся с любопытными проблемами. Matz зарелизил Ruby 3.0 по традиции на католическое Рождество и вместе с тем выкатил обратно несовместимые изменения в keyword arguments. Еще в предыдущей версии Ruby 2.7 начали сыпаться deprecation warning‘и, но руки не дошли это исправить. Теперь warning‘и превратились в exception‘ы и пришлось таки этим заняться.
Keyword arguments
Давайте разберемся, что поменяли в keyword arguments, и вначале проговорим, что это такое. Keyword arguments ввели давным давно - еще в Ruby 2.0. Формально это именованные параметры, которые поддерживают такие языки как Scala, Kotlin и даже PHP.
def foo(a:, b:, c:)
[a, b, c]
end
foo(c: 3, b: 2, a: 1)
=> [1, 2, 3]
Думаю, что чаще всего keyword arguments в Ruby использовали вместо
параметра options вместе с оператором **
:
# как было
def bar(a, options)
[a, options[:b], options[:c]]
end
# как стало
def bar(a, **options)
[a, options[:b], options[:c]]
end
Обратно несовместимые изменения
Изначально не было разницы между передачей параметров как keyword arguments и как hash. Вероятно чтобы облегчить переход. Keyword arguments автоматически конвертировались в hash и наоборот.
Но начиная с Ruby 3.0 началось разделение keyword arguments и позиционных аргументов. Это с подробностями описано в статье The Delegation Challenge of Ruby 2.7. Хотя статья и примечательная, она слишком объемная. Попробую кратко пересказать ее.
Изменилось поведение операторов *args
и **options
. Если раньше
*args
захватывал keyword arguments и они передавались как
завершающий hash-параметр, то теперь позиционные параметры и keyword
arguments разделяются строже. Приведу пример:
def foo(*a, **kw)
[a, kw]
end
Вызов foo
с keyword argument работает по старому:
# Ruby 2.6
foo(a: 1)
=> [[], {:a=>1}]
# Ruby 3.0
foo(a: 1)
=> [[], {:a=>1}]
Но если вместо этого передать hash - поведение меняется:
# Ruby 2.6
foo({a: 1})
=> [[], {:a=>1}]
# Ruby 3.0
foo({a: 1})
=> [[{:a=>1}], {}]
Завершающий hash-параметр раньше захватывался оператором **
и
передавался как keyword argument. Теперь он захватывается оператором
*
и передается как позиционный аргумент.
В этом нет ничего страшного ни для разработчиков приложений, ни для разработчиков библиотек. Но возникает неприятный нюанс с делегированием параметров. Это распространенная практика делегировать вызов метода другому объекту:
class A
def initialize(target)
@target = target
end
def method_missing(name, *args, &block)
@target.send(name, *args, &block)
end
end
proxy = A.new("")
=> #<A:0x00007fe570028bd8 @target="">
proxy << "abc"
proxy
=> #<A:0x00007fe570028bd8 @target="abc">
И в чем же проблема, спросите вы? Проблема в том, что теперь в Ruby 3.0
хотя *args
и захватит keyword arguments, при передаче параметров они
так и остаются позиционным параметром. Следовательно, метод ожидающий
keyword arguments их не получит и отработает некорректно:
obj = Object.new
def obj.foo(*args, **kw)
[args, kw]
end
proxy = A.new(obj)
obj.foo(1, a: 2)
=> [[1], {:a=>2}] # <=== правильный результат
proxy.foo(1, a: 2)
=> [[1, {:a=>2}], {}] # <=== некорректный результат
Какие варианты решения?
Если добавим явный **kw
параметр, это не решит проблему.
def method_missing(name, *args, **kw, &block)
@target.send(name, *args, **kw, &block)
end
Такой подход работает в Ruby 3.0 но в Ruby 2.6 и младше будет
добавляться дополнительный параметр {}
для keyword arguments, даже
если их не передали и target метод их не ожидает:
# Ruby 2.6
obj = Object.new
def obj.foo(*args)
args
end
proxy = A.new(obj)
proxy.foo(1)
=> [1, {}]
А это уже серьезная проблема. Если target метод не ожидает дополнительный параметр, то будет бросаться исключение:
def obj.bar
end
proxy.bar
# Traceback (most recent call last):
# 4: from .../bin/irb:11:in `<main>'
# 3: from (irb):21
# 2: from (irb):7:in `method_missing'
# 1: from (irb):19:in `bar'
# ArgumentError (wrong number of arguments (given 1, expected 0))
В Ruby 2.7 добавили костыль в виде метода
ruby2_keywords
. Он переключает механизм делегирования для конкретного
метода обратно на режим Ruby 2.6:
ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
Это рабочий подход и он облегчает переход на новые версии Ruby. Его
используют даже в Rails
(примеры).
Проблема только в том, что это временное решение. Через несколько лет
метод ruby2_keywords
уберут и придется отказаться от поддержки Ruby
2.6 и ранних версий.
Любопытно, что кроме Module#ruby2_keywords
и Proc#ruby2_keywords
, в
публичное API попали и другие костыльные методы, которые отмечены в
документации как “not for casual use”:
Hash.ruby2_keywords_hash?
Hash.ruby2_keywords_hash
В Ruby 2.7 добавили новый идеологически правильный способ делегировать
параметры - оператор ...
:
def method_missing(name, ...)
@target.send(name, ...)
end
Это работает но только начиная с Ruby 2.7, а в более ранних версиях недоступно.
Для разработчика приложения это не вызовет проблем. Версия Ruby зафиксирована. Берем подходящий способ делегирования и все работает. А вот я как разработчик библиотеки оказался в тупике.
Итоговое решение в Dynamoid
Dynamoid поддерживает старые версии Ruby (начиная с Ruby 2.3) и пока от этого отказываться не планирую. И в Dynamoid часто используется делегирование. Поэтому нужно поддерживать делегирование как в режиме Ruby 2.6, так и в режиме Ruby 2.7 и теперь в режиме Ruby 3.0. Dynamoid продолжит поддерживать старые версии Ruby даже после прекращения официальной поддержки Ruby core team. Согласно статистике https://stats.rubygems.org/ Ruby 2.3, которая вышла 7 лет назад и перестала поддерживаться 2 года назад, занимает долю в 45%:
Так как же быть? Поддерживать Ruby 3.0 надо. Делегирование в 3.0
ломается. Вариант с оператором ...
не подходит однозначно - это не
работает в Ruby 2.6 и младше. Вариант с методом ruby2_keywords
не
решает проблему для Dynamoid, а откладывает ее. Откладывает до момента,
когда прекратится поддержка Ruby 2.6, а будет это скоро.
Поэтому я рассматривал два варианта
- или убрать делегирование из Dynamoid
- или не использовать keyword arguments в методах, которые вызывают через делегирование.
Второй вариант оказался намного проще. Хотя keyword arguments используются в Dynamoid, только один приватный метод вызывался через делегирование. Исправление одной строчки кода и нескольких строчек в тестах и вуаля - тесты проходят на Ruby 3.0.
PS
Если посмотреть со стороны, то такое решение больше похоже на бегство от проблемы, чем на решение. С другой же стороны мы не попали в капкан, который расставили на бедных разработчиков Matz и Ruby core team.
Меня каждый раз удивляет, как легко Matz накидывает breaking changes.
Сразу вспоминается переход с Ruby 1.8 на 1.9, в котором было много таких
изменений. И россыпь if
-ов раскиданных по коду gem‘ов с проверкой
версии Ruby.
Если думаете, что мелкие breaking changes безобидны - это не так. Я переводил коммерческий проект с Ruby 2.2 на Ruby 2.5 и на это ушла неделя. В основном на обновление зависимостей, чтобы найти минимальную версию gem‘а с поддержкой Ruby 2.5 и минимизировать breaking changes уже самого gem‘а.