1 ポイント 投稿者 GN⁺ 2024-07-06 | 1件のコメント | WhatsAppで共有
  • 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.x0 になる
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.x0 になる
  • 核心は Aaggregate だという点である
  • 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.rdangling 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件のコメント

 
GN⁺ 2024-07-06
Hacker Newsのコメント
  • T値初期化すると結果が 0 になるという説明は正しく、最初は見落としていたが、著者は実際に x を値初期化していた
    全体として C++ の初期化規則は 40 年にわたる拡張と保守を経て難解で常識外れな部分が生まれてしまったが、99.9% くらいは期待どおりに動くと思う
    大きな改善は デフォルト初期化 を明示的なものにして、それ以外は常に値初期化することだと思う。大きな配列を 0 で埋めるコストを避けたいときだけ std::array = void; のような構文で明示するほうがよい

    • インスタンスを実際に初期化すれば問題は起きず、コンストラクタを呼びたいならコンストラクタを定義すればよい
      あまりに賢くやろうとして手に負えないと不満を言っている例に見える
  • アプローチ全体が間違っている。構造体に const 参照 を入れるべきではなく、どうしても必要なら std::reference_wrapper を使うほうが正しい
    返答はややぶっきらぼうだが、要点は記事の結論が完全に間違っているということ。手書きでコンストラクタを書かず、5/3/0 のルールに従い、const 参照を保持しなければならないなら rvalue の一時オブジェクトを渡していないか確認すべきだ。これはそれほど恐ろしい問題ではない

    • 5/3/0 のルール はデストラクタ、ムーブコンストラクタ、コピーコンストラクタに関するものだ。最後の例を除けば、記事は主に標準コンストラクタと呼べそうなものの奇妙な挙動を扱っている
    • 同感。筆者がごく基本的な原則やガイドラインをわざと無視して、文句の種を見つけ出した感じが強い
      基本的なチュートリアルを終えた程度の人でも、こういう作為的なシナリオは普通に避ける
    • std::reference_wrapper は一度も使ったことがなく、これまで関わった複数の C++ コードベースでも見たことがない
      深く複雑なテンプレートコードのどこかでは使われているのだろうが、言っていることが正しくても、私の経験では常識的な知識ではない
  • I Have No Mouth, and I Must Scream (1967) の原文はここで読める: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...

    • Harlan Ellison と一度電話で話したことがある。17 歳のとき、午前 2 時に家に電話がかかってきて、30 フィートの電話線を引きずって部屋に入り、ドアを閉めていた時代のことだ
      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 のように 特別なコンストラクタ がないという選択のほうが、多くのことを単純にしてくれる
    コンストラクタのない言語に移ったあとで、コンストラクタが恋しくなった人がいるのか気になる

    • インプレースで動作するコンストラクタを可能にする、やや難解で魔法のようなケースはいくつかある。たとえば Rust には、まだ保証された placement new 的な動作がない
      もちろん MaybeUninitptr::write でフィールドを 1 つずつ直接書き込むことはできるが、人間工学的ではなく、構造体側がそれを明示的に許可している必要がある
    • コンパイラが何もしてくれないことが「本当に単純にする」という話は、あまりよく分からない
      結局はすべてのコンストラクタを明示的に宣言して定義しなければならないという意味であり、C++ も最初からその選択肢を提供している。特殊メンバー関数の自動生成は、定型コードを避けたい人のために、ごく特定の場合にだけ動くよう追加された機能だ
      必要なら自分でコンストラクタを追加すれば普通にやっていける。特殊メンバー関数のせいで単純なことが複雑になると不満を言うのは、辞書に難しい単語があるから英語は単純ではないと不満を言うのに近い
    • Java のコンストラクタは比較的分かりやすいが、こちらの C++ 側は本当に黒魔術のようだ
      私の理解では、Rust のコンストラクタは基本的に Java と似たようなものではないのか?
    • Go の ゼロ初期化 も、ときどき言語仕様を細かく確認する必要がある
      https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
  • 裏で起きている 暗黙の動作 をすべて追加または可視化してくれる C++ ツールがあるのか気になる
    たとえば自動追加されるコンストラクタ、暗黙のコピーコンストラクタ、そのほか驚くようなものを見せてくれるツールのことだ

    • 現実的には Godboltcppinsights を組み合わせるのが最善だろう
  • T::T() = default; の場合、出力は 0 になると期待するだろうが、実際にはゴミ値になるという例は、それほど奇妙でもない
    関連リンク: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
    そうしなければ、ライブラリ利用者なら誰でもライブラリの動作を変えられてしまうからだ

    • 代案が筋が通らないからといって、この解決策が自動的に筋が通るわけではない。むしろ、C++ の設計がどれほど多くの 行き止まり に自ら追い込まれてきたかを示している