- C++のオブジェクト初期化は
T t;、T t{};、T t{...} のように似た文法でも default/value/list/aggregate initialization に分かれ、実際のメンバー状態が変わりうる
- デフォルトコンストラクタをどこで
= default するかによって zero-initialization の有無が変わり、クラス外で定義するとメンバーが未初期化の値のまま残ることがある
const int メンバーがあってデフォルトコンストラクタが削除されそうに見える型でも、aggregate であれば A a{}; が aggregate initialization を経て 0 に初期化されうる
- C++20 の丸括弧ベースの aggregate initialization は波括弧初期化よりもさらに許容的で、narrowing conversion や dangling reference などの違いを生むことがある
- 実務では暗黙コンストラクタと初期化規則の組み合わせに頼るより、明示的なコンストラクタで初期化意図を示したほうが例外を避けやすい
C++初期化構文が分岐するポイント
T t; は default-initialization を行う
T がクラス型でデフォルトコンストラクタを持つなら、そのコンストラクタを実行する
T が配列なら各要素を default-initialize する
- それ以外では何もしない
T t{}; は value-initialization のように見えるが、波括弧構文のためまず list-initialization として扱われる
- 単純な例でも違いはすぐに現れる
int x{}; は value-initialized されて 0 に初期化される
int y; は default-initialized され、値は初期化されない
- デフォルトコンストラクタを持つ
Pair p; と Pair q{}; はコンストラクタを呼び出す
- メンバーだけを持つ
SimplePair r; は default-initialized され、メンバーが未初期化になることがある
デフォルトコンストラクタの宣言位置が変える結果
- ユーザーがコンストラクタを宣言しない場合、コンパイラは implicitly-declared なデフォルトコンストラクタを宣言する
T() = default; をクラス内の最初の宣言で使うと、標準上は暗黙宣言されたコンストラクタとほぼ同様に扱われる
- 暗黙的に宣言された、または明示的に defaulted されたデフォルトコンストラクタが削除されていなければ、コンパイラは implicitly-defined default constructor を提供する
- 実装上は、空の本体と空のメンバー初期化リストを持つ
T() {} と等価である必要がある
- 次のコードでは
t.x は 0 になる
struct T {
int x;
T() = default;
};
T t{};
std::cout << t.x << std::endl;
t が value-initialized され、T のデフォルトコンストラクタが user-provided ではないため、先に zero-initialization が行われてからデフォルトコンストラクタが呼ばれるためである
クラス外の = default と user-provided コンストラクタ
- 同じ
= default でも、クラス外で定義すると user-provided コンストラクタになる
struct T {
int x;
T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
- この場合、
T::T() = default; は最初の宣言で defaulted されたものではなく、クラス外で defaulted として 定義 されたコンストラクタである
- value-initialization の規則では、user-provided または deleted なデフォルトコンストラクタがある場合は default-initialization を行う
- したがって上の例では zero-initialization なしにデフォルトコンストラクタだけが実行され、そのコンストラクタは何もしないので
t.x はゴミ値になる
デフォルトコンストラクタが削除される場合
- コンパイラが妥当なデフォルトコンストラクタを作れない場合、暗黙的に宣言されたデフォルトコンストラクタは deleted として定義されることがある
- 代表的な条件は次のとおり
- 非静的な参照メンバーがある場合
- default-construct または destruct が適切でない非静的メンバーや非抽象基底クラスがある場合
- デフォルトメンバー初期化子のない
const 非静的メンバーがあり、そのメンバーが const-default-constructible でない場合
- ユーザーがコンストラクタを直接提供すると、コンパイラは暗黙のデフォルトコンストラクタを定義しようとせず、deleted コンストラクタも作らない
- こうした条件があっても、C++ の初期化規則全体は非常に寛容に動作する
aggregate initialization が入り込む瞬間
struct A { const int x; }; A a{}; はデフォルトコンストラクタが削除されてコンパイルできないように見えるが、実際にはコンパイルできて a.x は 0 になる
- 核心は
A が aggregate だという点である
- aggregate は配列、または次の条件を満たすクラスである
- user-declared または inherited コンストラクタがない
- private/protected の直接の非静的データメンバーがない
- private/protected の直接基底クラスがない
- virtual 関数や virtual 基底クラスがない
- aggregate に list-initialization が行われると、特定の例外を除いて aggregate initialization が適用される
- 初期化子リストの各要素が aggregate の各要素を順に初期化する
- リスト要素数が足りなければ、残りの要素はデフォルトメンバー初期化子があればそれで初期化される
- デフォルトメンバー初期化子がなく参照でもなければ、空の初期化リストで copy-initialized される
- したがって
A a{}; はコンストラクタ呼び出しではなく、a.x が空リストで copy-list-initialized され、value-initialization を経て zero-initialization されるケースである
波括弧初期化の追加規則
T t{...} や T t = {...} のような波括弧ベースの構文は、概ね list-initialization である
T t{...} は direct-list-initialization
T t = {...} は copy-list-initialization
- aggregate でないクラス型で初期化リストが空、かつデフォルトコンストラクタがある場合は value-initialization が行われる
- それ以外では通常の overload resolution でコンストラクタが検討され、
std::initializer_list コンストラクタのオーバーロードが優先される
- 波括弧初期化では、1要素で初期化する際に narrowing conversion を許可しない
struct A {
const int x;
};
A b{4}; // 可能
A c{4.0f}; // narrowing conversion エラー
丸括弧初期化はさらに permissive
T t(a, b, c); のような丸括弧初期化は direct-non-list-initialization を呼び出し、direct-list-initialization に似ているが別の規則を持つ
T t(); はオブジェクト生成ではなく、引数なしで戻り値型が T の関数宣言と解釈される most vexing parse の問題がある
- C++20 からは aggregate にも丸括弧初期化を使える
- 丸括弧ベースの aggregate initialization は波括弧ベースの初期化とは異なる振る舞いをする
- narrowing conversion を許可する
- 残りの要素は空リスト初期化ではなく直接 value-initialized される
- 参照に束縛された一時オブジェクトの寿命を延長しない
struct T {
const int& r;
};
T t(42);
- 上のコードでは
t.r は dangling reference になり、読み取ると undefined behaviour である
std::initializer_list、コピーコンストラクタ、elision
- 丸括弧初期化は、
std::initializer_list<T> コンストラクタのオーバーロードがある場合でも T のコピーコンストラクタを呼び出せる
- 逆に波括弧初期化では
std::initializer_list コンストラクタが優先されることがある
struct T {
T(std::initializer_list<T>) {
std::cout << "list" << std::endl;
}
T(const T&) {
std::cout << "copy" << std::endl;
}
};
T t{}; // list
T s{t}; // list
T r(t); // copy
T q(T{}); // list, copy なし
T q(T{}); でコピーが起きないのは、direct- および copy-initialization において、initializer が T 型の prvalue ならオブジェクトがその式で直接初期化されるという規定があるためである
- この挙動は一般に copy elision として知られるが、標準はこれを明示的に elision とは呼んでいない
- list-initialization で同じ elision が可能かどうかについては標準の説明が不足しており、CWG issue 2311 が関連している
- GCC と Clang はたいていの場合 elision を行う
std::initializer_list<T> コンストラクタのオーバーロードがあると、GCC は elision の代わりにそのコンストラクタを使う
評価順序と実務的な結論
- 丸括弧初期化リストの要素評価順序は保証されない
- 波括弧初期化リストは要素を左から右へ厳密に評価する
- 静的変数初期化や constant initialization のような特殊規則もあるが、通常のオブジェクト初期化だけでも規則は十分に複雑である
- 実務では暗黙のデフォルトコンストラクタや初期化規則に頼るより、明示的なコンストラクタを書いて初期化意図を明確にするほうが安全である
1件のコメント
Hacker Newsのコメント
Tを値初期化すると結果が 0 になるという説明は正しく、最初は見落としていたが、著者は実際にxを値初期化していた全体として C++ の初期化規則は 40 年にわたる拡張と保守を経て難解で常識外れな部分が生まれてしまったが、99.9% くらいは期待どおりに動くと思う
大きな改善は デフォルト初期化 を明示的なものにして、それ以外は常に値初期化することだと思う。大きな配列を 0 で埋めるコストを避けたいときだけ
std::array = void;のような構文で明示するほうがよいあまりに賢くやろうとして手に負えないと不満を言っている例に見える
アプローチ全体が間違っている。構造体に const 参照 を入れるべきではなく、どうしても必要なら
std::reference_wrapperを使うほうが正しい返答はややぶっきらぼうだが、要点は記事の結論が完全に間違っているということ。手書きでコンストラクタを書かず、5/3/0 のルールに従い、const 参照を保持しなければならないなら rvalue の一時オブジェクトを渡していないか確認すべきだ。これはそれほど恐ろしい問題ではない
基本的なチュートリアルを終えた程度の人でも、こういう作為的なシナリオは普通に避ける
std::reference_wrapperは一度も使ったことがなく、これまで関わった複数の C++ コードベースでも見たことがない深く複雑なテンプレートコードのどこかでは使われているのだろうが、言っていることが正しくても、私の経験では常識的な知識ではない
I Have No Mouth, and I Must Scream (1967)の原文はここで読める: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...Martin H Greenberg の家族がうちに泊まっていて、彼は Martin を探していた。SF が好きで彼の本も何冊か読んでいたので、名前を聞いたあとは会話の記憶がかなりぼんやりしている。結局 Martin を起こして電話を代わったが、そのあと眠り直すのは難しかった
unless まで太字になっている箇所に皮肉っぽいコメントがないのが意外だった
T t{v0};やT t{v0, v1};のようなすてきな構文はあるが、要素 1 個の初期化 は要素 2 個以上の場合のようには安定して動かないパラメータパックで構造体を作りやすくする方向に進み、こうした形で初期化できる可変長配列っぽいものまでサポートする言語で、この違いは奇妙だ。型はもちろんテンプレートでもあり得る
手書きのコンストラクタを使うこともできるし、要素を 1 つだけ与えてタプルや配列を初期化することもできるが、特殊な場合には 見当違いのコンストラクタ が呼ばれることがある。C++11 の初期化リストが出たばかりの頃にこれを見つけて、正気とは思えないと感じた
C++ のさらなる奇妙さを見たいなら、C++ FQA を勧める: https://yosefk.com/c++fqa/
もう 15 年ほど前のものだが、C++ は古い機能や挙動をほとんど削除しないので、同時に古びてもいない
ブログテーマが本当に美しい。DEC 時代のコンピュータ から着想を得たのが明らかに感じられるのに、すっきりしていてミニマルで新鮮だ
ひどい話だ。C++ で怖い部分のひとつだと思う。すばらしい機能もあり、優秀な人たちが取り組んでいる言語だけに、なおさら残念だ
Herb の C++ syntax 2 のようなものが、私のような普通の人でも使える言語にしてくれることを期待している
記事の例は、プログラマが自分で書かないと決めた特殊メンバ関数を、言語が例外的に自動生成する機能の隅のケースをわざと突くように作られている
C++ には使った分だけコストを払うという設計目標があり、3 のルール / 5 のルール は C++ 入門レベルの基本知識だ。こうした特殊メンバ関数は特定の条件でのみコンパイラが生成するので、条件を満たさなければ自動生成されないというのも基本に含まれる
結局、使うコンストラクタを自分で設定しなければならないと知っていることが、そんなに理不尽なことだとは思えない。インスタンス生成時に初期化が必要だという事実も驚くような話ではない。現実には、人々は大騒ぎすることもなく、こういうやり方で実際の仕事をしている
これを読むと目まいがする。Java のコンストラクタとオブジェクト初期化を理解しようとしていたころを思い出す
少なくともこれまでの経験では、Go や Rust のように 特別なコンストラクタ がないという選択のほうが、多くのことを単純にしてくれる
コンストラクタのない言語に移ったあとで、コンストラクタが恋しくなった人がいるのか気になる
もちろん
MaybeUninitにptr::writeでフィールドを 1 つずつ直接書き込むことはできるが、人間工学的ではなく、構造体側がそれを明示的に許可している必要がある結局はすべてのコンストラクタを明示的に宣言して定義しなければならないという意味であり、C++ も最初からその選択肢を提供している。特殊メンバー関数の自動生成は、定型コードを避けたい人のために、ごく特定の場合にだけ動くよう追加された機能だ
必要なら自分でコンストラクタを追加すれば普通にやっていける。特殊メンバー関数のせいで単純なことが複雑になると不満を言うのは、辞書に難しい単語があるから英語は単純ではないと不満を言うのに近い
私の理解では、Rust のコンストラクタは基本的に Java と似たようなものではないのか?
https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
裏で起きている 暗黙の動作 をすべて追加または可視化してくれる C++ ツールがあるのか気になる
たとえば自動追加されるコンストラクタ、暗黙のコピーコンストラクタ、そのほか驚くようなものを見せてくれるツールのことだ
cppinsightsを組み合わせるのが最善だろうT::T() = default;の場合、出力は 0 になると期待するだろうが、実際にはゴミ値になるという例は、それほど奇妙でもない関連リンク: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
そうしなければ、ライブラリ利用者なら誰でもライブラリの動作を変えられてしまうからだ