pockestrap

Programmer's memo

絶対に抜け出せないループと、それを抜け出す方法

こんにちは。 id:Pocke です。Rubyで抜け出せないループを定義する方法を紹介します。

抜け出せるループ

Ruby にはKernel.#loopメソッドが定義されています。これはブロックの評価を永遠に繰り返すメソッドです。

# 永遠にhelloが出力される
loop do
  puts "hello"
end

loopメソッドは当然抜け出すこともできます。

# helloを1回出力した後ループから抜ける
loop do
  puts 'hello'
  break
end

# 同様にRubyを1回出力した後ループから抜ける
# メソッド内で`return`を呼ぶことで、メソッドごと抜け出すのに使われる
def f
  loop do
    puts 'Ruby'
    return
  end
end
f

# worldを1回出力した後、例外を出してループから抜ける(プログラムは異常終了する)
loop do
  puts 'world'
  raise 'bye!'
end

抜け出せないループ

では、Kernel.#loopとは違い、breakreturn, raiseを使っても抜け出せないループを考えてみましょう。Rubyでは、なんとそのようなメソッドが定義できてしまいます。それをinfinite_loopメソッドとして定義すると、以下のようになります。1

def infinite_loop(&block)
  tap do
    yield
  ensure
    redo
  end
end

# 永遠にhelloが出力される
infinite_loop do
  puts 'hello'
  break
end

# 永遠にRubyが出力される。
# メソッドを抜け出せない。
def f
  infinite_loop do
    puts 'Ruby'
    return
  end
end
f

# 永遠にworldが出力される。
# 例外は出力されない。
infinite_loop do
  puts 'world'
  raise 'bye!'
end

このコードを実行すると、breakなどをしているにも関わらず、ループから抜け出せないことが分かると思います。returnしたにも関わらずまた同じメソッドのコードが呼ばれるところなどはとてもおもしろいですね。

なお実行の際はこのプログラムはCtrl-cでも止められず、kill -9で止める必要があることに注意してください。

抜け出せないループの仕組み

infinite_loopメソッドのコードを再掲します。

def infinite_loop(&block)
  tap do
    yield
  ensure
    redo
  end
end

tapメソッド以外キーワードで構成されていて美しいですね。

このメソッドの挙動を解説すると、以下のようになります。

  1. infinite_loopメソッドが呼ばれる
  2. Kernel.#tapメソッドが呼ばれる
  3. Kernel.#tapメソッドは、それが受け取ったブロックを実行する
  4. ブロック内のyieldが実行され、block引数のブロックが実行される
    • ここでinfinite_loopメソッド利用側が任意の処理を実行する
  5. blockの実行から戻った後、常にensure節に進む
    • これが肝。ensure節は常に実行するコードを書くための構文であるため、例外が上がろうがbreakしようがreturnしようが常に評価される
  6. ensure節でredoが評価される
    • redotapメソッドの評価の一番先頭まで戻る。つまり、3の直前まで戻り、ループする。

このように、ensure節が必ず評価されることを利用して、抜け出せないループを実装することができました。

抜け出せないループを抜け出す方法

どんな矛も防ぐ盾が存在しないように、このループを抜け出す方法は存在します。 その方法をいくつか見てみましょう。

Kernel.#exit!

まずはつまらない方法から。exit!メソッドを呼ぶと簡単に抜け出せます。

def infinite_loop(&block)
  tap do
    yield
  ensure
    redo
  end
end

infinite_loop do
  puts 'hello'
  exit!
end

このコードを実行すると、helloを1回だけ出力して、プログラムが異常終了します。これはexit!メソッドがその場でプロセスを終了させるためです。

なお、Kernel.#exitメソッドではこのループは脱出できません。exitメソッドは単に例外を投げるメソッドであるためです。

TracePoint

TracePointを使うことでもこのループを抜けだせます。

このループの肝はensure節が必ず実行されることでした。そのensure節にも弱点はあります。ensure節で発生した例外やreturnによって、そのensure節が新たに評価されることが無いことです。

def f
  raise 'ex 1'
ensure
  raise 'ex 2'
end

f

上のコードを実行すると、ex 1の例外が起きたことによりensure節に入りますが、ex 2の例外によって再度このensure節に入ることはありません。再度ensure節に入ってしまったら簡単に無限ループが起きてしまいます。

これを応用して、infinite_loopメソッドのensure節の中で例外を起こしたりreturnしたりすればループを抜けられます。具体的には次のようなコードになります。

def infinite_loop(&block)
  tap do
    yield
  ensure
    redo
  end
end

infinite_loop do
  puts 'hello'
  TracePoint.trace(:line) { it.binding.eval 'return' if it.lineno == 5 }
end
puts 'world'

このコードを実行すると、helloを1回だけ出力してループを抜け出し、その後ループの外でworldを出力します。

これはTracePointのlineイベントを使って、ensure節のredoがある5行目でreturnすることで実現しています。returnの代わりにraiseをしてもループを抜け出せますが、例外が上がらないreturnのほうがきれいな感じがしますね。

最後に

抜け出せないループと、それを抜け出す方法の紹介でした。この他にも面白いループの抜け出し方を思いついたらぜひ教えてください。


  1. なお、infinite_loopの実装は当初再帰を使った形でstack overflowしていたのですが、ぺんさんredoを使ったより強固な形を実装してくれました。ぺんさんすごい。