はじめに:Ruby 2.7では動いていた奇妙なコード
とあるRailsプロジェクトをメンテしていたら、こんな奇妙なコードに遭遇しました。
def sample
data = {
a: nil,
b: { x: 1, y: 2 },
c: { x: 1 },
d: { y: 2 },
}
data.map do |k, x: 0, y: 0|
[k, x, y]
end
end
Ruby 2.7でこのsample
メソッドを実行すると以下のような結果が得られます。
# Ruby 2.7
sample
#=> [
# [:a, 0, 0],
# [:b, 1, 2],
# [:c, 1, 0],
# [:d, 0, 2],
# ]
これはいったい何をやってるの?
このメソッドでは以下の行でx
やy
のデフォルト値を設定しています。
data.map do |k, x: 0, y: 0|
これにより、このメソッドは以下のような挙動を示します。
-
a: nil
であれば、x
とy
はどちらもデフォルト値の0になります。 -
b: { x: 1, y: 2 }
はx
が1、y
が2になります(デフォルト値は使われない)。 -
c: { x: 1 }
はx
が1で、y
がデフォルト値の0になります。 -
d: { y: 2 }
はx
がデフォルト値の0で、y
が2になります。
個人的には「こんな凝ったコード書くなよ〜」と思うのですが、Ruby 2.7まではちゃんと動いていたので、それまではこれで良かったのかもしれません。
発生した問題:このコードはRuby 3.0以降ではちゃんと動かない
ただし、Ruby 2.7で-W:deprecated
オプションを付けて実行すると、以下のような警告メッセージが出ます。
warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
warning: The called method is defined here
そして、このコードをそのままRuby 3.0で動かすとRuby 2.7と異なり、x
もy
も全部0になります。
# Ruby 3.0だとxもyも全部0になる!
sample
#=> [
# [:a, 0, 0],
# [:b, 0, 0],
# [:c, 0, 0],
# [:d, 0, 0],
# ]
これはRuby 3.0で導入された「キーワード引数の分離」の影響です。
k, x: 0, y: 0
のような記述がブロックパラメータではなく、メソッドの仮引数であれば以下のようにハッシュに**
を付けることでこの問題を回避できます。
# 引用元 https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
def foo(k: 1)
p k
end
h = { k: 42 }
# **を付けるとハッシュオブジェクトをキーワード引数に変換できる
foo(**h) #=> 42
ただし、今回はブロックパラメータなので、**
を付ける作戦が使えません。
解決策:Ruby 3.0でも期待した結果が得られるようにコードを書き替える
そこで、やむを得ず今回は以下のようにコードを書き替えることにしました。
def sample
data = {
a: nil,
b: { x: 1, y: 2 },
c: { x: 1 },
d: { y: 2 },
}
- data.map do |k, x: 0, y: 0|
+ data.map do |k, h|
+ x, y = { x: 0, y: 0 }.merge(h || {}).values_at(:x, :y)
[k, x, y]
end
end
これでRuby 2.7と同じ結果を得ることができました。
# Ruby 3.0(修正後のコード)
sample
#=> [
# [:a, 0, 0],
# [:b, 1, 2],
# [:c, 1, 0],
# [:d, 0, 2],
# ]
x, y = { x: 0, y: 0 }.merge ...
の部分は他にもいろんな書き方があると思いますが、「**
を付けるだけでOK」みたいな、お手軽な解決方法はどうやらないみたいです。
Special thanks
この件はruby-jpのSlackチャンネルでつよつよRubyプログラマのみなさんに解決策をいろいろと相談させてもらいました。
相談に乗ってくださったRubyプログラマのみなさん、どうもありがとうございました!
おまけ
この記事を書くにあたって動作確認のために書いたテストコードです。
自分の手元で動かしてみたい場合にご利用ください。
require 'minitest/autorun'
class BlockParamsTest < Minitest::Test
def sample
data = {
a: nil,
b: { x: 1, y: 2 },
c: { x: 1 },
d: { y: 2 },
}
# Ruby 2.7
data.map do |k, x: 0, y: 0|
[k, x, y]
end
# Ruby 3.0
# data.map do |k, h|
# x, y = { x: 0, y: 0 }.merge(h || {}).values_at(:x, :y)
# [k, x, y]
# end
end
def test_sample
expected = [
[:a, 0, 0],
[:b, 1, 2],
[:c, 1, 0],
[:d, 0, 2],
]
assert_equal expected, sample
end
end