歴史: C++98では、リソース管理は「三つのルール」に従っていました。特定のクラスがカスタムデストラクタ、コピーコンストラクタ、またはコピー割り当て演算子を必要とする場合、すべての三つが必要になる可能性が高いというものでした。C++11がムーブセマンティクスを導入した際、これは「五つのルール」になり、ムーブコンストラクタとムーブ割り当てが追加されました。標準委員会は保守的なアプローチを選択しました:いかなるデストラクタ(トリビアルなものであっても)を宣言すると、ムーブ操作の暗黙の生成が抑制され、デストラクタによって管理されるリソースの偶発的な浅いムーブを防ぐことになります。
問題: クラス定義内で ~MyClass() = default; と書くと、「ユーザー宣言」デストラクタが作成されます。C++ の標準([class.copy.ctor]/3)によれば、これが存在すると、ムーブコンストラクタとムーブ割り当て演算子の暗黙の宣言が抑制されます。結果として、コンパイラーはクラスをコピー専用と見なし、実際に作業が行われていないにもかかわらず、std::vector の再割り当てや値返却最適化の間に高価なコピーセマンティクスに静かに戻ります。
解決策: 暗黙のムーブ生成を維持するために、デストラクタをクラス内だけで宣言し、外部でデフォルト定義を提供します:
class Optimized { public: ~Optimized(); // ここでのみ宣言 std::array<char, 4096> buffer; }; Optimized::~Optimized() = default; // 外部で定義
これにより、コンパイラーがムーブを生成するポイントでデストラクタは「ユーザー提供」ですが、「ユーザー宣言」ではなくなります。あるいは、すべての五つの特別メンバーを明示的にデフォルトし、あるいは生のリソースをstd::unique_ptrやコンテナで置き換えることにより、ゼロのルールに従うことを推奨します。
私たちは、MarketDataPacket オブジェクトを処理する高頻度取引エンジンでこれに直面しました。クラスはネットワークデータのための固定4KBバッファを保持していました:
class MarketDataPacket { public: ~MarketDataPacket() = default; // "明確さ"のためにヘッダに記述 char buffer[4096]; };
C++11に移行した後、遅延プロファイリングにより、パケットを値で返却しているにもかかわらず、CPUサイクルの40%がmemcpyに費やされていることが明らかになりました。問題の原因は、クラス内でデフォルト設定されたデストラクタが暗黙のムーブを削除し、std::vectorの成長や関数の戻り値の間に強制的にコピーを強いたことでした。
解決策1: noexceptムーブコンストラクタと割り当てを明示的に宣言します。これにより、ムーブが可能になり、パフォーマンス問題がすぐに解決します。しかし、メンバーを追加する際にはこれらの関数を手動で維持する必要があり、生のポインタが関与する場合は例外仕様の不一致リスクがあり、ゼロのルールに違反するようなボイラープレートが追加されます。
解決策2: デストラクタ定義を .cpp ファイルに移動し、MarketDataPacket::~MarketDataPacket() = default; とします。これにより、デストラクタをトリビアルなままにして、コンパイラー生成のムーブが復元されます。これにより、ゼロオーバーヘッドの抽象が維持され、未使用オブジェクトのデストラクタ呼び出しの省略など、コンパイラーの最適化が可能になります。唯一の欠点は、別のコンパイルユニットが必要になることですが、これは許容できるものでした。
解決策3: 生のバッファを**std::vector<uint8_t>またはstd::unique_ptrstd::byte[]**に置き換えます。これにより、完全なゼロのルールへの準拠が達成されます。ただし、これにより間接的な呼び出しやヒープ割り当てのオーバーヘッドが導入され、キャッシュの局所性が重要なマイクロ秒単位の取引経路では受け入れられません。
私たちは解決策2を選択しました。デフォルト設定をクラスの外に移動することで、暗黙のムーブを復元し、パケット処理のレイテンシを12μsから3μsに削減し、攻撃的なコンパイラー最適化を可能にしました。
文脈が同じであるのに、なぜコンパイラーはクラス内とクラス外のデフォルト設定を区別するのか?
その違いは意味的ではなく、統語的です。**C++はクラス定義のための単一パスの構文解析モデルを使用します。コンパイラーがクラスの閉じ括弧に達すると、暗黙のムーブ操作を生成するかどうかを決定する必要があります。内部で = default を見た場合、その時点でデストラクタは「ユーザー宣言」であるため、[class.copy]/7 に従った抑制規則がトリガーされます。コンパイラーは、外部定義に「先読み」してこの決定を変更することはできません。これはC++**のコンパイルモデルの基本的な制約です。
デストラクタを noexcept でマークすると、暗黙のムーブが復元されますか?
いいえ。暗黙のムーブ生成の抑制は、デストラクタがユーザー宣言であるかどうかに完全に依存し、その例外仕様には依存しません。ムーブをstd::vectorの再割り当てで使用するためには、ムーブをノーエクセプションでマークすることが重要ですが、クラス内のデフォルトデストラクタに noexcept を追加することだけでは、削除されたムーブ操作は復活しません。定義を外部に移動するか、ムーブを明示的にデフォルトに設定する必要があります。
ユーザー宣言のデストラクタは集約初期化にどのように影響しますか?
ユーザー宣言のデストラクタを持つクラスは集約ではなくなります。これは、ムーブを失うよりも大きな影響を及ぼすことが多いです。これは、指定された初期化子(C++20)や、明示的なコンストラクタなしで波括弧で囲まれた初期化リストを使用する能力を失うことを意味します。多くの開発者は集約初期化が機能することを期待しており、失敗した場合は驚くことがあります:
struct Config { ~Config() = default; // 集約を壊す int value; }; // Config c{42}; // エラー:一致するコンストラクタがありません
これは、ユーザー宣言のデストラクタの存在により、型システム内で非トリビアルな破棄セマンティクスを持つようになり、実際の複雑さに関係なく集約状態から除外されるために発生します。