破滅的プログラミングの末路#04 「例外の捕捉範囲を見誤る」

普通のプログラマなら直感的、反射神経的に理解しているような、
初歩的で単純な小ネタを記事にしていこうかと思います。
初歩的が故に、私のようなヘボなプログラマは、
(理解していたつもりでも)よくやらかします。

誤りや適切でない内容がある場合は、コメントいただければ、
修正、内容の調整等検討します。

1. 例外処理

(賛否両論はありますが)、
例外処理が言語仕様として備わっている言語は多々あります。

今回は、
try - catch方式の例外を想定して考えてみます。
Java, C#, C++, Pythonなどは同様の例外機構になっているはずです。
(Pythonでは、try - exceptといった方が伝わるかも。)

例外をどこからどこまで捕捉するかの範囲は非常に重要です。
これを見誤ると、よからぬことになる場合もあります。

2. 2つのコードを見比べてみよう

以下のコードは、他にも突っ込みどころは満載ですが、
とりあえずは、try - catchの範囲の部分にのみ着目してもらえればと思います。
(シンプルな例にしたいので、
スレッド使った方が効率良さそうとか、null大丈夫?とかは、今回は忘れてください。)

コードは、C#ですが、他の言語経験者でも、
なんとなく想像はできるかと思います。

複数のユーザに緊急メッセージを通知するようなメソッドを考えます。
ユーザのリストとメッセージ文字列を渡すと、
リストに含まれるユーザ全員にメッセージを通知するようなイメージです。
各ユーザへの通知では例外が発生する可能性があるので、例外を捕捉しています。

  • 実装その1
/// <summary>
/// 緊急メッセージを、複数ユーザに通知する。
/// </summary>
/// <param name="users">送信先のユーザリスト。</param>
/// <param name="message">送信するメッセージ。</param>
void NotifyUrgentMessage(User[] users, string message)
{
  try
  {
    // ユーザをリストしていく。
    foreach (var user in users)
    {
      // 各ユーザにメッセージを通知する。
      user.NotifyMessage(message);
    }

  }
  catch(NotificationException ne)
  {
    // 通知に失敗したのでログに出力
    Log.ErrorFormat("通知に失敗 : ErrorCode = {0}", ne.ErrorCode, ne);
  }

}
  • 実装その2

もう1つのコードもほぼ同じです。
同じ機能を、同じように実装しましたが、
try - catchのtryの範囲が異なります。
それ以外の違いは、ほぼありません(実は構造上の違いのおかげでログ出力内容がちょっと違います)。

/// <summary>
/// 緊急メッセージを、複数ユーザに通知する。
/// </summary>
/// <param name="users">送信先のユーザリスト。</param>
/// <param name="message">送信するメッセージ。</param>
void NotifyUrgentMessage(User[] users, string message)
{
  // ユーザをリストしていく。
  foreach (var user in users)
  {

    try
    {
      // 各ユーザにメッセージを通知する。
      user.NotifyMessage(message);
    }
    catch(NotificationException ne)
    {
      // 通知に失敗したのでログに出力
      Log.ErrorFormat("通知に失敗 : ErrorCode = {0}, UserGUID = {1}", ne.ErrorCode, user.Guid, ne);
    }

  }

}

2. 実装その1と、実装その2の違いを考える

NotificationException例外が発生しない状況では、どちらにも動作的な違いはありません。
(厳密には、動作パフォーマンス的な違いはあります。)

NotificationException例外が発生する状況では、同じ動作になる場合と、
まったく異なる動作になる場合があります。
本質的には、全然異なる動作になります。

Userリストに、Userが10人含まれていたと仮定します。
リストの3番目(0番目から始まる前提)でNotificationExceptionが発生した場合、
「実装その1」では、for文(foreach文)から抜けることになります。
リストの4番目以降の通知処理は実行されません。
3人に通知されたと期待できます。

一方、「実装その2」では、for文(foreach文)からは、抜けないので、
リストの4番目以降の通知処理は実行されます。
NotificationExceptionが3番目だけだったとすると、
9人に通知されたと期待できます。

NotificationExceptionが、
リストの最後である9番目(0番目から始まる前提)で発生した場合は、
「実装その1」でも「実装その2」でも、9人に通知されたことが期待できます。
ただし、リストのどこでNotificationExceptionが発生するかなんて、
普通はわかりません。

ポイントは例外がリストの何番目で発生するかによって、
通知が届くユーザの人数も変わってきます。
なにより、あるユーザの通知エラー(たとえば設定ミスなどの影響)によって、
他のユーザへの通知にも影響してしまっています。

NotificationExceptionが各Userに独立して発生する可能性がある例外なのであれば、
「実装その2」の方が優れていそうです。

3. 末路

今回の例に限らず、例外処理において、
tryする範囲を適切に実装しないと、よくないことが起こることがあります。

特に、try - catchが、
構造的に、for, foreach, whileなどの中にあるべきか、外にあるべきかは、
充分考察する必要があるかもしません。

また、繰り返し文を含まない場合でも、処理Aの結果を、処理Bで利用する場合などに、
例外のtryの範囲が適切でないために、
処理Aで結果が取得できていないのに処理Bに行ってしまって、
catchされない例外が期待しない先まで投げられてしまうというケースもまあまああります。

ResultA result = null;

try
{
  result = doA();
}
catch(AException ae)
{
   Log.Error(ae);
}

doB(result.Text)

(この例は単純すぎますが、)
doA()が、AExceptionを投げる可能性がある場合は、
例外が発生した場合、
doB()の呼び出し時のresult.Textの部分で、NullReferenceException(NullPointerException)が発生するはずです。

4. 対策

基本的には、例外処理の実装時には、よく考えて実装する必要があります。

例外処理のtryの範囲は、

  • 必要以上に広くしない
  • 必要以上に狭くしない

ということは、重要であると思います。

ほぼすべての例外処理に、適切な範囲が存在するはずです。

先ほどの、NullReferenceException(NullPointerException)の例では、
nullチェックする方法とかもあると思いますが、
今回の場合は、処理Aの結果が処理Bに影響しているのであれば、以下の方がすっきりすると思います。
(例外発生時に、どう対処するかは、今回は議論の中心ではありません。)

try
{
  var result = doA();

  doB(result.Text)
}
catch(AException ae)
{
   Log.Error(ae);
}
catch(BException be)
{
   Log.Error(be);
}

範囲が少し違うと、例外発生時に、
全体としては異なる挙動になることがあるので、
各種例外が発生したときに、どのような挙動になるかは充分な考慮が必要です。
また、可能な限り、例外発生時の挙動がテストできるとよいと思います。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.