Ruby2.7へ向けて
そろそろRuby2.7の季節がやってきましたね!
すでにアップデート準備はすませましたでしょうか?
Ruby3へ向けた移行パスの一環として、Ruby2.7からキーワード引数の仕様が変更されます。
これは多少の破壊的な変更があり、実際にRubyのアップデートを考えるときには悩みどころとなりそうです。
ここでは実際に手元のアプリケーションをRuby2.7.0-rc2に移行する際に遭遇したケースをヒントに、自分なりに想定できる対処方法などを記してみます。
※ なおキーワード引数の新仕様や、その具体的な挙動については、こちらに素晴らしくわかりやすい解説記事があるので参考にしてみてください。
なおこの移行に伴い、たくさんのgemにおいてコントリビュートチャンス祭りが発生すると思われます
Rubyエンジニアの皆様にはぜひぜひこの機会を生かしていただければと思っています!
なぜ変更されるのか
(私自身はただの傍観者なのですが)、外野から理解できている範囲では、Rubyコアコミッターであるmametterさんが2018年ごろから精力的に提案されていた問題提起がきっかけとなっているのかなと思います。
詳細は上にも書いた素晴らしい記事があるので省略しますが、ここにある3種類のポイントが主に解決すべき問題ということのようです。
非常に雑にアウトラインをまとめると、
- 現在のキーワード引数には、いくつかのケースにおいてbuggyな挙動がある
- これらは現在の仕様に全体的な整合性がないことに起因している
- キーワード引数の現仕様になんらかの破壊的変更をして新仕様に移行するしか、これらの問題を解決できない
という話のようです。
該当チケットを追いかけると、変更のインパクトをなるべく最小限に抑えつつ(下方互換を最大限に保ちつつ)、全体的に整合性のある仕様にする(さらにRuby2.7において適切なマイグレーションパスをつくる)ことにむけた議論がなされています。
Ruby仕様に精通したコミッターチームであっても、これはかなり難解な挑戦になっていたようで、様々なアイデアや非常に細かいコーナーケースの検証を経てようやく着地したのが現在の仕様のようです。(チケット上で紹介されているこちらの資料には、新仕様の方向性についてのわかりやすいまとめがあります)
仕様の勘所
具体的には冒頭でリンクした良記事や、公式リリースにおける解説を参考にしていただくのがよさそうです。
大きくはRuby3におけるキーワード引数の勘所として、以下のことを理解しておくといいかと思います。
前提
- メソッドは以下の3種類の引数を持てる
- ポジショナル引数 ・・・ 位置関係によってきまる(順序を変更すると異なる引数になる)
- キーワード引数 ・・・ キーワードラベルによって指定できる(キーワードの順序を変更できる)
- ブロック引数 ・・・ 1つだけブロックを受け取れる
この中で、主にキーワード引数の受け渡しに関連して、以下のように仕様が整理されました。
仕様A(大原則)
- キーワード引数を受け取るメソッドに対して、キーワード引数を渡す際には、ハッシュオブジェクトではなくキーワード引数として渡さなければならない
- Ruby2.6までは、ハッシュオブジェクトを(ポジショナル引数の最後にセットして)渡すと、キーワード引数として解釈してくれた
- Ruby2.7ではポジショナル引数の最後にハッシュオブジェクトを渡すと警告付きでキーワード引数として解釈する
- 来年に出る予定のRuby3からは、ハッシュオブジェクトを渡すとポジショナル引数として解釈される
仕様B(例外ルール)
-
キーワード引数を受け取らないメソッドに限っては、キーワード引数を渡した場合に、ハッシュオブジェクトに変換したものをポジショナル引数として受け取れる
- このケースについてはRuby2.6以下と下方互換性が保たれる
仕様C
- キーワード引数のキーは、シンボル以外のオブジェクトでも受け付ける(Ryby2.7以降の新仕様)
何が困るのか
主に、キーワード引数が必要なメソッドにハッシュオブジェクトを渡しているパターンがあります。
たとえばRailsなんかでよくあるコントローラーだと、ハッシュを別のメソッドに引数として渡す局面があります。
def create
user = assign_attributes(user_params.to_h.merge(role: :admin)) # 呼び出し側はハッシュオブジェクトを渡す
user.save!
end
private
def assign_attributes(**params) # 受け取り側はキーワード引数を要求
User.new **params
end
これに対して警告が出ます(Ruby3でエラーになる予定)
warning: The last argument is used as keyword parameters; maybe ** should be added to the call
warning: The called method `assign_attributes' is defined here
対策としては、
def create
user = assign_attributes(**user_params.to_h.merge(role: :admin)) # **演算子でハッシュオブジェクトを開く(キーワード化する)
user.save!
end
ということが必要になります。
実際にアプリケーションで使用していたActsAsTenantというgemにおいてこのタイプの警告が出ていたので、簡単なパッチを書きました。
delegationに気をつけろ
さて、この手の警告に関して少し対処が難しいケースがdelegationです。
ここでいうdelegationとは、「あるメソッドが受け取った引数(の全部または一部)を引数として別メソッドを呼び出す」というパターンを指します。
たとえば典型的にこういうコードがあります。
class APIWrapper
def execute(*args)
ExternalAPIClass.new.delegated_method(*args) # 丸投げする
end
end
ExternalAPIClass#delegated_method
がキーワード引数をとるメソッドだった場合、これでは正しく呼び出すことができなくなります。
(Ruby2.7では警告付きで動きますが、Ruby3系で死ぬことになります)
いくつか対処法がありますが、Ruby2.7以降を使う場合は
class APIWrapper
def execute(...)
ExternalAPIClass.new.delegated_method(...)
end
end
引数を「丸投げ」する記法、...
で対応できます。
ただし、この...
だと選択的に一部の引数だけ取り出す、ということができません。たとえば・・
class APIWrapper
def execute(*args, external: true)
if external
ExternalAPIClass.new.delegated_method(*args) # 丸投げする
else
# ...snip
end
end
end
この局面では...
で一部の引数を引き取ることができないため、Ruby2.7以降で動かしたい場合は明示的に
class APIWrapper
def execute(*args, **kwargs, external: true)
if external
ExternalAPIClass.new.delegated_method(*args, **kwargs) # 丸投げする
else
# ...snip
end
end
end
という対処が必要になります。
なお、ここでいうdelegationとは「委譲と継承」という文脈における委譲ではありません。
つまり継承においてもこの問題が起こる可能性があります。(実際にあるgemで踏みました)
class ParserBase
def parse!(*args, failure: :exception)
# ...snip
end
end
class Parser < ParserBase
def parse!(*args)
super # 暗黙のうちにargsを渡している
end
end
このようにdelegationが絡むと問題が発生することがあるのでアップデート時には気をつけてください。
難しいケース
通常のアプリケーションにおいては、「RubyアップデートのPRで、全てのコードをRuby2.7仕様に変更」という対処が可能です。
ただ様々なRubyバージョンで利用されるgemについては、現在のバージョンでの後方互換を保ちつつ、新バージョンにも対応できるパッチを入れたい、ということで少し難しい対処が必要になることがありそうです。
複数のRubyバージョン対応を意識したdelegation
これが現時点わかっている限り、かなり難しい対処を要求されそうなパターンです。
詳細の議論は省きますが、この記事に非常に良いまとめがあるので一読をオススメします。
(なお記事で触れられているpass_keywordsというアイデアはおそらくなくなったようで、Ruby2.7以降においては、ruby2_keywords
がRuby2.6 EOLの時点までの期間限定で導入されるという方向になった模様です)
※ なおRuby2.6以下での対処について、Ruby2.6以下にバックポートされると書きましたが、実際はバックポートされないとのことです。旧バージョンでも対応するためには、下のコード例にあるように自前でruby2_keywordsを定義するか、ruby2_keywords gemを利用することで対応することになります 1
対処例
このサンプルは、上の記事から引用しています。
もしwrapper_methodというメソッドが(引数を丸投げした上で)target_methodというメソッドを呼び出しているとして・・・
以下のようなコードを書くことで、Ruby2.6までの挙動を担保しつつ、今回のRuby2.7や、Ruby3(とくにRuby2.6がサポート対象外となるとされるRuby3.2系以降の世界)においても、意図通りに「引数を正しく丸投げするdelegation」を実現ことができそうです。
def ruby2_keywords(*); end unless respond_to?(:ruby2_keywords, true)
if RUBY_VERSION < "3"
ruby2_keywords def wrapper_method(*args, &block)
target_method(*args, &block)
end
else
def wrapper_method(*args, **kwargs, &block)
target_method(*args, **kwargs, &block)
end
end
なおこのRUBY_VERSIONを元にした条件分岐ですが、Ruby2.6がEOLになった未来(Ruby3.2登場のタイミング?)において、「ruby2_keywordsという(醜い)移行用メソッドは消滅させる」ということをmatzが明言しているために存在しています。
もし「Ruby3.1あたりでRuby2.6の下方互換を捨てる」という判断であればこの分岐は必要なさそうです。(その時点でelse節だけにすればよい)
実世界での対処
すでに一部のgem、たとえばrails本体ではこのメソッドをつかった数々の改修がなされているのを見ることができます。他にもdry-rbにおいてもこの手の改修を見ることができますね。
言いたいこと
ぜひ手元のアプリケーションをRuby2.7にアップデートして、警告を注視してください。
最初のうちは、アプリケーションだけでなくgem関連でも警告が出ると思います。これらが全て改修対象になります。
我々のような一開発者でも、ガシガシとコントリビュートできるこの大チャンスを生かさない手はない!(鼻息)
みんなであちこちのgemをアップデートして、きたるRuby3へ向けてスムーズな移行ができる世界を目指せるといいですね。
(蛇足ながら、もし記事の誤りに気づかれたら、コメント欄などでご指摘いただければ助かります)
最後に
この記事を書くにあたって、この二年間の様々な努力の跡を眺めながら巨人の足跡をたどらせていただきました。
様々な問題を根絶すべく、この難解な仕様変更を根気よく詰めてこられたRubyコミッター陣にはただただ頭が下がります。
またいち早くこの難しい変更を取り込み、移行の努力をされているRailsやRubocopなど著名gemのメンテナーの方々には感謝しきれません。
ありがとうございます。
検証環境
この記事では、手元の(Rails)アプリケーションに試験的にruby2.7.0-rc2を投入して、Ruby2.7.0-rc2へのアップデートにトライしています。
Ruby2.6.5でRSpecがオールグリーンの状態から出発して、Ruby2.7.0-rc2において、キーワード引数関係の警告がどうなるかを検証し、いくつか引っかかったケースを元に対処法を記事にしています。
もともとはRails6.0.2系のアプリケーションでしたが、キーワード引数の変更に対応させるためRailsバージョンは2019/12/21時点のmasterまで引き上げています。
-
コミッターのn0kadaさんが指摘されているツイートを偶然目にして、記事を訂正させていただきました。ありがとうございます。 ↩