11
1

More than 1 year has passed since last update.

Ruby 2.7まで動いていたデフォルト値付きのブロックパラメータをRuby 3.0向けに書き替える

Last updated at Posted at 2022-08-24

はじめに: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],
#   ] 

これはいったい何をやってるの?

このメソッドでは以下の行でxyのデフォルト値を設定しています。

data.map do |k, x: 0, y: 0|

これにより、このメソッドは以下のような挙動を示します。

  • a: nilであれば、xyはどちらもデフォルト値の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と異なり、xyも全部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
11
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1