2021-12-17 [長年日記]
_ Ruby3.0キーワード引数仕様変更に伴う書き換えをしたので調べたことのまとめ
igaigaです。この記事は Ruby Advent Calendar 2021 の17日目の記事です。昨日はフィヨルドブートキャンプでもお世話になっているyuuuさんの mruby-esp32-app-mirbをESP32で動かしてみた - panicの原因を見つけるまでの過程 でした。マイクロデバイスは胸が躍りますよね。
キーワード引数の仕様変更
Ruby2.7と3.0でキーワード引数の非互換を伴う仕様の整理が行われました。先日、Ruby2.6から2.7にバージョンアップすることがあり、この変更での書き換えを調べたのでまとめます。
また、本編とは直接関係ないですが、Ruby2.7.1から2.7.2へのバージョンアップでdeprecated カテゴリの警告はデフォルトで出力されなくなる変更が入ったので、Warningを出力するときは以下のようなオプションを指定します。 -W:deprecated
の代わりに -W
と書くとさらに広い範囲で全ての警告を表示します。
$ ruby -W:deprecated foo.rb
$ RUBYOPT='-W:deprecated' bin/rspec spec/foo_spec.rb
参考資料
以下の記事を参考にしました。特にmameさんの記事にたくさん助けられました。ありがとうございました。
- Keyword arguments are now separated from positional arguments. Code that resulted in deprecation warnings in Ruby 2.7 will now result in ArgumentError or different behavior. Feature #14183
- Ruby公式サイトの移行ガイド
- 『プロと読み解くRuby 2.7 NEWS』
- 『プロと読み解く Ruby 3.0 NEWS』
概論(mameさんの記事より抜粋)
Ruby 3.0で、キーワード引数が通常の引数とは独立した引数になりました。これは非互換な変更になっています。
# キーワード引数を受け取るメソッド
def foo(key: 42)
end
foo(key: 42) # OK: キーワード引数を渡している
opt = { key: 42 }
foo(opt) # NG: 普通の引数を渡しているのでエラー(2.7では警告付きで動く)
foo(**opt) # OK: ハッシュを明示的にキーワードに変換している
Ruby2.7までは普通の引数をキーワード引数に暗黙的に変換していましたが、3.0からはこの暗黙的変換を行わないようになりました。
ただし、キーワード引数から普通の引数への暗黙的変換は維持されています。削除するには互換性の影響が大きすぎるためだそうです。次のコードはRuby 3.0でも動作します。
# 普通のオプショナル引数を受け取るメソッド
def foo(opt = {})
end
foo(key: 42) # OK: キーワード引数が暗黙的に普通の引数に変換される
# # ↑は動きますが、今後は次のように書くのがおすすめです
# def foo(**opt)
# end
受け取った引数を別メソッドへ委譲するケースでのキーワード引数仕様変更
基本形
受け取った引数をそのまま全て別のメソッドの引数として渡すケースを考えます。Ruby2.7までは次のようなコードでした。
def foo(*args, &block)
target(*args, &block)
end
Ruby3.0では次のようなコードになります。
def foo(*args, **kwargs, &block)
target(*args, **kwargs, &block)
end
メソッド内で引数を変更しなければ、上記のコードと同じ動作を、以下のように委譲記法 ...
で書けます。
def foo(...)
target(...)
end
引数委譲記法 ...
の拡張
引数委譲記法 ...
では、先頭側の引数は変更できるようになっています。たとえば次のような書き方です。
def foo(arg1, ...) # 引数の先頭1つ目をarg1に入れ、残りは...で受け取る
target(...) # 引数の先頭1つ目を抜いた残りを渡す
end
Ruby3.0で ... の先頭を取り除いたり、新しい値を追加できるようになりました。Ruby2.7へもバックポートされています。nagachikaさんありがとうございます。
Arguments forwarding (...) now supports leading arguments. Feature #16378
Ruby2.7での該当commit(動作確認して、Ruby2.7.3で入っていることを確認): https://github.com/ruby/ruby/commit/27fca66207f2c35f2f44f6a7cbbe6fd153546082
以下、mameさんのRuby3.0リリース時の記事より
https://techlife.cookpad.com/entry/2020/12/25/155741
キーワード引数の分離の悪影響の1つに、引数を委譲するのがめんどうになることがあります。そのため、Ruby 2.7では引数を委譲するための構文が導入されたのですが、引数を一切変更できないので使えるケースが限定されていました。
Ruby 3.0では、次のように、先頭の引数を取り除いたり、新しい値を追加したりすることが許されるようになりました。
def method_missing(meth, ...)
send(:"do_#{meth}", ...)
end
先頭の引数以外はやはり変更できないのですが、これだけでも多くのケースが救われるという声が前述のヒアリングスレッドなどでも聞かれたため、導入されました。
実際に私の遭遇したケースでも先頭側の引数だけを取り除いて使いたいケースだったので、この機能で対応できました。
引数委譲記法 ...
のサンプルコード
Ruby2.7.3, 3.0.3で実行して同様の結果で動作しました。
Ruby2.7.2ではエラー(syntax error, unexpected (... def foo(arg1, ...)
)になりました。
class Example
def foo(arg1, ...) # 引数の先頭1つ目だけarg1に入れ、残りは...で受け取る
p arg1 #=> :first
bar(...) # 引数の先頭1つ目を抜いた残りを渡す
end
def bar(...)
p(...) #=> "second", {:third=>3, :forth=>4}
end
end
Example.new.foo(:first, "second", third: 3, forth: 4)
なお、上記のコードでの受け取り側引数arg1はarg1, arg2, arg3 と複数でも動作しました。
Ruby2.7 or Ruby3.0でのキーワード引数書き換え方法
"Handling argument delegation" を日本語訳したもの。
Ruby 2.6以前
Ruby 2では、*rest引数と&block引数を受け取り、その2つをターゲットメソッドに渡すことでデリゲーションメソッドを書くことができる。この動作では、位置引数(positional argument)とキーワード引数の自動変換により、キーワード引数も暗黙のうちに処理される。
def foo(*args, &block)
target(*args, &block)
end
Ruby 3.0以降
キーワード引数を明示的に委譲する必要があります。
def foo(*args, **kwargs, &block)
target(*args, **kwargs, &block)
end
また、Ruby 2.6 以前との互換性を必要とせず、引数を変更しない場合は、Ruby 2.7 で導入された新しいデリゲーション構文 (...) を使用することができます。
def foo(...)
target(...)
end
Ruby 2.7
要約: Module#ruby2_keywords と delegate *args, &block を使えばいいのです。
ruby2_keywords def foo(*args, &block)
target(*args, &block)
end
ruby2_keywordsはHashの最後の引数としてキーワード引数を受け取り、他のメソッドを呼び出す際にキーワード引数として渡します。
実際、Ruby 2.7 では多くの場合、この新しいスタイルのデリゲーションが可能です。しかし、既知のコーナーケースがあります。次のセクションを参照してください。
Ruby 2.6以前, 2.7, 3.0以降で動く書き方
要約: やっぱり Module#ruby2_keywords を使えばOK
ruby2_keywords def foo(*args, &block)
target(*args, &block)
end
残念ながら、Ruby 2.6 以前のバージョンでは新しいデリゲーションスタイルを正しく扱えないため、古いスタイルのデリゲーション(つまり **kwargs がない)を使用する必要があります。これがキーワード引数分離の理由の一つです。詳細は最後のセクションで説明します。また、ruby2_keywordsを使うと、Ruby2.7や3.0でも古いスタイルのまま実行することができます。2.6以前ではruby2_keywordsは定義されていませんので、ruby2_keywords gemを使うか、自分で定義してください。
def ruby2_keywords(*)
end if RUBY_VERSION < "2.7"
もしあなたのコードがRuby 2.6以前で動作する必要がなければ、Ruby 2.7の新しいスタイルを試してみてもよいでしょう。ほとんどの場合、うまくいきます。しかし、以下のような不幸なコーナーケースがあることに注意してください。
def target(*args)
p args
end
def foo(*args, **kwargs, &block)
target(*args, **kwargs, &block)
end
foo({}) #=> Ruby 2.7: [] ({} is dropped)
foo({}, **{}) #=> Ruby 2.7: [{}] (You can pass {} by explicitly passing "no" keywords)
空のHash引数は自動的に変換されて**kwargsに吸収され、デリゲーションコールは空のキーワードHashを削除するので、targetに引数は渡されない。私たちが知る限り、これは唯一のコーナーケースです。
最後の行で述べたように、**{}を使うことでこの問題を回避することができます。
本当に移植性が心配なら、ruby2_keywords を使ってください。(Ruby 2.6以前では、キーワード引数には多くの問題があります。) ruby2_keywordsはRuby 2.6が終了した後、将来的に削除されるかもしれません。そのときは、キーワード引数を明示的に委譲することをお勧めします(上記のRuby 3のコードを参照)。