こんにちは! マネーフォワード クラウド経費 というサービスで 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
■ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』
■だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』