[C++] 例外送出からキャッチまでのあいだ

C++throw式はどんな型のオブジェクトであっても投げることができます。この是非は置いておいて、あるthrow式に対して適切にcatch節(例外ハンドラ)が用意されている場合に、呼び出される例外ハンドラは厳密にどのように決まるのでしょうか?なんとなくthrow式の引数と同じような型ならマッチする気はしますが、そのルールは関数のオーバーロード解決時のものとは異なる気がします。

catch節での型マッチング

例外オブジェクトの型をEとすると、対応する例外ハンドラの決定はそのcatch節の宣言型(以下、ハンドラの型)とEをマッチングすることによって行われます。マッチングとはオーバーロード解決のような複雑な処理とは異なり、単純な型の比較によって行われます。

マッチングの前にまず、ハンドラの型が配列型(T[])もしくは関数型(R(Args...))である場合そのハンドラの型はdecayされ、Tの配列型はTのポインタ型(T[] -> T*)、関数型は関数ポインタ型(R(Args...) -> R(*)(Args...))としてマッチングされます。

その上で、例外オブジェクトの型Eとハンドラの型は次のいずれかの場合にマッチしているとみなされます

  • ハンドラの型がcv Tもしくはcv T&で、ETが(トップレベルのCV修飾を無視して)同じ型の場合
  • ハンドラの型がcv Tもしくはcv T&で、TEの曖昧でないpublicな基底クラスである場合
    • すなわち、std::derived_from<E, T>trueとなる場合
  • ハンドラの型がcv Tもしくはconst T&で、T, Eが共にポインタ/メンバポインタ型かつ、Eが次のどれか(1つ以上)の変換によってTに変換可能である場合
    • private/protectedもしくは曖昧な(基底)クラスへの変換を含まない、標準ポインタ変換
    • 関数ポインタ変換
    • 修飾変換
  • ハンドラの型がcv Tもしくはconst T&で、Tが共にポインタ/メンバポインタ型かつ、Estd::nullptr_tの場合
  • ハンドラの型が...で宣言されている場合

ここでのcvとは省略可能なconst/volatile修飾を表しています。とはいえポインタや参照型の参照先に対するもの(非トップレベルのCV修飾)以外にvolatileが意味を持つとは思えないので、ここではconstだと考えればいいでしょう。

トップレベルのCV修飾はポインタ型の場合T const * const(=const T * const)のような宣言における*の右側にくるCV修飾がトップレベルのCV修飾となります。したがって、1つ目の条件では例えば、int*int* constが同じ型とみなされ、int*const int*は同じ型だとはみなされないことになります。参照型ではトップレベルのCV修飾を行えないためint&const int&は異なる型とみなされ(ただし後述する理由によりこのマッチングは起こらない)、その他の型の場合はCV修飾があればそれがトップレベルのものなので、intconst intは同じ型とみなされます。これらのマッチングは、ハンドラの型で例外オブジェクトの型を受けることを考えると納得できると思います。

3つ目の条件における各種ポインタ変換は前提条件を満たした上で、おおよそE -> Tの暗黙変換が通る場合と言い換えることができます(基底クラスへの変換は制限がありますが)。ここではそのようにお茶を濁して複雑な暗黙変換の世界へは深入りしません、cppreferenceの暗黙変換ページをご参照ください・・・

そして、このようなマッチングは、実行時にtryブロック中で例外が投げられた場合にそれに付属するcatch節に対して実行時に行われ、例外ハンドラに対するマッチングの順序はソースコード上で現れている順番通りに行われます。このため、例外ハンドラの順番によっては必ずしも最適なマッチングとならない場合や決して呼ばれない例外ハンドラが存在することになりますが、例えコンパイル時にそれがわかっていたとしても並べ替えられたりはしません(コンパイラが優しいと警告はしてくれるかもしれません)。

#include <stdexcept>
#include <exception>

void f() noexcept(false);

int main() {
  try {
    f();
  }
  catch (int) {}  // #1
  catch (const char*) {}  // #2
  catch (const std::exception&) {}    // #3
  catch (const std::logic_error&) {}  // #4
}

この例の場合、f()から例外が投げられると対応する例外ハンドラのマッチングは#1 -> #2 -> #3 -> #4の順で行われます。そして、#4のハンドラは決して呼ばれることがありません。

この関数f()が別の翻訳単位(特にビルド済のdll/so)で定義されている場合、ここから投げられる例外は予測不可能となります。したがって、例外ハンドラのマッチングは実行時にしか行うことができず、実行時に(しかも例外発生時というクリティカルな状況で)それを行うためにオーバーロード解決のような複雑な手順をとることも憚られるため、上記のような比較的簡易なマッチングによって例外ハンドラを決定しているのだと思われます。

例外ハンドラで使えない型

例外ハンドラの型として次のものを指定するとコンパイルエラーとなります

  • 不完全型
  • 抽象クラス型
  • 右辺値参照型
  • 不完全型へのポインタ/参照
    • cv void*を除く
void f() noexcept(false);

// 不完全型
struct S;

// 抽象クラス型
struct A {
  virtual void f() = 0;
  virtual ~A() = default;
};

int main() {
  try {
    f();
  }
  catch (S) {}        // ng
  catch (A) {}        // ng
  catch (int&&) {}    // ng
  catch (const S*) {} // ng
  catch (const S&) {} // ng
  catch (A*) {} // ok
  catch (A&) {} // ok
}

抽象クラスは値として指定できないだけで、ポインタ/参照であれば指定可能です。

例外オブジェクトの型

ハンドラの型のマッチング方法は分かりましたが、それを考えようとするともう一つよく分からないことが出てきます。それは、例外オブジェクトの型Eがどう決まるのかです。それは当然throw式のオペランドの型から決まりますが、オペランドの型そのものではなく少し減衰されます。

例外オブジェクトの型Eは、throw式のオペランドの型(コンパイル時の静的型)をTとします。まず、このTは式の静的型であり値カテゴリの情報を含まないため参照型ではありません。そして、このTは次のような変換を受けます

  • TのトップレベルのCV修飾を除去する
  • Tが型Uの配列型もしくは関数型Uである場合、TUのポインタ型へdecayする

この時、Eが次のいずれかの型に該当する場合、コンパイルエラーとなります

  • 不完全型
  • 抽象クラス型
  • 不完全型へのポインタ
    • cv void*を除く

このようにして決定された型Eが例外オブジェクトの型となり、例外ハンドラのマッチングにおいてはこのEがそのまま使用されます。

変換をまとめると次のようになります

throw式のオペランドの型 例外オブジェクトの型
T T
const T T
T* T*
T* const T*
T const * T const *
T const * const T const *
R(Args...) R(*)(Args...)
T[] T*

繰り返しますが、throw式のオペランドの型を取得する時に参照(正確には値カテゴリ)は考慮されないため、throw式に参照を渡しても参照をスローすることはできません。それでもあえてthrow式のオペランド型で参照を考慮するとすると、例外オブジェクトの型は次のようになります

throw式のオペランドの型 例外オブジェクトの型
T& T
T&& T
const T& T

例外オブジェクトの状態

例外オブジェクトの型と例外ハンドラのマッチングの雰囲気をつかむと、throwprvalueを投げた時でもTに対してT&const T&で受けられる事に気づきます。C++の値カテゴリのルールから、const T&で受けられるのはさほど不思議ではないかもしれませんが、T&で受けられるのは少し奇妙です。

すると不思議になってくるのは、例外オブジェクトの状態、特に値カテゴリについてです。例外オブジェクトとはどこにいてどのような状態にあるのでしょうか?また、例外ハンドラでそれをキャッチしたとき、キャッチしているものは一体何なのでしょうか?

例外が投げられると、スタックの巻き戻しが始まり、例外オブジェクトをキャッチ可能な例外ハンドラに到達(この決定は先程の型マッチングによる)するまでスタックが巻き戻され、その間に存在したオブジェクトを破棄していきます。例外オブジェクトはこのスタック巻き戻しの間生存している必要があり、なおかつ例外が投げられたコンテキストにおけるスタックの深さは実行時に決まり、どこで例外がハンドルされるかも分かりません。また、どこかのハンドラでキャッチされた時でも、再スローされる可能性があるため、ハンドラとは無関係に生存している必要があります。

規格では、例外オブジェクトを配置するメモリ領域がどう確保されるかは未規定とされており、Itanium C++ ABI(多くの非Windows環境)ではヒープ領域、Windows(MSVC) ABIではスタック領域があてがわれるようです。

例外オブジェクトは、そのように確保された領域にthrow式のオペランドからコピー初期化されます。従って、throw式で一時オブジェクトやローカルオブジェクト(への参照)を投げた時でも、一旦通常のスタックとは隔離された領域にコピーされています。

例外オブジェクト初期化時のコピー初期化は、例外オブジェクトをex、その型をEthrow式のオペランド(式)をopとすると、次のような初期化と同じです。

E exobj = op;

前述のように、ここでのEは必ず非参照型になります。

従って、スタック巻き戻しが起きている間例外オブジェクトはそれとは無関係な領域でグローバルオブジェクトであるかのように生存しています。ただし、例外オブジェクトは一時オブジェクトの一種とされます。

例外ハンドラ引数の初期化

例外オブジェクトは型マッチングでマッチした最も近くにあるハンドラによって捕捉されます。捕捉するハンドラが確定した後、ハンドラの型が...ではなくそのハンドラの引数(catch(T arg)arg)が存在する場合、それは例外オブジェクトから初期化されます。

例外オブジェクトの型をE、ハンドラの型をcv Tもしくはcv T&、ハンドラの引数をargとすると、次のどちらかの経路で例外ハンドラの引数は初期化されます

  • TEの基底クラスの場合、argは例外オブジェクトのTに対応する基底クラスのサブオブジェクトを指定する左辺値からコピー初期化される
  • それ以外の場合、argは例外オブジェクトの左辺値からコピー初期化される

いずれにしてもコピー初期化されますが、その際に例外オブジェクトはいつも左辺値として扱われます。これは他の一時オブジェクトとは異なる扱いとなります。

コピー初期化と言いますが、ハンドラの型がcv T&である場合は例外オブジェクトへの参照が初期化されコピーは起こりません。

また、ハンドラの型の制約によって、Eがポインタ型以外の場合はここでは基底クラスへのスライシング以外の暗黙変換は起こりません。Eがポインタ型の場合は基底クラスのポインタへの変換も含めた暗黙変換が起こり得ます。

ここまでを理解すると、例外ハンドラの型として右辺値参照型が禁止されている理由も見えてきます。それは単純に、例外ハンドラが右辺値参照をキャッチすることはあり得ないからです。

コピー省略が起こるところ

Tのオブジェクトのコピー初期化とは、初期化するオブジェクトをobj、初期化式をinitとすると、次のような初期化と同じです

T obj = init;

ここでは、Tが非参照型(特にクラス型)であり、式initの値カテゴリがprvalueである場合にコピー省略がなされます。

例外のスローから例外ハンドラによるキャッチまでの経路では、throw式における例外オブジェクトの構築時にコピー省略が起こる可能性があります。例外オブジェクトの型Eは非参照型なのでthrow式のオペランドprvalueである場合に、例外オブジェクトはコピー省略によってthrow式のオペランドから直接初期化されます。

一方、例外ハンドラの引数を例外オブジェクトから初期化する際には、例外オブジェクトを左辺値としてコピー初期化しようとするため、コピー省略は起こりません。

struct Test {
  Test() {
    std::cout << "call default constructor.\n";
  }

  Test(const Test&) {
    std::cout << "call copy constructor.\n";
  }
};

int main() {
  try {
    throw Test{}; // デフォルトコンストラクタ呼び出しのみ
  }
  catch (Test t)  // コピーコンストラクタ呼び出し
  {
    std::cout << "exception handled.\n";
  }
}

このthrow Test{};Testのローカルオブジェクトを渡すように書き換えると、コピー省略が行われなくなります(throw式のオペランドが左辺値になるため)。

例外オブジェクトの寿命

例外オブジェクトはスタック巻き戻しが起きている間生存しています。どこかの例外ハンドラで例外オブジェクトがキャッチされたとしても、そこから再スローされると同じ例外オブジェクトがスローされます(つまり、再スロー時には例外オブジェクトは破棄されません)。

そんな例外オブジェクトが寿命の終わりを迎えるのは、次のどちらかの場所です

  1. 例外オブジェクトをキャッチした例外ハンドラが再スローせずに終了した場合
    • その例外ハンドラの引数が破棄された直後に、例外オブジェクトは破棄される
  2. 例外オブジェクトを参照するstd::exception_ptrオブジェクトが破棄された時
    • そのデストラクタがリターンする前に、例外オブジェクトは破棄される

ただし、どちらの場合でも必ずしも例外オブジェクトが破棄されるとは限りません。それは例えば、ある例外オブジェクトを参照するstd::exception_ptrが他の場所で生存している場合が該当します。

例外オブジェクトがこのような寿命を持つことから、例外ハンドラで非const参照で例外オブジェクトを受けてそのハンドラから再スローする場合に、例外オブジェクトに変更を加えることができます。

int main() {
  try {
    try {
      throw 20; // 例外オブジェクトはint型、20で初期化
    } catch (int& n) {
      std::cout << "catch ref : " << n << '\n'; // 20が出力される
      n = 30; // nは例外オブジェクトを直接参照している

      throw;  // 再スロー
    }
  } catch (int& n) {
    std::cout << "catch ref : " << n << '\n'; // 30が出力される
  }
}

std::exception_ptr

例外ハンドラの内部では、std::current_exception()によって現在の例外オブジェクトを指すstd::exception_ptrを取得することができます。std::exception_ptrで例外オブジェクトを捕捉しておくと、例外オブジェクトの寿命をその例外ハンドラ(スローから始まるスタック巻き戻しの間)の期間を超えて延長させることができ、また別のスレッドに運び出すことができます。

std::exception_ptrは例外オブジェクトの型を隠蔽したstd::shared_ptrとおおよそ同じような雰囲気で使用することができます。例えば、std::exception_ptrをコピーすると、同じ例外オブジェクトを参照するstd::exception_ptrが増え、どちらかが破棄されてももう片方が生存していれば例外オブジェクトも破棄されません。

とはいえ前述のように、Itanium C++ ABIとWindows(MSVC)の実装では例外オブジェクトを保持しておくための領域を確保する方法が異なります。MSVC実装をはじめとするスタックのどこかに例外オブジェクトを構築する環境の場合、std::exception_ptrは必ずしもポインタのようになっておらず、std::exception_ptr自身の領域内部に例外オブジェクトを保存している場合があります。

そのような環境の場合、std::exception_ptrを作成/コピーするたびに例外オブジェクトがコピーされ、例外オブジェクトは例外ハンドラの終わりでいつも破棄されその領域も解放されます。そのような場合でも、例外オブジェクトの寿命やstd::exception_ptrの扱いなどは一見同様であり、例外オブジェクト周りの仕様はそのような実装を想定されて設計されています。

ちなみに、std::current_exception()は当初から例外オブジェクト領域の自由度を認めるように規定されていたのですが、対になるstd::rethrow_exception()はそうなっていませんでした(例外オブジェクトのコピーを認めていなかった)。これはC++23にて修正され、MSVCをはじめとする処理系の実装が規格準拠の振る舞いとなるようにされました(C++20まではMSVC等の実装が規格違反になっていただけで、C++23で実装方法が変わるわけではない)。

throw式の直接のオペランドから例外ハンドラの引数までの間には、例外オブジェクトの型の決定及び例外ハンドラの型マッチングという2段階のフィルタが挟まっており、これによってスローした値とほぼ同じ型でしかキャッチできなくなっています。

例えば整数型の場合でも、他の場所とは異なり暗黙変換してキャッチするようなことにはならず、投げた直接の型(およびそのCV 参照付きの型)のハンドラでキャッチされます。

int main() {
  try {
    throw 10; // int型の値をスロー
  } catch (short) {
    std::cout << "catch short.\n";
  } catch (unsigned int) {
    std::cout << "catch uint.\n";
  } catch (long long) {
    std::cout << "catch long long.\n";
  } catch (unsigned long long) {
    std::cout << "catch ulong long.\n";
  } catch (int) {
    std::cout << "catch int.\n";  // ここにくる
  }
}

ここで、最後のハンドラの型はintの他に、int&, const int, const int&のどれに変えてもキャッチされます。しかし、それ以外の型でキャッチすることはできません。

これと同様に、prvalue0をスローする場合に暗黙にnullptrに変換されたりしません。

int main() {
  try {
    throw 0;
  } catch (std::nullptr_t) {
    std::cout << "catch nullptr_t.\n";
  } catch (int*) {
    std::cout << "catch int*.\n";
  } catch (int) {
    std::cout << "catch int.\n";  // ここにくる
  }
}

一方、ハンドラの型マッチングで特別扱いされているため、nullptrをスローすると任意のポインタ型でキャッチできます。

int main() {
  try {
    throw nullptr;
  } catch (void*) {
    std::cout << "catch void*.\n";  // ここにくる
  }

  try {
    throw nullptr;
  } catch (int*) {
    std::cout << "catch int*.\n"; // ここにくる
  }

  try {
    throw nullptr;
  } catch (double*) {
    std::cout << "catch double*.\n";  // ここにくる
  }

  try {
    throw nullptr;
  } catch (std::nullptr_t) {
    std::cout << "catch std::nullptr_t.\n"; // ここにくる
  } 
}

同様に、例外オブジェクトの型Tに対してその(publicで曖昧でない)基底クラスDのオブジェクトor参照の場合も特別扱いされているため、自然に受ける事ができます。

int main() {
  try {
    throw std::runtime_error{"test throw"};
  } catch (const std::exception& ex) {
    std::cout << "catch std::exception : " << ex.what() << '\n';
  }
}

ただし、polymorphicな型がスローされているときに、その基底クラスを値でキャッチすると意図通りになりません。特に、ハンドラで抽象クラス型を値で受けようとするとコンパイルエラーになります。

// 抽象クラス型
struct A {
  virtual void f() = 0;
  virtual ~A() = default;
};

struct D : A {
  virtual void f() {
    std::cout << "D::f()\n";
  }
};


int main() {
  try {
    throw std::runtime_error{"test throw"};
  } catch (std::exception ex) {
    std::cout << "catch std::exception : " << ex.what() << '\n';  // what()は空
  }

  try {
    throw D{};
  } catch (A a) { // コンパイルエラー
    std::cout << "catch A \n";
  }
}

余談 : 決して選択されない例外ハンドラ

例外ハンドラの型として配列参照型と関数参照型を指定すると決してキャッチされないと指定されています。実際、例外オブジェクトの型が配列型/配列参照型/関数参照型になることはなく、例外ハンドラの型マッチング時に配列参照/関数参照がマッチングするルールはありません。

ただし、これはGCC(12)ではそうなっていないようです。clangもバージョンによってはセグフォ起こしたりしています。

int main() {
  try {
    int array[1]{1};
    throw array;  
  } catch (const int(&)[1]) {
    std::cout << "catch array ref\n";  // GCC 12はこっち
  } catch (const int*) {
    std::cout << "catch\n"; // clang 17はこっち
  } catch (...) {
    std::cout << "through\n";
  }
}
void f() {}

int main() {
  try {
    throw +f;  
  } catch (void(&)()) {
    std::cout << "catch function ref\n";  // GCC 12はこっち
  } catch (void(*)()) {
    std::cout << "catch\n"; // clang 17はこっち
  } catch (...) {
    std::cout << "through\n";
  }
}

関数ポインタはともかく、配列を投げるときの例外オブジェクトの型はint*に減衰されているため、その要素がきちんと例外オブジェクトに保存されるには規格の規定だけでは足りない気もします。あるいは、例外オブジェクトの型とはハンドラのマッチングのための表層的なものでしかなく、実際の例外オブジェクトの型とは異なるということなのかもしれません。力尽きたので、この謎の真相はどなたかにお任せします・・・

参考文献

この記事のMarkdownソース