😱

C++ の noexcept specifier のちょっとややこしい仕様

2023/03/16に公開

はじめに

C++11 以降の C++ には noexcept specifier[1][2] というものがあります。
関数・メンバ関数の宣言に指定することで、その関数・メンバ関数が例外を送出する可能性があるかどうかをコンパイラに伝えることができます。

これの仕様を正確に理解していなかったために、遭遇した問題の解決に手間取ったので書き残しておきます。

遭遇した問題

要約

基底クラスのデストラクタを非仮想 (non virtual) から仮想 (virtual) に変更したら、派生クラスの定義におけるデストラクタの宣言でコンパイルエラーが発生しました。

前提

図のような派生関係にあるクラスがあります。

figure 1. 登場するクラス群

ここで、 2 つの基底クラス BaseA, BaseB は他者から提供されるもので、派生クラス Derived のみを自分が実装しています。

ちなみに、基底クラスが 2 つあります (いわゆる「多重継承」) が、それは直接の原因ではありません。

ある日のこと

基底クラス BaseA, BaseB が更新されたため、それを取り込んだところ、それまで成功していたコンパイルが失敗するようになりました。
エラーメッセージはこんな感じです (コンパイラは GCC):

error: looser exception specification on overriding virtual function 'virtual Derived::~Derived() noexcept (false)'
note: overridden function is 'virtual BaseB::~BaseB() noexcept'

変更内容

前述したとおり、基底クラスのデストラクタが非仮想 (non virtual) から仮想 (virtual) に変更されました (table 1)。

table 1. 各クラスの変更前, 変更後のデストラクタ

クラス 変更前の宣言 仮想? 変更後の宣言 仮想?
BaseA ~BaseA(); 非仮想 virtual ~BaseA(); 仮想
BaseB ~BaseB(); 非仮想 virtual ~BaseB(); 仮想
Derived ~Derived(); 非仮想 ~Derived(); 仮想

派生クラス Derived のデストラクタの宣言は変更していませんが、基底クラスのデストラクタが仮想に変わったため、こちらも仮想デストラクタとなりますね。

原因

これだけだとなぜエラーとなったのかわからないと思いますが、基底クラスのデストラクタの例外送出可能性[3]に原因がありました。
実は、一方のクラス (BaseA としましょう) のデストラクタが、「例外を送出する可能性がある (potentially-throwing)」ものだったのです (table 2)。
なお、デストラクタの例外送出可能性は、変更前 (非仮想) と変更後 (仮想) とで変化はありません。

table 2. 各クラスのデストラクタの例外送出可能性 (1)

クラス デストラクタの例外送出可能性
BaseA 例外を送出する可能性がある (potentially-throwing)
BaseB 例外を送出しない (non-throwing)
Derived (このあとご説明)

これによってなぜエラーとなるのかはこのあとご説明します。

エラーのメカニズム

エラーとなった原因を理解するには、 C++ の例外仕様の仕様をきちんと理解する必要があります。
規格 (現行 C++20 [ISO/IEC 14882:2020] のドラフトである N4861[4]) の該当部分を見てみましょう。
すべて 14.5 Exception specifications [except.spec] で、引用文の冒頭の数字はパラグラフ番号です。

3
If a declaration of a function does not have a noexcept-specifier, the declaration has a potentially throwing exception specification unless it is a destructor or a deallocation function or is defaulted on its first declaration, in which cases the excepion specification is as specified below and no other declaration for that function shall have a noexcept-specifier.

(拙訳)
noexcept specifier が指定されていない関数宣言は、次の場合を除いて、例外を送出する可能性があるとみなされる:

  • その関数がデストラクタである。
  • (略)

2 つの基底クラス (BaseA, BaseB) および派生クラス (Derived) のデストラクタにはいずれも noexcept specifier は指定されていませんでしたが、このパラグラフ 3 の仕様により、例外を送出しない (non-throwing) とみなされるはずです。

ところが。

8
The exception specification for an implicitly-declared destructor, or a destructor without a noexcept-specifier, is potentially-throwing if and only if any of the destructors for any of its potentially constructed subobjects is potentially-throwing or the destructor is virtual and the destructor of any virtual base class is potentially-throwing.

(拙訳)
暗黙的に宣言されたデストラクタまたは noexcept specifier が指定されていないデストラクタは、次の場合に限り、例外を送出する可能性があるとみなされる:

  • 構築され得るいずれかのサブオブジェクトのデストラクタが例外を送出する可能性がある。 または、
  • デストラクタが仮想で、かつ、いずれかの仮想基底クラスのデストラクタが例外を送出する可能性がある。

これがポイントですね。
前述のとおり、 2 つの基底クラスのうち BaseA のデストラクタは例外を送出する可能性がありました。
そのためこのパラグラフ 8 の仕様により、派生クラス Derived のデストラクタも例外を送出する可能性がある (potentially-throwing) とみなされます。
先の表を完成させましょう (table 3)。

table 3. 各クラスのデストラクタの例外送出可能性 (2)

クラス デストラクタの例外送出可能性
BaseA 例外を送出する可能性がある (potentially-throwing)
BaseB 例外を送出しない (non-throwing)
Derived 例外を送出する可能性がある (potentially-throwing)

もうひとつ。

4
If a virtual function has a non-throwing exception specification, all declarations, including the definition, of any function that overrides that virtual function in any derived class shall have a non-throwing exception specification, unless the overriding function is defined as deleted.

(拙訳)
仮想関数が例外を送出しないと指定されている場合、それを派生クラスでオーバーライドしているすべての宣言 (定義を含む) は例外を送出しないと指定されなくてはならない。
ただし、オーバーライドしている関数が delete 定義されている場合を除く。

これですね。
先の table 3 の、基底クラス BaseB と派生クラス Derived のデストラクタの例外送出可能性をご覧ください。
基底クラス BaseB の仮想デストラクタは例外を送出しない (non-throwing) とされているわけですから、それを派生クラスでオーバーライドしている Derived::~Derived も例外を送出しない (non-throwing) と指定されていなくてはなりません。
しかし、実際には派生クラス Derived の仮想デストラクタは例外を送出する可能性がある (potentially-throwing) ので、このパラグラフ 4 の仕様に違反しています。
これがコンパイルエラーの原因だと考えられます。

パラグラフ 4 の仕様は仮想関数にのみ適用されるため、変更前 (基底クラス BaseB のデストラクタは非仮想) にはコンパイルエラーが発生していなかったわけです。

再びエラーメッセージを見てみましょう:

error: looser exception specification on overriding virtual function 'virtual Derived::~Derived() noexcept (false)'
note: overridden function is 'virtual BaseB::~BaseB() noexcept'

納得できますね。

考察 (っぽい何か)

基底クラスの仮想デストラクタを非仮想に変更することで派生クラスに影響があることは理解していましたが、逆方向の変更が問題となり得るということは意識できていませんでした。

なお、今回のケースのように、非仮想の (それも public な) デストラクタを持つクラスから派生させるのは、使い方によっては危険です[5]


関数の例外送出可能性はコードを注意深く読まないと判断できない[6]ため、今回のケースのように noexcept 指定されていないとやっかいなことになることがあります。
例外を送出しない関数は、 noexcept specifier でそれを明示してやると助かりますね。


noexcept specifier は、うまく使えばコンパイラにとってコードの最適化の可能性を高めることができて有用だと頭ではわかっていますが、個人的には使いづらく感じていました。
ある関数に noexcept (あるいは noexcept(true)) を指定したとして、後日関数の実装変更によって noexcept を守れなくなった場合、 noexcept(false) に変更してしまうとその関数を呼んでいる側に影響を与えてしまうためです。
また、自分自身が呼び出している他の関数の例外送出可能性も注意深く考慮しないと noexcept 指定はできません[7]
そのため、どう考えても例外を送出することにはならないだろうと確証を持てるような (たいていシンプルな) 関数にしか noexcept 指定はしていませんでした。

ただ、その関数の利用者に対しては、その関数がどのような例外を送出する可能性があるのかを提示する必要があるはずです (そうでないと適切な例外ハンドリングができません)。
そのため、 noexcept 指定しないことで将来の例外送出可能性の変更の猶予を確保するというのは、ある種の責任放棄とも言えると思います。


ところで、一般的にはデストラクタは例外を送出すべきではないとされています[8]
今回のケースでは、基底クラス BaseA のデストラクタは例外を送出する可能性がある (potentially-throwing) ものでしたが、それはたぶん意図したものではなく、前掲したパラグラフ 8 の仕様により、そうなってしまったのだと推測されます。

おわりに

今回、コンパイルエラーが発生してからその解消にいたるまで多少手間取りました。
エラーメッセージから何となく見当はついたものの、中途半端に noexcept specifier の存在は知っていたために規格に手を伸ばす前に無駄な時間を費やしてしまいました。
知っていればすぐに解決できたでしょうし、そもそもそのようなコードを書かずにすんだかもしれません。
よい教訓となりました。

脚注
  1. noexcept specifier (since C++11) - cppreference.com. https://en.cppreference.com/w/cpp/language/noexcept_spec. ↩︎

  2. noexcept - cpprefjp C++日本語リファレンス. https://cpprefjp.github.io/lang/cpp11/noexcept.html. ↩︎

  3. たった今、勝手に命名しました。 ↩︎

  4. Working Draft, Standard for Programming Language C++ [N4861]. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf. ↩︎

  5. C.35: A base class destructor should be either public and virtual, or protected and non-virtual. C++ Core Guidelines. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#c35-a-base-class-destructor-should-be-either-public-and-virtual-or-protected-and-non-virtual. ↩︎

  6. noexcept operator を使えば簡単にわかります。 ↩︎

  7. 指定はできるのですが、実行時に例外が送出されると std::terminate 関数が呼び出されてプログラムは異常終了します。 もう少し正確には規格のパラグラフ 5 をご覧ください。 ↩︎

  8. C.37: Make destructors noexcept. C++ Core Guidelines. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#c37-make-destructors-noexcept ↩︎

Discussion