SmartHR Tech Blog

SmartHR 開発者ブログ

Ruby 2.7.0-preview2 で変わる Numbered parameter

お疲れ様です。 SmartHR 社で業務委託を承っております osyo と申します。
業務では主に activerecord-bitemporal の開発・保守を行っております。
前回は Ruby 2.7.0-preview1 Numbered parameters 完全攻略! という記事を書かせていただきました。

さて、先日 Ruby 2.7.0-preview2 がリリースされました。
Ruby 2.7.0-preview1 がリリースされてから約5ヶ月経ち Numbered parameter の仕様に関していろいろと変更点がありました。
そこで今回は preview1 から preview2 にかけて Numbered parameter の一体何が変わったのかをまとめてみたいと思います。

!!!注意!!!

この記事に書かれている事は Ruby 2.7.0-preview2 を元にしています。
Ruby 2.7.0 が正式にリリースされた時にまた Numbered parameter の挙動が変わっている可能性があるので注意して下さい!!!

Numbered parameter とは

Numbered parameter とはブロックの仮引数を省略して _1 という記号で引数を参照できる記法の事になります。

# 通常の書き方
# ブロック内で引数を受け取る時は仮引数を定義して参照する
(1..10).map { |it| it * 2 }

# Numbered parameter を使った書き方
# _1 が第一引数を参照するように記述する事が出来る
(1..10).map { _1 * 2 }

_1 は第一引数を、_2 は第二引数を…という感じで『_ + 数値』で任意の引数番号を参照することが出来ます。
基本的には、

proc { _1 + _2 }

というブロックは、

proc { |_1, _2| _1 + _1 }

というブロックと同じ意味になります。
では、この Numbered parameter が preview1 からどのように変わったのかをみていきたいと思います。
ちなみにわたしは Numbered parameter のことを略してナンパラと読んでいます。

@1 から _1 に変更された

いきなりですが Numbered parameter で使用する記号が @1 から _1 という名前に変更されました。
preview2 以降で Numbered parameter を利用したい場合は _1, _2, _3 ...という名前を使用する必要があります。

preview1

p (1..10)
  .select { @1.even? }
  .map { @1 * 2 }
  .sort { @2 <=> @1 }
# => [20, 16, 12, 8, 4]

preview2

p (1..10)
  .select { _1.even? }
  .map { _1 * 2 }
  .sort { _2 <=> _1 }
# => [20, 16, 12, 8, 4]

見た目的には @1 から _1 に変わっただけなんですが、それ以外の細かい挙動も変わっているのでいくつか説明していきたいと思います。

Numbered parameter 以外で _1 を使用した場合

実は _1 という名前は Ruby 2.6 現在でも変数名やメソッド名で有効な名前になります。

# メソッド名として定義しても oK
def _1
  # ローカル変数名として定義
  _2 = 42
  _2 + 3
end

# メソッドとして呼び出せる
p _1
# => 45

しかし、Ruby 2.7 以降ではコンテキストによって _1 の意味が異なるようになります。

def _1
  :method
end

# _1 だけであれば Numbered parameter
p proc { _1 }.call 42
# => 42

# メソッドっぽい呼び出しであればメソッド呼び出し
p proc { _1() }.call 42
# => :method
p proc { self._1 }.call 42
# => :method

# ブロックの外であればメソッド呼び出し
p _1
# => :method

また、変数名で _1 という名前を使用した場合に警告が出るようになっているので注意して下さい。

# warning: `_1' is used as numbered parameter
_1 =42

ブロックをネストした場合の挙動が変わった

preview1 の時代と比較して preview2 ではブロックがネストした場合の挙動が変わりました。

preview1

proc {
  # OK : 外の proc の引数を返す
  p [@1, @2]
  # => ["A", "B"]
  proc {
    # OK : 内側の proc の引数を返す
    p [@1, @2]
    # => ["C", "D"]
  }.call "C", "D"
}.call "A", "B"

preview2

# OK : proc がネストしていても片方でしか Numbered parameter を参照してない
proc {
  # 外側で Numbered parameter を参照している
  p [_1, _2]
  # => ["A", "B"]
  proc {
    # 内側では Numbered parameter を参照していない
  }.call "C", "D"
}.call "A", "B"

proc {
  proc {
    # 内側でのみ Numbered parameter を参照している
    p [_1, _2]
    # => ["C", "D"]
  }.call "C", "D"
}.call "A", "B"

# OK : ネストせずに別の proc で Numbered parameter を参照している
proc {
  proc {
    p [_1, _2]
    # => ["C", "D"]
  }.call "C", "D"

  proc {
    p [_1, _2]
    # => ["E", "F"]
  }.call "E", "F"
}.call "A", "B"


# Error : proc がネストしており、両方で Numbered parameter を参照している
proc {
  # Error: numbered parameter is already used in
  #        outer block here
  p [_1, _2]
  proc {
    # Error: numbered parameter is already used in
    #        outer block here
    p [_1, _2]
  }.call "C", "D"
}.call "A", "B"

preview1 ではブロックがネストしている状態で Numbered parameter を使用しても『一番内側』のブロックの引数を参照するだけでした。
しかし preview2 では外と内の両方のブロックで Numbered parameter を参照するとエラーになるようになりました。
実際使ってみると困りそうなケースがありそうな気がしないでもないんですがどうなんでしょうねー。

配列を受け取った時の挙動が変わった

配列を受け取った時の挙動が @1 から _1 で変わりました。

preview1

# 配列を渡された場合、配列の先頭が @1 に代入される
p proc { @1 }.call [1, 2]
# => 1

# @1, @2 の両方で受け取ったときもそのまま
p proc { [@1, @2] }.call [1, 2]
# => [1, 2]

preview2

# 配列を渡された場合、_1 はそのまま配列で受け取る
p proc { _1 }.call [1, 2]
# => [1, 2]

# _1, _2 の両方で受け取った時は分割する
p proc { [_1, _2] }.call [1, 2]
# => [1, 2]

これは preview1 の時代には @1

proc { |a,| }

のように , を付けた状態で引数を受け取るような挙動になっていました。
しかし _1 の場合は、

proc { |a| }

のように , がない状態で引数を受け取るような挙動に変更されました。 なので

p proc { _1 }.call [1, 2]
# => [1, 2]

p proc { [_1, _2] }.call [1, 2]
# => [1, 2]

は、

p proc { |_1| _1 }.call [1, 2]
# => [1, 2]

p proc { |_1, _2| [_1, _2] }.call [1, 2]
# => [1, 2]

と、同じ意味になります。
ここが @1 から _1 で挙動が大きく変わった点であり Numbered parameter を使って一番ハマる部分だと思うので注意して使用する必要があります。 _2 を使う使わないで _1 の意味が変わってしまい個人的には一貫性がなくてつらそうなんですがうーん…。
特に Hash をイテレーションする場合にギョッとすることが多いと思います。

hash = { a: 1, b: 2, c: 3 }

# _1 だけの場合は _1 = [key, value] になる
p hash.map { _1 }

# _2 も参照している場合は _1 = key, _2 = value になる
p hash.map { _2; _1 }

ちなみに当初は _0 というものが存在しており _0|_0|_1|_1,| にするという話もありました。
これであれば必要に応じて _0_1 を使い分けることが出来るのでより柔軟に対応することが出来ます。
しかし、紆余曲折あり _0 という名前はよくないんじゃないか?ということで現状の挙動になっています。
_1|_1, ではなくて |_1| になった経緯も含め、気になる方は以下の issues を読んでみるといいと思います。

使用できるパラメータ数が減った

@1 では @1 〜 @100 まで使用することが出来ましたが _1 の場合は _1 〜 _9 まで使用することが出来ます。

preview1

# OK
p proc { [@1, @100] }.call (1..200).to_a
# => [1, 100]

# Error: too large numbered parameter
p proc { [@1, @101] }.call (1..200).to_a

preview2

# OK
p proc { [_1, _9] }.call (1..10).to_a
# => [1, 9]

# Error: undefined local variable or method `_10' for main:Object (NameError)
p proc { [_1, _10] }.call (1..10).to_a

メタ的に取得する

@1 はメタ的に取得出来ませんでしたが _1 の場合は Binding#local_variable_get でメタ的に取得する事は可能です。

p proc {
  [_1, binding.local_variable_get(:_1)]
}.call 42
# => [42, 42]

ただし、取得したい Numbered parameter を使用していなければ Binding#local_variable_get で取得できません。

# _1 を使用していないと _1 が定義されないので取得できない
proc {
  # Error: local variable `_1' is not defined for #<Binding:0x00005613c9106b10> (NameError)
  binding.local_variable_get(:_1)
}.call 42

# これは他の Numbered parameter も同じ
proc {
  _1
  # OK
  binding.local_variable_get(:_1)
  # NG
  binding.local_variable_get(:_2)
}.call 42

ちなみに _9 だけ使用した場合でも _1 〜 _9 の全てが定義されているので次のようにして全ての引数を取得する事は出来ます。

# _9 だけ使用しても _1 ~ _9 が定義されている
p proc { _9 }.parameters
# => [[:opt, :_1], [:opt, :_2], [:opt, :_3], [:opt, :_4], [:opt, :_5], [:opt, :_6], [:opt, :_7], [:opt, :_8], [:opt, :_9]]

p proc {
  # _9 を呼び出しておけば _1 ~ _9 の全ての Numbered parameter が定義される
  _9
  # _1 ~ _9 を取得する
  (1..9).each_with_object(binding).map { |i, b| b.local_variable_get(:"_#{i}") }
}.call *(-5..5)
# => [-5, -4, -3, -2, -1, 0, 1, 2, 3]

ネストしている map のブロック内で Numbered parameter が使えないのはちょっと不便ですね。

eval で使用する

@1 とは違い _1eval でも参照可能です。
ただし、先程の Binding#local_variable_get と同様に _1 を定義しておく必要があります。

preview1

# Error: numbered parameter outside block (SyntaxError)
p proc {
  @1
  eval("@1")
}.call 3

preview2

# Error: numbered parameter outside block (SyntaxError)
p proc {
  eval("_1")
}.call 42

# OK : 定義した _1 を参照している
p proc {
  _1
  eval("_1")
}.call 42

binding.irb_1 は参照できる

@1 の時は binding.irb 上で @1 を参照しようとしても出来なかったんですが _1 では参照できるようになっていました。

preview1

proc {
  @1 + @2
  # ここで起動した irb 上で @1 や @2 は参照できない
  binding.irb
}.call 1, 2, 3

preview2

proc {
  _1 + _2
  # ここで起動した irb 上で _1 や _2 は参照できる!  
  # ただし、_3 など定義してない Numbered parameter は参照できないので注意
#   binding.irb
}.call 1, 2, 3

まとめ

前回に続いて Numbered parameter の挙動についてまとめました。
当初は @1 から _1 に記号が変わるだけかと思っていたんですが、この記事を書くにあたりいろいろと比較してみたら細かい部分で違いがありました。
やはり _1 という名前がが現行の Ruby でも有効な名前というのが結構つらそうですね。
以下のコードが動作した時は頭がおかしくなりそうになりました。

p proc {
  _1
  (1..10).map {
    # 外側の Numbered parameter を呼び出す
    eval("_1")
  }
}.call 42
# => [42, 42, 42, 42, 42, 42, 42, 42, 42, 42]

_1 = 3
p proc {
  _1
  (1..10).map {
    # 一番外にある _1 変数を参照する
    eval("_1")
  }
}.call 42
# => [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]

ナンパラ無限に難しくない???
Ruby 2.7 のリリースまであと2ヶ月弱ですが、最終的にどうなってしまうんですかねー。

Ruby について議論したいという方は!

Numbered parameter に限らず Ruby 開発者と実際に議論してみたりや直接質問してみたい!という方がいれば Ruby Hack Challenge Holiday というイベントに参加してみることをおすすめします!

Ruby Hack Challenge Holiday とは RubyインタプリタをC言語などでハックするイベントになります。
これだけ聞くとなんか難しそうな事しているように聞こえますが、実際には用意されている資料を参考にすると割とスムーズに進める事が出来ます(C言語が全くわからないとちょっとつらいですが…。
また、このイベントを主催しているのが Ruby コミッタの方なので Ruby について直接話をすることも出来ます。
更に今回は @soutaro さんが「Ruby の型をどう書くのか」「実際に組込クラスの型をつけて貢献する方法」についてレクチャーして頂けるそうです。

Ruby の型について気になっている方がいればぜひ参加してみるとよいと思います!