assertenforce

前の2章では、プログラムの正確性を確保するために例外とscope文がどのように使用されるかを説明した。assertは、プログラムが基づいている特定の仮定が有効であることを保証することで、同じ目的を達成するためのもう1つの強力なツールだ。

例外をスローすべきか、assertを呼び出すべきかを判断するのが難しい場合もある。以下の例では、特に理由を説明せずに、すべてassertを使用する。この違いについては、この章の後半で説明する。

必ずしも明らかではないが、プログラムには仮定がたくさんある。例えば、次の関数は、2つのageパラメータが両方とも0以上であることを仮定して記述されている。

double averageAge(double first, double second) {
    return (first + second) / 2;
}
D

プログラムで負の年齢値が指定されることは決してないかもしれないが、この関数は平均値を計算し、その値はプログラム内で気づかれることなく使用され、プログラムが誤ったデータで実行され続ける結果になる可能性がある。

別の例として、次の関数は、常に"sing"または"dance"の2つのコマンドで呼び出されることを前提としている。

void applyCommand(string command) {
    if (command == "sing") {
        robotSing();

    } else {
        robotDance();
    }
}
D

この仮定のため、robotDance()関数は、"sing"以外のすべてのコマンドに対して、有効か無効かに関係なく呼び出される。

このような仮定がプログラマーの頭の中だけにある場合、プログラムは誤って動作してしまう可能性がある。assert文は、仮定をチェックし、それが有効でない場合はプログラムを直ちに終了する。

構文

assertは2つの方法で使用できる:

assert(logical_expression);
assert(logical_expression, message);
D

論理式は、プログラムに関する仮定を表す。assertは、その式を評価して、その仮定が正しいかどうかを検証する。論理式の値がtrueの場合、その仮定は正しいとみなされる。それ以外の場合、その仮定は誤りであり、AssertErrorがスローされる。

その名前が示すとおり、この例外はErrorから継承されており、例外の章で説明したように、Errorから継承された例外は決してキャッチしてはならない。無効な仮定の下でプログラムを継続するのではなく、プログラムを直ちに終了することが重要だ。

上記のaverageAge()の2つの暗黙の前提は、次の関数のように2つのassert呼び出しで明示的に表現することができる。

double averageAge(double first, double second) {
    assert(first >= 0);
    assert(second >= 0);

    return (first + second) / 2;
}

void main() {
    auto result = averageAge(-1, 10);
}

これらのassertチェックは、"両方の年齢が0以上である"という仮定を意味する。また、"この関数は、両方の年齢が0以上である場合にのみ正しく動作する"という意味とも解釈できる。

assertは、その仮定を検証し、仮定が成立しない場合はAssertErrorでプログラムを終了する:

core.exception.AssertError@deneme(2): Assertionの失敗
Undefined

メッセージ内の@文字以降の部分は、assertチェックが失敗したソースファイルと行番号を示している。上記の出力によると、失敗したassertは、ファイルdeneme.dの2行目にある。

assertの別の構文では、assertチェックが失敗した際にカスタムメッセージを表示することができる:

assert(first >= 0, "Age cannot be negative.");
D

出力:

core.exception.AssertError@deneme.d(2): 年齢はマイナスになることはない。
Undefined

プログラムがコードパスに入ることは不可能だと考えられる場合がある。そのような場合、assertチェックを失敗させる論理式として、リテラルfalseを使用するのが一般的だ。例えば、applyCommand()関数が"sing"および"dance"以外のコマンドで呼び出されることは決してないことを示し、そのような可能性を防ぐために、assert(false)不可能な分岐に挿入することができる。

void applyCommand(string command) {
    if (command == "sing") {
        robotSing();

    } else if (command == "dance") {
        robotDance();

    } else {
        assert(false);
    }
}
D

この関数は、それが認識する2つのコマンドでのみ動作することが保証される。(注釈:ここでは、final switchを使用することも可能だ。)

static assert

assertチェックはプログラムの正しい実行を確認するためのもので、プログラムが実際に実行されている際に適用される。プログラムの構造に関する他のチェックもあり、これらはコンパイル時に適用できる。

static assertは、コンパイル時に適用されるassertの対応機能だ。この機能の利点は、正しく実行されないプログラムがコンパイルされないことだ。当然のことながら、論理式はコンパイル時に評価できる必要がある。

例えば、メニューのタイトルが幅の制限のある出力デバイスに出力されると仮定すると、次のstatic assertにより、そのタイトルが制限幅を超えることは決してない。

enum dstring menuTitle = "Command Menu";
static assert(menuTitle.length <= 16);
D

文字列は、コンパイル時にその長さを評価できるように、enumと定義されていることに注意。

プログラマーがタイトルをより説明的なものに変更したと仮定しよう:

enum dstring menuTitle = "Directional Commands Menu";
static assert(menuTitle.length <= 16);
D

static assertのチェックにより、プログラムのコンパイルが阻止される:

エラー: static assert  (25u <= 16u)はfalse
Undefined

これにより、プログラマーは出力デバイスの制限を思い出させられる。

static assertテンプレートで使用するとさらに有用だ。テンプレートについては後述する。

assert 絶対に真であっても

"絶対に真である"と強調するのは、プログラムに関する仮定が偽であることはそもそも想定されていないからだ。プログラムのエラーの多くは、絶対に真であると仮定された仮定によって引き起こされる。

そのため、不必要だと思われる場合でも、assertチェックを活用しよう。指定された年の各月の日数を返す次の関数を見てみよう。

int[] monthDays(int year) {
    int[] days = [
        31, februaryDays(year),
        31, 30, 31, 30, 31, 31, 30, 31, 30, 31
    ];

    assert((sum(days) == 365) ||
           (sum(days) == 366));

    return days;
}
D

assertのチェックは、関数は当然365または366を返すので、不要に見えるかもしれない。しかし、これらのチェックは、februaryDays()関数内でも潜在的なミスを防ぐ役割を果たしている。例えば、februaryDays()が30を返した場合、プログラムは終了してしまう。

もう1つの不要に見えるチェックは、スライスの長さが常に12になることを保証する:

assert(days.length == 12);
D

これにより、意図せずにスライスから要素が削除されたり、要素が追加されたりすることも検出される。このようなチェックは、プログラムの正確性を確保するための重要なツールだ。

assertは、後で説明するユニットテストや 契約プログラミングでも使用される基本的なツールでもある。

値も副作用もない

式は値を生成したり、副作用を引き起こしたりすることをこれまで見てきた。assertチェックには値はなく、副作用も生じない

D言語では、論理式の評価は副作用を持ってはならないと規定されている。assertは、プログラムの状態をただ受動的に観察する存在でなければならない。

assertチェックの無効化

assertはプログラムの正確性に関するものなので、プログラムが十分にテストされた後は不要であると見なすことができる。さらに、assertチェックは値を生成せず、副作用も持たないため、プログラムから削除しても何の影響もない。

コンパイラスイッチ-releaseは、assertチェックをプログラムに含めていないかのように無視する:

dmd deneme.d -release
Bash

これにより、assertチェックの潜在的に遅い論理式を評価しないことで、プログラムの実行速度を向上させることができる。

例外として、論理式としてリテラルfalse(または0)を含むassertチェックは、‑releaseでコンパイルした場合でも無効にはならない。これは、assert(false)は、コードのブロックが決して到達しないことを保証するためのものであり、‑releaseコンパイルの場合でもそれを防止すべきだからだ。

enforce例外をスローする場合

予期しない状況すべてがプログラムのエラーを示すわけではない。プログラムでは、予期しない入力や予期しない環境状態が発生する場合もある。例えば、ユーザーが入力したデータは、assertチェックで検証すべきではない。無効なデータは、プログラム自体の正確性とは何の関係もないからだ。このような場合、これまでのプログラムで行ってきたように、例外をスローするのが適切である。

std.exception.enforceは、例外をスローする便利な方法だ。例えば、特定の条件が満たされない場合に例外をスローしなければならない場合を考えてみよう。

if (count < 3) {
    throw new Exception("Must be at least 3.");
}
D

enforce()は、条件チェックとthrow文をラップするものだ。以下のコードは、前のコードと同等である。

import std.exception;
// ...
    enforce(count >= 3, "Must be at least 3.");
D

if文と比較して、論理式が否定されていることに注意。これで、何が強制されているかが明確になった。

使い方

assertは、プログラマーのエラーをキャッチするためのものだ。上記の関数monthDays()および変数menuTitleassertが保護する条件は、すべてプログラマーのミスに関するものだ。

assertチェックに頼るべきか、例外をスローすべきかを判断するのが難しい場合もある。その判断は、予期しない状況がプログラムのコーディングの問題によるものかどうかによって行うべきだ。

それ以外の場合は、タスクを実行できないときにプログラムは例外をスローしなければならない。enforce()は、例外をスローするときに表現力豊かで便利だ。

もう1つ考慮すべき点は、予期しない状況が何らかの方法で改善できるかどうかだ。入力データに関する問題についてエラーメッセージを表示するなどの特別な処理もできない場合は、例外をスローするのが適切だ。そうすることで、例外をスローしたコードの呼び出し元は、その例外をキャッチして、エラー状態から回復するための特別な処理を行うことができる。

演習
  1. 次のプログラムには、複数のassertチェックが含まれている。プログラムをコンパイルして実行し、assertチェックによって検出されるバグを発見してくれ。

    このプログラムは、ユーザーから開始時刻と継続時間を受け取り、開始時刻に継続時間を加算して終了時刻を計算する:

    06:09から10時間8分後は16:17である。

    struct型を定義することで、この問題をよりすっきりとした形で記述できることに注意。このプログラムについては、後の章で再び取り上げる。

    import std.stdio;
    import std.string;
    import std.exception;
    
    /* メッセージを表示した後、
     * 時刻を時と分として読み込む。 */
    void readTime(string message,
                  out int hour,
                  out int minute) {
        write(message, "? (HH:MM) ");
    
        readf(" %s:%s", &hour, &minute);
    
        enforce((hour >= 0) && (hour <= 23) &&
                (minute >= 0) && (minute <= 59),
                "Invalid time!");
    }
    
    /* 時刻を文字列形式で返す。 */
    string timeToString(int hour, int minute) {
        assert((hour >= 0) && (hour <= 23));
        assert((minute >= 0) && (minute <= 59));
    
        return format("%02s:%02s", hour, minute);
    }
    
    /* 開始時刻に継続時間を加算し、
     * その結果を3番目のパラメータのペアとして返す。 */
    void addDuration(int startHour, int startMinute,
                     int durationHour, int durationMinute,
                     out int resultHour, out int resultMinute) {
        resultHour = startHour + durationHour;
        resultMinute = startMinute + durationMinute;
    
        if (resultMinute > 59) {
            ++resultHour;
        }
    }
    
    void main() {
        int startHour;
        int startMinute;
        readTime("Start time", startMinute, startHour);
    
        int durationHour;
        int durationMinute;
        readTime("Duration", durationHour, durationMinute);
    
        int endHour;
        int endMinute;
        addDuration(startHour, startMinute,
                    durationHour, durationMinute,
                    endHour, endMinute);
    
        writefln("%s hours and %s minutes after %s is %s.",
                 durationHour, durationMinute,
                 timeToString(startHour, startMinute),
                 timeToString(endHour, endMinute));
    }

    プログラムを実行し、開始時刻として06:09、継続時間として1:2を入力しよう。プログラムが正常に終了することを確認しよう。

    注釈:出力に問題があることに気付くかもしれない。この問題は、assertのチェックによってすぐに発見できるので、ここでは無視しよう。

  2. 今回は、06:0915:2を入力しよう。プログラムがAssertErrorで終了することを確認しよう。アサートメッセージで示されたプログラムの行に移動し、どのassertチェックが失敗したかを確認しよう。この特定の失敗の原因を特定するには、時間がかかる場合がある。
  3. 06:0920:0を入力し、同じassertのチェックが依然として失敗することを確認し、そのバグも修正しよう。
  4. プログラムを修正して、時間を12時間形式で"am"または"pm"の表示付きで出力するようにする。