導入: なぜ「エラー処理」をフロー制御に使うべきではないのか
実務でScalaやHaskellなどの関数型言語を扱っていると、MonadError(あるいはEither型やOption型)の強力なショートサーキット機能に魅了されることがあります。しかし、本来「異常事態」を伝えるためのMonadErrorを、単なるループの早期脱出(break文の代わり)として利用すると、コードの可読性と保守性が著しく低下します。本記事では、なぜこれが「アンチパターン」とされるのか、そしてどう解決すべきかを解説します。
基礎知識: MonadErrorの本来の役割
MonadErrorは、計算の途中で「想定外の失敗」が発生した場合に、後続の処理をスキップしてエラーハンドリングへ移行するための抽象概念です。
throwErrorは、その名の通り「エラーを投げる」ためのメソッドです。一方、ループの脱出や条件分岐は「通常の制御フロー」の一部です。これらを混同すると、コードの読み手は「このthrowErrorは致命的なエラーなのか、それとも単なる終了条件なのか」を判別するために、文脈を深追いせざるを得なくなります。
実装/解決策: 正しい「制御フロー」の設計
「処理の早期終了」を表現したい場合は、MonadErrorではなく、より適切なデータ型や構文を使うべきです。具体的には以下の手法を推奨します。
1. 再帰関数を使う: 関数型言語において、ループの脱出は再帰の終了条件として表現するのが最も自然です。
2. Option型やEither型を活用する: 失敗ではなく「結果が存在しない」ケースであれば、Option型でラップして処理を継続するほうが、意味論として正当です。
3. コレクション操作を極める: 早期終了が必要な場面の多くは、実はfilterやtakeWhileなどの高階関数で置き換え可能です。
サンプルプログラム: アンチパターンと改善例
以下は、ScalaのCatsライブラリを想定したコードです。
// 悪い例: MonadErrorでループを強制終了させている
// 読み手は「この例外はキャッチすべきか?」と混乱する
def badExample[F[_]: MonadError[[_], Throwable]](list: List[Int]): F[Int] = {
list.foldLeft(0.pure[F]) { (acc, x) =>
if (x < 0) x.raiseError // 単なる脱出のためにエラーを投げている
else acc.map(_ + x)
}
}
// 良い例: 再帰を使って「終了条件」を明示する
// これなら「処理が終了したこと」が型レベルで明白になる
def goodExample(list: List[Int]): Int = {
def loop(remaining: List[Int], acc: Int): Int = remaining match {
case Nil => acc
case x :: xs if x < 0 => acc // エラーではなく「正常な終了」として処理
case x :: xs => loop(xs, acc + x)
}
loop(list, 0)
}
応用・注意点: 現場で生き残るための判断基準
現場でコードレビューをする際は、「このエラーはリトライ可能なのか、それとも単なるロジック上の分岐なのか」を問いかけてください。
もし例外処理が「単なる制御フロー」の隠蔽に使われているなら、それは将来的にバグの温床になります。例外を投げるのは「システムが継続不可能な状態になったとき」だけに限定しましょう。制御フローを扱う際は、標準の再帰や高階関数を使うことが、結果として最も堅牢で保守性の高いコードを生み出します。

コメント