🏈

例外処理の概念を説明してみる。

2023/05/27に公開

概要

最近、社内の勉強会で例外処理についての話があり、いろいろな考えがあるなと思い、
プログラミングを学び始めた時に、例外の概念について理解するのが大変だったなと思い出し書こうと思いました。

対象者

前述の通り、学び始めた時の追体験を書くだけなので
対象としては 「例外」の概念を学んでいる途中のプログラマー となります。

例外処理のベストプラクティス的なものを書いてる訳ではないことだけ先に書いておきます。

一般的な例外処理について

例外処理とは

いわゆるエラーが出た時に、出しっ放しにさせておくのではなく、出た時の事を考えて対応をしておきましょうということです。

家を建てる時に、地震について考えていないと、いざ地震が起こった時に崩壊の可能性があります。
家が崩壊した後に「考慮してませんでした!」になってしまっては大事ですね。

システムやサービスでも、このようにエラーの対応を入れていないと取り返しのつかないことになりかねないことがあります。

実際の例外

まずは、わざと例外を発生させるやり方で概要を書きます。
僕はよく、TypeScriptを書くのでTypeScriptで表します。

まずは適当にログを吐き出すTSを作ります。

index.ts
function main() {
  console.log('Hello world!')
}

main()

次に例外を投げる関数のモジュールを作ります。

libs/throwException.ts
export const throwException = (message: string): never => {
  throw new Error(message);
}

実際に埋め込んで実行してみます。

index.ts
import { throwException } from './libs/throwException';

function main() {
+ throwException('exception')
  console.log('Hello world!')
}

main()

当然ですが、エラーが起こります。
error Command failed with exit code 1.

注目するべきところは、throw の記述です。
直訳すると 投げる になりますが、そのまま「例外を投げる」などと言うことが多いです。
投げるという表現から連想しやすいですが、実際に「例外というボール」を投げてるようなイメージです。
つまり、関数内で起こったエラーが投げられると、宙に浮かんでいるのを想像してもらえば分かりやすいです。

宙に浮いてる状態のことを 野放し とここでは表現します。(野放しは、あくまで説明のためにここだけの話で、一般的に使われてはないです。)

野放しにしないようにするためには、そのボールを「キャッチ」しないといけません。
そこで出てくるのが、catch文というもので、 「try-catch」 とよく表現します。

実際に上記コードに埋め込んでみます。

index.ts
+ import { throwException } from './libs/throwException';

function main() {
+  try {
    throwException('exception')
+  } catch (err) {
+    console.error(err)
+  }
  console.log('Hello world!')
}

main()

例外が野放しになっていた時は、 Hello World! が表示されなかったと思います。
しかし、catchをすると表示されたと思います。

まず、try文の中の処理が走ります。
try文の中でエラーが発生すると、例外が投げられます。
それをcatch文でキャッチすることで、そのエラーを手にする事ができます。
catch (err) この部分です。
そのエラーを手にした後はどうするか決めることができます。

例えば、そのボールを投げ返す、とか、ポケットに仕舞う、とか、次の人に投げるなどを決めることができるわけです。
ここでは、console.errorでログ出力することに使うことにしています。

そうして、エラーは処理されると、次の処理に移ります。
(ここでは、 console.log('Hello world!') が引き続き処理されています。)
エラーが発生したら、そこで処理を中断したい場合は、またそのエラーをthrowで投げるか、returnで関数自体の処理を中断します。

ちなみにtryが成功しても、catchでエラーを捕まえても最終的に何かを動作させたい時に、finally文というのもあり、以下のように書きます。

try {
  // try処理
} catch {
  // catch処理
} finally {
  // finally処理
}

どういう時に使うか

上記の説明のところで、エラーと表現したり、例外と表現したりと混乱してしまった人もいるかもしれません。
ここでは、あえて特に使い分けなどはしていません。
興味ある方は、こちらを参考にしていただくと良いかもしれません。

この例外(エラー)ですが、どこで発生するか

  • 外部通信
  • DB操作
  • ファイル操作
  • 配列操作
  • JSONなどのパース処理

などが考えられます。
こういう処理を書く時に、try-catchで例外を受け取って、適切に処理をしないといけないです。
特に外部通信や、DB操作などは自分達のプログラムではないところでのエラーの可能性もあるので(サーバの問題だったり)ちゃんとデバッグしやすくするためや、ユーザーへの通知をどうするかといったことを決める時にもしっかり定義しておいたり、回避策を考えておく必要がありますね。

例外は呼び出し元に投げられる

例外が発生した時、throwによって投げられると言いましたが、ではどこに投げられるでしょうか。
それは呼び出し元の関数に投げられます。

先ほどのコードを再利用してみます。

index
+ import { executeThrowException } from './libs/executeThrowException';
- import { throwException } from './libs/throwException';

function main() {
  try {
+    executeThrowException()
-    throwException('exception')
  } catch (err) {
    console.error(err)
  }
  console.log('Hello world!')
}

main()

上記で実行しているexecuteThrowExceptionという関数を作ります。

executeThrowException.ts
import { throwException } from './throwException';

export const executeThrowException = () => {
  throwException('exception')
}

executeThrowExceptionでは、エラーを投げるthrowException関数を実行しているだけです。
これを実行しても、index.tsでちゃんとエラーハンドリングしているのでエラーログを吐き出してくれます。

throwExceptionで投げられたエラーは呼び出し元に投げられるので上記の処理では

  1. throwExceptionで投げられたエラーがexcuteExceptionに届く
  2. excuteExceptionでは特にエラーハンドリングしていないので、さらに呼び出し元のindex.tsに渡される
  3. throwExceptionで投げられたエラーがindex.tsに届きハンドリングされる

という処理の流れになります。

エラーを握りつぶす

先ほど呼び出し元に渡されると言いましたが、上記では二段階渡されて行っています。
しかし、もしかするとこれが三段階、四段階になることもあるでしょう。

そうした場合、どこかで適切にエラーハンドリングをしていないと
どこでエラーが発生したとか、そもそもエラーが発生しているのかにすら気づかない場合があります。

実際に、excuteThrowExceptionでエラーを握りつぶすということをやってみましょう。

executeThrowException.ts
import { throwException } from './throwException';

export const executeThrowException = () => {
+  try {
    throwException('exception')
+  } catch {}
}

上記コードでは、途中ハンドリングしていなかったので
素通りで呼び出し元に渡していたエラーをあえてキャッチしています。
しかし、キャッチした後何もしていません。

catch {}

これはエラーを握りつぶすと言います。
つまり、エラーが発生しているのに、投げられたエラーをキャッチしたのに
そのボールを持ったまま何もせず立っているだけという状態になります。

これが野球であれば試合は止まるでしょう。
しかし、プログラムの場合は止まりません。
その呼び出し元の処理の続きが始まります。

その結果

console.log('Hello world!')

が実行されるだけになるわけです。
出力だけ見ると、正しく実行されてるように見えますよね。
しかし、実際はthrowExceptionでエラーが投げられている訳です。

怖いですよね。
エラーが発生しているのに、ログにはそれが表示されず適切に終了したように見えている訳です。
もし、このエラーが決算などの処理中に発生していたとしたら、考えるだけで卒倒しそうです。

これがエラーを握りつぶすことで全てが闇に葬られる現象です。
エラーハンドリングの大事さが伝わりましたでしょうか。

エラーハンドリングを挟むか挟まないか

呼び出し元に届くのであれば、一番元となるところでハンドリングして、その中の処理ではしなくていいのでは?と考える人もいるかと思います。

もちろん、二段階、三段階ほどの関数のネストであればしなくても良い場合もあるでしょう。
しかし、その関数でエラーが出た時にだけ挟みたい処理などがあった場合は、途中途中でもエラーハンドリングする必要が出てきます。

どこまでの処理が通っていて、どこまでが通っていないかなどを知るにはログ出力が大事になってきます。
デバッグに有効なログ出力を行うには適所でログを挟んでおく方がプログラム的にも可読性が高まりデバッグがしやすいということにも繋がります。

ネストが深いと、最後まで見ていかないとエラーのポイントが掴めない、など手間が増えたりもします。
Sentryなどのエラー監視ツールなどを使っている場合も、適切な場所で通知したいといった時は、全部を大元で管理するのではなく、処理毎にハンドリングするのがデバッグしやすいでしょう。

一概に、挟む必要がないとも言い切れないし、挟まないといけないとも言えないですが
どちらもありえるし、うまく使い分けができるようになる必要があるということですね。

まとめ

エラーハンドリングは結構難しい問題だったりします。
ReactでもError Boundaryというアプリケーションを止めずにコンポーネント内でエラーを処理するなどの機能も用意されています。(https://ja.legacy.reactjs.org/docs/error-boundaries.html)

ただ、確実にやらなきゃいけないことという認識は持っておく必要があり、避ける事は許されないということは伝わったと思います。

僕自身もまだたまにエラーハンドリングを忘れたり、適切にハンドリングできていない時がありますが、ちゃんとできるようになることが良いプログラマーになる必須条件だとも思います。

Discussion