関数及びメソッド内で、エラーをキャッチして再帰させる場合の注意点
はじめに
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)
上の再帰関数は、start
とn
が一致する瞬間があることを前提としているが、例えば元々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
を起こすのだが、このNameError
をrescue
してしまい、そのままスコープに突っ込んでいく。このとき、一つのコーディングミスによって、無限のループが実現してしまう。
このように、基本的にはエラーをキャッチした場合には、再帰させるべきではないということが言える。
とりあえずの解決: 終了条件に試行回数を入れる
とはいえ、何かの事情によって、上記のようなコードを動かせない場合であったり、あるいは既に出来上がっていて、できるだけ動かしたくない場合がある。もし再帰する場所でなんとかする場合、終了条件に試行回数を入れることによって、とりあえずのところ解決は出来る。
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以下で何らかのコーディングミスが発生した場合
に対して、「指定回数試したらそのままエラーを返す」といった挙動を行うことが出来る。
まとめ
実際に、このコードをみて、上記のようにまとめて見ない限り、この実装の問題点に気がつくことがなかった。恐らく本来であるならば、「掴まえたいエラーのクラスは指定する」「本当に再帰させるべきなのかどうかを考え、他の方法がないか考え直してみる」というのが必要のように思える。何かさっくりとものを作る場合において、もしかしたら自分も上記のような実装をしてしまうかもしれない。
再帰は、確かに便利だし、慣れてしまうと、手癖で再帰的に実装することが多いけれども、上記のような点について気をつけないと、思わぬバグを踏んでしまうな、と思わされる事例のように感じた。