Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

[Ruby] #eachにブロックを渡す場合/渡さない場合の挙動の違い

こんにちは、hachi8833です。

今回は、#eachにブロックを渡して変数に代入する場合と、ブロックを渡さずに変数に代入する場合の挙動の違いについてまとめました。

記事後半ではmorimorihogeさんが#eachの挙動を深掘りしてくれました。

#eachの挙動の違い

1. #eachにブロックを渡す場合

#each{|str| puts "#{str} hohoho"}というブロックを渡してaに代入してみます。

arr = %w(hoge huga piyo foo baa)
a = arr.each {|str| puts "#{str} hohoho"}
pp a   #=> ["hoge", "huga", "piyo", "foo", "baa"]

aにはarrのArrayオブジェクトがそのまま入ります。ArrayにはEnumerator#nextはないので、続けて以下を実行するとエラーになります。

a.next #=> エラー

2. #eachにブロックを渡さない場合

今度は#eachにブロックを渡さずにaに代入してみます。

arr = %w(hoge huga piyo foo baa)
a = arr.each
pp a   #=> #<Enumerator: ...>

aにはEnumeratorオブジェクトが入りますので、#nextで順に取り出せます。

a.next #=> "hoge"
a.next #=> "huga"
a.next #=> "piyo"
a.next #=> "foo"
a.next #=> "bar"
a.next #=> エラー(終端に到達)

内部イテレータと外部イテレータ

morimorihogeさんとkazzさんによると、ブロックなしの#each外部イテレータとしてのEnumeratorオブジェクトを返すそうです。

逆に#eachにブロックを渡すと(この使い方がほとんどだと思います)、内部イテレータから渡された値をブロック変数(ブロック内の| |で囲まれた変数)としてループを回せます。

ブロックあり#each
内部イテレータを利用してループする
ブロックなし#each
外部イテレータを返す

以下のリファレンスマニュアルで言う「一部のイテレータ」のひとつが#eachですね。

Enumerator を生成するには Enumerator.newあるいは Object#to_enum, Object#enum_for を利用します。また、一部のイテレータはブロックを渡さずに呼び出すと繰り返しを実行する代わりに enumerator を生成して返します
Ruby 2.4.0 リファレンスマニュアル: Enumeratorクラスより

内部イテレータを使うブロックあり#eachは、以下のようにイテレータの戻り値をそのまま返します。実際、1.ではaarrの配列がそのまま代入されていましたね。

ブロック付きで呼び出された場合は、 生成時に指定したイテレータの戻り値をそのまま返します。
Ruby 2.4.0 リファレンスマニュアル: Enumerator#eachより

なお、結果を取り出したい場合はmapなどを使います。

pry(main)> a = arr.map {|str| str * 2}
#=> a は ["hogehoge", "hugahuga", "piyopiyo", "foofoo", "baabaa"]になる

追伸: Enumerator#each の仕様について

ruby-doc.orgのEnumeratorによると、Enumeratorのほとんどのメソッドはブロックなしの場合にはイテレーションをラップするEnumeratorを返すとのことです。

Most methods have two forms: a block form where the contents are evaluated for each item in the enumeration, and a non-block form which returns a new Enumerator wrapping the iteration.
ruby-doc.orgのEnumeratorより

ブロックなしの#eachの動作について明確に規定されているとまではいきませんが、多くの場合はEnumeratorを返すようです。

参考: matzによる内部イテレータと外部イテレータの解説

まつもと直伝 プログラミングのオキテ 第5回(2)に、matz直々の解説がありますので、一部を引用します。2005年の記事なのでご了承ください。

内部イテレータは余分なクラスを作らず,使うのも作るのも簡単です。しかし,言語がクロージャをサポートしていないとループ本体と外側とで情報を共有するために工夫が必要になり,C言語の例で見たようにループとしての使い勝手が悪くなります。このため,クロージャを持たないC++やJavaでは外部イテレータが採用されているのです。

 一方,外部イテレータ方式では,コンテナとイテレータはお互いに密接な関係を持ちますから,作るのも難しく,利用するためのコード量も少々増えます。外部イテレータ方式を,一行で書けるeachメソッドを用いた繰り返しと比べてみると一目瞭然でしょう。しかし,外部イテレータ方式にも長所があります。複数のコンテナから一つずつ要素を取り出し,並行に処理するような手続きは外部イテレータでは簡単に書けますが,内部イテレータではそうはいきません。

「内部イテレータではクロージャが重要」なんですね。

Appendix: Enumerableが要求する#eachの詳細仕様はどうなっているのか?

※この節だけmorimorihogeが書いてます

ここまではArrayやHashのようなRuby組み込みクラスを想定して書かれた内容なのですが、自分たちでEnumerableなクラスを作成する場合、そもそも引数無し#eachが外部イテレータを返すように実装することは必須なのでしょうか?
Enumerableのドキュメントには以下のように書かれています。

このモジュールの メソッドは全て each を用いて定義されているので、インクルード するクラスには each が定義されていなければなりません。
docs.ruby-lang.org: Enumerableモジュールのドキュメントよりより

ただ、この書き方だけでは#eachの具体的な実装についてまでは言及されておらず、穿った見方をすれば

class HogeCollection
  include Enumerable

  def each
    puts 'あばばばばばば'
  end
end

みたいなコードでもいいんじゃないかという話にもなってしまいます(ちなみに実はエラーにならず動きます。意味があるかどうかは別としてw)。
一方、ruby-doc.orgの方のEnumerableドキュメントでは以下のようにもう少し細かい指定があります。

The class must provide a method each, which yields successive members of the collection.
ruby-doc.org: Enumerableモジュールのドキュメントより

#eachはcollectionのメンバに対して連続的にyieldしろ、的な指定ですね。これで少なくとも#eachはyieldしなければいけない(blockを受け取って処理する必要がある)ことがわかります。

しかし、ここで再掲となりますが、今回のタイトルにもある引数無し#eachについては指定がありません。

Most methods have two forms: a block form where the contents are evaluated for each item in the enumeration, and a non-block form which returns a new Enumerator wrapping the iteration.
ruby-doc.orgのEnumeratorより

「Most methods」というところがミソで、この書き方だとsome methodsは引数無し#eachを呼び出した際にEnumeratorが返ってこない可能性もあるということになります。
これだと、Enumerableを汎用的に受け取って外部イテレータを使った処理を書く時には

def oreore_method(collection)
  iterator = collection.each
  if iterator.kind_of? Enumerator
    # 処理
  else
    raise 'collection.each does not return Enumerator'
  end
end

みたいな例外をいちいち記述しないといけないのかな?とも取れます。結論から言うとこれはYESです。Enumerableなクラスから外部イテレータを取り出したいと思ったときに#eachを使ってしまうと予期しない結果が起こる可能性があります。

しかし、とはいえEnumerableから確実に外部イテレータを取得したい、ということもあるかもしれません。そんなときは#collect#mapを使うと引数なし#eachがEnumeratorを返さない実装になっていてもEnumeratorを取り出すことができます
具体的に見てみましょう。

class HogeCollection
  include Enumerable

  def initialize
    @members = [1,2,3]
  end

  def each(&block)
    if block_given?
      @members.each{ |obj| yield obj }
    else
      puts 'no block given.'
    end
  end
end

とした上でPryで動かしてみます。この場合、

[2] pry(main)> hoge = HogeCollection.new
=> #<HogeCollection:0x007ff3b1929e90 @members=[1, 2, 3]>
[3] pry(main)> hoge.each
no block given.
=> nil

と#eachは確かにnilを返してしまうのですが、

[4] pry(main)> hoge.map
=> #<Enumerator: ...>

と#mapするとEnumeratorを取得することができます。もちろんこのEnumeratorは普通に#nextできて、

[7] pry(main)> e.next
=> 1
[8] pry(main)> e.next
=> 2
[9] pry(main)> e.next
=> 3
[10] pry(main)> e.next
StopIteration: iteration reached an end
from (pry):23:in `next'

と期待した動作をしてくれます。つまり、Enumerableモジュールは#eachがブロックに対してyieldすること以上は要求していないということになります。
整理すると以下の通りになります。

  • #eachにblockを渡した場合に要素がyieldされることはEnumerableモジュールのinclude条件に規定されている
  • #eachにblockを渡さない場合にEnumeratorが返るかどうかは規定されていないが、慣例的には返却されることが多い
  • もし#eachがEnumeratorを返さなくても、引数無し#map等のEnumeratorを明示的に返却するメソッドを使えばEnumeratorを取り出すことができる

公式ドキュメントの通りといえば通りなのですが、この辺りは誤解していると汎用的に書いたつもりが動かないケースを生み出してしまうので気をつけると良いと思います。

関連記事


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。