Ruby の Enumerator とたわむれる

こんにちは!
マネーフォワード クラウド経費 というサービスで Rails エンジニアをやっている野田 (@quanon_jp) と申します。

クラウド経費の開発拠点は福岡にあるのですが、福岡拠点では不定期で tech talk というカジュアルな社内 LT 会を行っています。
先日、この会で Ruby の Enumerator クラスについてお話しました (個人的に大好きなんです 💖) 。

今回はその内容を本エンジニアブログでもお伝えできればと思います。

 

バージョン情報

この記事のコード例では Ruby 2.7 を用います。

$ ruby -v
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin18]

 

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

配列などのコレクションの要素を列挙する仕組みとして イテレータ があります。
これは外部イテレータと内部イテレータに二分できます。

外部イテレータ

外部イテレータはコレクションとイテレータが独立している仕組みです。
Enumerator オブジェクトは Enumerator#next を呼ぶことで次の要素を取り出すことができ、これを while 式や Kernel.#loop メソッドで繰り返し呼ぶことで要素を列挙することができます。

authors = Enumerator.new do |y|
  # Enumerator::Yielder#<< メソッドを呼ぶことで次の要素を定義する。
  y << '村上春樹'
  y << '吉本ばなな'
  y << '小川洋子'
end

authors.next #=> "村上春樹"
authors.next #=> "吉本ばなな"
authors.next #=> "小川洋子"
authors.next # StopIteration: iteration reached an end がはっせいする

# loop メソッドのブロック内で StopIteration が発生するとループを終了する。
loop { puts(authors.next) }
# 村上春樹
# 吉本ばなな
# 小川洋子

内部イテレータ

内部イテレータはイテレータがコレクション内部に実装されている仕組みです。
Array#each がまさにその典型ですね。

authors = %w(村上春樹 吉本ばなな 小川洋子)
authors.each { |author| puts(author) }
# 村上春樹
# 吉本ばなな
# 小川洋子

authors = Enumerator.new do |y|
  y << '村上春樹'
  y << '吉本ばなな'
  y << '小川洋子'
end
# Enumerator も Enumerator#each で列挙できる。
authors.each { |author| puts(author) }
# 村上春樹
# 吉本ばなな
# 小川洋子

 

Enumerator の使い所

コレクションを表現するのに Array で事足りると思うのですが、なぜ Enumerator も存在するのでしょうか 🤔

Enumerator の使い所 1: 遅延評価

Array はすべての要素をメモリ上に展開しますが、Enumerator の場合は実際に要素を取り出すまでは要素をメモリ上に展開しません。
例えば Range#map は返り値の Array オブジェクトがメモリ上に展開されます。
そのため、Range オブジェクトの長さが無限だと、同じく無限長の Array オブジェクトを生成しようとし、結果が帰ってこなくなります 😢

def fizzbuzz(n)
  return 'FizzBuzz' if n % 15 == 0
  return 'Buzz' if n % 5 == 0
  return 'Fizz' if n % 3 == 0

  n
end

(1..100).size
#=> 100
(1..100).map { |i| fizzbuzz(i) }
#=> [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz", ..., "Buzz"]

(1..).size
#=> Infinity
(1..).map { |i| fizzbuzz(i) }
# 反応なし……

ここで Enumerator を使ってみます。
そうすると、無限の長さのコレクションを表現した上で、必要な分だけ要素を取得するということが可能になります。

enum = Enumerator.new do |y|
  # 一見、無限にループしているように見えるが…… 👀
  (1..).each { |i| y << fizzbuzz(i) }
end

enum
#=> #<Enumerator: ...>
enum.first(15) # この時点で初めて評価される!
#=> [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]

また Enumerator::Lazy を使う方法もあります。
Enumerator と Enumerator::Lazy は瓜二つの双子 👯 のような関係です。表現しているものは同じなのですが、同名のメソッドの挙動が異なります。
例えば、ブロック引数ありの Enumerator#map が Array オブジェクトを返すのに対し、Enumerator::Lazy#map は Enumerator::Lazy オブジェクトを返します。
そのため、評価を遅延した状態ままメソッドチェーンを続けることができます。

(1..).lazy
#=> #<Enumerator::Lazy: ...>
lazy_enum = (1..).lazy.map { |i| fizzbuzz(i) }
#=> #<Enumerator::Lazy: ...>
lazy_enum.first(15) # この時点で初めて評価される!
#=> [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]

lazy_enum = (1..).lazy.map { |i| fizzbuzz(i) }.select { |x| x.is_a?(Integer) }
#=> #<Enumerator::Lazy: ...>
lazy_enum.first(10) # この時点で初めて評価される!
#=> [1, 2, 4, 7, 8, 11, 13, 14, 16, 17]

遅延評価によってメモリ消費を抑えることができるおかげで、例えば大容量のテキストファイルを読み込む際にも便利です。

# 大容量のテキストファイルの各行の先頭に行番号をつけた配列がほしい。
lines = Pathname('path/to/huge.txt').each_line
#=> #<Enumerator: ...>
numbered_lines = lines.lazy.with_index(1).map { |line, i| "#{i}: #{line}" }
#=> #<Enumerator::Lazy: ...>
numbered_lines.first(5) # この時点で初めて評価される!

Enumerator の使い所 2: 複雑なループ構造の抽象化

以下のようにネストしたループをシンプルに扱いたいために、ブロック引数を受け取る独自のメソッドを定義した場面を想定します。

author = Author.create!(name: '村上春樹')
author.books.create!(title: '羊をめぐる冒険')

author = Author.create!(name: '吉本ばなな')
author.books.create!(title: 'キッチン')
author.books.create!(title: 'TUGUMI')

# ネストしたループをシンプルに扱いたいのでメソッド化した。
def each_author_and_book
  i = 1
  Author.find_each do |author|
    author.books.find_each do |book|
      yield(author, book, i)
      i += 1
    end
  end
end

each_author_and_book do |author, book, i|
  puts("#{i}: 『#{book.title}』 #{author.name}")
end
# 1: 『羊をめぐる冒険』 村上春樹
# 2: 『キッチン』 吉本ばなな
# 3: 『TUGUMI』 吉本ばなな

ここで、ネストしたループを Enumerator.new でラップして Enumerator オブジェクトを返すような構造にしてみます。
すると、オブジェクトとして扱えるおかげでさらにメソッドチェーンするなどの活用ができて便利です。
Array オブジェクトで表現する方法もありますが、遅延評価できるという点に加え yield (Enumerator::Yielder#<<) を使う方が次の要素を宣言的に定義することができて分かりやすいと思います。

def author_and_book_pairs
  Enumerator.new do |y|
    Author.find_each do |author|
      author.books.find_each do |book|
        y << [author, book]
      end
    end
  end
end

results = author_and_book_pairs.map.with_index(1) do |(author, book), i|
  "#{i}: 『#{book.title}』 #{author.name}"
end
results.each { |result| puts(result) }
# 1: 『羊をめぐる冒険』 村上春樹
# 2: 『キッチン』 吉本ばなな
# 3: 『TUGUMI』 吉本ばなな

# 次のように書く方法もあるが、結果が Array オブジェクトとして即座に評価される上に
# Array#flat_map や Array#flatten を使ってフラットな配列になるように工夫する必要がある。
def author_and_book_pairs
  Author.all.flat_map do |author|
    author.books.map do |book|
      [author, book]
    end
  end
end

 

最後に

Enumerator あるいは Enumerator::Lazy の使い所を知っておくといろいろと便利な場面があります。
さらに Ruby 2.7 では Enumerator.produce や Enumerator::Lazy#eager, Enumerator::Yielder#to_proc が追加されるなど、便利さが (地味に) 向上しています。
ぜひ、日頃から Enumerator を活用できる場所がないか探してみると Ruby ライフが楽しくなると思いますよ 😉✨

 

マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【採用サイトのご案内】
マネーフォワード採用サイト
Wantedly

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

本業に集中できる新しいオンライン融資サービス 『Money Forward BizAccel』

Pocket