のんびり精進

調べた情報などをおすそ分けできれば。

例外(Exception)って扱いにくくない?

例外の長い歴史の中で既に語りつくされている気がしますが、既存の情報をあまり見ていないのでわかりません。

記事のカテゴリとして Dart を含めていますが、例外を持つ様々な言語で共通するところがあると思います。

経験など

昨年まで業務で PHPJavaScript などを使っていました。 PHP の例外は PHP 5 から存在していて入社はそれより後でしたが、まだ無かった頃のレガシーなコードも扱うことがあり、ライブラリを利用するときか自作するときに使うくらいでした。

一方、業務外で使っていたのは Go で、そちらには例外がありません。 panic という似たものはあっても非なるもの(例外と同様の使い方をするべきではないもの)です。

Go で例外の代わりにエラーを伝えるには、関数が多値を返せるという言語仕様を活用します。 第二戻り値などで error 型かそのインタフェースを実装した型の値を返し、受け取った側は nil かどうかでエラーの有無を判断したり型で種類を判断したりするだけなので単純明快です。

そんなわけで、戻り値を使う方式により慣れていて、例外は不慣れでした/(今も)です。

例外への抵抗感

  • Java でのつまづき
    • 「非検査」例外は対処しなくて良さそうな名前なのに対処が必要なの?(戸惑い)
    • 非検査例外 とは、RuntimeException やそのサブクラス??
      • サブクラスかどうかはどうやって知るの?
        • 大昔なので現在ほどの IDE の支援はなかった
        • 今考えれば、型の確認をすれば良いのかも
  • どこで例外が起こるのかわかりにくい
    • サードパーティのライブラリで例外が発生し得るのにドキュメントに書かれていなくて苦労した
  • 例外を握りつぶす人がいる
    • 頻発して困るからといってチームメンバーが理解しないまま catch して揉み消していた(涙)

握りつぶしについては、社内のレベルの低さを表していると思って当時悲しかったのですが、ネットで調べてみる と割とよくあることだとわかりました。1

実際のところどうなのでしょう? 周りにそんな人はいますか?

いるとすれば、例外がわかりにくい / 扱いにくい / 握りつぶしやすいことを物語っているように思えます。

例外と向き合うようになって感じたこと

Flutter でアプリ開発をするようになって Dart を日々使うことになり、例外にも向き合わねばならなくなりました。 モバイルアプリ側の例外発生からユーザ向けエラー表示までの流れをどうしたものかと悩みました。

もう慣れはしましたが、例外を例外のままうまく扱う方法は見つからず、次のようなことを感じました。

  • 捕捉しないとプログラムがそこで停止してしまうのが怖い
    • Flutter 製のアプリはそれでも落ちないが、例外が起こったメソッドから適切な戻り値が返らない
    • Error と違ってランタイムで発生するものなので開発中に起こらずにバグが潜在し得る
  • 捕捉したとしても、そこからエラーの種類を UI 等に伝える方法の工夫が必要
    • 成否を bool 型で表すメソッドの場合、例えば「通信の異常が原因」を呼び出し元にどう伝える?
  • 既に捕捉&対処が済んでいるのかどうかわかりにくい
  • 上記を解決するために UI の箇所(などエラー情報を必要とする箇所)で捕捉するのも困る
    • 多段にメソッドを呼び出した先で発生したとき、途中のメソッドも何らかの対処が必要な場合がある
      • 途中で捕捉して rethrow すれば良いが、そのせいで多重にロギングしているコードがあった
    • そもそも捕捉せずにレイヤーを突き抜けさせるのは良くないのでは
      • Model の層の例外を丸投げして UI 層がその知識を持たなければならなくなるのは避けたい
      • 例外に持たせたメッセージをそのままユーザ向けに表示してしまっているアプリがあった
    • 例外の種類が様々にありすぎて、発生と捕捉の箇所が離れるほど対処すべき種類が多くなる
      • 例えば API アクセス時に通信・サーバ・パラメータ・データ等に起因する各例外があるとすると、最寄りの呼び出し箇所ではその限られた例外だけに対処すれば済むが、離れた箇所では途中のメソッドで起こり得る他の例外にも対処する必要がある

みんな悩まないの?

Flutter ではいつも「状態管理」がくどいほど話題になります。 エラーハンドリングも似た面があると思いますが、なぜか悩みとしてほとんど聞きません。

あまり聞かないだけで、うまくハンドリングできていない(のに気づいていない)人が実は多いのでしょうか。

  • 作った本人が気づかないまま起こってしまっている可能性
    • 先述のような、多重にロギングしてしまうケース、例外のメッセージが時々そのまま表示されてしまうケース 2 などもこれ
  • Flutter では例外時に落ちないで動き続けることが悩みを表面化しにくくさせている可能性
  • フロントエンドでは DB 等の例外をバックエンドほど不安視しない可能性
    • ローカル DB 関連の失敗はストレージの空き不足など特殊な状況でしか起こらないという考え
    • Firestore などに接続失敗してもローカルのキャッシュで動くからいいという考え
    • 大勢のデータが消える、他人のデータが見える、といった重大な事態に至らないという考え

その後 Reddit で同じような悩みを持っている人がいましたが、程々の盛り上がりで終わりました。

www.reddit.com

代替方法

みんなが悩まなくても私は悩みます。 また、悩む人が少なければ関連記事も少なくて、自分で考えなければなりませんでした。

そんな中でいただけた情報

Twitter でつぶやいてみたところ、有力な方法を一ついただけました。

ntaoo.hatenablog.com

Dartpackage:async が持つ Result というクラスを使う方法でした。 これは何らかの処理の結果またはエラーをラップするもので、そのラップしたものを戻り値として返し、受け取った側でエラーの有無による条件分岐で通常の処理とエラーハンドリングを行えます。

良い点

  • Result 型が返されることでハンドリングが必要だとわかる
  • 非同期処理に使う Future 型やリアクティブな Stream 型も capture して Result 型にできる

気になった点

  • Future 等を capture した場合、Future の処理内で起きた例外がそのまま伝えられる
  • 結果かエラーをいちいち Result で包まないといけない

包むのは最初は煩わしく感じられましたが、例外を避けてあえて戻り値で結果/エラーを受け渡ししようとするわけですから、どうしてもそうなります。 むしろ単純で好ましく思えるようになりました。3

なお、この Result に似た手法が他の言語でも使われているようです。 下記の記事に目を通した感じでは Scala と Rust はそれに近いものに思えました。

qiita.com

qiita.com

自分の方法

Result の情報をいただいたときに既に考えがあり、それから実装して先月パッケージ化しました。 しかし最近になって重大なバグに気づき、修正のために大幅に作り直したところ、結局 Result に似たもの(渡し方は逆方向)になってしまいました。

最初の仕様

  • 例外は発生の最寄り箇所で捕捉する
  • 起こった例外を独自のエラー型の値(enum など)に置き換えて通知する
    • 別の層の例外を UI が意識する必要がなくなり、UI の関心に沿った独自エラーを UI 層で受け取れる
  • そのようなエラーが起こり得る処理を scope() というメソッドに渡して実行
  • エラーの通知があると listener が呼ばれる
    • そこで直近のエラーの値が保管される
    • 同時にロギングも可能
  • スコープが終わったとき、指定した条件に該当していればエラーハンドラが呼ばれる
    • 条件は処理結果かエラー種類を使って設定
      • !result とか error == ErrorTypes.connect とか
    • エラーハンドラはあらかじめセットしておくこともスコープごとにセットすることも可能

重大バグ発覚

  • スコープとその中で実行される処理の間で紐づけをしていなかった
    • スコープの開始順にエラーが通知されてくると想定してしまっていた
  • そのため、所要時間が異なる複数スコープが非同期に実行されると誤作動する
    • 例えば、通知~スコープ終了の間に時間がかかって終わる順序が前後するとダメ

修正(大改修)

  • スコープと処理の紐づけのために、スコープ内で作ったオブジェクトを引数で処理に渡す方式に変更
    • そのオブジェクトが持つ通知メソッドを使うことで、どのスコープの処理のエラーなのかわかる
    • Result が戻り値でエラーを返すのに対して逆方向に引数でオブジェクトを渡している
  • その代わり、メソッドがエラー通知用オブジェクトを引数に取るのを見てハンドリングの必要性がわかる
    • これも Result が戻り値でエラーハンドリングの必要性を判断できるのに似ている

こうなると Result で足りた説が出てきます。 そうかもしれません。

一応利点を挙げておきます。

利点

  • あらかじめエラーハンドラとロガーを設定しておけるのでエラー発生ごとに書かなくて済む
  • エラーハンドラを見ればエラー種類ごとの対処方法(エラー表示等)がどうなっているかわかる
  • エラー確認箇所で必ず scope() を使うので、ソースコード全体から探し出しやすくなる
  • 例外を早く捕捉して独自のエラー型に置き換えるよう README に明記している

これらが魅力に思えないなら Result で足りるでしょうし、Dart チーム製なので安心できるでしょう。

というわけで自作パッケージの名前は伏せますが、万一人気が出るようなことがあれば別記事にして紹介します。


  1. 中高生の頃から C# を使っていて得意だと言っている人でしたけども…。

  2. おそらく Exception を継承した自作の型では良い感じにメッセージが入るようにしていたものの、自作以外の例外のメッセージも表示されてしまうことを考慮できていなかったものと思われます。

  3. 何が起きているのか見えにくくするような抽象化した方法のほうが最近は遠慮したくなります。ボイラープレートやら何かを行う際の手続きやらを取り除いてシンプルに見せるのは表面上の良さでしかなく、結局は愚直な方法が一番良く思えます。