エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

再帰でyieldする

Rubyを手足のように使う方々には常識っぽい内容のような気がしますが、今回はじめてちゃんと調べて学んだので備忘のために記録。

参照情報:

使ったRubyのバージョンは1.8.7です。

ことのはじまりは、再帰的にファイルを一覧したかった

要は。findコマンドでファイルを一覧するようなことをRubyでしたかったわけです。

$ find . -type f
./a
./b
./c
./d/d-a
./d/d-b
./d/d-c


単に表示するだけなら、たとえばこんな感じ。

def files0(path)
  if FileTest.directory? path
    Dir.foreach(path) do |item|
      next if [".", ".."].include? item
      files0("#{path}/#{item}") # *2
    end
  else
    puts path # *1
  end
end

path = ARGV[0] || "."

files0(path)

だけども。できることならファイル探索と表示を分離したい。たとえばこんなふうに書きたい。

files0(path) do |file|
  puts file
end

Arrayなどに集める方法もありますが、メモリを消費しそうなので今回はその方法はパス。
ならばと*1のところをyield pathと書き換えると、再帰をする*2のところの呼び出し方が合わなくてエラーになります。

yield下にyieldを架す

呼び出す形が違うためにエラーになるのなら、同じ形にすればいいのかな?と書き直してみたのがコレ。

def files1(path)
  if FileTest.directory? path
    Dir.foreach(path) do |item|
      next if [".", ".."].include? item
      files1("#{path}/#{item}") do |file|
        yield file
      end
    end
  else
    yield path
  end
end

path = ARGV[0] || "."

files1(path) do |file|
  puts file
end

一応動きます。が。再帰の奥深くからyieldされると、それを呼び出したところでyieldして、さらにそれを呼び出したところでyieldして…となるのでとても効率悪そう。実際再帰の深さは関係なくてyieldするところからはブロックが実行できればいいわけで、効率を脇に置いても面倒なことになっている感じがひしひしと。

ブロックを引数としてわたす

再帰的にフォルダを検索してファイルが見つかったらなにかをする、の「なにかをする」の部分は再帰が浅くても深くても同じわけで、これを持ち回って必要なときに「なにかをする」ようにすればいいと考え、ブロックまわりの情報を検索。
はい、ありました。
引数を&で受けるとブロックを受けられます。最初に受け取ったブロックを再帰呼び出しのときに持ち回るようにしてみます。こんな感じ。

def files2(path, &block)
  if FileTest.directory? path
    Dir.foreach(path) do |item|
      next if [".", ".."].include? item
      files2("#{path}/#{item}", &block)
    end
  else
    block.call path
  end
end

path = ARGV[0] || "."

files2(path) do |file|
  puts file
end

無事動作。無事目的を果たすことができました。

ブロックがなかったときのばあい

やりたいことはこれでできたのですが、ブロックを与えなかったばあい第2引数はnilになってblock.call pathのところでエラーになってしまいます。これの対策。

標準のライブラリではどうなっているのかと確かめてみると。

$ irb
irb(main):001:0> [].each
=> #

Enumerable::Enumeratorインスタンスを返しているようです。
なので、Enumerable::Enumeratorを調べる。
Enumerable::Enumerator.newは第1引数にレシーバ、第2引数にセレクタ、第3引数以降にパラメータを与えると、繰り返し呼び出しを実現するEnumerable::Enumeratorインスタンスを生成します。

これを使って書き直してみると、こんな感じ。

def files3(path, &block)
  return Enumerable::Enumerator.new(self, :files3, path) if not block
  if FileTest.directory? path
    Dir.foreach(path) do |item|
      next if [".", ".."].include? item
      files3("#{path}/#{item}", &block)
    end
  else
    block.call path
  end
end

path = ARGV[0] || "."

同じようにブロックを与えても動作。

files3(path) do |file|
  puts file
end

次にブロックを与えなかったばあい、その1。
Enumerable::Enumerator#eachで回しています。

f2 = files3(path)

f2.each do |file|
  puts file
end

ブロックを与えなかったばあい、その2。
loopで回し、Enumerable::Enumerator#nextで要素を取り出しています。

f2 = files3(path)

loop do
  puts f2.next
end