普通のプログラマなら直感的、反射神経的に理解しているような、
初歩的で単純な小ネタを記事にしていこうかと思います。
初歩的が故に、私のようなヘボなプログラマは、
(理解していたつもりでも)よくやらかします。
誤りや適切でない内容がある場合は、コメントいただければ、
修正、内容の調整等検討します。
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);
}
範囲が少し違うと、例外発生時に、
全体としては異なる挙動になることがあるので、
各種例外が発生したときに、どのような挙動になるかは充分な考慮が必要です。
また、可能な限り、例外発生時の挙動がテストできるとよいと思います。