KabologHomeXGitHub

Dartの非同期処理の理解を深める(エラーハンドリング編)

Dart Advent Calendar 2022 の 19 日目の記事です。

はじめに

Dart でのプログラミングでは非同期処理が付き物で、エラーの対策も付きまといます。
そんな重要なエラーハンドリングにおいて頭に入れておきたい注意点についてまとめます。

※アプリでの状態管理と絡むハンドリング方法の話ではありません。
※前半はありふれた話で退屈かもしれません。

try-catch では await しないと捕捉できない

Future<List<Item>> を返す fetchItem() を実行して結果を返す fetch() があるとします。

Future<List<Item>> fetch() async {  
  try {  
    return fetchItems(); // 捕捉漏れ  
  } on Exception {  
    return [];  
  }  
}  

fetchItems() から返る Future を fetch() からもそのまま返すときには return await fetchItems() とせずに await を省略して書くことが多いと思います。
unnecessary_await_in_return の lint ルールを有効にしていると、await が付いていれば警告されます。

しかし、try のブロック内で省略するとルールを有効にしていても警告が出ず、さらに fetchItems() でエラーが起こっても捕捉されません。

うっかり await を忘れるだけで捕捉漏れが起きてしまうので怖いですね。
付け忘れに気づきやすくする lint ルールは今のところは知る限りでは存在しません。

onError()ならawaitの有無に左右されない

try で await し忘れることで起こる問題は onError() を使うと避けることができます。

Future<List<Item>> fetch() {  
  return fetchItems().onError((e, s) => []);  
}  

onError() は、型が曖昧な catchError() の改善版として Dart 2.12 で追加 された extension method(FutureExtensions のメソッド)で、中身はほとんど catchError() と同じです。

この例では try-catch より簡潔になりましたが、Exception の種類ごとに分岐するなら if などを使って記述がかえって長くなるかもしれません。
それでも await を忘れるミスを防げるので安全面で良いと思います。

try-catch での on の省略を防ぐ lint ルール

catch のところで on を省略するとあらゆるものを捕捉できます。
しかし Effective Dart で非推奨 となっていて、省略を防ぐ avoid_catches_without_on_clauses という lint ルールもあります。

推奨されない理由(クリックで開く)

そのルールのページでは次のように説明されています。

Using catch clauses without on clauses make your code prone to encountering unexpected errors that won't be thrown (and thus will go unnoticed).

意訳: on 節なしで catch 節を使うと、throw されない予期せぬエラーに遭遇しやすくなって気づかないままになります。

とてもわかりにくいです。
on を省略して Exception 以外も捕捉すれば気づきやすくなりそうなのに、なぜ?

Effective Dart の説明 も読んでみます。

Do you want any assert() statements inside that code to effectively vanish since you’re catching the thrown AssertionErrors?

意訳: throw される AssertionError を捕捉することでコード内の assert() が実質的に見えなくなってしまうのがいいのでしょうか?(反語的)

assert() のように throw のキーワードを使わずに発生するエラーのことを「throw されない予期せぬエラー」と表現しているのでしょうか…。
「thrown AssertionErrors」とも書かれていて矛盾している気がしますが、自分で頭の中で補って次のように理解しました。

自分の理解:

API を作った人が意図的に throw したものもそうでないものも全部捕捉して自分で揉み消すことができ、気づきにくくなってしまう。
(捕捉しなければ、エラーが出力されたりプログラムが止まったりして気づけるのに。)

--- 推奨されない理由はここまで ---

avoid_catches_without_on_clauses は lints や flutter_lints のパッケージに含まれていませんが、私は Effective Dart に従って自分で有効にしています。
今後も有効のままにするかどうかはわからなくて、下記がその理由です。

onError()は捕捉漏れがない(と思っていた)

上記の lint ルールや Effective Dart の説明を読むと、揉み消すのが悪いのであって揉み消さなければ良いように思えます。

Effective Dart にはこうも書かれています。

In rare cases, you may wish to catch any runtime error. This is usually in framework or low-level code that tries to insulate arbitrary application code from causing problems.

意訳: 稀に、どんなランタイムエラーも捕捉したいことがあります。通常は、フレームワークや低レベルのコードで任意のアプリケーションコードが問題を起こさないようにしようとする部分です。

「in framework or low-level code」となっていますが、パッケージも当てはまると思います。
例えば Riverpod の AsyncValue.guard() でも on は省略されています。
(どんなエラーもガードできる機能を提供する理由なので上記の「問題を起こさないように」という理由とは合致しませんが、パッケージなどで on の省略を必要とするケースの一例ではあります。)

そのような場合、try-catch でもいいのですが、代わりに onError() が使えそうです。
そうすると await し忘れを防ぎつつ全てを捕捉することができます。

then() を組み合わせて「成功時は結果を加工、エラー時は null」のようなことも可能です。

Future<User?> fetchUser() {  
  return fetchProfile()  
      .then<User?>(User.fromProfile)  
      .onError((e, s) => null);  
}  

onError() にも穴があった

onError() のほうが万能だと思うようになって活用していたのですが、コールバックが呼ばれない場合があることに先月気づきました。
実はこれが記事を書くことにしたきっかけです。

Future<void> main() async {  
  final result = await calcOrThrow(-10).onError((e, s) => 0);  
  ...  
}  
  
Future<int> calcOrThrow(int value) {  
  if (value < 0) {  
    throw Exception('ERROR');  
  }  
  return calc(value);  
}  
  
Future<int> calc(int value) async {  
  return value * 2;  
}  

原因

実際のコードはもっと複雑で、上記程度まで簡略化すると発生条件が見えてました。
次の箇所です。

Future<int> calcOrThrow(int value) { // asyncなし  

この部分に async を付けないと起こり、付けると起こらないのです。
付けていなかったのは、その関数内で await する箇所がなくて付ける意味がないと考えたためです。

バグではないと思いつつも、ドキュメントの情報と linter が不十分に思えたので報告し、そこでのやり取りで詳細がわかりました。

  • onError() は Future が返されたときだけ呼ばれる
  • calcOrThrow() の戻り値の型は Future<int> なので Future が返るように思えるが、同期的に throw しているので Future を返せていない

エラーを throw するときにも同期/非同期の区別があるとは意識していませんでした。
非同期に throw するというのは、エラーを Future として返すのと同じ(またはそれに近い仕組み)のようです。

解決方法

次のいずれかで解決できます。

  • async を付ける
  • Future.error() を返す
    • return Future.error(Exception('ERROR'));

discarded_futures の lint ルールを勧められましたが、そのルールは calcOrThrow() の戻り値の型が Future<int> ではなく int の場合にしか効かないので、防止には役立ちそうにありませんでした。

背景

async がないことで想定外の動作になるなら付けることを必須にすればいいのに…。
そう思うかもしれませんが、そうしないのには理由があるそうです。

  • Futureasync / await より古くから Dart にある
    • async を付けないで Future を返すことができるのはその名残
  • プリミティブな非同期処理の中には async を付けない書き方が必要なものもある
  • そういったことで async を付けない書き方を禁止することはできない

「穴があった」と書きましたが、正しく Future を返せば防げるところで誤用して起こるだけです。
誤用を防ぎにくい穴があるのは事実ですが、言語の欠陥ではないと思います。

この問題がおこるケース

上の例のように「非同期処理を実行して結果をそのまま返すが、実行前に引数を確認して不正であれば throw する」というケースが考えられます。
例えば、バックエンドの API を呼ぼうとするところで条件を満たさなくて即エラーにする場合です。

非同期処理を行う関数自体の中で throw するケースでは、その関数には async が付いているはずなので起こらないと思います。

改善の可能性

報告したことで、非同期処理における同期的な throw の対策となる lint ルールを提案する issue が立てられました。

proposal: sync_throw_in_async · Issue #3822 · dart-lang/linter

そこに書かれていることを一部抜粋します。

  • これまでにも何度も出てきていた話ではある
  • Future を返す関数ではエラーも必ず Future で返すべきだと繰り返し勧めてはいるが、意外なことに非同期関連のドキュメントの中に見当たらないので、そのことも改善したほうがいい
  • 問題の影響は小さい
    • 起こったときしか見えなくてエラーとして表れる頻度も低いので問題を認識しにくい
    • 使い間違えたときに catchError() / onError() でエラーをつかめないだけ

このように重大とは扱われていないものの、改善の余地の認識はありそうです。
頻繁に扱う非同期処理は安心して使えるほうがいいので、少しでも改善されればいいと思います。

Xに投稿する