🍇

19.2 例外の様々な機能とアサーション(ユーザー定義例外、例外チェーン、リソース自動クローズ、assert文など)~Java Basic編

2023/11/05に公開

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、25年以上に渡って企業システムの開発に携わってきました。特にアーキテクトとして、ミッションクリティカルなシステムの技術設計や、Javaフレームワーク開発などの豊富な経験を有しています。
様々なセミナーでの登壇や雑誌への技術記事寄稿の実績があり、また以下のような書籍も執筆しています。

いずれもJava EEJakarta EE)を中心にした企業システム開発のための書籍です。中でも 「アプリケーションアーキテクチャ設計パターン」は、(Javaに限定されない)比較的普遍的なテーマを扱っており、内容的にはまだまだ陳腐化していないため、興味のある方は是非手に取っていただけると幸いです(中級者向け)。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『Java Basic編』の一部の範囲をカバーしたものです。『Java Basic編』はこちらのリンクから購入できます(セールス対象外のためいつも同じ価格)。また定価の約30%OFFで購入可能なクーポンをZenn内で定期的に発行していますので、興味のある方は、ぜひ私の他の記事をチェックしてみてください。

この講座は、以下のような皆様にお薦めします。

  • Javaの言語仕様や文法を正しく理解すると同時に、現場での実践的なスキル習得を目指している方
  • 新卒でIT企業に入社、またはIT部門に配属になった、新米システムエンジニアの方
  • 長年IT部門で活躍されてきた中堅層の方で、学び直し(リスキル)に挑戦しようとしている方
  • 今後、フリーランスエンジニアとしてのキャリアを検討している方
  • Chat GPT」のエンジニアリングへの活用に興味のある方
  • Oracle認定Javaプログラマ」の資格取得を目指している方
  • IT企業やIT部門の教育研修部門において、新人研修やリスキルのためのオンライン教材をお探しの方

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

https://zenn.dev/kenya_saitoh/articles/3fe26f51ab001b

19.2 例外の様々な機能とアサーション

チャプターの概要

このチャプターでは、ユーザー定義例外や例外チェーンなど例外のその他の機能や、プログラムの動作を検証するためのアサーションの仕組みについて学びます。

19.2.1 ユーザー定義例外の作成方法

ユーザー定義例外とは

これまでのレッスンで登場した例外クラスは、いずれもJava SEのクラスライブラリによって提供されるものでした。
例外クラスは、提供されるものを使用するだけではなく、開発者自身で作成することも可能です。このような例外クラスを、本コースでは「ユーザー定義例外」と呼称します。
ユーザー定義例外の主な用途は、業務的なエラーを表すことにあります。業務的なエラーにも、想定しうるものと想定しえないものがあります。例えば「残高不足により引き落としができないエラー」は業務上想定しうるものですが、「存在するはずのマスターデータがないエラー」は業務上想定しえないものです。
これらのエラーを、チェック例外にするか非チェック例外にするかは、ユーザー定義例外の重要な設計項目の1つです。典型的なケースとしては、業務上想定しうるエラーの場合は適切なハンドリングが必要なためにチェック例外にし、業務上想定しえないエラーの場合は非チェック例外にする、というものがよく見られますが、これはあくまでも開発者の戦略に委ねられます。

ユーザー定義例外の作成方法

ユーザー定義例外は、チェック例外にする場合はjava.lang.Exceptionを、非チェック例外にする場合はjava.lang.RuntimeExceptionを、それぞれ継承して作成します。例外クラスは「○○Exception」という名前を付けるのが一般的です。
それではここで、ユーザー定義例外として「残高不足」を表す例外クラス(BalanceExceptionクラス)を作成してみましょう。以下のコードを見てください。

pro.kensait.java.basic.lsn_19_2_1.BalanceException
public class BalanceException extends Exception { //【1】
    private BigDecimal balance; //【2】
    public BalanceException(String message) { //【3】
        super(message);
    }
    public BalanceException(Throwable cause) { //【4】
        super(cause);
    }
    public BalanceException(String message, Throwable cause) { //【5】
        super(message, cause);
    }
    public BalanceException(String message, BigDecimal balance) { //【6】
        super(message);
        this.balance = balance;
    }
    // アクセサメソッド
    ........
}

BalanceExceptionクラスは、チェック例外として使用するためjava.lang.Exceptionを継承しています【1】。ユーザー定義例外のコンストラクタでは、最上位の親クラスであるThrowableクラスのコンストラクタをそのまま呼び出し、メッセージ、根本原因という2つの属性を初期化するケースが一般的です【3、4、5】。またこのクラス独自の属性として、エラー発生時の残高を表すbalanceフィールドを追加しました【2】。それに伴い、balanceフィールドを初期化するためのコンストラクタ【6】とアクセサメソッドも追加しています。
プログラム実行中に残高不足が発生したら、この例外クラスのオブジェクトを以下のように生成し、送出します。

snippet (pro.kensait.java.basic.lsn_19_2_1.Main)
throw new BalanceException("残高不足発生", new BigDecimal(5000));

ユーザー定義例外のオブジェクトもJava SE例外と同じように、throw文によって送出します。ここでは2つの引数を持つコンストラクタによってBalanceExceptionオブジェクトを生成し、それをthrow文に指定します。この例外クラスはチェック例外のため、呼び出し元でのハンドリングが強制されます。balanceフィールドの値は、顧客向けのエラーメッセージ等で使用されることになるでしょう。

19.2.2 例外チェーン

例外チェーンとは

例外チェーンとは、catchブロックの中で一度キャッチした例外を、別の例外に詰め替えて(ラップ)、再度送出する機能です。このとき発生源となった例外(=キャッチした例外)を、根本原因と呼びます。
もちろん、わざわざ例外を詰め替えすことなく、捕捉した例外をそのまま送出することも可能です。それでは、例外をチェーンする必然性はどういった点にあるのでしょうか。
例えばECサイトにおいて、「カートに入った商品を注文する」という責務を持つ「注文クラス」があるものとします。「注文クラス」の内部処理では、対象商品の在庫不足や、クレジットカードの有効期限切れといった、様々な理由でエラー(例外)が発生する可能性があります。このようなとき、内部で発生した様々なエラーをそのまま送出するのではなく、「注文例外」という例外に詰め替えてあげると、「注文クラス」の利用者は「注文例外」だけを意識すればよくなるため、例外ハンドリングを効率化できます。後から「注文クラス」に仕様変更があり、エラーのパターンが増えたとしても、「注文クラス」の利用者は直接的な影響を受けることはありません。

【図19-2-1】例外チェーン
image.png

例外チェーンの具体例

それでは、例外チェーンの挙動を具体的に見ていきましょう。これまでの例と同じように、Main、Foo、Barという3つのクラスを作成します。これらのクラスは、Main→Foo→Barというフローでメソッド呼び出しが行われます。これらは既出のコードとほとんど同じですが、Fooクラスにて例外ハンドリングを行い、例外をラップしている点が特徴です。ラップする例外はユーザー定義例外のBusinessExceptionクラスで、業務エラーを表す汎用的な例外です。
BusinessExceptionクラスは、コンストラクタのみを持つシンプルな例外クラスのため、ここではコードは割愛します。

pro.kensait.java.basic.lsn_19_2_2.Main
public class Main {
    public static void main(String[] args) {
        Foo foo = new Foo();
        try {
            int length = foo.process(args[0]);
            System.out.println(length);
        } catch (BusinessException be) { //【1】
            be.printStackTrace();
            //【2】このように根本原因を取り出すことも可能
            Throwable t = be.getCause();
        }
    }
}
pro.kensait.java.basic.lsn_19_2_2.Foo
public class Foo {
    public int process(String param) throws BusinessException {
        try {
            Bar bar = new Bar();
            int length = bar.process(param);
            return length;
        } catch (IllegalArgumentException iae) {
            throw new BusinessException("業務例外発生", iae); // 【3】例外チェーン
        }
    }
}
pro.kensait.java.basic.lsn_19_2_2.Bar
public class Bar {
    public int process(String param) {
        int length = param.length();
        if (10 < length) {
            throw new IllegalArgumentException("文字列長が過大");
        }
        return length;
    }
}

Fooクラスを見ると、呼び出し先(ここではBarクラス)で発生したIllegalArgumentExceptionを捕捉し、BusinessExceptionにラップしています【3】。このように例外をラップするには、新しい例外オブジェクトを生成するときに、根本原因となった例外をコンストラクタに指定します。生成した例外オブジェクトは、throw文によって送出します。そしてMainクラスのcatchブロックでBusinessExceptionを捕捉し、例外ハンドリングを行っています【1】。
このcatchブロックで、受け取った例外オブジェクトのgetCause()メソッドを呼び出す【2】と、根本原因の例外(この例ではIllegalArgumentException)を取り出すことができます。
このようにプログラム内で発生した様々な例外クラスを、例外チェーンによって特定の例外に置き換える、という点がポイントです。このようにすると、呼び出し元であるMainクラスは「とにかくBusinessExceptionであること」だけを意識すれば良いため、仮に後の仕様変更で業務エラーのパターンが増えたとしても、直接的な影響を受けることはありません。

例外チェーンにおけるスタックトレース

前項の例では、MainクラスにおいてIllegalArgumentExceptionからチェーンされたBusinessExceptionを受け取りましたが、この例外オブジェクトのprintStackTrace()メソッドを呼び出すと、以下のようなスタックトレースが出力されます。

pro.kensait.java.basic.lsn_19_2_2.BusinessException: 業務例外発生
    at pro.kensait.java.basic.lsn_19_2_2.Foo.process(Foo.java:10)
    at pro.kensait.java.basic.lsn_19_2_2.Main.main(Main.java:13)
Caused by: java.lang.IllegalArgumentException: 文字列長が過大
    at pro.kensait.java.basic.lsn_19_2_2.Bar.process(Bar.java:7)
    at pro.kensait.java.basic.lsn_19_2_2.Foo.process(Foo.java:7)
    ... 1 more

例外チェーンにおいてスタックトレースを出力すると、受け取った例外のスタックトレースに加えて、その根本原因となった例外のスタックトレースが「Caused By」以降に連なって表示されます。根本原因にさらに別の根本原因がある場合は、「Caused By」のブロックが下方向に連続して表示されます。規模の大きなアプリケーションでは、「Caused By」のブロックが幾えにもわたって連なるケースが良く見られますが、大元の根本原因は一番下に表示される、という点を理解しておきましょう。
なおこのスタックトレースの最下行を見ると「... 1 more」となっています。これは「チェーンされた例外のスタックトレースの最後の1行と重複している」ためその部分の出力を省略する、という意味です。適宜1つ上のスタックトレースを参照することで、補って読むと良いでしょう。

19.2.3 リソースの自動クローズ

リソースの自動クローズとは

レッスン19.1.6で取り上げたように、finallyブロックの主な目的はファイルやネットワークといったリソースのクローズにあります。try-with-resources文を使うと、finallyブロックを記述しなくてもリソースを自動クローズすることができます。
try-with-resources文の構文を次に示します。

【構文】try-with-resources文
try (....リソースをオープンして変数に格納....) {
    ....例外が発生する可能性のある処理....
} 
....catchブロック(0~複数回)....
....finallyブロック(01回)....

tryキーワードの後ろに( )を付与し、その中にリソースをオープンして変数に格納する処理を記述します。ここでオープンされたリソースは、finallyブロックと同じような考え方で、例外が発生する・しないに関わらず最終的に必ずクローズされます。
この構文に指定可能なリソースは、java.lang.AutoCloseableインタフェースをimplementsする必要があります(していないとコンパイルエラー)。
なお本コースでは取り上げませんが、Java SEのクラスライブラリで提供される主要なリソース(ファイルやネットワーク)は、基本的にこのインタフェースをimplementsしています。
また( )内で宣言された変数にはリソースのオブジェクトが格納されますが、tryブロック内の処理でこの変数にアクセスが可能です。

リソース自動クローズの具体例

ここでは、リソースの自動クローズを具体的に見ていきましょう。
レッスン19.1.6のMainクラスとまったく同じ処理を、この機能を利用して実装すると以下のようになります。

pro.kensait.java.basic.lsn_19_2_3.Main
public class Main {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("foo.txt");
        try (BufferedReader br = Files.newBufferedReader(path)) { //【1】
            String line;
            while ((line = br.readLine()) != null) { //【2】
                System.out.println(line);
            }
        }
    }
}

tryブロックの( )内に、リソースをオープンする処理を記述します【1】。このコードでは、BufferedReaderというファイルを読み込むためのリソースをオープンしています。対象のリソースはここでは1つだけですが、複数ある場合は( )内を;で区切り、複数のリソースをオープンすることも可能です。
tryブロックの中の処理では、詳細は割愛しますが、変数br(BufferedReaderオブジェクト)にアクセスすることでファイルから行単位に読み込みを行い、その内容をコンソールに表示しています【2】。
catchブロックやfinallyブロックの記述は通常のtry-catch文と同様ですが、このコードでは結果的にtryブロックのみになっています。
このコードを既出のMainクラス(レッスン19.1.6)と比較すると、大幅にコード量が削減できていることが分かるかと思います。

19.2.4 アサーションとその考え方

アサーションとは

アサーションとは、プログラムが正しく動作しているかどうかを検証するための考え方の一種です。
アサーションでは、不変条件、事前条件、事後条件という3つの条件で、検証と行うものとされています。まず不変条件では、正しくインスタンスが生成されているか、またはメソッド呼び出し後にインスタンスが正しい状態になっているかを検証します。次に事前条件では、渡されたメソッド引数が当該メソッドの仕様に違反していないことを検証します。最後に事後条件では、メソッドが返す戻り値が当該メソッドの仕様に違反していないことを検証します。
Javaには、このようなアサーションが言語仕様として組み込まれており、以下のようなassert文で実現します。

【構文】assert文
assert 条件式
assert 条件式 : 検証エラーメッセージ

このようにassertキーワードと、その後ろに検証のための条件式(boolean値を返す式)を記述します。ここで指定する条件式は「正しい仕様に即した場合の条件式」なので、例えば「変数xが0以上」が仕様の場合は0 <= xになります。また条件式の後ろを:で区切り、検証エラーが発生した時のメッセージを文字列で指定することも可能です。
アサーションは「表明」と訳されますが、プログラムにassert文を追加することで当該処理の仕様が「表明」されるため、ドキュメンテーションとしての効果もあるとされています。

assert文の有効化

assert文による検証は、デフォルトでは無効になっています。その理由は、assert文による検証エラーは一種のプログラム不良であり、テスト段階で解消されるべきものだから、というものです。
この機能を有効にするためには、javaコマンドによってメインクラスを実行するときに「-ea」オプションが必要です。assert文が有効な状態で検証エラーが発生すると、java.lang.AssertionError(java.lang.Errorの子クラス)が例外として送出されます。
送出されたAssertionErrorは、assert文に指定したエラーメッセージを属性に持つため、それをコンソールなどに表示することによって、検証エラーの内容を確認することができます。アサーションによる検証は、アプリケーションが本番稼働中に発生するケースは想定されていないため、このエラーをハンドリングする必要はありません。

assert文の具体例

ここでは、特殊な計算を行うCalculatorクラスを題材に、assert文の挙動を見ていきましょう。
まず素のCalculatorクラスのコードを以下に示します。

pro.kensait.java.basic.lsn_19_2_4.Calculator
public class Calculator {
    private final int initValue; // 初期値
    public Calculator(int initValue) {
        this.initValue = initValue;
    }
    public int add(int param1, int param2) {
        int result = initValue + param1 * param2; // 計算結果
        return result;
    }
}

このクラスは初期値としてinitValueを持ち、それをコンストラクタで初期化します。add()メソッドはparam1、param2という引数を2つ取り、それらを乗算した値をinitValueに加算して返す、という機能を持っています。
このクラスには、以下のような不変条件、事前条件、事後条件があるものとします。

  • 不変条件 … 初期値initValueは100以上であること
  • 事前条件 … add()メソッドの引数は、0~5の範囲であること
  • 事後条件 … add()メソッドの戻り値は、結果的に100以上になること

Calculatorクラスにこれらの条件をassert文で埋め込むと、以下のようになります。

pro.kensait.java.basic.lsn_19_2_4.Calculator2
public class Calculator2 {
    private final int initValue;
    public Calculator(int initValue) {
        assert 100 <= initValue : "initValue is wrong value"; //【1】不変条件
        this.initValue = initValue;
    }
    public int add(int param1, int param2) {
        assert 0 <= param1 && param1 <= 5 : "param1 is wrong value"; //【2】事前条件
        assert 0 <= param2 && param2 <= 5 : "param2 is wrong value"; //【3】事前条件
        int result = initValue + param1 * param2;
        assert 100 <= result : "result is wrong value"; //【4】事後条件
        return result;
    }
}

順番に見ていきましょう。
まず不変条件はコンストラクタに埋め込み、初期化を行う前に、引数として渡されたinitValueに対して「100以上であること」を検証しています【1】。
次にadd()メソッドでは、メソッド本体の処理を行う前に、引数として渡されたparam1、param2に対して「0~5の範囲であること」を検証しています【2、3】。
さらにadd()メソッドでは、戻り値を返す前に、結果的に値が「100以上になること」を検証しています【4】。
このようにして作成したCalculator2を、以下のようにして呼び出してみましょう。

snippet (pro.kensait.java.basic.lsn_19_2_4.Main_2)
Calculator2 calc = new Calculator2(100);
int result = calc.add(4, 8);

このコードを実行すると、不変条件は問題ありませんが、2番目の引数(param2)が「0~5の範囲であること」ではないため事前条件に抵触し、検証エラーが発生します。

不変条件や事前条件のあり方

このレッスンでは、アサーションの仕組みついて紹介しました。実は昨今の実アプリケーション開発では、この仕組みはほとんど使われていない、というのが実情です。
まず不変条件や事前条件は、どのように検証されるべきでしょうか。
実アプリケーション開発では、コンストラクタやメソッドに引き渡される値は、通常は外部から渡されます(例えばWebアプリケーションだったらユーザーが入力)。外部からどのような値が渡されるかは不明なため、その値によって不変条件や事前条件に違反することがないように、当該クラスが呼び出されるよりも前の段階で、バリデーションという仕組みで検証するケースが大半です。
それではバリデーションさえあれば、それぞれのクラスでは不変条件や事前条件の検証は一切必要ないかと言うと、必ずしもそうとは言い切れません。例えば前項のCalculatorクラスが、あるアプリケーション専用のクラスであり、必ず呼び出される前にバリデーションが行われる保証がある場合は、不変条件や事前条件を埋め込む必要はないかもしれません。逆にCalculatorクラスは独立性が高く、別のアプリケーションでも汎用的に使われる可能性がある、というケースも考えられます。そのようなケースでは、「クラスの責務」として不変条件や事前条件の検証ロジックを内部に埋め込むべきです。
例えばWebアプリケーションの処理シーケンスの中では、バリデーションおよび不変条件・事前条件は、以下のような場所に埋め込まれることになるでしょう。

【図19-2-2】Webアプリケーションにおける不変条件や事前条件
image.png

それでは「クラスの責務として検証ロジックを内部に埋め込むべきだ」という判断がなされた場合こそ、assert文の出番ではないでしょうか。確かにそうかもしれませんが、実際の開発ではassert文ではなく、if文と例外による検証処理が行われるケースが大半です。
例えば前項で登場したCalculatorクラスのadd()メソッドは、if文と例外(IllegalArgumentException)を用いて、以下のように事前条件を検証することができます。

snippet (pro.kensait.java.basic.lsn_19_2_4.Calculator3)
public int add(int param1, int param2) {
    if (! (0 <= param1 && param1 <= 5))
        throw new IllegalArgumentException("param1 is wrong value");
    if (! (0 <= param2 && param2 <= 5))
        throw new IllegalArgumentException("param2 is wrong value");
    int result = initValue + param1 * param2;
    return result;
}

if文には検証エラーになるための条件式を指定するため、必然的にassert文に指定した条件式を、論理的に否定した形になります。このようにif文と例外を使った検証の方が、本番稼働時にも適用され、統一感のあるエラーハンドリングも可能になるため、実開発では利用されるケースが多いでしょう。

事後条件の実際

事後条件は、どのように検証されるべきでしょうか。
事後条件は戻り値に対する条件ですが、メソッド引数や外部環境によって戻り値の取りえる値は大きく変わるため、メソッド内での検証には限界があります。従って事後条件の検証は、メソッド内に埋め込むのではなく、単体テストを行うとき、当該クラスの呼び出し元となるテストクラスで行う、というのが一般的だと考えられます。
そのような場合assert文を使うことも可能ですが、単体テストではそれに特化したJUnitなどのテスティングフレームワークを使った方が効率的に検証できるため、やはりここでもassert文の用途は限定的になるでしょう。

このチャプターで学んだこと

このチャプターでは、以下のことを学びました。

  1. ユーザー定義例外を作成する方法について。
  2. 例外チェーンの仕組みと根本原因の調べ方について。
  3. リソース自動クローズの仕組みについて。
  4. assert文によるアサーションと不変条件や事前条件のあり方について。

Discussion