Line 1: Error: Invalid Blog('by Esehara' )

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

>> Zanmemo

あと何かあれは 「esehara あっと じーめーる」 か @esehara まで

関数及びメソッド内で、エラーをキャッチして再帰させる場合の注意点

はじめに

Rubyの練習ということで、とあるレポジトリに対して、手直しのpull requestを送ったりした。いま確認したら、無事mergeされていたようだ。

さて、その中で「個人的に面白いな」と思ったコードがあったので、そのことについて考えたことを記事にしたいと思う。

題材: エラーが起きたときに、自身を呼び出してリトライするコード

対象のコードとは、下記のようなコードのことだ。rescueはブロック内(下の場合は関数内)において、エラーが発生した場合、そのエラーをキャッチして実行されるものだ。try - catch、またはexceptだと考えてほしい。コードはRubyである。

def recursion_in_rescue
  other_call
rescue
  recursion_in_rescue
end

このとき、other_callにおいて、以下の条件があったとする。

  • other_call以下においてエラーが発生する恐れがある
  • リトライを繰り返すと、正常値が帰ってくる可能性がある

この二つが当てはまるとする。

再帰において気をつけること: 停止条件に到達するかどうか

再帰を利用するにおいて、メリットが一つあるとするならば「停止条件が明確である」という点があるように思われる。例えば、1から任意のnまでの数字を出力する再帰関数を定義する場合、

def print_n(n, start=1)
    print "#{start}\n"
    if n != start
      print_n(n, start + 1)
    end
end

という風に定義できる。また、1から始めたくないときは、start引数に1以外を渡してやればよい。

一見、良さそうに見えるこの定義なのだが、勘のいい人なら解る通り、以下の引数を渡すとSystemStackErrorとなる。

print_n(10, 100)

上の再帰関数は、startnが一致する瞬間があることを前提としているが、例えば元々startの引数が、nよりも大きい場合、停止条件に到達しないために、永遠に再帰してしまうことがある。

上記の場合は、比較的簡単な「凡ミス」であるということが出来るけれども、しかし「停止条件に到達するかどうか」という問題は、見かけ以上に厄介だと思う。

事例: コラッツの問題

例えば、コラッツの問題と呼ばれる数論の難問は、その見かけに比べて、未だに「必ず1に到達する」のか証明できていないものだ。詳しい話については、上記のリンクで見てもらうとして、実際にコードを書いてみよう。

def collatz(n)
  print "#{n} -> "
  if n == 1
    print "end"
  elsif n % 2 == 0
    collatz(n / 2)
  elsif n % 2 == 1
    collatz(3 * n + 1)
  end
end

こんな簡単な再帰関数ですら、これが停止すること(1になること)を証明することは難しい。もちろん、上記のリンクを見れば解る通り、コンピューターによって、十分な範囲で停止されることは確認されているため、実用上では、この関数において無限ループすることが無いと確証は出来る(何に実用するのかは別として)。

ポイントは、確かに再帰関数は、停止条件は明確であるのだが、しかしその停止条件に到達するのかどうか、という点が難しい点になる。これは、別に再帰関数に限らず、一般的なwhileや、あるいはイテレーターにおいても同様のことが言える。

エラーをキャッチして再帰させることの危険性

望ましい解決としては、エラーメッセージによって、試行する場合と、試行しない場合を分けたりするのが良いように感じる(場合によってはカスタムエラーを使う)。しかし、諸条件によって、汎用的なエラーを捕まえざるを得ない場合がある。

このとき、単純なrescueはすべてのエラーを捕まえてしまうので、rescue内で再帰させると、危険なことになる。基本的に、下記のような、エラーを何でも捕まえてしまうrescue内で再帰させるのは望ましくないように感じる。

例えば下記の場合において、無限ループに陥ってしまう。

def typo
  you are fool
end

def infinity_loop
  typo
rescue
  print "You are the idiot, hahahaha~~~~ ^-^ "
  infinity_loop
end

Rubyの場合、この手のtypoに関しては、未定義の変数ならびにメソッドを呼び出したという旨のNameErrorを起こすのだが、このNameErrorrescueしてしまい、そのままスコープに突っ込んでいく。このとき、一つのコーディングミスによって、無限のループが実現してしまう。

このように、基本的にはエラーをキャッチした場合には、再帰させるべきではないということが言える。

とりあえずの解決: 終了条件に試行回数を入れる

とはいえ、何かの事情によって、上記のようなコードを動かせない場合であったり、あるいは既に出来上がっていて、できるだけ動かしたくない場合がある。もし再帰する場所でなんとかする場合、終了条件に試行回数を入れることによって、とりあえずのところ解決は出来る。

def not_inifinity(count=10)
  typo_typo
rescue => e
  if count < 1
    raise e
  else
    print "Retry :: #{count} \n"
    not_inifinity(count - 1)
  end
end

このようにすることによって、以下の状態、つまり

  • typo_typoからいつもエラーが帰ってきて終わらない場合
  • typo以下で何らかのコーディングミスが発生した場合

に対して、「指定回数試したらそのままエラーを返す」といった挙動を行うことが出来る。

まとめ

実際に、このコードをみて、上記のようにまとめて見ない限り、この実装の問題点に気がつくことがなかった。恐らく本来であるならば、「掴まえたいエラーのクラスは指定する」「本当に再帰させるべきなのかどうかを考え、他の方法がないか考え直してみる」というのが必要のように思える。何かさっくりとものを作る場合において、もしかしたら自分も上記のような実装をしてしまうかもしれない。

再帰は、確かに便利だし、慣れてしまうと、手癖で再帰的に実装することが多いけれども、上記のような点について気をつけないと、思わぬバグを踏んでしまうな、と思わされる事例のように感じた。