LoginSignup
141
155

はじめに

6月に凄腕エンジニアさんから学んだ例外の話というQiita記事を書かせていただいたところ、かなり反響がありました。(2023年07月08日時点で570いいね、550ストック、はてなブックマークが560usersにブックマークされています)

コメントなども目を通させていただいたところ、自分に基本的な例外の知識が足りないなと思ったので、いろいろな記事に目を通したり、本を読んだりして、インプットしました。

そのアウトプットとして今回記事を書きます。

エラーと例外

この記事ではエラーと例外という二つの概念は同じ概念で交換可能なものとして扱います。
ソフトウェア設計のトレードオフと誤りより引用)

Javaでは【プログラムではどうにもできない事態が起きた時に発生するものがエラー、そうではないものは例外】というような考え方があったり、他にも【想定内であれば例外、想定外であればエラー】という考え方があったりしますが、

普遍的にエラーと例外の違いがあるわけではなく、その文脈ごとに定義されていたりする話なので、この記事では特に使い分けはしません。

例外の区別の色々

業務エラー

業務設計の中で想定されている範囲内で処理が分岐し、正常終了できなかった場合のエラーのことをさします。
具体であげると

  • ポイントが足りません
  • 利用期間が終了しました
  • 重複顧客IDを認めていない新規登録業務を考えた場合の、指定された顧客IDがすでに他で登録されてしまっていた
  • ユーザーが権限のないページにアクセスしようとした場合
  • すでに登録済みのユーザーIDでアカウント登録しようとした場合

というようなものをあげることができると思います。
言葉を崩して説明をすると「お前が悪い、ちゃんとやり直せ」と言えるエラーです。
業務エラーへの対応は、ユーザーにやり直しをさせることです。

システムエラー

業務設計の想定範囲外の異常事態が発生し、処理を正常に遂行できなくなった場合のエラーをさします。
具体であげると

  • データベースがダウンしている
  • データベースに接続できなかった
  • ネットワークエラー
  • 実装にバグがある
  • データ不整合
  • API連携失敗

というようなものをあげることができると思います。
「お前が悪い、ちゃんとやり直せ」と言えるエラーが業務エラーだったとしたら、システムエラーはその逆で、ユーザーではどうにもできないエラーです。
基本は言語やフレームワークレベルでハンドリングをやってくれるため、特別に自分達でcatchとかthrowとかをする必要はありません。

注意
Javaの文脈だと、データベースやネットワークを取り扱うときは検査例外といって、必ずtry-catchをしないといけないという事情があります。
あとはファイルなどのリソースの解放などはtry-catchを書いて対応しないといけない場面があったりします。

検査例外

Javaの文脈で出てくる言葉ですが、例外を学ぶ過程で必ず目にすると思います。
Javaがコンパイル時に検査を行う例外のことです。
この例外がでる可能性のあるメソッドを呼び出す時に、try-catchでハンドリングをしないとコンパイル自体できなくなります。
具体でいうとIOExceptionやSQLExceptionなどです。

呼び出し側に責任のない例外といっても良いと思います。

try-catchを強制している背景としては、回復処理(リソースの解放など)を忘れさせないようにするものだと思っています。

非検査例外

Javaの文脈で出てくる言葉ですが、例外を学ぶ過程で必ず目にすると思います。
Java以外で例外といったらこの非検査例外に該当すると思って良いと思います。

検査例外と違い、try-catchで例外ハンドリングをしなくて大丈夫です。
正しいプログラムを書くことで回避できる例外でもあります。
具体でいうとNullPointerExceptionやArrayIndexOutOfBoundsExceptionなどです。

回復可能なエラー

  • ネットワークエラーが起こり、リトライする
  • DBの接続がうまくいかずリトライする
  • リソースをクローズする
  • 深刻ではないタスクエラー(例えばソフトウェアの使用状況の統計ログを送るだけの場所でエラーが起きた場合、おそらくソフトウェア自体はそのまま実行を継続して良い)

などが挙げられると思います。
回復可能なエラーであればtry-catchを使う必要が出てきたりします。

回復不可能なエラー

  • コードと一緒にあるべきリソースがない。
  • コードを誤用している
  • プログラミングエラー

などが挙げられると思います。
ここら辺のエラーが起こっても、回復しようがないため、回復不可能です。
回復不可能なエラーは基本的にはcatchせず、Webフレームワークまで例外を伝えるのが良いと思っています。

C#チームのEric Lipper氏の分類

致命的な例外

プロセスに深刻な問題が発生し、今にも死にそうな状態にある場合に発生する例外です。
プログラマーの過失ではないです。
復旧は無理です。
対処方法としては何もしません。(伝えるべきところにちゃんと伝わるように。)

うっかり例外

プログラマーのうっかりミスにより発生する例外です。
プログラマの過失です。
本来、テスト・デバッグの段階で全て洗い出すべきものです。
具体でいうとNull​Reference​Exception、Index​Out​Of​Range​Exception などです。
対処方法としては何もしません。(伝えるべきところにちゃんと伝わるように。)

頭の痛い例外

失敗する可能性を前提とすべきAPIが、失敗時にスローしてくる例外です。
APIの設計ミスによるものです。
例外対応は例外をスローしないAPIを探す、そんなメソッドがなかったらtry-catchします。

外因性の例外

プログラムの外部の環境に原因のある例外です。
避けようはないですが、対処しようがある場合もあります。
例外対応は適宜対応します。
処理しなければいけない例外というのは、本来は外因性の例外だったりします。

例外のメリット、デメリット

メリット

大域脱出

例外が発生した場合、もしくは例外をthrowした場合にどんなに階層が深くても、実行中の処理を中止し、その例外をtry-catchしているところ(catchしていなければWebフレームワークの例外処理機構まで)、まで処理を戻すことができます。

この大域脱出があるおかげで、上位の層の1箇所で例外のハンドリングをするということがしやすくなっています。
(WebフレームワークでAPI開発をしているとして、エラーが起こってもデフォルトでステータスコード500のレスポンスが返ってきたりすると思います。
これも上位の層で例外のハンドリングがされている挙動だと思っています。)

1箇所でハンドリングすることで、例外処理を個別でしなくて良くなったりします。
例外が起こったら勝手に通知がいって勝手にログが落とされる というような仕組みを作りやすくなります。
(個別でtry-catchしてログに落とすとか通知するとかしていると、開発者単位で対応の差が出てきてしまったり、例外を間違えて握りつぶしてしまったりする可能性が増えたりします。)

例外は基本catchしない、Webフレームワークの例外機構に例外のハンドリングを任せる という方針はこの大域脱出があるから実現しやすいものだと思っています。

戻り値で特殊な値を返すことができる

これは型がある言語にだけ享受されるメリットです。

型がある場合は、
例えば

function sum(a:number, b:number): number {
  return a + b;
}

というようなメソッドがあった時に、このメソッドは足し算をした結果(数値)を返します。
このメソッドで、【計算できなかった】を表現するのに、戻り値は使えません。(例えば-9999999が返ってきたら【計算ができなかった】ことにするというように)
そういう時に例外を使うことで、上位の層に【計算ができなかった】という情報を戻すことができます。

一方型がない言語はそもそも色々な種類の値を返すことができるため、あんまりこのメリットは享受できません。
(例えばnullを返すとか、文字列を返すとかErrorオブジェクトを返すとか、タプルを返すとか、Result型を返すとかが自由にできるため)

コンストラクタ内で異常を伝える方法が例外しかない

コンストラクタの仕様上、コンストラクタに渡された値がおかしかったという表現をするには例外を投げるしかありません。
(コンストラクタでResult型を返すとかができないので。。。)

注意
例外のメリットである大域脱出や戻り値で特殊な値を返すことができる という機能的な部分だけの恩恵に乗っかりたいがために、例外を多用してはいけません。
例えば下記のように例外を使うのは良くありません。

public void useExceptionsForFlowControl() {
    try {
        while (true) {
            increaseCount();
        }
    } catch (MaximumCountReachedException ex) {
    }
    //Continue execution
}

public void increaseCount()
    throws MaximumCountReachedException {
    if (count >= 5000)
        throw new MaximumCountReachedException();
}

デメリット

このデメリットを調べる時には主にGoogleのC++スタイルガイド日本語訳を参考にしました。

throw文を追加する時の考慮点が多い

throw文をあるメソッドに追加したら、そのメソッドの呼び出し元全てを確認しなければいけません。
呼び出し元でその例外をcatchする必要があるのか、一切キャッチしなくても大丈夫なのかを確認する必要があります。
これが階層が深くなればなるほど大変になります。

プログラムのコードからコントロールフローを評価するのが難しくなる

思いもよらない箇所から関数を抜けるかもしれないからです。
それがそのままメンテナンスやデバッグの難しさにつながります。

例外安全なプログラムを組むのが大変

例外安全なプログラムを組むためには、RAIIとそれと異なるコーディングプラクティスとの両方を必要とします。
(Googleのスタイルガイドにはこう書いてありますが、ここの安全性は言語やフレームワークごとに違ってくるのではないかと思います。
今時の言語やフレームワークを利用していればある程度例外安全になるのではないかなと思っています。。。)

開発者単位で対応が分かれる

本来適切ではない時に例外を投げたり、安全ではないのに復旧を試みたりするということを、開発者がおこなってしまうかも知れません。
このようなことを制限するために、規約やガイドのようなものを用意しないといけなくなります。

補足
デメリットは上で挙げましたが、
GoogleのC++スタイルガイド日本語訳では
「特に新しいプロジェクトの場合は、例外を扱うコストよりも、その恩恵が上回るでしょう。」
というふうに記述されていたり、

「我々の例外の使用に対するアドバイスは、哲学的あるいは道徳的背景に基づくものではなく、あくまで実践的なものとしての話です。」
「もし、スクラッチからすべてのことをやり直さなくてはならないのであれば、何かが違っていたかもしれません。」
というふうに記述されていたりするので、Googleの文脈で という前提も大きいかも知れません。

基本事項

基本的にはcatchしない。フレームワークの例外機構に任せる

個別にtry-catchを書くと、ログを落としたり、通知すべきところに通知をしたりという処理が開発者単位で違ってきてしまったり、本来上に例外を伝えてあげないといけないのに例外を握りつぶしてしまったり、ということが起こり得ます。

それであれば上位層で、例外が投げられたらログを落とす、通知すべきところに通知をするという仕組みを整えておいて、try-catchは基本しない というふうにしておくと良いと思います(例外設計の共通化)。

そうすることで開発者単位で対応が違うなんてことや、漏れたり、変に例外を握りつぶしたりなどがなくなったり、開発する時に必要以上にtry-catchに悩まなくても良くなります。

回復処理が必要な箇所でのみtry-catchを書く

上で基本はtry-catchは書かなくて良いと書いたのですが、書かないといけない場面があります。
それは回復可能なエラーが起こった時です。
ネットワークエラーや接続ができなかったというのであればリトライすることができるかも知れません。
IO系の処理でエラーが起こったのであればリソースを閉じる必要があります(リソースリークは避けないといけない)。
深刻ではないタスクエラーの場合はあえてログだけ落として、ソフトウェア自体の実行を継続したい場合もあるかも知れません。
あとはこの例外が起こったら、このメソッドを呼びたいというふうに回復させたい場合があるかも知れません。

これらのように回復したい場合、しなければいけない場合はtry-catchを書く必要が出てきます。
逆に回復できない例外に関してはcatchする意味はあまりありません。

できる限り抽象的な型でcatchはしない。具象的な型でcatchする。

上ではtry-catchは回復処理が必要なタイミングでかかなければいけないということを書きました。
抽象的な型でキャッチした場合、その型でキャッチできる具体的な型の例外に必要な回復処理を網羅できるのでしょうか?
回復の仕方は具体的な型単位で違ってくると思います。
ですので、具体的な型でcatchして、その型に必要な回復処理を書くというのが大前提だと思っています。

あとは本来キャッチしてはいけない例外をキャッチしてしまう恐れもあります。
例えばRubyではNoMemoryErrorというクラスがあるのですが、このクラスはExceptionクラスを継承しています。
Exceptionクラスでcatchしてしまうとこのような重大なエラー(伝えるべきところに伝えないといけないエラー)までcatchしてしまう可能性もあるのです。

まとめると、具体的な型単位で必要な回復処理が違ってくることが多いため、という理由と重大なエラー(伝えるべきところに伝えないといけないエラー)をキャッチしないようにするというため、という理由で抽象的な型でcatchしてはいけません。

早い失敗、目立つ失敗

失敗が後ろに倒れ込むと、もとの失敗している時点まで遡ったりしないといけなくなります。
それが膨大な作業になったりもします。
(ここで失敗しているように見えるけど、実際はここですでに失敗していたのか。。。というふうに)

早い失敗は、回復可能なエラーだった場合、呼び出し元がエラーから、適切かつ安全に回復できる可能性を最大化します。
回復不可能だった場合はエンジニアが素早く問題を特定して修正できる可能性を最大化します。

コードが早く目立って失敗するなら、(リリース前の)開発やテストの最中にバグを見つける可能性は高くなります。

変にtry-catchをしてしまい、例外を握りつぶしてしまう、裏で例外が投げられているのに無理に動かし続けるなど、異常が見えなくなるようなことはしないでください。
死ぬはずのプログラムを無理に生かしておいてはいけません。

例外は起こった時にちゃんと伝えるべき場所に伝えられるようにしましょう。

例外が起こった時の挙動は上位の層でハンドリングする

例外が起こった時に回復する必要があるかどうか、どのように回復処理をするのかは上位層が知っていることが多いです。
同じ例外でもケースごとに回復の仕方が違っていたりもすると思います。
あとは例えば、WebやCLIなどのプレゼンテーション単位で違ってきたりもするかも知れません。

下位の層は例外を投げるだけ、上位の層はその例外をcatchするかしないかを決める。catchするとしたら適切な回復処理をする。
というような方針だとシンプルになると思います。

【例外】の捉え方について

どちらかというと厳格め

  • 本当に例外的な、致命的な状況にのみ、例外を使うべき。
  • 呼び出し元がどうにもできない問題のみ例外を使うべき。
  • 例外的な状況のみを報告するために例外を使用するべき。
  • 例外やエラーはビジネスロジックを制御するために使うべきではない。例外やエラーはコードにおけるイレギュラーな振る舞いを示すために扱う。

というような考え方があります。
Go言語も考え方的にはこっちの厳格よりだと思います。
基本はreturnされたタプルのオブジェクトに対してif文を書き、呼び出したメソッド内でエラーが起こったかどうかを判定し、本当に致命的な場合はpanicで大域脱出するというような方針だと思っています。

あとはGo言語のコミュニティで広く認知されている開発者のDave Cheney氏がブログで、【いろんなことに例外を使った結果として、「Javaの例外はまったく例外的なものではなくなってしまった」】(deeplで日本語訳したもの)と取り上げていたりするので、おそらく厳格よりの考え方を大切にされているのだと思います。

本来はこちらの考え方から始まって、開発者が色々と開発していったりして、保守運用していったりして時間が経って、【どちらかというと緩め】な考え方が生まれてきたのではないかなと個人的に思っています。

そしておそらく理論的には、こちらが正解なのだろうなと思っています。

どちらかというと緩め

例えばPythonでは例外はかなり頻繁に利用されているみたいです。

Pythonの事実上すべての標準モジュールが例外を使用しているし、Python自体も実に様々な状況において例外を送出する。

例外とは何か? 通常これはエラーであり、何かがうまくいかなかったことを知らせるものだ

ここら辺を見ていただくとわかるのですが、先ほどの厳格めな考え方とは違い、ふんわりしていると思います。

凄腕エンジニアさんから学んだ例外の話にも書いてあるのですが、自分が尊敬しているエンジニアさんには

例外はそんなに大層な、難しいものではなくて、期待する挙動以外は全て例外で良くて、かといって全部キャッチする必要はなくて、必要なものだけキャッチしてそうじゃないものはキャッチしなくて良い。
例外は時にはキャッチせずに見せるべきところに見せる必要がある。

と教えていただきました。

あとは、後ろで触れるのですが独自例外(カスタム例外、業務例外クラス)クラスを作ることについても許容できる人は、どちらかというとこちらの緩めな考え方の人なのだろうなと思っていたりします。
あとはありえない分岐にきた時に例外を投げたり、事前条件を確認してそれを満たしていなかったら例外を投げたり、そういう例外の使い方をしてきた人もこちらの緩めな考え方の人なのだろうなと思っています。

この緩めな考え方は、最初は【どちらかというと厳格め】から始まって、色々なプログラムやシステムが開発されたり、保守運用をするようになってきて、厳格めの考え方だと上手くいかない場面が出てきて、例外を上手く利用した方が保守運用がうまくいくのでは?という人たちが生まれてきて広まってきたのではないか?、そういう歴史があったんじゃないかな?と個人的には想像したりしています。

凄腕エンジニアさんから学んだ例外の話に出てくる【メンテナンス前提の例外】というのも、どちらかというと厳格めな人たちには否定されるものだろうなと思ったり。。。

理論的にはこちらの考え方は間違えているのかも知れません。
ですが、保守運用や開発を進めていく上では、自分はこちらの考え方の方が良いことが多いのではないかなと思っています。
(もちろん前提、言語やフレームワーク、プロジェクトの文脈もあると思いますが。。。)

なのでこちらの考え方も現場に寄り添った正解ではあるのかなと思っています。

思想の違い

調べながら大きく分けて二つの考え方がありそうだなと思いました。

オブジェクト指向ベースのtry-catch、throwを利用するアプローチ

これは普通にtry-catch、throwを利用するような従来の、有名なやり方です。

関数型ベースの、値でエラーを返却するアプローチ

これはResult型を返したりTry型を返したりするのが該当すると思っています。

関数型プログラミングの主要な哲学は、関数呼び出しにより起こり得る全ての結果を、型を使ってモデル化することだったりします。
呼び出す関数が失敗する可能性がある場合、関数の戻り値の型と宣言された例外として結果をモデル化します。

この哲学だと、例えば計算メソッドが基本は数値を返すが、エラーが起こった時に例外が投げられる というような副作用的なことを許すことはできないのです。

try-catchがいいの? Result型のような値を返すようなアプローチがいいの?

前提自分はResult型のアプローチでプロダクトの開発、保守運用をしたことがないので、調べた知識の範囲内にはなってしまいますが意見を述べたいと思います。

言語やフレームワークの哲学に沿う

関数型であるなら、Result型のアプローチ、Go言語であればタプルのアプローチ、というふうに言語ごとに例外についての考えやハンドリングのコードがちゃんと提示されていたりするので、そこに沿うのが前提良いのかと思っています。

try-catch前提の言語のバックエンド開発ではResult型は使わない方が良さそう

Result型などの、エラーを返却する値で表現する方法は、【ただthrowではなくてreturnでResult型などを返すようにしているだけ】という手段や構文の話というよりは、例外やエラーに対する考え方や哲学を言語単位で実現するために行き着いた地点であるような気がしていて、その形だけ真似するというのは、どこかでうまくいかなくなるんじゃないかなと思っています。

それにtry-catch前提の言語のWebフレームワークは大体は例外機構が備わっていて、例外を投げるだけである部分で必ずキャッチされるようになっていると思います。
Webフレームワークの例外機構を利用するためには、Result型などの値を返すっていうアプローチだとバケツリレーをしないといけなくて、階層が深くなればなるほど大変になります。
Webフレームワークの例外機構は例外の大域脱出ができた方が活かしきれると思います。

try-catchに慣れ親しんだ人が急にResult型チックな方法をとると大変かもしれない

例外に慣れ親しんだ人たちは例外のハンドリングは基本Webフレームワークに任せ、何かあったらそこまで大域脱出を利用して例外を伝えるようにする というようなアプローチをとってきたと思います。

ログや通知もWebフレームワークの例外ハンドリング部分で設定したりなどしていたと思います。

それが値を返却するアプローチだと、呼び出し元でif文を書きその処理が成功したかどうかを都度確認しないといけません。
そして、一番上に返したいな〜って時もバケツリレーで値を返す必要が出てきたりします。

エラーが起きた時の挙動を上の層が決める というのを実現しにくそうだったりします。
そこらへんが少し不便に感じるのではないかと思っています。

独自例外クラス(カスタム例外、業務例外クラス)を作ることについて

業務エラー単位で例外クラスを作り、それをthrowするというやり方です。
例外でクライアントにより具体的な情報を伝える場面で独自例外を作成します。
(例えばポイントが足りなかった時にthrowする、NoPointExceptionクラスのような。。。)

↓詳しい方針ややり方などの具体の話はこちらの記事にまとめているので是非読んでみてください。
凄腕エンジニアさんから学んだ例外の話

上記で触れた、【例外】の捉え方についての、【どちらかというと厳密め】の人たちにとっては受け入れられないやり方だと思っています。

ただし、現場的には独自例外を作ると、デバッグやトラブルシューティングがかなり楽になったり、メソッドを呼び出す時の事前条件が明確になったりする面がかなりあり、自分はメリットが大きいのではないかと思っています。
(クラス名を見ただけで何が起こったのかわかるのはかなり便利です。。。)

「独自例外クラス」、「業務例外クラス」、「カスラム例外クラス」、「アプリケーション例外クラス」という言葉で調べてみると様々な情報が出てきますし、Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考 にも独自例外の例が出てきていたりしました(RuntimeExceptionを継承したNegativeNumberExceptionとして)。

今では割と独自例外、業務例外、カスタム例外は現場で普通に使われているのではないかという気はしています。
(このパターンがアンチパターンだ!絶対ダメだ!っていうことではないのかなと思っています。)

最後に

色々と例外のことを調べてみてとても学びが多かったです。
言語ごとの哲学チックな部分や、例外やエラーについての考え方の違いなどがかなりあり、インプットは楽しかったです。
あとはエラーや例外との向き合い方についても知見が溜まりました。
技術者としてまた一歩成長できたと思います。

たくさんインプットする前に、【凄腕エンジニアさんから学んだ例外の話】を書きましたが、今読んでみてもそんなに間違えているとは思えませんでした。

おそらくですが、独自例外クラス(カスタム例外、業務例外クラス)を使った現場で苦しい経験をしないと、この根本的な部分の意見は変わらなさそうです。

おそらく【凄腕エンジニアさんから学んだ例外の話】に否定的な方々は、独自例外クラス(カスタム例外、業務例外クラス)を用いた案件などで辛い思いをされた方なのではないかな〜と思いました。

この例外の部分は使っている言語やフレームワーク、プロジェクトのメンバーやプロダクトなどの文脈によって本当に正解が変わってくるものだと思うので、ここに書いた例外の話が全てではないのは前提ですが、この記事を通して、何かしらの学びを提供できていたら嬉しいです。

ここまで読んでいただきありがとうございました!
本当に感謝です。

読んだ資料や本

141
155
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
141
155