☄️

Ruby のブロックをちゃんと理解する

2021/05/13に公開

Ruby のブロックのおさらい

ブロック基本

ブロックについての説明は、Ruby リファレンスのメソッド呼び出しのページにあります。

ブロック付きメソッドとは制御構造の抽象化のために用いられるメソッドです。(中略)
do ... end または { ... } で囲まれたコードの断片 (ブロックと呼ばれる)を後ろに付けてメソッドを呼び出すと、そのメソッドの内部からブロックを評価できます。

メソッド呼び出し(super・ブロック付き・yield) (Ruby 2.7.0 リファレンスマニュアル)

つまり、ブロックはメソッドに付随して現れるものということがわかります。よく使う例として、
each メソッドや map メソッド で現れます。

# eachの例
array = [1,2,3]
array.each do |i|
  p i
end
#=> 1
#   2
#   3

# mapの例
array = [1,2,3]
double_array = array.map do |n|
  n * 2
end
# => [2, 4, 6]

また、ブロックの脱出や次のループへのジャンプは break, next をそれぞれ用います。

[1,2,3].each do |i|
  next if i == 2
  p i
end
#=> 1
#   3

[1,2,3].each do |i|
  break if i == 2
  p i
end
#=> 1

メソッドがブロックを取る場合のリファレンス上の表記

Ruby リファレンス(each

each {|item| .... } -> self

Rails のリファレンス(Enumerable#index_by
rails のリファレンスでは { ... } を取ることを例示しています。

people.index_by(&:login)
# => { "nextangle" => <Person ...>, "chade-" => <Person ...>, ...}

people.index_by { |person| "#{person.first_name} #{person.last_name}" }
# => { "Chade- Fowlersburg-e" => <Person ...>, "David Heinemeier Hansson" => <Person ...>, ...}

1行目は一旦無視して、4行目を見ると、たしかに{ ... } を取るメソッドになっています。1行目はなにをしているのでしょうか?
実はブロック付きメソッドにはブロックの代わりに渡せるものがあります。次の節で説明します。

ブロックの代わりにメソッドに渡せるもの

rails のリファレンスの例示で、1行目の people.index_by(&:login){ ... }do ... end もとっていません。
この記法はたまによく用いられます。以下の2つの実行結果は同じになります。

[1, 2, 3].map do |n|
  n.to_s
end
# => ["1", "2", "3"]

[1, 2, 3].map(&:to_s)
# => ["1", "2", "3"]

つまり、map メソッドの動作だけを見ると「:to_sシンボルと同名のメソッドを配列の要素それぞれをレシーバーとして実行する」ように見えます。
先程の rails リファレンスの例、people.index_by(&:login) は、「:loginシンボルと同名のメソッドを people 配列の要素それぞれをレシーバーとして実行する」と解釈できます。

この理解でも大体の場合大丈夫ですが、なぜこれで動作するのでしょうか?不思議です。

Ruby リファレンスによると、ブロック付きメソッドでは、ブロックの代わりに受け取れるオブジェクトが2つあります。
手続きオブジェクト(Proc)とメソッドオブジェクト(Method)です。
people.index_by(&:login) を解釈する上で重要なのは手続きオブジェクトなので手続きオブジェクトの説明をします。

手続き(Proc)オブジェクトとは

手続きオブジェクトは Ruby リファレンスには次のように書かれています

ブロックをコンテキスト(ローカル変数のスコープやスタックフレーム)とともにオブジェクト化した手続きオブジェクトです。

と記載があり、続いて例が記載されています。

var = 1
$foo = Proc.new { var }
var = 2

def foo
  $foo.call
end

p foo       # => 2

ブロックをそのままオブジェクト化したもののようです。そしてこの Proc オブジェクトは以下のようにブロックの代わりに用いることができます。

to_s_proc = Proc.new do |n|
  n.to_s
end
[1, 2, 3].map(&to_s_proc)
# => ["1", "2", "3"]

[1, 2, 3].map do |n|
  n.to_s
end
# => ["1", "2", "3"]

上と下の実行結果は同じです。ブロックの代わりにアンパサンド(&)で修飾した Proc オブジェクトを渡すことができます。map(&:to_s) に少し近づきました。
しかし、:to_s はもちろん Proc オブジェクトではありません。Symbol オブジェクトです。なぜブロックの代わりに渡せるのでしょうか。

to_proc メソッドを持つオブジェクト

実は Ruby のリファレンスにはおまけのように書いてあるのですが、Proc オブジェクト、 Method オブジェクトのほかにもう一つ、ブロックの代わりに渡せるオブジェクトがあります。to_proc メソッドを持つオブジェクトです。

to_proc メソッドを持つオブジェクトならば、`&' 修飾した引数として渡すことができます。デフォルトで Proc、Method オブジェクトは共に to_proc メソッドを持ちます。to_proc はメソッド呼び出し時に実行され、Proc オブジェクトを返すことが期待されます。

以下のような例がリファレンスには載っています。

class Foo
  def to_proc
    Proc.new {|v| p v}
  end
end

[1,2,3].each(&Foo.new)

=> 1
   2
   3

to_proc メソッドを持つオブジェクトはブロックの代わりに渡すと、暗黙的にto_procメソッドが呼ばれ、Proc オブジェクトとしてブロックの代わりに渡されます。
そして実は Symbol は to_proc メソッドが実装されています。つまり、以下の3つは同じ出力になります。

[1, 2, 3].map do |n|
  :to_s.to_proc.call(n)
end
# => ["1", "2", "3"]
# Proc オブジェクトを直接呼び出す場合は call メソッドを用います

[1, 2, 3].map(&:to_s.to_proc)
# => ["1", "2", "3"]
# 明示的にto_proc して Proc オブジェクトをブロックの代わりに渡している

[1, 2, 3].map(&:to_s)
# => ["1", "2", "3"]
# to_proc を持つオブジェクトとして :to_s を渡している。

ブロックの代わりに渡せるものまとめ

  • ブロックの代わりに3つ渡せるオブジェクトがある
    • Proc オブジェクト
    • Method オブジェクト
    • to_proc メソッドを持つオブジェクト
  • map(&:to_s) という表現は Symbol オブジェクトに to_proc メソッドが実装されているためにできる

ブロック付きメソッドの宣言

ブロック付きメソッドを宣言するには以下のような選択肢があります

  • yield を使う方法
  • Proc.new, Kernel#proc
  • 仮引数の最後に &をつけた変数を定義
def foo(count)
  count.times { yield }
end
# yield

def foo(count)
  count.times { Proc.new.call }
end
# Proc.new

def foo(count, &block)
  count.times { block.call }
end
# &をつけた変数

foo(3) { print "Ruby! "}
#=> Ruby! Ruby! Ruby!

参考

メソッド呼び出し(super・ブロック付き・yield) (Ruby 2.7.0 リファレンスマニュアル)
class Proc (Ruby 2.7.0 リファレンスマニュアル)
制御構造 (Ruby 2.7.0 リファレンスマニュアル)

Discussion