例外(Exception)脳の作り方


注:この記事を読むためにはJavaの基礎知識及び、オブジェクト指向の基礎知識の理解(オブジェクト指向の5つの基礎)が必要です。

はじめに

例外がオブジェクト指向と関係あるかないかはよくわかりませんが、私は実務でJavaを使用しているときに最後までよく悩んでいたのが、この例外のモデリングでした。この例外というのは書く必要があるにもかかわらず、なかなか上手く理解ができませんでした。
 最近になっていろいろ雑誌の記事やWebサイトで、よくまとまったものが出てきましたので、それを読んで勉強しましたので、これをオブジェクト指向の初学者のみなさんにも分かりやすいような説明を考えて見たいとおもって書いてみました。
 完全なものとは思っていませんので、ご意見お待ちしております。

例外(Exception)とは

 例外は参考文献1によると「正常時のフロー」から「異常時のフロー」への分岐を「例外」と呼び、この分岐が生じることを「例外が発生する」といいます。うーむ。なんのこっちゃさっぱりわかりませんね。他には例外はプログラム中で発生するエラーであるともあります。こちらのほうはなんだか分かりやすそうですね。

例外ってなんだろう?

 ところで、今まで皆さんはJavaのコーディングを行うときに何故例外のコーディングを行ってきましたか?「try〜catch」とかをかかないと、コンパイルが通らないからでしょうか? 私はそうでした。意味もわからんととりあえず「catchかthrowと書いとけ。」みたいな感じでした。
 また、Javaのプログラムを実行してエラーになった場合に出てくる「スタックとレース」ってどう思います?「なんだかよく分からないメッセージが延々とでてきたで。ま、とりあえず一番最初のメッセージだけみればええんやろ」と私は思っていました。「Shacho.java 20行 Null Pointerのエラーです」とかでてこいよなぁ。と最初は思っていました。しかし、あるとき気づいたのです。

「例外ってめちゃ便利では?」と。

なんで例外ってあるんだろう?

 プログラムを作成する場合のエラーの処理はとても大変ですよね。昔、非オブジェクトな言語でコーディングをしているときも、「あ、ここでnullきたらアカンからnullがもしきたらこうしよう」というように、どんどんif文が深〜くなって、そのif〜else文が何重にもなってるし、重複しているコーディングもあるし、そもそも、エラーの処理をどこで書くのがいいのかもよくわかりませんでした。共通関数つくったらその呼び元なのか、共通関数の中でやるのか?

 折角ですから、次のようなシナリオのプログラムを一丁作ってみましょう。一つは例外でコーディングしたもの、もう一つは非例外でコーディングしてみましょう。

 よくあるユーザのログイン処理をやって見ましょう。プログラムの実行結果はこんな感じでしょう。
LoginCommandというクラスを実行します。LoginCommandの書式は

java LoginCommand username password データベース起動フラグ[0|1]

という書式です。データベース起動フラグは、サンプル用ですが、0でデータベースを起動しておかない、1でデータベースを起動しておくということを表しています。LoginCommandはパッケージがoo(例外脳用)nonoo(非例外脳用)を考えます

それぞれのパッケージの指定は省きますが、実行イメージは以下のようになっています。

実行イメージ

ユーザ/パスワードの正しい組み合わせがkent/beckの場合

1.ログインできる場合
> java LoginCommand kent beck 1
> ログインが成功しました

2.ユーザ名/パスワードが間違ってるとき

> java LoginCommand kent back 1
> ユーザまたはパスワードが間違っています

> java LoginCommand kunt beck 1
> ユーザまたはパスワードが間違っています

3.データベースに接続できない場合
> java LoginCommand kent beck 0
> システムの異常です。管理者にご相談ください

このようなシステムのシナリオは以下のようだったとします。

シナリオ

ユースケース名  :ユーザログイン
アクタ         :ユーザ
事前条件      :なし
成功時保障    :ユーザがシステムにログインしている
主成功シナリオ  : 1.システムがアクタにログインを要求する
             2.アクタがユーザ名/パスワードを入力する
             3.システムが入力されたユーザ名/パスワードを検証する
             4.ログインの成功した旨を表示する
拡張:          3a.ユーザ名/パスワードが間違っている場合
             3b.ユーザが存在しない場合
             3c.データベースに接続できない場合

シナリオをよく理解している必要はありませんが、簡単に説明しておきます。今はこの記事を理解するために、主成功シナリオと拡張の意味がわかっていればよいでしょう。シナリオをもう一度簡単におさらいすると、ある場面でのシステムの使われ方の一例を示しているものです。
 ユースケース名はその名前、アクタはそのシナリオでシステムを使うユーザ、そして事前条件はそのシナリオを実行するに当たって、最初に実行していないといけないこと、成功時保障はそのシナリオが実行されるとどうなるかという状態、そして、主成功シナリオはエラー処理を除いた、そのシナリオが一番上手いった状態のシナリオを書きます。拡張は主成功シナリオどおりで無い場合のシナリオを書いていきます。

モデル

モデリングはとっても適当ですが、こんな感じにしてみましょう。適当なので、各クラスにメソッドなどを追加したり、Exceptionを新規に作成するのもありです。

図1.サンプルの適当なクラス図


ちなみにdbパッケージ配下は偽データベースを表しています。偽データベースのクラス(Database)は既に作成済みです。この偽データベースの使い方はこんな感じです。
Database.startup();                       …@データベースを起動します。
Database db = Database.connect();             …Aデータベースに接続します。
Record record = db.findByPrimaryKey(tablename, key); …Btablenameのテーブルをkeyで検索します

@で偽データベースを起動します。
Aで、偽データベースに接続します。もし、偽データベースが起動していなかったらDatabaseNotFoundExceptionがスローされます。
Btablenameの表の主キー(key)検索を実行します。とはいっても偽なので、user表の偽データが詰まっています。user表にはkent/beck(user/password)というデータとmartin/fowler(同右)というデータが入っています。key=kentで検索したら、表の1レコードを表すRecordクラスのオブジェクトとして、kent/beckのデータが帰ってきます。user表のkentを検索して、ユーザ名とパスワードを取るイメージは例えば

Record record = db.findByPrimaryKey("user", "kent");
System.out.println("ユーザ名:" + record.getName());
System.out.println("パスワード:" + record.getPassword());

という感じになります。

まず、ここまでの情報で仕様を満たすようなコーディング(LoginCommandクラスとUserクラスの作成)を思いのままに実施してみましょう。

『演習タイム』

さて、できましたか?では、私の回答例を解説しましょう。左側が例外脳で右側が非例外脳です。
テスト付きソースコードもダウンロードできるようにしています。ちなみにWebの表示の都合上全角スペースなどを使用しているので、カットアンドペースト不可です。

例外脳 非例外脳
package oo;
import db.*;
/**
  * Userを表すドメインオブジェクト
*/
public class User {
private User(){}
/**
  * キーからUserオブジェクトを作成する
  * @param key 検索キー
  * @param db  データベースオブジェクト
  * @return Userオブジェクト
*/
public static User getUser(String key, Database db) throws ObjectNotFoundException {
    return getUser(getRecord(key, db));
}
/**
  * ログインするメソッド
  * @param name ユーザ名
  * @param password パスワード
  * @param db Databaseクラスのオブジェクト
  * @return Userクラスのオブジェクト
*/
public static User login(String name, String password, Database db) throws LoginException {
    try {
        User user = getUser(name, db);
        if(user.getPassword().equals(password)) {
            return user;
        } else {
            throw new LoginException();
        }
    } catch (ObjectNotFoundException e) {
        throw new LoginException(e);
    }
}

/**
  * 名前を返却する
  * @return 名前
*/
public String getName() {
    return name;
}

/**
  * パスワードを返却する
  * @return パスワード
*/
public String getPassword() {
    return password;
}

private static Record getRecord(String key, Database db)
throws ObjectNotFoundException {
    return db.findByPrimaryKey("user", key);
}

private static User getUser(Record record) {
    User user = new User();
    user.name = record.getName();
    user.password = record.getPassword();
    return user;
}
private String name;
private String password;
}

package nonoo;
import db.*;
/**
  * 例外脳なしなUserクラス
**/
public class User {
/**
  * ログイン成功
**/
public static int LOGIN_SUCCESS = 1;
/**
  * ログイン失敗
**/
public static int LOGIN_FAIL = 2;

// テスト用
User() {}
/**
  * ユーザクラスを取得するファクトリ
  * @param name キーの名前
  * @param db
  * @return nameをもったUserオブジェクト
  * ただし、該当するnameをもったUserがいない場合空のUserオブジェクトを返却
  * し、データベースが接続できない場合はnullを返却する
*/
public static User getUser(String name, Database db) {
    if(db != null) {
        Record record = null;
        try {
            record = db.findByPrimaryKey("user", name);
        } catch (ObjectNotFoundException oe) {
            return new User();
        }
        User user = new User();
        user.name = record.getName();
        user.password = record.getPassword();
        return user;
    } else {
        return null;
    }
}
/**
  * ログインするメソッド
  * @param password パスワード
  * @return 戻り値
*/
public int login(String password) {
    if(password != null) {
        if(password.equals(this.password)) {
            return LOGIN_SUCCESS;
        }
    }
    return LOGIN_FAIL;
}
/**
  * このオブジェクトが空か調べる
  * @return 空かどうか
*/
public boolean isEmpty() {
    if(name == null && password == null) {
        return true;
    } else {
        return false;
    }
}
private String name;
private String password;
}
Userクラス Userクラス
package oo;
import db.*;
/**
  * 例外脳型のコーディング
*/
public class LoginCommand {

public static void main(String[] args) {
    LoginCommand command = new LoginCommand();
    command.execute(args[0], args[1], args[2]);
}
public void execute(String name, String password, String startupFlag) {
// データベースが起動していない場合をサンプル用にシュミレート
    if("1".equals(startupFlag)) {
        Database.startup();
    }

    try {
        Database db = Database.connect();
        User user = User.login(name, password, db);
        System.out.println("ログイン成功しました");
    } catch (DatabaseNotFoundException de) {
        System.out.println("システム管理者にご連絡ください");
    } catch (LoginException le) {
        System.out.println("ユーザもしくはパスワードが間違っています");
    }
}
}
package nonoo;
import db.*;
/**
* 非例外脳のコーディング
*/
public class LoginCommand {
public static void main(String[] args) {
    LoginCommand command = new LoginCommand();
    command.execute(args[0], args[1], args[2]);
}

public void execute(String name, String password, String startupFlag) {
// データベースが起動していない場合をサンプル用にシュミレート
    if("1".equals(startupFlag)) {
        Database.startup();
    }
    Database db = null;
    try {
        db = Database.connect();
    } catch (DatabaseNotFoundException e) {
    // データベースが起動していないケース
        System.out.println("システム管理者にご連絡ください");
        System.exit(0);
    }
    User user = User.getUser(name, db);
    if(user != null) {
        if(user.isEmpty()){
            // ユーザが見つからないケース
            System.out.println("ユーザもしくはパスワードが間違っています");
        } else {
        // 正常コース
            int result = user.login(password);
            if(result == User.LOGIN_SUCCESS) {
                // 正常コース
                System.out.println("ログイン成功しました");
            } else {
                // パスワードが違う
                System.out.println("ユーザもしくはパスワードが間違っています");
            }
        }
    } else {
        // データベースが起動していないケース
        System.out.println("システム管理者にご連絡ください");
    }
}
}
LoginCommandクラス LoginCommandクラス

ちょっと長いので見にくいですね。とりあえず、ポイントを絞って解説しましょう。例外脳発生のコツは以下のポイントを押させればOKです。

  1. 例外は各クラスが分かるような例外に編み変えよう
  2. 事前条件を考えよう
  3. 例外を処理できるところが例外の処理しよう
  4. シナリオの「主成功シナリオ」が普通のコード、拡張の部分がそのまま例外を表している

さて、一つづつ解説することにしましょう。例外を考えるには先ず奥のクラス(Userクラス)から考えていきます。

1. 例外は各クラスが分かるように編み変えよう

 まず、このクラス構成で、各クラスの責任を考えて見ましょう。既存のDatabaseクラスはもちろんデータベースを表しています。データベースを起動したり、データベースを検索したりする責任があります。次にUserを考えると、このクラスはその名のとおり「ユーザ」をあらわすクラスです。カプセル化の教えどおり、このクラスの利用者はこのユーザのデータがデータベースから来ているかどうかなど知らなくてよいことになっています。実際にはデータベースの「ユーザ表」のデータを管理しています。最後にLoginCommandクラスですが、これはユーザインターフェイスもかねていますが、名前のとおり、ログインするというアクションを表しているクラスです。
 
 各クラスの責任は上記のようになっているので、LoginCommandクラスはUserクラスがデータベースの「ユーザ表」のデータをカプセル化して管理しているということは知らないはずです。LoginCommandがUserクラスを使うのは、ユーザのログインをしたいからというだけであって、それが成功したのか失敗したのかということさえ分かればいいはずです。「データベースの表(ユーザ表)にその主キー(例えばkent)のデータがない」、とかは知らなくてもいいはずです。
 ですのでDatabaseクラスのfindByPrimaryKey()メソッドはObjectNotFoundExceptionを返却しますが、これはあくまでデータベース的な話であって、LoginCommandクラスは「ログインに成功したか、成功しなかったのか?」にしか興味がないはずです。ですので、新たにLoginExceptionというExceptionを作ることにしました。

public static User login(String name, String password, Database db) throws LoginException {
    try {
        User user = getUser(name, db);
        if(user.getPassword().equals(password)) {
            return user;
        } else {
            throw new LoginException();
        }
    } catch (ObjectNotFoundException e) {
        throw new LoginException(e);                     …@
    }
}

例外脳のUserクラスのloginメソッド

    try {
        User user = User.login(name, password, db);
        System.out.println("ログイン成功しました");
    } catch (LoginException le) {
        System.out.println("ユーザもしくはパスワードが間違っています");
    }

例外脳のLoginCommandクラスのログイン部分(抜粋)

このように、LoginCommandクラスからみたら、loginメソッドからはLoginExceptionしかかえって来ませんので、内部でデータベースがつかわれているかどうか知りません。もちろんDatabaseクラスのfindByPrimaryKey()メソッドをUserクラスの中で使うので、内部でObjectNotFoundExceptionがスローされるのですが、それを@のように編み変えています。
 このように、LogingCommandクラスが知らなくてもいい例外はLoginCommandが興味のある例外に編みかえてあげるテクニックを覚えましょう。

2. 事前条件を考えよう

 事前条件ってなんだか難しい言葉ですね。「メソッドを使うときの事前条件」という説明をすると、「このメソッドを使うときは、この条件は満たしといてね。」というお約束のことを言います。なんで、ここでこんなことを考えないといけないでしょうか?非例外脳の例で説明してみましょう。事前条件を考えないコーディングをすると、例えばUserクラスのlogin()メソッドやそれを使っている部分はこんなになってしまいます。

/**
  * ユーザクラスを取得するファクトリ
  * @param name キーの名前
  * @param db
  * @return nameをもったUserオブジェクト
  * ただし、該当するnameをもったUserがいない場合空のUserオブジェクトを返却
  * し、データベースが接続できない場合はnullを返却する
*/
public static User getUser(String name, Database db) {
    if(db != null) {
        Record record = null;
        try {
            record = db.findByPrimaryKey("user", name);
        } catch (ObjectNotFoundException oe) {
            return new User();
        }
        User user = new User();
        user.name = record.getName();
        user.password = record.getPassword();
        return user;
    } else {
        return null;
    }
}
public int login(String password) {
    if(password != null) {
        if(password.equals(this.password)) {
            return LOGIN_SUCCESS;
        }
    }
    return LOGIN_FAIL;
}

非例外脳のUserクラスのloginメソッド

    Database db = null;
    try {
        db = Database.connect();
    } catch (DatabaseNotFoundException e) {
    // データベースが起動していないケース
        System.out.println("システム管理者にご連絡ください");
        System.exit(0);
    }
    User user = User.getUser(name, db);
    if(user != null) {
        if(user.isEmpty()){
            // ユーザが見つからないケース
            System.out.println("ユーザもしくはパスワードが間違っています");
        } else {
        // 正常コース
            int result = user.login(password);
            if(result == User.LOGIN_SUCCESS) {
                // 正常コース
                System.out.println("ログイン成功しました");
            } else {
                // パスワードが違う
                System.out.println("ユーザもしくはパスワードが間違っています");
            }
        }
    } else {
        // データベースが起動していないケース
        System.out.println("システム管理者にご連絡ください");
    }

非例外脳のLoginCommandクラスのログイン部分(抜粋)

例えばUserクラスがLoginCommandクラスから呼ばれるとして、UserクラスのgetUser()メソッド、login()メソッドではDatabseクラスのオブジェクトとユーザ名が引数になっています。それらの例えばnullチェックや、Databaseがすでに起動しているか?のチェックなんかはどこがやればいいのでしょうか?どこがやればいいかわからなかったらとりあえずUserクラスにも、LoginCommandクラスでも心配なので両方でチェックしてしまいそうです。これがこの非例外脳のコーディングになっています。ですので同じようなチェックをUserクラスでもLoginCommandクラスでもやっていてなんだか凄くごちゃごちゃしていますね。
 
 ここで、役に立つのが事前条件の考え方です。まず、私は次のように考えました。

つまりlogin()やgetUser()メソッドは「データベースが起動している」という前提にしたのだ。どうやってそれを決めたかって?それは私が独断と偏見できめました。「そんなんでええか?」って?ええのです。他の例でいくと以下のようなメソッドがあるとします。

public boolean isEqual(String name1, String name2) {
  if(name1.equals(name2)) {
    return true;
  } else {
    return false;
  }
}

この例では、例えばname1がnullだったら、NullPointerExceptionになってしまいます。ですので、このメソッド内でnullチェックをしないといけないはずです。しかし、事前条件であなたが、「このメソッドを使う時にはnullを引数にしないこと」と決めればそのコーディングはしなくていい決まりになっています。ですので、逆にこのメソッドを使う側でその処置をしないといけないということになります。一般的にはこういった事前条件がある場合はメソッドのコメントに書いておくとよいです。
 なんかいい加減そうですが、オブジェクト指向の場合はオブジェクトがオブジェクトをよんでまたそのオブジェクトが・・・という状況があたりまえなので、そのすべてのクラスで例えばnullチェックなんかをしたら、それはもう大変面倒なことになるのです。(非例外脳のようにたった2階層ですが)しかもExceptionの機構があるので、内部でExceptionがおこってもExceptionをスローして呼び出しもとのほうで処理できるので、特に問題でもないというわけです。ですので、事前条件をしっかり決めてコーディングすることによって、クラスの設計がすっきりするわけです。
 ちなみにどこでもかしこでもnullチェックするようなコーディングのことを防御的プログラミングって言うらしいです。

3. 例外を処理できるところが処理しよう

 というわけなので、事前条件を決めることによって、発生する例外をどこで処理すべきかというのがはっきりしてきます。例外脳のコーディングではこんな感じです。

しっかりと、出てくるExceptionをどこで処理するか?というのが決まっているので余計な重複したコーディングがでてきません。

4. シナリオの「主成功シナリオ」が普通のコード、拡張の部分がそのまま例外を表している

 最後の1つのテクニックをご紹介しましょう。例外脳のLoginCommandの実際の処理の抜粋を見ると面白いことに気づきます。LoginCommandはLoginというアクションを表しています。

例外脳のLoginCommand(抜粋) シナリオ
    try {
        Database db = Database.connect();

        User user = User.login(name, password, db);

        System.out.println("ログイン成功しました");
    } catch (LoginException le) {
        System.out.println("ユーザもしくはパスワードが間違っています");
    }
    } catch (DatabaseNotFoundException de) {
        System.out.println("システム管理者にご連絡ください");
}
主成功シナリオ  : 1.システムがアクタにログインを要求する
             2.アクタがユーザ名/パスワードを入力する

             3.システムが入力されたユーザ名/パスワード
              を検証する
             4.ログインの成功した旨を表示する
拡張:          3a.ユーザ名/パスワードが間違っている場合
             3b.ユーザが存在しない場合



             3c.データベースに接続できない場合

よく見ると、tryの中のロジック部分が主成功シナリオのと一緒で、逆に拡張の部分はcatchのコーディングと一緒です。このようにアクションのコーディングが(ユースケースの)シナリオの流れと一致します。Javaの例ですとSessionBeanやCommandの部分にこのコーディングが見られることが多いです。このように、try〜catchの仕組みはシナリオとも凄く親和性がよろしくて、要求→分析→設計→コーディングにシームレスにマッピングされているのが、現実世界っぽい世界をオブジェクトで表現できるオブジェクト指向っぽくていいですね。

おまけですが、例外を使った場合例のだらだらでてくるスタックトレースのことについて少々。オブジェクト指向の場合、オブジェクトが協調して1つの仕事をするため、どこのクラスのオブジェクトから、どのクラスのオブジェクトが呼ばれたか?ということはデバッグをするにあたって必須となります。ですので、1行だけでなくてだらだらと出てくるわけです。

最後に

さらに例外をより深く学びたい人のために以下のような参考文献をお勧めします。

Desgin By Contract By Example.
JavaWorld誌 2002/12月号 攻略! Javaの例外処理
JavaWorld誌 2002/9月号  Design by Contract実践のススメ
他にいいのがあったら、MLで教えてくださりまし。



牛尾 剛 (Tsuyoshi Ushio) Ver .01 [2003/8/22]