Rubyのバージョンアップによってエラーが発生するようになった

バージョンアップによって既存コードが動かなくなる(エラーになる)という現象に初めて遭遇しました。
話には聞くものの、経験したことがなかったので「本当にあるんだ...」と謎に嬉しい気持ちになりました。

バージョンアップをしたのはRuby本体のバージョンです。
元々はRuby2.7系だったものを、少し前にRuby3.1系にバージョンアップしました。しかし、バージョンアップをした当時、エラーが発生するようになっていることには気づいておらず、先日、エラーログの調査する中でようやく気づきました。幸か不幸かあまり使われていない機能だったため、エラーログがほとんど出ていなかったのです。

該当のメソッド

エラー(ArgumentError)が発生するようになったのはgoogle-cloud-firestoreのupdateメソッドです。

def update data, update_time: nil
  ensure_client!

  resp = client.batch do |b|
    b.update self, data, update_time: update_time
  end
  resp.write_results.first
end

google-cloud-ruby/google-cloud-firestore

といってもupdateメソッドの内部で例外が発生するようになったという訳ではありません。
なんとコードを全く変えていないにも関わらず、呼び出し側でエラーが発生するようになったのです。

irb> ...update(name: 'okabe', age: 26)

in `update': wrong number of arguments (given 0, expected 1) (ArgumentError)

最初は「google-cloud-firestoreのupdateメソッドの引数の定義が変わったかな?」...と疑いました。
しかし、かなり前のバージョンからupdateメソッドには変更がなかったので、この仮説は全くの見当違いでした。
原因が全く分からず、途方に暮れていた所、Rubyのバージョンが変化していることに気づいたのです。

Rubyのバージョンアップが原因だった

先ほどのupdateメソッドを模倣した関数を用意しました。
この関数を使ってRuby2.7系とRuby3.1系で、それぞれ挙動を検証してみます。Rubyのバージョン切り替えにはrbenvを使用しました。

# verification.rb
def update(data, update_time: nil)
  puts data
  puts data.class
end

puts RUBY_VERSION
update(hoge: 1, foo: 2)

Ruby3.1x

エラーログと同様にArgumentErrorが発生しました。

verification.rb:one:in `update': wrong number of arguments (given 0, expected 1) (ArgumentError)
  from verification.rb:seven:in `<main>'

Ruby2.7x

なんとエラーにならず普通に通りました。
以前、この現象に気づいてなかったのはエラーが出ないためだったのでしょうか。
とはいえ警告のログは出力されているので、完全に見落としていたようです。

2.7.1
verification.rb:seven: warning: Passing the keyword argument as the last hash parameter is deprecated
verification.rb:one: warning: The called method `update' is defined here
{:hoge=>1, :foo=>2}
Hash

警告の内容を見てみると「最後のハッシュパラメータとしてのキーワード引数は非推奨です」とのことです。
キーワード引数というのはdef hoge(bar:)のように定義することで呼び出し側でhoge(bar: 'foo')のようにキーワードを利用して引数を指定することが可能となるものです。またRubyでは関数に定義された最後の引数のハッシュの括弧({})を省略可能という仕様があります。

def update(data)
  puts data
  puts data.class
end

update(hoge: 1, foo: 2)
# {:hoge=>1, :foo=>2}
# Hash

先ほどのupdateメソッドでは第2引数がキーワード引数になっていました。
どうやらキーワード引数なのか、最後の関数の引数にハッシュが指定されたのか判別できない状況でRuby2.7x系とRuby3.x系で扱い方が変わったのだと思われます。

やはり、振る舞いが変わっていた

キーワード引数の振る舞いの違いについて調べてみると...ビンゴでした。
Ruby3.0系にバージョンアップされた際にキーワード引数の振る舞いが変わっていたようです。

Ruby2では、キーワード引数が末尾のハッシュ位置引数として扱われることがあります。
また、末尾のハッシュ引数がキーワード引数として扱われることもあります。
この自動変換は場合によっては複雑になりすぎてしまい、本記事末尾で後述するようにトラブルの原因になることがあります。
そのため、この自動変換をRuby 2.7で非推奨とし、Ruby 3.0で廃止する予定です。

Ruby 3.0における位置引数とキーワード引数の分離について

明示的にキーワード引数ではなく、ハッシュを渡したい場合は呼び出し側で{}を書いてね...とのことです。

def update(data, update_time: nil)
  puts data
  puts data.class
end

puts RUBY_VERSION
update({ hoge: 1, foo: 2 })

# 3.1.2
# {:hoge=>1, :foo=>2}
# Hash

最後に

なかなかに珍しい体験ができて、僕としては少し...ハッピーです。
今回、紹介した事例はRuby3以前はかなり問題だったようで、異なるケースで多くのバグレポートが報告されていたそうです。

bugs.ruby-lang.org

めっちゃ議論されてる...

こんなカオスな状況になるなら...との思いでRuby3へのバージョンアップに伴い廃止という判断がされました。
自由度が高いことはすなわち、さまざまなケースを考えないといけないということです。僕としては「〇〇したいならこうしてね」と明確に決めてもらうことで、選択肢が限られているという方が嬉しいです。

改善、ありがとうございます。本当にコミュニティには感謝しかありません。