syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

C++における構造体のコンストラクタはユーザー定義すべきではない(私見)

C++のクラス (class) と構造体 (struct) は、デフォルトのメンバーアクセスレベルが異なるだけなので、機能的な違いはありません*1。構造体でも、メンバー変数だけでなくメンバー関数を定義することができます。当然コンストラクタやデストラクタをユーザー定義することもできます。

コード例:

#include <string>

struct MyStruct {
    std::string name; // デフォルトコンストラクタで空文字列に必ず初期化されるので、コンストラクタのメンバー初期化子リストに記述する必要はない。
    bool isEnabled;
    bool isChecked;
    float positionX;
    float positionY;
    int orderZ;

    // ゼロ相当の値で POD 型メンバー変数を初期化。
    MyStruct()
    : isEnabled()
    , isChecked()
    , positionX()
    , positionY()
    , orderZ()
    {}

// デストラクタをユーザー定義していない場合、コンパイラが自動生成する。
// いずれにせよメンバー変数のデストラクタは自動実行されるため、値型として定義された name メンバーは自動的に破棄される。
};

int main() {
    // 構造体のデフォルトコンストラクタがユーザー定義されている場合、デフォルト初期化したローカル変数の内容は指定した値で初期化される。
    MyStruct s;
}

ただ個人的な意見としては、構造体のほうは基本的に複数のpublicデータをまとめて管理するだけの簡素な使い方(Cの構造体やPascalのレコードとほぼ同様の使い方)をするべきだと思っているので、コンストラクタはユーザー定義しないことのほうが多いです。当然デストラクタもユーザー定義しません。デストラクタをコンパイラに自動定義させる場合、デフォルトで非仮想となり、「基底クラスBaseへのポインタと仮想デストラクタを経由して派生クラスDerivedのオブジェクトをdeleteする」ということ(ポリモーフィックな削除)はできないため、構造体は派生型を定義しない方針で運用します(C++11以降は、Cコードと共有する構造体でなければfinalを付けてしまったほうがよいかも)。C#の構造体は言語仕様レベルで仮想メソッドや派生型を定義できなくなっていますが、C++では運用で実現します。

C# (.NET) では、クラスは参照型、構造体は値型であり、機能および型システム上での明確な違いが存在しますが、使い分けについてのガイドラインもあります。

C++/CLIでは、C#のクラスに相当するref class / ref structと、C#の構造体に相当するvalue class / value structを定義することができます。

Javaには構造体相当機能がないものの、Java 16でレコードクラスが追加されました。ちなみにProject Valhallaでは、値クラスvalue classの導入が検討されているそうです。

Swiftもクラスは参照型、構造体は値型で、明確な違いが存在します。C#の設計が優れていたことが証明されました。

グラフィックスプログラミングでよく利用される、2 - 4次元の座標情報をまとめて管理する固定長のベクトル型(float×2のVector2Fや、double×3のVector3Dなど)は、未来永劫メンバーの数が変わることはないので、こういった変化の小さい汎用的な型に関してはアプリケーションコード側の記述の簡素化のために引数付きコンストラクタや算術演算子オーバーロードを定義することもありますが、将来的な拡張でメンバーが追加されうる構造体に関しては、コンストラクタを書かないほうがむしろ良いのではないかと思います。

もしC++でユーザー定義のコンストラクタやデストラクタが必要になった場合、大抵そのデータ型は構造体ではなくクラスで実現するべき(メンバー変数を隠蔽して、アクセッサーを用意するべき)、ということを示唆しています。特にポインタ型のメンバー変数をpublicにして公開すると、正規の手順を経ることなく外部コードから勝手にでたらめなアドレスを代入したり、ポインタが指している領域を勝手に削除したり、といったことができてしまうため、危険性が増します。

そのコンストラクタ、本当に必要ですか?

C/C++において、組み込みの数値型や任意のポインタ型、あるいはそれらから成る構造体や配列といったPOD型の非静的メンバー変数(インスタンス変数)の内容は、デフォルトでは不定値になります。そのため、クラスの非静的メンバー変数はユーザー定義のコンストラクタにて確実にゼロなどの値で初期化しておくべきです。C++11以降はJavaC#のようにクラス定義ブロック内でメンバー変数に直接初期値を与えることもできるようになったので、こちらを使う手もあります。ただし、コンストラクタのメンバー初期化子リストで指定した値のほうが優先されるというルールがあるため、この機能を利用する場合は混乱を招かないように注意が必要です。

しかし、そもそもゼロ初期化の構文や値初期化の構文を使えば、ユーザー定義のコンストラクタがなくとも、すべてのPOD型メンバーをゼロ相当の値で初期化することができます。これにより、少なくとも不定値による未定義動作だけは防げます。

#include <memory>

struct MyPoint {
    int x;
    int y;
};

int main() {
    // 構造体のデフォルトコンストラクタがユーザー定義されていない場合、デフォルト初期化したローカル変数の内容は不定値になる。
    MyPoint ptBad1;
    // 以下もデフォルト初期化となり、メンバー変数の値は不定値になる。
    std::unique_ptr<MyPoint> ptBad2(new MyPoint);

    // 以下はすべてゼロ初期化される。
    MyPoint ptGood1a = MyPoint();
    MyPoint ptGood1b = {};
    MyPoint ptGood1c{}; // C++11 以降。
    std::unique_ptr<MyPoint> ptGood2a(new MyPoint());
    auto ptGood2b = std::make_unique<MyPoint>();
}

逆に、ゼロ初期化や値初期化の構文を使ったとしても、コンストラクタをユーザー定義しているにもかかわらず、POD型メンバー変数をそのコンストラクタで初期化し忘れていた場合、そのメンバーの値は不定値になってしまいます。詳しくは以下のエントリでも述べていますが、自分が構造体のコンストラクタを積極的に定義したくない理由のひとつがコレです。

sygh.hatenadiary.jp

C/C++は未定義動作や未規定動作の落とし穴があちこちに存在する危険な言語なんですが、その恐ろしさを理解していない不用心なプログラマーが多すぎる。コードレビューしていると、POD型ローカル変数を宣言時に初期化せず不定値のまま使い始めているコードのほか、前述のようにコンストラクタをユーザー定義しているにもかかわらず、POD型メンバー変数をそのコンストラクタで初期化し忘れている(メンバー初期化子リストに記述していないだけでなく、コンストラクタ本体ブロックでも何も代入していない)危険なクラスや構造体もよく見かけます。C++の言語仕様上は合法なのでコンパイル自体は通ってしまうのですが、未定義動作を引き起こす危険なコードです。たとえデフォルト初期化されても不定値にしないようにするフールプルーフ目的でコンストラクタをユーザー定義したつもりが、逆に未定義動作のバグや脆弱性を作り込んでしまうわけです。現在のコンパイラあるいは静的コード分析ツールはこういった危険なコードに対して、ガイドライン警告やlint警告を出してくれるのですが、残念ながら不用心なプログラマーは有用な警告をすべて無視してしまいます。たとえIDE上で分かりやすく強調(ハイライト)表示されていても、彼らは無視してしまいます。なぜさっき書いたコードがハイライト表示されているのか、ということに対して何の疑問も持たないんでしょうね。自分としては、刃物の使い方を知らない状態で不用意に振り回して欲しくないんですよ。要するに、「うかつにコンストラクタを定義するな、もし定義するならば注意を払って実装しろ。新しいメンバーを追加したらコンストラクタも正しくメンテナンスしろ」と言いたい。

ちなみに未初期化のローカル変数や未初期化のメンバー変数を使用すると、MSVCではデフォルトでC4700のコンパイラ警告が出ます。ユーザー定義のコンストラクタで未初期化のメンバー変数が残っている場合、コンパイラ警告は出ませんが、静的コード分析ツールがC26495の警告を出してくれます。GCCでも-Wuninitialized-Wmaybe-uninitializedコンパイラ警告が出ますが、ユーザー定義のコンストラクタで未初期化のメンバー変数が残っている場合、警告が出ません。Clangではいずれも警告が出ませんが、Clang-Tidyのcppcoreguidelines-init-variablescppcoreguidelines-pro-type-member-initがあります。これらの警告が出ているコードは、致命的な問題を引き起こす危険性が高いので、いっそエラーになるように設定してしまったほうがよいと思います。

しかし、変数の宣言時初期化を心がけていれば、フールプルーフのためにコンストラクタをユーザー定義する必要はありません。そもそも、メンバー変数がすべてpublicの構造体を、メンバー変数がすべてprivateのクラスと同じように使えるかのように見せかけること自体が間違いなんです。

構造体のコンストラクタのユースケース1

「ゼロあるいはゼロ相当以外の値をデフォルト時の無効値として使用したい場合」は、構造体のコンストラクタをユーザー定義したくなることがあるかもしれません。たとえばインデックス番号はゼロも有効なので、負数-1を無効値として使いたい場合などが考えられます。
しかし標準C++STLコンテナでは配列インデックスに符号無しのsize_t型を使うので、無効値として負数は使えません。かといって最大値SIZE_MAXを無効値とみなすというのは危険です。もしC++において無効な状態を表現できるインデックスデータの変数を定義したい場合は、C++17のstd::optional<size_t>boost::optional<size_t>を使うべきです。std::optionalのデフォルト値はstd::nulloptboost::optionalのデフォルト値はboost::noneとなるので、これらの型を持つメンバーをコンストラクタで明示的に初期化する必要は特にありません。std::optionalboost::optionalは、有効な値を持たない状態(無効な状態)がありえることを型情報で明確に示しており、また値を持たない状態で値を取得しようとすると、それぞれstd::bad_optional_access例外やboost::bad_optional_access例外をスローしてくれるので安全です。とはいえ、Optional型に有効な値が設定されていても、本当に有効範囲内のインデックスであるかどうか(インデックス値が配列のサイズ以上になっていないか)ということは利用時にチェックするコードを書くべきですが。
一方、C++非標準のAPIや、サードパーティー製のライブラリでは、配列インデックスに符号付きのintint32_t相当を使っているものもあると思います。Optional型よりも軽量でお手軽な数値型を使いたい気持ちは分かりますし、C++以外の他の言語との相互運用*2では単純な数値型のほうが便利ですが、ただの数値型では、無効なインデックス値に何を使うかという外部仕様をいちいちドキュメント化しないといけないし、万が一負数を使って配列にインデックスアクセスしてしまった場合、C/C++では未定義動作を引き起こします。

構造体のコンストラクタのユースケース2

「ユーザー定義されているものの、メンバーを適切に初期化しておらず、不定値になってしまう怠惰なデフォルトコンストラクタを持つ型」をメンバー変数に持つ構造体の場合はどうでしょうか。このようなメンバー変数は、前述のようにゼロ初期化や値初期化の構文を使っても不定値になってしまいます。そのため、構造体にコンストラクタを定義し、メンバー初期化子リストで引数付きコンストラクタを使用して初期化するなどの対処をする必要があります。とはいえ、そもそもこのような怠惰なコンストラクタをユーザー定義するのは邪悪な設計の典型であり、ましてや怠惰なコンストラクタを持つ邪悪な型を構造体に含めるべきではありません。どうしてもそのような邪悪な型の変数をメンバーに含めなければならない場合、構造体ではなくクラスとして定義し、そのメンバー変数は外部から直接アクセスできないように隠蔽すべきです。

ちなみにC#の構造体はバージョン9まで、引数のないコンストラクタ(デフォルトコンストラクタ)はユーザー定義できず、デフォルトコンストラクタでは構造体のフィールドは必ずその型の既定値で初期化される仕様になっていました(C#では、構造体でもクラスでも、特に指定がないかぎり、数値型や列挙型のフィールドはゼロ相当の値で初期化され、参照型のフィールドはnullで初期化されます)。デフォルトコンストラクタがユーザー定義できるようになったのはC# 10からです。

構造体のコンストラクタのユースケース3

「構造体に引数付きコンストラクタを定義しておくと、メンバーを追加したときに引数を増やすので、既存コードのコンストラクタ呼び出し箇所がコンパイルエラーとなり、修正すべき箇所が分かりやすくなる」という意見もあるかもしれません。しかし、もしメンバーをN個増やしたと同時にN個減らした場合、コンストラクタの引数の数は変わらないので、場合によっては既存コードのコンストラクタ呼び出し箇所を修正しなくてもコンパイルが(意図せず)通ってしまう可能性もあります。

struct MyPie {
    double centerX;
    double centerY;
    double radiusX;
    double radiusY;
    //double sweepAngleDegrees; // 旧メンバー。
    double sweepAngleRadians; // 新メンバー。

#if 0
    // 旧コンストラクタ。
    explicit MyPie(double centerX, double centerY, double radiusX, double radiusY, double sweepAngleDegrees)
    : centerX(centerX)
    , centerY(centerY)
    , radiusX(radiusX)
    , radiusY(radiusY)
    , sweepAngleDegrees(sweepAngleDegrees)
    {}
#else
    // 新コンストラクタ。
    explicit MyPie(double centerX, double centerY, double radiusX, double radiusY, double sweepAngleRadians)
    : centerX(centerX)
    , centerY(centerY)
    , radiusX(radiusX)
    , radiusY(radiusY)
    , sweepAngleRadians(sweepAngleRadians)
    {}
#endf
};

int main() {
    // degrees で指定する旧コード。そのままでもコンパイル自体は通ってしまい、仕様変更に気づかない。
    MyPie pie1(100, 200, 40, 30, 180.0);
    // コンストラクタがユーザー定義されているかどうかにかかわらず、C++11 以降は以下のように書くこともできる。
    MyPie pie2{100, 200, 40, 30, 180.0};
    // コンストラクタがユーザー定義されていない場合、あるいは C++11 以降で explicit 指定されていない場合、以下のように書くことができる。
    //MyPie pie3 = {100, 200, 40, 30, 180.0};
}

Objective-CやSwiftのメソッドは、引数にラベルを付けることができ、オーバーロードは引数の型ではなくラベルによって実現しますが、メソッドのラベル(セマンティクス)を変更するとメソッドインターフェイスの互換性が破壊され、既存のコードがコンパイルエラーになる(破壊的仕様変更になる)ので、修正すべき個所がすぐに分かるようになっています。一方、JavaC#など、C++系統の言語におけるメソッドやコンストラクタは、引数の型が異なる場合にオーバーロードを定義でき、引数の名前はシグネチャに影響しないので、もし引数におかしな名前やスペルミスを含む名前を付けてしまったときでも、メソッドインターフェイスの形式的な(型システム上の)互換性を維持したままこっそりリネームするといったことができますが、当該引数の意味や使い方(仕様)を変えても型が同じ(または暗黙変換可能な型同士)だった場合は既存コードのコンパイルが通ってしまい、修正すべき個所がすぐには分かりません。どちらの言語系統の仕様も、メリットとデメリットがありますが、互換性が破壊されたはずなのに既存コードのコンパイルが通ってしまうのは明らかに問題があります。
C++/Java/C#においてうかつにメソッドやコンストラクタのオーバーロードを定義するのは危険です。公開APIのようにファーストパーティ以外にも広く使われている場合、互換性のない機能追加や仕様変更を実施したときはコンパイルし直してもらう必要がありますが、既存のオーバーロードのせいで身動きがとれなくなってしまう可能性があります。こういうときは引数付きコンストラクタの代わりに異なる名前を持つファクトリ関数を別々に用意したほうがよく、上記のコード例では初期化したMyPie構造体のインスタンスを戻り値で返すMakeMyPieWithSweepAngleDegrees()MakeMyPieWithSweepAngleRadians()を定義するべきです。オーバーロードとは異なり、型システム上で同じシグネチャを持つ新ファクトリ関数を定義しても、旧ファクトリ関数を維持したままにすることができます。

そもそもdegreeをあとからradianに変えるような互換性破壊をするなよ、というのはもっともですが、特に非公開コードにおいて、機能追加や仕様変更によって既存コードの互換性を破壊せざるを得ないことはどうしてもあります。そういった互換性破壊をしたとき、容易に気づけるようなコードになっているかどうか、という点で、クラスや構造体の引数付きコンストラクタのオーバーロードには潜在的な拡張性欠損の問題がある、ということを述べているわけです。

余談ですが、個人的に引数付きのコンストラクタは、コピーコンストラクタとムーブコンストラクタを除いて、一律explicitを付けるようにしています。explicitがない場合、引数の型のオブジェクトから暗黙変換することができる「変換コンストラクタ」(converting constructor) となりますが、特に引数が1つだけの場合、意図しない変換による問題を引き起こすことが多いです。なお、C++11以降は、複数の引数を持つコンストラクタにexplicitがない場合、初期化子リスト{...}から暗黙変換することもできるようになっています。とはいえ、コンストラクタを呼び出しているのかどうかが判然としないので、個人的には好みではありません。ほとんどの場合、暗黙変換はメリットよりもデメリットのほうが大きすぎて、採用する積極的な理由がありません。

ちなみにC/C++の構造体の初期化には、初期化子リストを使うこともできますが、構造体のメンバーの並びを変えたときなどに既存コードの互換性が崩れるので、いったんゼロ初期化してから各メンバー変数に明示的に代入するコードのほうが安全です。コンパイラ最適化が効けば二重初期化のコストはかかりません。C++11以降の一様初期化 (uniform initialization) も同様です。Windows APIのように、バイナリレベルの後方互換性を維持する必要のあるAPIでは、構造体の既存メンバーの並びを変えたり削除したりするようなことはせず、新しいメンバーを追加する場合であっても必ず末尾に追加するようになっていますが、バイナリレベルの後方互換性を維持する必要のないユーザー定義のコードでは、リファクタリングの際に既存の構造体のメンバーの並びを変えたり削除したりすることもあります。構造体に新しくメンバーを追加する場合、不要なパディングが入らないよう、領域節約のためにメンバーの並びを調整したほうがよいこともあります。前述の例のようにメンバー変数を増減させたときも、メンバー変数の名前をコード上で明示しておけば、仕様が変わったときにコンパイルエラーになるため、修正すべき個所がすぐに分かります。メンバーアクセスの記述量は増えますが、リテラルをだらだら並べるよりも遥かに可読性が高いと感じます。

struct MyPie {
    double centerX;
    double centerY;
    double radiusX;
    double radiusY;
    //double sweepAngleDegrees; // 旧メンバー。
    double sweepAngleRadians; // 新メンバー。
};

int main() {
    MyPie pie1 = {};
    pie1.centerX = 100;
    pie1.centerY = 200;
    pie1.radiusX = 40;
    pie1.radiusY = 30;
    // degrees で指定する旧コード。コンパイルエラーになるので仕様変更に気づきやすい。
    pie1.sweepAngleDegrees = 180.0;
}

C++20では、C99やC# 3.0以降/VB 9.0 (2008) 以降のように、初期化子リスト内でメンバー名を指定できるようになりました(指示付き初期化: designated initialization)。C99の指示付き初期化と比べると融通が利かず、若干機能面で劣りますが、これはもうC++11でさっさと導入しておけばよかったのではないかと思います。

従来の初期化子リストやコンストラクタ呼び出しと違い、各メンバーに明示的代入する方式では、構造体変数をconst修飾やconstexpr修飾することができないという欠点がありますが、指示付き初期化は両者の良いところ取りができる優れた方法です。

さらにC++20では、コンストラクタがユーザー定義されていなくても、ブレース{...}ではなくパーレン(...)を使って集成体の初期化ができるようになりましたが、前述の理由から指示付き初期化のほうがお勧めです。

ちなみにVBDelphiには省略記法としてのWith文がありますが、スコープが分かりづらいなどの問題点もあります。

再掲:そのコンストラクタ、本当に必要ですか?

上記のように、構造体のコンストラクタをユーザー定義するユースケースはいくつか考えられるものの、逆にメンテナンス性が低下するデメリットのほうが大きいため、やはりユーザー定義すべきではないと考えています。

もうひとつ、構造体のコンストラクタを定義したくない理由のひとつとして、constexprでないコンストラクタをユーザー定義すると、その型のインスタンスはconstexprにできない(コンパイル時定数にできない)ということが挙げられます。constexprは暗黙的にインラインでなければならないので、もし構造体をヘッダーファイルに定義している場合は、そのconstexprコンストラクタの定義をヘッダーに書く必要があります。コーディング規約で関数の実装をヘッダー側ではなくソースファイル側に書くよう定めている場合もあると思いますが、そのようなルールはconstexprコンストラクタと相性が悪いです。constexprコンストラクタを定義するか、あるいはコンストラクタを一切ユーザー定義していなければ、初期化子リストもしくはconstexprファクトリ関数を使ってconstexprインスタンスを生成することができます。

結論:C++を捨てましょう

C++は本当にめんどくさいですよね。でも大丈夫です。世の中には、もっと安全で効率が良く、コンパイルも高速で実行速度も十分な後発プログラミング言語がたくさんあります。ほとんどの場合、あなたの書いているコードはC++である必要はありません。単に古いコード資産を捨てるのが億劫なだけでしょ。必要最低限のものだけ残して、あとは全部捨ててください。C++はバックエンドのためだけにある言語です。フロントエンドをC++で書くなど狂気の沙汰です。

*1:もともとCに毛の生えた「C with Classes」から発展・分化したのがC++なので、いびつで非合理的・不条理とも言える設計が言語仕様のあちこちに散見されます。

*2:JavaC#と相互運用する場合は、コレクションのインデックスには32bit符号付き整数型を使うことが多いと思います。.NET documentation C# Coding Conventions - C# | Microsoft Learn