💨

開発版の Ruby 3.4 に『ブロック引数を利用しないメソッドにブロック引数を渡すと警告を出力する』対応が入った

2024/08/01に公開

結構前の話になるんですが開発版の Ruby 3.4 で次のように『ブロック引数を渡したときにそのメソッドがブロック引数を利用していないと警告がでる』という対応が入りました。
例えば以下のように hoge メソッドでブロック引数は利用していないんですが、このメソッドを呼び出すときにブロック引数を渡すと警告が出るようになります。

# このメソッドではブロック引数は利用していない
def hoge
end

# メソッド内でブロック引数を利用していない場合に警告がでる
# warning: the passed block for 'Object#hoge' defined at test.rb:1 may be ignored
hoge {}

また、この警告は『 -w オプションがついてるときのみ』出力されます。

# test.rb
def hoge
  puts "hello"
end

hoge {}
# -w がない場合は警告はでない
$ ruby test.rb
hello
# -w がある場合に警告が出る
$ ruby -w test.rb
test.rb:6: warning: the block passed to 'Object#hoge' defined at test.rb:2 may be ignored
hello

提案自体は5年以上前(Ruby 2.6 のリリース後)からされてはいたんですが開発版の Ruby 3.4 で対応が入った形になります。

背景

この対応の議論は Feature #15554: warn/error passing a block to a method which never use a block で行われてます。
背景としては以下のように『ブロック引数が呼ばれることを期待するが実際にはブロック引数は呼ばれない』みたいなコードを書いてしまう可能性があります。
このような問題を解決したいというのが今回の対応のモチベーションになります。

def my_open(name)
  open(name)
end

# Kernel#open と同じような感じでブロック引数が渡せることを期待するが
# #my_open ではブロック引数を利用していないので意図する動作が行われない
my_open(name){ |f| important_work_with f }

この対応をするにあたりどこまで対応するのか、ルールや実装はどうするのかの議論がチケット内で行われています。

全体的なルール

以下のケースに該当する場合は『ブロック引数が利用されている』という風に定義されます。

  • (1) メソッドの引数にブロック引数がある場合( hoge(&block)
  • (2) メソッド内で yield を利用している場合
  • (3) メソッド内で super を利用したときにブロック引数をフォワードしている場合
  • (4) メソッド内でシングルトンメソッドを利用している場合
    • これはチケットの説明で言及されているんですが Ruby 3.0 時点ですでに無効な構文となっているので特に考えなくてもよさそうです

参照: https://bugs.ruby-lang.org/issues/15554#Define-use-a-block-methods

なので逆に上に該当しないメソッドの場合は『ブロック引数が利用されていない』と解釈がされてブロック引数を渡すと警告が出力されます。
それぞれ詳しく解説していきます。

(1) メソッドの引数にブロック引数がある場合( hoge(&block)

次のようにメソッドの引数でブロック引数がある場合は『ブロック引数を利用している』と定義されます。
なので以下の場合では警告は出力されません。

# これはブロック引数がある
def hoge(&block)
end

# ブロック引数があるので警告はでない
# no warning
hoge {}

逆にいうと『ブロック引数がない場合』は『ブロック引数を利用していない』と解釈されてブロック引数を渡すと警告が出力されます。

# これはブロック引数がない
def hoge
end

# ブロック引数がないメソッドにブロック引数を渡すと警告がでる
# warning: warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge {}

# send でも同様に警告が出る
# warning: warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
send(:hoge) {}

# & 渡しでも警告が出る
# warning: warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge(&:to_s)

また eval 経由で呼び出した場合にも警告がでます。

def hoge
end

# warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
eval("hoge {}")

ちなみに同じメソッドが複数回呼び出される場合は『1回のみ』警告が出力されます。

def hoge
end

(1..10).each {
  # この場合、警告は1回のみ出力される
  # warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
  hoge {}
}
def hoge
end

# 以下の場合も1回のみ警告が出力される
# warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge {}

# no warning
hoge {}

ここら辺のルールは深堀できていないので認識がちょっと違うかも…?(別ファイルの場合はどうなるのか、とか。

(2) メソッド内で yield を利用している場合

ブロック引数を受け取らないがブロック内で yield を利用している場合には『ブロック引数を利用している』と解釈されます。
なので以下のような場合は警告は出力されません。

# ブロック引数はないが内部で yield を利用している
def hoge
  yield
end

# ブロック引数を利用しているので警告は出ない
# no warning
hoge {}

ただし、現状の実装では eval 内で yield を呼び出した場合は警告が出力されます。

def hoge
  eval("yield")
end

# warning: warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge {}

これに関しては今後どうなるんですかねー。

(3) メソッド内で super を利用したときにブロック引数をフォワードしている場合

super 関連はちょっとややこしいです。
次のように superを呼び出した場合はいずれも『(暗黙的に)自身のメソッドに渡されたブロック引数を親メソッドに渡す』という挙動になります。

class Super
  def case1; yield end
  def case2; yield end
  def case3; yield end
end

class Sub < Super
  # 以下の書き方はいずれも自身に渡されたブロック引数を super に渡した状態になる
  def case1
    super
  end

  def case2
    super()
  end

  def case3(&block)
    super(&block)
  end
end

sub = Sub.new

# ここで渡したブロック引数はサブクラスのメソッドを経由して親クラスのメソッドにフォワードされる
sub.case1 { pp "case1" }   # => "case1"
sub.case2 { pp "case2" }   # => "case2"
sub.case3 { pp "case3" }   # => "case3"

なので上記のような super の書き方であれば『ブロック引数を利用している』と解釈されて警告は出力されません。

一方で以下のような super の書き方の場合は super を呼び出すときに『明示的に』ブロック引数を渡しています。
つまり『(暗黙的に)自身のメソッドに渡されたブロック引数を親メソッドに渡さない』という挙動になり『自身で受け取ったブロック引数は利用していない』と解釈されます。
なので以下の場合は警告が出力されます。

class Super
  def case1; end
  def case2; end
  def case3; end
end

class Sub < Super
  # 以下の書き方はいずれも自身に渡されたブロック引数は super に渡さずに
  # super を呼び出すタイミングで別のブロック引数を渡している
  def case1
    super(&:nil)
  end

  def case2
    super() {}
  end

  def case3
    super {}
  end
end

sub = Sub.new

# 以下で渡したブロック引数はいずれも呼び出されることはないので警告が出る
# warning: the block passed to 'Sub#case1' defined at test.rb:10 may be ignored
sub.case1 { pp "case1" }   # => nil

# warning: the block passed to 'Sub#case2' defined at test.rb:14 may be ignored
sub.case2 { pp "case2" }   # => nil

# warning: the block passed to 'Sub#case3' defined at test.rb:18 may be ignored
sub.case3 { pp "case3" }   # => nil

これはそもそも super がかなり特殊な挙動になっているので個別に対応しているような形になっています。

(4) メソッド内でシングルトンメソッドを利用している場合(今は無効な構文)

これはチケットの説明で言及されている例なんですが Ruby 2.7 時点では有効な構文でした。
しかし Ruby 3.0 で無効な構文になったようです。

def hoge
  class << Object.new
    yield
  end
end

hoge { pp "hello" }
# Ruby 2.7 => "hello"
# Ruby 3.0 => error: Invalid yield (SyntaxError)

なので現時点では特に考慮しなくてもよさそうです。

次からは少し細かい話になります。

ダックタイピング的なメソッドを呼び出した場合にどうするのか問題

以下のように同じ名前のメソッドを呼び出したときに片方でブロック引数を利用している場合は警告が出力されません。

class X
  def hoge
    yield
  end
end

class Y < X
  def hoge
  end
end

x, y = X.new, Y.new

# no warning
[x, y].map { _1.hoge {} }

これは元々警告が出力されるようになっていたんですが、誤検知が多いので relax unused block warning for duck typing by ko1 · Pull Request #10559 · ruby/ruby で別途『警告が出力されないように』対応されました。
例えば Rails だと Object#try(arg, &block) ではなくて NilClass#try(arg) を呼び出した場合とかで誤検知の話が上がったみたいです。
参照: https://bugs.ruby-lang.org/issues/15554#note-28

ただ、上記の対応の影響で以下のような呼び出しの場合でも警告は出力されません。

class X
  def hoge
    yield
  end
end

class Y < X
  def hoge
  end
end

# Y#hoge だけを呼び出した場合でも警告がでない
# no warning
Y.new.hoge {}

条件としては『同じ名前のメソッドでいずれかのメソッドでブロック引数が利用されていれば〜』みたいな判定になっているぽい?
これに関しては以下のコメントでも言及されています。
参照: https://bugs.ruby-lang.org/issues/15554#note-32
このあたりの対応を厳密にどこまでやるのかは難しそうですねえ。

個人的には https://bugs.ruby-lang.org/issues/15554#note-40 でも言及されているんですが、ダックピングするメソッドのシグネチャは一緒にする方が自然ではある(し、実際位置引数などは一緒のシグネチャにする必要がある)のでブロック引数も一緒にするほうが自然なんじゃないかな、という気はしますねえ。

class X
  def hoge(a, &block)
    block.call a + a
  end
end

class Y < X
  # a を利用しない場合でも引数を受け取る必要があるので仮引数は定義しておく
  def hoge(a = nil, &)
    # ここでは引数は参照しない
  end
end

この話の延長線上で実験的に厳格モードを追加して Rails のテストでどれだけ影響があるのか検証しているようです。

参照

この厳格モードはあくまでも検証用なのでリリース前には削除される予定です。

それ以外の細かいケース

Ruby 以外で実装されているメソッドにブロック引数を渡した場合

例えば組み込みのメソッドなどの C言語のレイヤーで実装されている場合は警告が出力されません。

# object_id は本来ブロック引数を受け取るようなメソッドではないが警告は出ない
# no warning
1.object_id {}

&nil をブロック引数として渡した場合

ブロック引数を利用していないメソッドに &nil を渡した場合には警告は出力されません。

def hoge
end

# これは警告はでる
# warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge(&:to_s)

# こっちは警告が出ない
# no warning
hoge(&nil)

&nil はそもそも『メソッドにブロック引数を渡していない』という風に解釈されるので警告がでない感じですね。

def hoge
  block_given?
end

# これはブロック引数を渡している
pp hoge(&:so_s)   # => true

# これはブロック引数を渡していないと解釈される
pp hoge(&nil)   # => false

メソッド内で #block_given? を利用した場合

メソッド内で #block_given? だけを利用した場合は『ブロック引数を利用していない』と解釈されます。
なので以下のような場合は警告が出力されます。

def hoge
  block_given?
end

# warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
hoge {}

これは『 #block_given?yield やブロック引数と一緒に利用していることを想定している』ので特に対応はされていないようですね。
参照: https://bugs.ruby-lang.org/issues/15554#block_given

ただ、以下のようなリアルケースで意図しない警告が出力されている、という報告がありました。

def distribution(key, value = UNSPECIFIED, sample_rate: nil, tags: nil, no_prefix: false, &block)
  # ブロック引数の有無に関するエラーハンドリングを別のメソッドで行っている
  check_block_or_numeric_value(value, &block)
  check_tags_and_sample_rate(sample_rate, tags)

  super
end

private

# このメソッドでは #block_given? を用いてエラーハンドリングを行っている
def check_block_or_numeric_value(value)
  if block_given?
    raise ArgumentError, "The value argument should not be set when providing a block" unless value == UNSPECIFIED
  else
    raise ArgumentError, "The value argument should be a number, got #{value.inspect}" unless value.is_a?(Numeric)
  end
end

参照: https://bugs.ruby-lang.org/issues/15554#note-26

super 経由でメソッドを呼び出した場合

super メソッドではブロック引数は利用していないが super を呼び出すときにブロック引数を渡しても警告は出力されません。

class Super
  def hoge; end
end

class Sub < Super
  def hoge
    # 親メソッドではブロック引数を利用していないが警告はでない
    super {}
  end
end

Sub.new.hoge

これは意図してないような気もするんですが super がかなり特殊だからなんですかね?

#instance_eval / class_eval でメソッドを定義した場合

eval("yield") だと『ブロック引数を利用していない』と解釈されていたんですが #instance_eval / class_eval 経由でメソッド定義した場合は『ブロック引数を利用している』と解釈されるので警告は出力されないようですね。

class X; end

X.class_eval <<~EOS
def hoge
  yield
end
EOS

x = X.new
# no warning
x.hoge {}

x.instance_eval <<~EOS
def foo
  yield
end
EOS

# no warning
x.foo {}

yield を消すと警告が出力されるようになります。

class X; end

X.class_eval <<~EOS
def hoge
  # yield
end
EOS

x = X.new
# warning: the block passed to 'X#hoge' defined at (eval at test.rb:3):1 may be ignored
x.hoge {}

x.instance_eval <<~EOS
def foo
  # yield
end
EOS

# warning: the block passed to 'foo' defined at (eval at test.rb:13):1 may be ignored
x.foo {}

Method, UnboundMethod 経由でメソッドを呼び出した場合

Method#call などでメソッドを呼び出したときには警告は出力されません。

class X
  def hoge
  end
end

x = X.new

# 以下は2つとも警告は出ない
# no warning
x.method(:hoge).call {}

# no warning
X.instance_method(:hoge).bind(x).call {}

これは『ブロック引数が呼び出されるメソッド(上記だと #hoge )に直接渡されるのではなくて Method#call に渡されているから』になるんですかねー。

(...) で引数をフォワードした場合

(...) でブロック引数を含む引数をフォワードした場合にも警告が出力されます。

def hoge
end

def foo(...)
  hoge(...)
end

# warning: the block passed to 'Object#hoge' defined at test.rb:1 may be ignored
foo {}

まとめ

警告がでるケース

# ブロック引数がなかったり yield を呼び出してないメソッドにブロック引数を渡すと警告が出る
def case1
end

# warning: the block passed to 'Object#hoge' defined at test.rb:2 may be ignored
case1 {}

# また block_given? を参照しただけではブロック引数は利用されていないと判断されるのでこれも同様に警告が出る
def case2
  block_given?
end

# warning: the block passed to 'Object#case2' defined at test.rb:9 may be ignored
case2 {}


# eval("yeild") もブロック引数を利用しているとは現状はみなしていない
def case3
  eval("yield")
end

# warning: the block passed to 'Object#case3' defined at test.rb:18 may be ignored
case3 {}


# super を呼び出したときに暗黙的にブロック引数が渡されなかった場合
# super では一部の書き方で暗黙的にブロック引数が渡されるんですが、それに該当しない場合になる
class Super
  def case4; end
  def case5; end
  def case6; end
end

class Sub < Super
  # 以下の書き方はいずれも自身に渡されたブロック引数は super に渡さずに
  # super を呼び出すタイミングで別のブロック引数を渡している
  def case4
    super(&:nil)
  end

  def case5
    super() {}
  end

  def case6
    super {}
  end
end

sub = Sub.new

# 以下で渡したブロック引数はいずれも呼び出されることはないので警告が出る
# warning: the block passed to 'Sub#case4' defined at test.rb:37 may be ignored
sub.case4 { pp "case1" }   # => nil

# warning: the block passed to 'Sub#case5' defined at test.rb:41 may be ignored
sub.case5 { pp "case2" }   # => nil

# warning: the block passed to 'Sub#case6' defined at test.rb:45 may be ignored
sub.case6 { pp "case3" }   # => nil

警告がでないケース

ブロック引数を渡したメソッドが『ブロック引数を利用している場合』には警告は出力されません。

# ブロック引数が定義されている場合
def case1(&block)
end

# no warning
case1 {}

# ブロック引数はないが内部で yield を使用している場合
def case2(&block)
end

# no warning
case2 {}


# ブロック引数は利用していないが &nil を渡した場合
# &nil はブロック引数を渡していないことになる
def case3
end

# no warning
case3(&nil)


# super を呼び出したときに暗黙的に super にブロック引数が渡される場合
class Super
  def case4; yield end
  def case5; yield end
  def case6; yield end
end

class Sub < Super
  # 以下の書き方はいずれも自身に渡されたブロック引数を super に渡した状態になる
  def case4
    super
  end

  def case5
    super()
  end

  def case6(&block)
    super(&block)
  end
end

sub = Sub.new

# ここで渡したブロック引数はサブクラスのメソッドを経由して親クラスのメソッドにフォワードされる
# no warning
sub.case4 { pp "case1" }   # => "case1"
# no warning
sub.case5 { pp "case2" }   # => "case2"
# no warning
sub.case6 { pp "case3" }   # => "case3"


# 同名のメソッドがある時にいずれかのメソッドでブロック引数を利用している場合
class X
  # こっちではブロック引数を利用している
  def case7
    yield
  end
end

class Y
  # こっちではブロック引数を利用していない
  def case7
  end
end

# いずれかの同名のメソッドでブロック引数を利用している場合は警告は出ない
# no warning
Y.new.case7


# 親メソッドがブロック引数を利用していなくても super にメソッドを渡すと
class Super
  def hoge; end
end

class Sub < Super
  def hoge
    # 親メソッドではブロック引数を利用していないが警告はでない
    super {}
  end
end

Sub.new.hoge

所感

ざっと触ってみた所感ですが基本的には『明らかにブロック引数を利用していない』ケースのみ警告が出ている感じではあるのでリアルケースだとそこまで誤検知はなさそう…?
メソッド内で eval("yield") を呼び出した場合のみ『ブロック引数を利用していない』と解釈される部分が気になるぐらいですかねー。
普段はこういう書き方はしないと思うんですがライブラリ内部とかで特殊な書き方をしている場合にこのケースでどれだけ困るのかは気になるところ。
まあこのあたりは実際にプロダクトで動かしてみないとわからないところではありますねえ。
実際にこの機能が Ruby 3.4 に入ったあとにもどこまで警告を厳格化するのか、みたいな議論が引き続き行われています。
個人的には厳格モードがあったほうがいろいろと検知しやすそうな気はしますがどこまでノイズになるのかも試してみないところには見えてこなさそうですねえ。

以下、チケットのコメントで気になったところ。

GitHubで編集を提案

Discussion