大学でC++の演習が始まったが例外には触れないようなので触れさせる

例外は本当に便利な機能だ。戻り値でエラーを通知するC的な関数を排除して、戻り値は関数の計算結果のために予約しつつエラーを通知できる。大学ではC++をやるが、例外を使わないC++C++ではなくBetter Cだ。とは言え、例外にはデメリットもある。メリットとデメリットのバランスを調整してうまく使えばC++erを名乗れるようになるだろう (知らんけど)。

最初に言っておくと私はC++の規格書を読んでいない。そのため、規格書を通読した人間より多くの間違いを書くことがあるのだが、マサカリを投げずに優しく指摘してくれたら誠実に対応したいと思う。

この記事の対象読者は大学でC++の講義や演習が始まったけど、それらで例外を習わないことが確定している大学生だ。

例外の構文

まず例外の構文を知ろう。例外についての解説はそのあとに行う。解説は実際のコードを交えて進めるので解説を理解するために例外の構文を知ることは必要不可欠だ。

例外を投げる

例外を投げるにはthrow式を用いる。throw式には二つの意味がある。一つは新しい例外を投げる意味、もう一つは現在処理中の例外を投げなおす意味だ。後者はcatch節内でしか使うことができない。

新しく例外を投げる場合、throw式には例外オブジェクトを指定する。例外オブジェクトは完全型でCopyConstructibleかつ Destructibleな静的型である必要がある。

throw std::runtime_error("some error!");

処理中の例外を投げなおす場合、throw式には例外オブジェクトを指定しない。

try { /* ... */ }
catch(...) {
    throw;
}

例外をキャッチする

tryブロックがフライングして先に登場してしまった。お察しの通り、投げられた例外をキャッチするのはtryブロックだ。例外をキャッチするにはtryの直後に続く複文で例外が送出される必要がある。tryブロックは一つ以上の例外ハンドラーを伴う。例外ハンドラーはcatch(処理する例外オブジェクトの型)の形で記述される。すべての例外オブジェクトをキャッチする例外ハンドラーもcatch(...)と書くことができる。

try{ throw_exception(); }
catch(std::exception&) { /* do something */ } // std::exceptionとその派生型のみをハンドルする
cathc(...) {}                                 // すべての例外をハンドルする

例外が投げられた場合、catch節の順番通りに例外オブジェクトとマッチするかチェックされるため、制約の厳しいcatch節は緩いcatch節より前に書く必要がある。

try{ throw_exception(); }
catch(...) {}
catch(std::exception&) { /* この副文は絶対に実行されない */ }

tryブロックは文なので関数本体をマルっとtryブロックにすることができる。

void hoge() try{}
catch(...){}

例外の基本

例外というのはエラーが発生したときに投げるものだ。例えばオーバーフローを検出して符号なし整数同士を足し算する関数を書きたいが、計算結果は当然戻り値で受け取りたいし、オーバーフローがあったかどうかも当然知りたい。そんなときに例外は役に立つ。試しにその関数を書いてみよう。

#include <type_traits>

template<class Unsigned, std::enable_if_t<std::is_unsigned_v<Unsigned>>* = nullptr>
auto add(Unsigned a, Unsigned b) {
    using u_t = Unsigned;
    return std::numeric_limits<u_t>::max() - a >= b
        ? a + b
        : throw std::overflow_error("result is out of range");
}

この関数ではオーバーフローが発生する場合は例外を投げてそのことを通知し、オーバーフローが起きない場合はそのまま和を返す。比較のためにCでもオーバーフロー検出付きの加算関数を書こう。

#include <stdbool.h>
#include <limits.h>
bool add(unsigned a, unsigned b, unsigned* const result) {
    return UINT_MAX - a >= b
        ? *result = a + b, true
        : false;
}

ついでに両関数を実際に使って例外の便利さとC的関数でエラー処理する苦痛を実感しよう。加算して値を標準出力に出力する。利用側コードは簡単のためC++unsigned int型が32bitと仮定して書く。[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

#include <climits>
#include <type_traits>
#include <iostream>

template<class Unsigned, std::enable_if_t<std::is_unsigned_v<Unsigned>>* = nullptr>
auto add(Unsigned a, Unsigned b) {
    using u_t = Unsigned;
    return std::numeric_limits<u_t>::max() - a >= b
        ? a + b
        : throw std::overflow_error("result is out of range");
}
bool add(unsigned a, unsigned b, unsigned* const result) {
    return UINT_MAX - a >= b
        ? *result = a + b, true
        : false;
}

int main() {
    {   // C-like
        unsigned result;
        if(!add(0xFFFF'FF0E, 2, &result))
            std::cout << "overflow!!!!\n";
        else std::cout << result << '\n';
        
        if(!add(0xFFFF'FFFE, 2, &result))
            std::cout << "overflow!!!!\n";
        else std::cout << result << '\n';
    }
    try {   // C++-like
        std::cout << add(0xFFFF'FF0Eu, 2u) << '\n';
        std::cout << add(0xFFFF'FFFEu, 2u) << '\n';
    } catch(std::runtime_error& e) { std::cout << e.what() << '\n'; }
}

好奇心が旺盛な読者諸君ならC++-likeadd関数の実引数を色々と値を変えてみて実行しているだろう。例えば、現状のコードでは最初のadd呼び出しは例外を投げないが、30行目と31行目の文を入れ替えて実行してみると出力されるものが変わってくるはずだ。

例外が投げられたらそれ以降のtry内の文が実行されないことに気づいただろうか。C++では例外が投げられるとスタックをtryブロックの先頭まで巻き戻す。時間が巻き戻るわけではないので、すでに実行された副作用などに影響を受けた状態が復元するわけではないが、tryブロック内で積まれたスタックは元に戻る。スタックにあったオブジェクトのデストラクタは確実に呼ばれるので安心してほしい。スタックがtryブロックの先頭にまで戻ったら次は投げられた例外オブジェクトにマッチする例外ハンドラーが前から順番に検索される。最初にマッチした例外ハンドラーが実行されて、制御はtryブロックの次の段階に移る。ここでもしマッチする例外ハンドラーがなければ、例外は外側のスコープに伝播する。例外が最終的にどのcatch節でも処理されなかった場合std::terminateが呼ばれプログラムは終了する。例えば以下のようなプログラムは例外を処理しないためstd::terminateが呼ばれる。

int main() { throw 1; }

処理しなかった場合にプログラムが絶対に終了してしまうというのは例外のデメリットの一つだが、そんなものは処理すれば関係なくなるのでこの際どうでもいい。より深刻なデメリットにはスタック巻き戻しや、例外オブジェクトのコピーによって発生する時間的、空間的損失だ。例外を使うことはプログラムの実行速度を下げ、少しのスタックと未既定の記憶領域を消費する。気になるのは例外を使うことによってどのくらい速度が落ちるのかだが、これは例外が送出される頻度と処理系に依存する。

では早速エラーの検出を例外を使うか、if文を使うかでどちらがどのくらい速くなるのか例外の送出頻度ごとに計測しよう。処理系はGCC8.3.0にしよう。wandboxで気軽にコードを実行できるのはとてもいい時代になったものだ。実行結果は上から順に100%、50%、33%、10%、1%、0.1%、0.01%、0.01%、0%の確率でエラーが起こる場合の条件分岐によるエラーチェックが例外を使う場合に比べて何倍速いかを表している。高い確率でエラーが起こる場合、例外を使うとifによるエラーチェックより1000倍近く遅くなっている。逆にエラーがほとんど起こらない場合、例外を使う場合の方が若干早くなっている。このように例外は大量に送出すると非常に遅くなるので注意が必要だ。

wandbox.org

診断のためのライブラリ

標準ライブラリでは例外オブジェクトやそれに関連する様々な関数やクラスが定義されている。stdexceptのあたりを見ておけばひとまず困ることはないだろう。

ja.cppreference.com

例外仕様

関数が例外を投げるか投げないか投げるとしたらどんな例外を投げるかは関数の宣言から分かる場合がある。

古の方法 (C++17で削除)

例外を投げることを指定する古の方法はC++17で削除された。古のコードを読む必要がある場合だけ見てほしい。古のコードを読まない場合はココを飛ばして次のクールでスマートな最新の方法に飛ぼう。

古の方法で関数が例外を投げることを伝えるのは非常に苦労する。まず関数が投げる可能性がある例外をすべてリストアップする。これはまぎれもなくすべてだ。関数内でメモリアロケーションをする若しくはメモリアロケーションを行う関数を呼び出している場合はstd::bad_allocが投げられる可能性がある。STLのコンテナにアクセスしてatメソッドを使えばstd::out_of_rangeが投げられる可能性がある。std::wstring_convert::from_bytesstd::wstring_convert::to_bytesを呼び出していたらstd::range_errorを投げる可能性がある。まさにありとあらゆる可能性を考えて全ての例外オブジェクトの型をthrow()の括弧の中に書く必要がある。が、投げるオブジェクトの基底クラスを書けば実は問題ない。std::exceptionの派生クラスしか例外オブジェクトになり得ないような関数であれば、throw(std::exception)で事足りるので安心だ。

古の方法では例外を投げない場合はとても簡単だ。関数の引数リストの後ろ (にある非静的メンバ関数のcv修飾の後ろ) にthrow()と書くだけだ。ただし、クールでスマートな最新の方法はもっと簡単だ。

// std::exceptionとstd::stringとそれらの派生クラスだけを投げる(C++17で削除された例外仕様)
// 他の例外を投げた場合、たとえ適切なcatch節があったとしても実行時エラー
void hoge() throw(std::exception, std::string);

// 例外を投げない
// 例外を投げない古い例外仕様まだ削除されていないが、C++20で削除される予定だ (C++11で非推奨)
void fuga() throw();

クールでスマートな最新の方法

実際のところ、throw(types...)を使う古い例外仕様はどのコンパイラもまともに実装しなかった。例外を投げないことを指定するthrow()だけは便利に使うことができたため、2019年現在も非推奨ながら生き残っている。

古の時代は終わった。令和も始まろうかという現代にthrow(types...)は古いのだ。新しい例外仕様にはnoexcept指定子を使う。ちなみに、C++にはnoexcept演算子もある。これらは意味が違う。

noexcept指定子は関数の引数リストの後ろ (にある非静的メンバ関数のcv修飾の後ろ) に書く。式を指定しない場合はその関数は例外を投げない (投げた場合はstd::terminateが呼ばれる)。noexcept指定子には式を指定することも出来る。その式がtrueに評価されるとき関数は如何なる例外も投げることはない (投げた場合はstd::terminateが呼ばれる)。

// noexceptに式を指定しない場合
void hoge() noexcept;

// noexceptに式を指定する場合
void fuga() noexcept(true);
void piyo() noexcept(false); // 例外を投げるかもしれない。
void nya() noexcept(1 == 1); // 1==1はtrueなのでnya関数は例外を投げない。

noexcept演算子は式が例外を投げる可能性があるかどうかをbool型の定数値にしてくれる。例外を投げない場合にtrue、それ以外の場合でfalseに評価される。

void hoge() noexcept;
void fuga() noexcept(false);
void piyo();

static_assert(noexcept(hoge()) == true);
static_assert(noexcept(fuga()) == false);
static_assert(noexcept(piyo()) == false);

例外を投げるべきときと投げるべきでないとき

例外を投げるべきかどうかの判断は非常に難しい。例外を投げるかどうか迷ったときはスタックを巻き戻したいかどうかを考えると良い。引数が不正だった とか、むしゃくしゃしてやった などとエラーメッセージに書くために例外を投げることは避けるべきだ。引数が不正な場合はassertでもいいかもしれない。もしくは例外も投げず、assertもせず、単にログを出力して後で見たときにわかればいいだけかもしれない。例外を投げるのはもっと例外的な状況に限るべきだ。リソースの確保に失敗した とか、関数を実行するための事前条件が満たされていなかった とか、実行時に直ちに問題になる状況では例外を投げるのは適切であることが多い。

例外を投げるべきでないという判断は非常に簡単だ。例外を投げることはスタックを巻き戻すことだ。もし関数がfor文の中で使われるようなものなら、どう考えても例外を投げるべきではない。STLat()メソッドは例外を投げる可能性があるが、その関数を呼び出す前に例外を投げるかどうかを実行時に判断できる。どうしても例外を投げる必要があるのにその関数がループの中で呼ばれそうならば、別の手段で例外を投げない事前条件を満たしているか判断できるようにするべきだ。

例外を投げるべきでない関数は他にもある。デストラクタのようなリソースを解放する種類の関数だ。例外を投げるとスタックが巻き戻り、自動オブジェクトのデストラクタが呼ばれる。スタックの巻き戻し中にデストラクタで例外が投げられ、それがデストラクタ内でキャッチされなければ漏れなくstd::terminateが呼ばれる。また、どうしてもデストラクタで例外を投げて、それをデストラクタの外に伝播させたい場合は、デストラクタの例外指定は明示的にnoexcept(false)にしなければならない。また、std::terminateが呼ばれるのを回避したいならばstd::uncaught_exceptions()で他の例外が処理中でないことを確かめなければならない。他の例外が処理中ならばデストラクタで起きたエラーは隠蔽するか別の方法で通知すべきだ。