Проблема с 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‘а.

Ссылки