😽

【bugs.ruby Advent Calender】binding.local_variablesのバグ報告と itに関する話【24日目】

2024/12/24に公開

bugs.ruby Advent Calender 24日目の記事です。

これはなに

今年1年間通してみてきた bugs.ruby のチケットの中から気になったものを1つずつ取り上げていく Advent Calender です。
取り上げるチケットは基本的にこのブログで取り上げたものになります。
記事のまとめは ここを参照 してください。

[Bug #20965] it vs binding.local_variables

Ruby 3.4 のリリースまであと1日!という絶妙なタイミングで盛り上がっているチケットがあったのでそれについて書きます。

チケットの内容自体は以下のように binding.local_variables の結果が _1it で異なっているというバグ報告になります。

# _1 も it も使用していない場合はなにも返さない
p(proc { binding.local_variables }.call)  # []

# ブロック内で _1 を参照している場合は :_1 が含まれる
p(proc { _1; binding.local_variables }.call)  # [:_1]

# 一方 it の方は参照していても :it は含まれない
p(proc { it; binding.local_variables }.call)  # []

_1 の場合はブロック内で定義されるとそれがローカル変数のように振る舞われるので binding.local_variables などでその情報を取得することができます。
一方で it はブロック内で参照されていても binding.local_variables には含まれません。

これなんですが最初は一貫性を保つために開発版の Ruby 3.4-dev で :it が含まれるように対応がされました。

# it がブロック内で参照されていればローカル変数として振る舞うようになった
p(proc { it; binding.local_variables }.call)  # [:it]

っていうのが っていうのが 2024/12/19 時点の話になります。

その後どうなったか

上記の対応が行われたあとに Bug #20970: it /1/i raises undefined method 'it' for main (NoMethodError) even if binding.local_variables includes it にて意図しない挙動が発生しているというバグ報告が起票されまいsた。
内容は以下のコードが parse.yprism で挙動に差異があるという内容です。

i = 2
42.tap do
  p it                # => 42
  p local_variables   # => [:it, :i]
  p it /1/i
  # parse.y => 21
  # prism   => NoMethodError
end

binding.local_variables の対応前は parse.y でも NoMethodError が発生していました。

これはどういうことかというと parse.yprismit の解釈が異なっていることに起因しています。

  • parse.y => it が変数として定義されているので it / 1 / i という除算として処理される
  • prism => it がメソッドとして解釈され it(/1//i) と処理される

これは hoge /1/i というコードは Ruby だと曖昧で hoge がどう定義されているかによってどう処理されるのかが変わることに起因しています。
例えば hoge が『変数』として定義されている場合は 除算 として処理されます。

hoge = 42
i = 2

# 変数がある場合は除算として処理される
# hoge / 1 / i と同等
p(hoge /1/i) # => 1

一方で hoge が『メソッド』として定義されている場合は メソッドの引数に /1/i が渡されるという挙動になります。

def hoge(x) = x
i = 2

# メソッドが定義されている場合はメソッド呼び出しとして処理される
# hoge(/1/i) と同等
p(hoge /1/i)  # => /1/i

このように hoge がどう定義されているのかによって処理が異なります。

それを踏まえた上で今回問題になっていた以下のコードをみてみると元々は it は『変数』ではなくて『メソッド』として解釈されていたのが binding.local_variables の対応により『変数』として解釈されてしまったことが起因らしいです。

i = 2
42.tap do
  p it                # => 42
  p local_variables   # => [:it, :i]
  # この it がメソッドではなくて変数として解釈されて挙動が変わってしまった
  p it /1/i
end

これはむずかしい…。
また、加えて https://bugs.ruby-lang.org/issues/20965#note-7 のような指摘もされています。

最終的にどうなったのか

Ruby 3.4 のリリース直前ということもあり binding.local_variables:it を返す対応は Revert されて :it を返さないようになりました(元の挙動に戻りました。
いやー最初に読んだときは『一貫性を保つほうがええし :it を返すようにすればええやん?』と思ったんですが内部的にはいろいろと難しい話が絡んで来るんですね…。
ともあれ、うっかり Ruby 3.4 に入らなくてよかったという話ではありますね。

it /1/i の話も何回かみた記憶はあるんですがほとんど問題になることはないのでなかなか理解するのが難しいですねえ。

おまけ: it をネストした時にどうなる?

このチケットを理解するにあたり it を使ったコードをいろいろと試していたんですがネストして参照した時に _1it で違いがあることを見つけました。
_1 の場合はブロックをネストして参照するとエラーになります。

"foo".tap do
  p _1
  "bar".tap do
    # SyntaxError: numbered parameter is already used in outer block
    p _1
  end
end

一方で it をネストしたブロックで参照した場合は『一番内側のブロックの引数』を参照します。

"foo".tap do
  p it # => "foo"
  "bar".tap do
    p it # => "bar"
  end
end

これって意図されているのか…?と思ってたら Bug #20930: Different semantics for nested it and _1 で議論されており、これは意図する挙動みたいですね。

実際に _1 をネストして使いたいことはめちゃくちゃあっていつも泣きながらブロックの仮引数を定義しているんですがそれが it だと関係なくなるんでね…。
正直 it はかなり嫌いな構文ではあるんですが、ネストして使用できることが許容されているのであれば it 使いたくなってきちゃったな…一番いいのは _1 でもネストしてブロックの引数が参照できることなんですが。

と、言うことで Ruby 3.4 の直前で変なバグが入らなくてよかったよね、って話でした。
Ruby 3.4 は果たして無事にリリースできるのか…!

関連

GitHubで編集を提案

Discussion