O'Reilly Early Releaseで購入したEffective Modern C++のドラフト版の読書記録です。
※この記事はあくまで私の備忘録です。(断りなく自分の解釈や考え・感想を入れたり、理由もなく内容を省略したり、間違いや曲解もあると思います。誤りの指摘は歓迎です。)
Item 7: オブジェクトを生成する時、()と{}を区別しよう
以下の文は全てint型変数を0で初期化する
int x(0); int y = 0; int z{0}; int z = {0};
初期化と代入の区別
Widget w1; // デフォルトコンストラクタが呼ばれる Widget w2 = w1; // コピーコンストラクタが呼ばれる w1 = w2; // 代入(operator=が呼ばれる)
C++11で追加された{} (braced initialization) はuniform initializationの実装。
{}を使うと、C++11以前はできなかったコンテナの初期化が可能。
std::vector<int> v{1, 2, 3};
初期化方法に違いが生じるケース
C++11では、非staticデータメンバの初期化が可能になったが、()ではできない
class Widget { private: int x{0}; // OK int y = 0; // OK int z(0); // エラー! }
std::atomicsなどのコピー不可能なオブジェクトは=で初期化できない
std::atomic<int> ai1{0}; // OK std::atomic<int> ai2(0); // OK std::atomic<int> ai3 = 0; // エラー!{}は組み込み型のnarrowing conversionを禁止する
double x, y, z; ... int sum1{x + y + z}; // エラー! doubleからintへの変換に非対応 int sum2(x + y + z); // OK double型の値がint型に丸められる int sum3 = x + y + z; // OK 同上{}はmost vexing parseを起こさない
Widget w1(10); // 10を引数に取るWidgetのコンストラクタが呼ばれる Widget w2(); // most vexing parse! Widget型を返す関数の宣言と解釈される void f(const Widget& w = Widget()); // Widgetのデフォルトコンストラクタが呼ばれる Widget w1{10}; //コンストラクタが呼ばれる Widget w2{}; // デフォルトコンストラクタが呼ばれる void f(const Widget& w = Widget{}); //デフォルトコンストラクタが呼ばれる
{}の直感に反する動作
autoは、{}で初期化されるオブジェクトをstd::initializer_list型と型推定する
auto v1 = -1; // v1はint型 auto v2(-1); // v2はint型 auto v3{-1}; // v3はstd::initializer_list<int>型 auto v4 = {-1}; // v4はstd::initializer_list<int>型
std::initializer_listの無いコンストラクタは通常の動作
class Widget { public: Widget(int i, bool b); Widget(int i, double d); ... }; Widget w1(10, true); // 1番目のコンストラクタが呼ばれる Widget w2{10, true}; // 同上 Widget w3(10, 5.0); // 2番目のコンストラクタが呼ばれる Widget w4{10, 5.0}; // 同上
しかしstd::initializer_listがある場合、{}は常にそちらを優先する
class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<long double> il); ... }; Widget w1(10, true); // 1番目のコンストラクタが呼ばれる Widget w2{10, true}; // std::init_listのコンストラクタが呼ばれる // 10とtrueはlong doubleに変換される Widget w3(10, 5.0); // 2番目のコンストラクタが呼ばれる Widget w4{10, 5.0}; // std::init_listのコンストラクタが呼ばれる // 10と5.0はlong doubleに変換される
narrowing conversionが生じてコンパイルエラーになってもstd::initializer_listを優先する
class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<bool> il); ... }; Widget w{10, 5.0}; // narrowing conversionによるコンパイルエラー!
しかし変換が存在しない場合は他の候補を読みに行く
class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<std::string> il); ... }; Widget w1(10, true); // 1番目のコンストラクタが呼ばれる Widget w2{10, true}; // 1番目のコンストラクタが呼ばれる Widget w3(10, 5.0); // 2番目のコンストラクタが呼ばれる Widget w4{10, 5.0}; // 2番目のコンストラクタが呼ばれる
空の{}は空のstd::initializer_listではなく引数が無いことを表す
class Widget { public: Widget(); Widget(std::initializer_list<int> il); ... }; Widget w1; // デフォルトコンストラクタが呼ばれる Widget w2{}; // デフォルトコンストラクタが呼ばれる Widget w3(); // most vexing parse! 関数が宣言される Widget w4({}); // std::init_listのコンストラクタが呼ばれる(リストは空) Widget w5{{}}; // 同上
コピー・ムーブコンストラクタは通常通り呼ばれる
(Widget型であれば、たとえint型への変換が定義されていても、コピーコンストラクタ・ムーブコンストラクタが呼ばれる)
class Widget { public: Widget(const Widget& rhs); Widget(Widget&& rhs); Widget(std::initializer_list<int> il); operator int() const; ... }; auto w6{w5}; // コピーコンストラクタが呼ばれる auto w7{std::move(w5)}; // ムーブコンストラクタが呼ばれる
これらのような難解なルールは、std::vectorという身近な例にも影響を与える。
std::vector<int> v1(10, 20); // 20を値とする要素を10個持つvector std::vector<int> v2{10, 20}; // 10と20の2つの要素を持つvector
クラスデザインのガイドライン
ユーザーが()を使っても{}を使っても同じコンストラクタが呼ばれるデザインが良い。(その点においてstd::vectorは悪い例。)
std::initializer_listを引数に取るコンストラクターが無い状態からそれを新たに加える場合、既存のコードがその新しいコンストラクターを呼び出すように変化する危険性が生じる。
そのような改変は、十分に熟考してから行うべきである。
()と{}の利用ガイドライン
選択肢1: 原則{}を使う
{}は最も広範囲に適用でき、narrowing conversionを防ぎ、C++のmost vexing parseを起こさないという理由で{}を使用する。 ただし、先述のように{}を使用できないいくつかの場合において()を使用する。
選択肢2: 原則()を使う
()はC++98の文法と互換性があり、autoによるstd::initializer_list型推定の問題を回避し、std::initializer_listコンストラクタが意図せず呼び出されることが無いということを重んじて、()を使用する。 ただし、vectorの初期化などのように{}が必要な場面では{}を使用する。
片方の選択肢がもう片方より優れているということはない。
どちらかを選び、一貫した姿勢でコードを書くと良いだろう。
template関数やクラスを設計する場合はさらに事情が複雑になる。
template<typename T, typename... Args> void doSomeWork(const T& obj, Args&&.. args) { T localObject(std::forward<Args>(args)...); T localObject{std::forward<Args>(args)...}; }
これにたとえばユーザーがvectorを入れると、先述のように異なる結果になってしまう。
std::vector<int> v; ... doSomeWork(v, 10, 20);
std::make_uniqueとstd::make_sharedはこの問題に直面し、()を採用してその旨をインターフェースに記述することで解決した。 他には、タグディスパッチによる解決方法もある。(Item 29)
Things to Remember
- {}による初期化は最も広く適用可能な初期化であり、narrowing conversionを防ぎ、most vexing parseを起こさない
- Item 2の通り、autoは{}で初期化されたオブジェクトをstd::initializer_list型と型推定する
- {}による初期化は、std::initializer_listをパラメータに持つコンストラクタがある場合、たとえ他のコンストラクタの方がマッチしててもそちらが呼ばれる。
- ()と{}のどちらで初期化するかで違いが生じる例はstd::vectorの2引数コンストラクタ
- template内のオブジェクト生成において()と{}のどちらを選ぶかという決断は困難になりうる