パソコンでプログラミングしよう ウィンドウズC++プログラミング環境の構築
1.8.2.5(4)
本サイトは移転しました。旧アドレスからのリダイレクトは2025年03月31日(月)まで有効です。
🛈
C++最厄構文

C++で最も厄介と言われる構文を説明し、リスト初期化({...}による初期化)の優位性と限界を議論する。

C++03より古くサイト作成者が初心者だった頃、サンプルコードMyClassクラスのc1変数を値初期化したつもりが必ずしもc1をMyClassインスタンスとして定義しない事に気付いた。それはc2変数のようなデフォルト初期化で代替できるものの、なぜそうなってしまうか暫く理解できなかった。

struct MyClass
{
MyClass() {}
void memFunc() {}
};
int main()
{
MyClass c1(); // ()による値初期化で変数定義したつもり
//c1.memFunc(); // error: request for member 'memFunc' in 'c1', which is of non-class type 'MyClass()'.
MyClass c2; // デフォルト初期化で変数定義
c2.memFunc(); // OK
MyClass c3{}; // {}による値初期化で変数定義、C++11以降
c3.memFunc(); // OK
MyClass* pc4=new MyClass(); // ()による値初期化でnew式
pc4->memFunc();
delete pc4;
}

組み込み型でも事情は変わらないがデフォルト初期化による代替は厳密ではない。

int main()
{
int i1(); // ()による値初期化で変数定義したつもりだが関数宣言
int i2; // デフォルト初期化でi2=不定
int i3{}; // {}による値初期化でi3=0、C++11以降
int* pi4=new int(); // ()による値初期化で*pi4=0
}

後に教科書でc1およびi1が関数として宣言されていたことを知る(Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, pp.33-35)。スコット・メイヤーズはこれを"the most vexing parse"と呼び、本サイトは"C++最厄構文"という訳語をあてる。本記事はこれを規格に照らして再確認する。丸括弧(...)に起因する問題なのでC++11以降は波括弧{...}による初期化子で回避できるが、{...}は別の問題を顕わにして本記事はそれについても議論する。初期化に関する文脈と用語は初期化とオーバーロード(1)に従うとして各々の説明や規格参照は省く。

本記事においてオブジェクトはC++規格定義(JTC1/SC22/WG21 N4659 4.5/p1)とする。他記事のほとんどで用いる"クラスとインスタンスの総称"ではないことに留意いただきたい。本記事において関数とは断らない限り自由関数(クラスメンバ関数ではない)とする。

C++最厄構文とは何か

C++最厄構文とは教科書に説明されたもの以上でない。要は変数定義での(...)による初期化と関数宣言が特定条件で区別できず、規格はこれを関数宣言と見なすことである(N4659 9.8/p1)。本記事はこれを2つの形式に分類し、形式1は引数の無い単純な形式で、形式2は引数1個以上のより複雑な形式とする。引数とは変数定義では実引数であり、関数宣言では仮引数であり、つまり形式2は実引数と仮引数が曖昧となる構文である。

引数の無い場合(形式1)

以下のf1にFooインスタンスのデフォルト初期化を伴う変数定義を意図しよう。f1は意図に反し形式1のC++最厄構文となって、仮引数が無くFoo型を返す関数宣言でその型はFoo()である。f2とf3は対比とする直接初期化を伴う変数定義で、これらは意図通りの変数定義文となる。

struct Foo
{
Foo() {}
Foo(int) {}
};
int main()
{
Foo f1(); // 値初期化で変数定義したつもりが関数宣言でFoo()型
Foo f2(0); // 実引数0による直接初期化
int i=0;
Foo f3(i); // 実引数iによる直接初期化
}

Foo f1();は変数定義文と関数宣言文で曖昧となって、規格により関数宣言文として扱われる(11.6/p11 Note)。関数f1は複合文(9.3)すなわちブロックスコープ(6.3.3)で宣言されて、ここでの複合文はすなわち関数本体(11.4.1/p1)に等しい。f1を関数としてコールするには定義が必要だがその定義は名前空間スコープ(6.3.6)(あるクラスのfriendであればクラススコープ(6.3.7)も可)でしか行えない。なおグローバルスコープ(global scope)もグローバル名前空間(global namespace)のスコープである事を確認しておく(6.3.6/p3)。逆に言えばブロックスコープで関数宣言できてしまうわけで、C++最厄構文はその副作用と言える。ブロックスコープの関数宣言については後に確認する。

引数1個以上の場合(形式2)

Fooインスタンスをコンストラクタ実引数とするBarインスタンスの直接初期化を伴う変数定義を意図しよう。ここでは全てのFooインスタンスを(...)による明示的変換式(8.2.3)の結果(一時オブジェクト)で与える。明示的変換式の(...)による初期化自体は変数定義のそれのような制限は持たないことを思い出そう。b1とb2は意図通りに直接初期化を伴う変数定義となる。b3とb4はしかし意図に反して形式2のC++最厄構文となる。b3は仮引数がFoo型でBar型を返す関数宣言でその型はBar(Foo)である。b4は仮引数がFoo(*)()型でBar型を返す関数宣言でその型はBar(Foo(*)())である。b5とb6は(...)内が仮引数リストとなり得ず意図通りの変数定義となる。

struct Foo
{
Foo() {}
Foo(int) {}
};
struct Bar
{
Bar(Foo) {}
};
int main()
{
Bar b1(Foo(0)); // Foo(0)による直接初期化
Foo f2(0);
Bar b2(f2); // Foo f2(0)たる変数f2による直接初期化
int i=0;
Bar b3(Foo(i)); // Foo(i)による直接初期化で変数定義したつもりが関数宣言でBar(Foo)型
Bar b4(Foo()); // Foo()による直接初期化で変数定義したつもりが関数宣言でBar(Foo(*)())型
Bar b5((Foo(i))); // Foo(i)による直接初期化、(Foo(i))は仮引数になれない
Bar b6((Foo())); // Foo()によるC++直接初期化、(Foo())は仮引数になれない
}

b3を解説する。これが直接初期化を伴う変数定義文であればFoo(i)は直接初期化による明示的変換式であるが、関数宣言文であればFoo(i)はFoo型の仮引数iの宣言となる。後者を規格で確認する。関数宣言の(...)内は仮引数宣言節(parameter-declaration-clause)であり(11.3.5/p1)、仮引数宣言節は仮引数宣言(parameter-declaration)のリストである(p3)。仮引数宣言は宣言指定子並び(decl-specifier-seq)と宣言子(declarator)または(空かもしれない)仮想宣言子(abstract-declarator)の結合である(p3)。宣言指定子並びは宣言指定子(decl-specifier)の並び(10.1/p1)で、宣言指定子を議論の範囲で単純化すれば型定義指定子(defining-type-specifier)に等しく(p1)、型定義指定子は関数仮引数の制約(11.3.5/p11)から型指定子(10.1.7/p1)に限定され、型指定子とはcv修飾(constなど)されるかもしれない型である。宣言子/仮想宣言子を議論の範囲で単純化すれば、宣言子は変数、配列、関数の名前を必要であればポインタ演算子(ptr-operator)(*や&など)で前置するもので(11/p4)、仮想宣言子は宣言子から名前を除いたもので(11.1/p1)、ポインタ演算子の前置が無ければ仮想宣言子は空である。長々と規格を追いかけてきたが、つまりは関数宣言文でFoo型の仮引数iを宣言するならそれはFoo iであり、名前が不要ならそれはFooであり、あなたの常識に一致する。ところでiの宣言はFoo(i)でも良く(11.3/p6)、これはFoo(i)は明示的変換式であるというあなたの常識に一致しないかもしれない。結果としてBar b3(Foo(i));は変数定義文(Foo(i)は明示的変換式)と関数宣言文(Foo(i)は仮引数iの宣言)で曖昧となって、規格により関数宣言文として扱われる。

覚え書き
名前iは(定義でない)関数宣言であれば関数プロトタイプスコープに導入され(6.3.4/p1)、関数定義であれば本体{...}のブロックスコープまで含み(6.3.3/p2)、外郭のスコープで別にiが宣言されていればそれを隠す(13.2/p1)。(定義でない)関数宣言ならiはプレースホルダー以上の意味を持たない。

b4を解説する。これが直接初期化を伴う変数定義文であればFoo()はデフォルト初期化による明示的変換式であるが、関数宣言文であればFoo()はFoo(*)()型の仮引数の仮想宣言(名前を持たない仮引数の宣言)となる。後者を規格で確認する。Foo()が関数宣言文の仮引数に現れるとすれば、それは明示的変換式ではなく型宣言のはずなので、以下は型宣言を前提として議論を進める。Foo f1()とすればf1という名前でFoo()型の宣言(関数宣言)となることは形式1で説明した。この名前を省けばFoo()となり、すなわちこれはFoo()型の仮想宣言である。b4はこの仮想宣言を仮引数に持つBar(Foo())型の関数宣言と見なせるが関数はオブジェクトではない。初期化はオブジェクトのみが可能で仮引数は実引数でコピー初期化されるため、Foo()型の仮引数は本来なら許されない。しかし仮引数において関数型は関数ポインタ型に修正されるという特別ルールが存在し(11.3.5/p5)、つまりFoo(*)()型へ修正される。結果としてBar b4(Foo());は変数定義文(Foo()は明示的変換式)と関数宣言文(Foo()はFoo(*)()の仮想宣言)で曖昧となって、規格により関数宣言文として扱われる。

覚え書き
関数名を特別のルールで扱うのはC言語から受け継ぐ。関数ID式は常に左辺値式であるが、右辺値を要求する式では標準変換の一つである関数からポインタ変換(Function-to-pointer conversion)で関数ポインタ型に変換される。仮引数に関数型が現れれば関数ポインタ型に修正される。オブジェクトとして扱えない関数をポインタ演算子無しで取り廻すための特別ルールとされるが混乱を招くことが多い。なお配列型はオブジェクトの一つであるが、ほぼ同様の特別ルールを持つ。
void func() {} // void()型関数の定義
void func_para_1(void(*)()) {} // 仮引数はvoid(*)()型すなわちvoid()関数のポインタ型
void func_para_2(void()) {} // 仮引数はvoid()型でなくてvoid(*)()型
int main()
{
//void func1()=func; // エラー、関数型は初期化できない
void (*pfunc2)()=&func; // OK、関数ポインタ型は初期化できる
void (*pfunc3)()=func; // OK、funcは標準変換でポインタ型に変換
func_para_1(&func); // OK、実引数は関数ポインタ型
func_para_1(func); // OK、funcは標準変換でポインタ型に変換
func_para_2(&func); // OK、実引数は関数ポインタ型
func_para_2(func); // OK、funcは標準変換でポインタ型に変換
}

b3とb4を誤って変数定義文と見なせば実引数が異なるだけだが、正しい関数宣言文としては異なる関数型である事に注意したい。スコット・メイヤーズの教科書はC++標準ライブラリを用いて入力ストリームイテレータからコンテナを初期化したつもりが意図しない関数宣言になる場合を示した(以下、メイヤーズサンプル)。サイト作成者も何度か痛い目にあった記憶がある。

#include <fstream>
#include <list>
#include <iterator>
using namespace std;
int main()
{
ifstream dataFile("data.txt");
// Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, p.34
list<int> data(istream_iterator<int>(dataFile),
istream_iterator<int>());
// コンパイルエラー、dataはlist<int>型ではなくて
// list<int>(istream_iterator<int>,istream_iterator<int>(*)())関数型
data.size();
}

ブロックスコープの関数宣言

ブロックスコープの関数宣言を確認する。スコープは名前の有効範囲であり(6.3.1/p1)、ブロックは0個以上の文を{...}で囲み複合文とも呼ばれ(9.3/p1)、それ自体も文の一つなので(9/p1)ネストできる。関数宣言が定義となるためには少なくとも関数本体が必要で(11.4.1/p1)、関数本体とは=defaultなどの例外を除けば複合文である(p1)。ブロックスコープはブロック内に定義されるスコープであり、宣言文で開始してブロック終端で終了する(6.3.3/p1)。関数宣言の仮引数名として宣言されればスコープはそこから開始して最も外側のブロック(ほとんどの場合に関数本体)終端で終了する(p2)。他にif文などの丸括弧内宣言もブロックスコープとなり得る(p3,p4)。結局、ブロックスコープとは関数本体内部のネスト可能なスコープと見なして問題なく、同じ名前があれば内側のスコープが外側のそれを隠す(6.3.10/p1)。

プログラムは1つ以上の翻訳単位を結合する(6.5/p1)。翻訳単位は宣言文の集合体で(p1)、定義されたスコープ以外のスコープに宣言で導入される名前はリンケージ(linkage)を持つ(p2)。名前が他の翻訳単位のスコープあるいは同じ翻訳単位の異なるスコープから参照される場合を外部(external)リンケージ(p2.1)、同じ翻訳単位の異なるスコープからのみ参照される場合を内部(internal)リンケージと呼び(p2.2)、他の場合はリンケージをもたない(p2.3)。外部リンケージ⊃内部リンケージという関係を覚えておこう。エンティティの定義が名前空間スコープならリンケージを持ち、ブロックスコープであればリンケージを持たない(p2-p6)。無名名前空間は内部リンケージで、それに属するエンティティも内部リンケージとなる(p4)。名前のある名前空間は外部リンケージで、それに属するエンティティも変数と関数を例外として外部リンケージとなる(p3)。名前のある名前空間でconstでない変数と関数のデフォルトは外部リンケージで(p3.1)、const変数のデフォルトは内部リンケージで(p3.2)、ストレージクラス指定子(strage-class-specifier)のstatic/extern(10.1.1/p1)で内部/外部を明示指定できる(6.5/p3.1,p3.1)。あるクラスのfriend関数はクラススコープ内で定義できるが、その場合はクラスと同じスコープにあると見なす(14.3/p7)。

ブロックスコープの関数宣言とストレージクラス指定子externを持つ変数宣言は、最も内側の名前空間スコープに同じエンティティがあればそれと同じリンケージとして、そういったエンティティが無ければ外部リンケージとする(6.5/p6)。外部リンケージ⊃内部リンケージなので外部リンケージが必ずしも別翻訳単位へのリンクを伴うとは限らない。ブロックスコープでストレージクラス指定子staticとする関数宣言はできない(10.1.1/p4)。このように関数はブロックスコープで宣言できるが、定義は名前空間スコープ(あるクラスのfriendであればクラススコープも可)でしかできない(11.4.1/p2)。

void func1() {} // 関数定義、外部リンケージ(*1)
void func2() {} // 関数定義、外部リンケージ(*2)
static void func3() {} // 関数定義、内部リンケージ(*3)
static void func4() {} // 関数定義、内部リンケージ(*4)
int main()
{
void func0(); // 関数宣言、恐らく他翻訳単位へのリンク
void func1(); // 関数宣言、外部リンケージ、(*1)のブロックスコープへの導入
extern void func2(); // 関数宣言、外部リンケージ、(*2)のブロックスコープへの導入
void func3(); // 関数宣言、内部リンケージ、(*3)のブロックスコープへの導入
extern void func4(); // 関数宣言、内部リンケージ、(*4)のブロックスコープへの導入
}
覚え書き
ストレージクラス指定子staticは文脈依存で意味が変わる。名前空間スコープではexternと共に変数と関数のリンケージを指定するが、ブロックスコープではexternと異なり変数のストレージ期間を指定し、関数に与える事はできない。クラススコープでは静的メンバを指定する。
static int i1; // 静的ストレージで内部リンケージ(*1)
extern int i2; // 静的ストレージで外部リンケージ(*2)
int main()
{
int i1; // 動的ストレージでリンケージ無し、(*1)と無関係
int i2; // 動的ストレージでリンケージ無し、(*2)と無関係
{
static int i1; // 静的ストレージでリンケージ無し、(*1)と無関係
extern int i2; // 静的ストレージで外部リンケージ、(*2)のブロックスコープへの導入
}
{
extern int i1; // 静的ストレージで内部リンケージ、(*1)のブロックスコープへの導入
static int i2; // 静的ストレージでリンケージ無し、(*2)と無関係
}
}

まとめれば関数本体(ブロックスコープ)で関数宣言できて、C++最厄構文をもたらす結果となる。

C++最厄構文の回避方法

C++最厄構文となる条件

形式1と形式2を併せてC++最厄構文となる条件をまとめる。

  • (...)による初期化を伴う変数定義文を意図する。
  • (...)へ与える0個以上の実引数全てがT()あるいはT(x)形式の明示的変換式を意図して、ここでTは型名でxは単一の非修飾ID式(主に変数名)である。

この文は意図に反し関数宣言文で、T()はT(*)()関数ポインタ型仮引数仮想宣言、T(x)はT型仮引数宣言である。(...)へ与える実引数の少なくとも1つがT()/T(x)以外の形式であるならば、この文は意図通りの変数定義文となる。

struct Foobar {Foobar(...) {}};
struct Baz {Baz(...) {}};
int i1,i2;
int main()
{
// C++最厄構文になる、全てFoobar型を返す関数宣言
Foobar fx0(); // Foobar()型
Foobar fx1(Baz(i1)); // Foobar(Baz)型
Foobar fx2(Baz(i1),Baz(i2)); // Foobar(Baz,Baz)型
Foobar fx3(Baz(i1),Baz(i2),Baz()); // Foobar(Baz,Baz,Baz(*)())型
// C++最厄構文にならない、全てFoobar型変数定義
Foobar f01((Baz(i1)));
Foobar f02(Baz(i1),(Baz(i2)));
Foobar f03((Baz(i1)),Baz(i2),Baz());
Baz b1(i1);Foobar f04(b1,Baz(i2),Baz());
Foobar f05(Baz(i1+0),Baz(i2),Baz());
Foobar f06(Baz(::i1),Baz(i2),Baz());
Foobar f07(Baz{i1},Baz(i2),Baz()); // C++11以降
Foobar f11(Baz(i1,i1),Baz(i2),Baz());
Foobar f12(Baz(0),Baz(i2),Baz());
Foobar f13(0,Baz(i2),Baz());
}

FoobarクラスとBazクラスのコンストラクタ仮引数を省略記号(ellipsis)のみとしている。任意数の任意型実引数をコンストラクタに渡して常にコンパイル可能で、サンプルコードの簡略化を目的とするだけのものであって他意は無い。

覚え書き
本記事の文脈において(...)は常に初期化子()と(a)の総称としている。省略記号も...なので関数宣言の仮引数がそれのみなら名前に(...)が後置される。後者はサンプルコード内に留め、本文中の(...)は常に前者を指す。

C++11より前の回避方法

最初に(...)による初期化(値初期化/直接初期化)を維持するという制約を課す。()による値初期化すなわち先のサンプルコードfx0は、C++11より前に回避方法は存在しない。aを実引数リストとして(a)による直接初期化は実引数の少なくとも1つがT(x)の形式にならないようにする。サンプルコードfx3のBaz(i1)を例とすれば(Baz(i1))、Baz b1(i1)たる変数b1、Baz(i1+0)、Baz(::i1)とすれば良いが、i1+0(i1は演算可能)や::i1(i1は修飾可能)は場合によって不可能で、括弧式(Baz(i1))か変数b1とするのが間違いない。教科書もこれらを推奨していた。

制約を外せば=T(...)による初期化(明示的変換式からのコピー初期化)すれば良く、最も標準的な回避方法と言える。セマンティクスは明示的変換式T(...)の作成する一時オブジェクトを定義変数にコピーするが、規格はコンパイラ最適化による一時オブジェクト省略(RVO)を許してコピーコンストラクタはそれがコピー以外の副作用をもたらすとしても必ずしもコールされない。ただしC++17より前はセマンティクスの維持は必要でコピーコンストラクタ=deleteとするとコンパイルエラーとなる。つまり全ての場合においてC++最厄構文を回避できる方法ではない。

struct Foobar
{
Foobar(...) {}
//Foobar(const Foobar&)=delete; // (*1)
};
struct Baz {Baz(...) {}};
int i1,i2;
int main()
{
// C++最厄構文になる、全てFoobar型を返す関数宣言
Foobar fx0(); // Foobar()型
Foobar fx3(Baz(i1),Baz(i2),Baz()); // Foobar(Baz,Baz,Baz(*)())型
// C++最厄構文にならない、全てFoobar型の変数定義、明示的変換式からのコピー初期化
// ただし(*1)をアンコメントするとC++17より前でコンパイルエラー
Foobar fc0=Foobar();
Foobar fc3=Foobar(Baz(i1),Baz(i2),Baz());
}
覚え書き
本記事の主題から逸れる。他意無くFoobarのコンストラクタ仮引数を省略記号のみとしたが手抜きが過ぎたようだ。C++17より前でff1は(*1)をアンコメントするとコンパイルエラーとなる一方でff2はOKを維持する。サンプルコードに不要な要素が追加されてしまった。
struct Foobar
{
Foobar(...) {} // (*0)
//Foobar(const Foobar&)=delete; // (*1)
};
int main()
{
Foobar ff1(0); // (*1)をアンコメントするとC++17より前でコンパイルエラー
Foobar ff2(0,0); // (*1)をアンコメントしてもOK
}
オーバーロードに関する文脈と用語は初期化とオーバーロード(2)に従うものとする。(*1)をアンコメントしたとする。ff1を直接初期化する関数候補は(*0)と(*1)である。(*0)が実行可能なのは説明するまでもなく、int型実引数は省略記号変換シーケンスで暗黙変換される。(*1)が実行可能であるにはFoobar型へ暗黙変換できなければならず、(*0)がint型実引数からのユーザー定義変換シーケンスを可能とするためやはり実行可能となる。両者は共に実行可能で、省略記号変換シーケンス(*0)とユーザー定義シーケンス(*1)の優劣判定で(*1)が選択される。結果としてff1は(*0)の作成する一時オブジェクトを(*1)でコピーするというセマンティクスとなり(RVOで実際に(*1)をコールするとは限らないが)、(*1)=deleteでコンパイルエラーとなる。一方ff2の実行可能関数は(*0)だけなので(*1)の存否は関係しない。

Tがクラス型でデフォルトコンストラクタをユーザー定義すればデフォルト初期化と値初期化は同じである。デフォルトコンストラクタがコンパイラ生成だとしても、それが自明(trivial)でなければデフォルト初期化と値初期化は同じである。つまりこれらの場合であればデフォルト初期化で値初期化を意図したC++最厄構文を回避することもできて、一番最初のサンプルコードで例を示した。ただしそれ以外の場合、デフォルト初期化と値初期化は同じとは言えない(詳しくは初期化とオーバーロード(1))。

C++11以降の回避方法

C++11以降で初期化子に波括弧{...}を利用できるようになって、これで初期化すれば関数宣言文になりようがない。もちろんBazの初期化子も統一して{...}すべきだろう。

struct Foobar {Foobar(...) {}};
struct Baz {Baz(...) {}};
int i1,i2;
int main()
{
// C++11以降でC++最厄構文にならない、全てFoobar型の変数定義
Foobar fx0{};
Foobar fx1{Baz(i1)};
Foobar fx2{Baz(i1),Baz(i2)};
Foobar fx3{Baz(i1),Baz(i2),Baz()};
// Bazの初期化子も{...}に統一すべきで以下が推奨される、全てFoobar型の変数定義
Foobar fy3{Baz{i1},Baz{i2},Baz{}};
// 逆に以下でもC++最厄構文にならない、全てFoobar型の変数定義
Foobar fz3(Baz{i1},Baz{i2},Baz{});
}

明示的変換式からのコピー初期化も{...}できる。C++17以降は純右辺値式からのコピー省略が保証されコピーコンストラクタ=deleteとしてもコンパイルエラーとならない。

struct Foobar
{
Foobar(...) {}
//Foobar(const Foobar&)=delete; // (*1)
};
struct Baz {Baz(...) {}};
int i1,i2;
int main()
{
// C++最厄構文にならない、全てFoobar型の変数定義、明示的変換式からのコピー初期化
// (*1)をアンコメントするとC++17より前でコンパイルエラーだが、C++17以降はOK
Foobar fc0=Foobar{};
Foobar fc3=Foobar{Baz{i1},Baz{i2},Baz{}};
}

メイヤーズサンプルも{...}で書き換えてめでたしめでたし。

#include <fstream>
#include <list>
#include <iterator>
using namespace std;
int main()
{
ifstream dataFile{"data.txt"};
// Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, p.34
// Replaced all parentheses with curly brackets.
list<int> data{istream_iterator<int>{dataFile},
istream_iterator<int>{}};
// コンパイルOK、dataはlist<int>型
data.size();
}

波括弧初期子規格化の経緯

リスト初期化({...}による初期化)がC++11に採用される経緯を紐解き、初期化とオーバーロード(1)を補う。C++11策定に向けた二つの主要な提案書を論拠とし、以降はそれぞれをN2215とN2640として参照する。

リスト初期化

vをある型の値としてX型オブジェクトをvで初期化する場合に四つの構文が存在する(N2215, p.3)。

X t1 = v; // “copy initialization” possibly copy construction
X t2(v); // direct initialization
X t3 = { v }; // initialize using initializer list
X t4 = X(v); // make an X from v and copy it to t4

どの構文が実際に使えるかはオブジェクトの種類とvの型に依存するが、それぞれが適当な型を選べるとすれば以下にまとめられる。

スカラー 配列 集約クラス 集約でないクラス
t1
t2
t3
t4

t3の構文すなわち初期化リスト(initializer list)が集約(配列あるいは集約クラス型)の各要素を対応する値で初期化するのはC言語より受け継ぐ。スカラーもまた初期化リストで初期化できるが、やはりC言語より受け継ぐ(JTC1/SC22/WG14 N1570 6.7.9/p11)。このt3すなわち={v}を集約でないクラスの直接初期化へ拡張してvをコンストラクタへの実引数とする(N2215, p4)。本来はコピー初期化の文脈にある関数コールにも{v}を使えるとして、例えば関数f(X)をf({v})でコールすれば仮引数は={v}で初期される。v単一のみならず任意数の値によるリストへも拡張可能として(N2215, p5)、以降は={...}あるいは{...}と表記する。

集約でないクラスがstd::vectorのようなコンテナの場合、配列同様に{...}から各要素を初期化できれば便利で、その目的でinitializer_listクラステンプレートとその特殊化を仮引数型とする初期化リストコンストラクタ(N2215はシーケンスコンストラクタ(sequence constructor)と呼んだ)(N2215, p7)を導入する。{...}で初期化する場合に限り初期化リストコンストラクタも候補となり、それがオーバーロード選択されればコンテナの各要素を{...}の対応する値で初期化する。初期化リストコンストラクタが他のコンストラクタと曖昧となる場合は初期化リストコンストラクタを選択するが(N2215, p6)理由は後述する。

={...}の=は省くことができる(N2215, p27)。のみならずコンストラクタに実引数を与える(...)は全て{...}で置き換える事ができる(N2215, p28)。初期化リストはネストできる(N2215, p22)。

vector<vector<int>> v = { {1,2,3}, {4,5,6}, {7,8,9} }; // a 3 by 3 matrix
Map<string,int> m = { {“ardwark”,91}, {“bison”, 43} };

このように当初は={...}と{...}を区別せず共に直接初期化と定義したが、意図せずexplicit修飾されたコンストラクタ(explicitコンストラクタ)(変換コンストラクタでないコンストラクタ)をコールしてしまうため議論を呼ぶ(N2640, p.1)。

struct Array {
explicit Array(int);
// ...
};
void print(Array); // (1)
struct Number {
Number(long long);
};
void print(Number); // (2)
print({100}); // Prefers (1); i.e., constructs a temporary Array

N2640は直接初期化とコピー初期化をコピーの必要可否で分類するのではなく、以下の様に位置付けることを主張した(N2640, pp.2-3)。

  • コンストラクタコールによる構築("ctor-call")
  • 値の受け渡しによる構築("conversion")

"ctor-call"が直接初期化に対応して、(...)でコンストラクタコールしていると見なせる。"conversion"がコピー初期化に対応して、受け渡される値が一意の変換元から定められる。

struct Cmplx { Cmplx(double re, double im); ... };
Cmplx f() {
Cmplx z1(1, 2); // "Ctor-call".
auto *p = new Cmplx(3, 4); // "Ctor-call".
Cmplx z2 = z1+*p; // "Conversion" from value z1+*p.
delete p;
z1 = Cmplx(1, 2); // "Ctor-call".
return z2*z1; // "Conversion" from value z2*z1.
}
struct String { explicit String(int capacity); };
String x(10); // "Ctor-call"; string of capacity 10.
String y = x; // "Conversion" from string value x to y.
String z = 10; // Error: 10 isn't the "value" of a string.

この観点に立てばコンストラクタは、渡される実引数が構築される値へ変換されるのではなく構築に必要な他の要素を指定するのであれば、explicitとしなければならない。

C++03以前に変換元から一時的な値を構築するコンパクトな手段は無く"ctor-call"に頼らざるを得ないが、{...}はこれをほぼ理想的に解決する(N2640, pp.4-5)。={...}あるいはf({...})における{...}を特定の"conversion"を目的として値をグループ化するものと見なし、それがクラス型の"conversion"であればユーザー定義変換のランクとして非explicitコンストラクタを必要とする。つまりC++03以前と異なり、複数の仮引数を持つコンストラクタも変換元を複数の適切の値から成るとしてユーザー定義変換になり得て、それを抑止するにはexplicitとする。初期化リストコンストラクタも同様とする。{...}が新たな初期化を導入すると見なし、それぞれの"conversion"が{...}で区分されれば複数のユーザー定義変換の連鎖が可能となる(N2640, pp.5-6)。

struct Str {
Str(char const*);
};
struct HashEntry {
HashEntry(Str, Str);
};
struct HashTable {
insert_entries(std::initializer_list<HashEntry>);
};
HashTable h;
h.insert_entries({ { "U.S.A.", "Washington D.C." },
{ "Belgium", "Brussels" },
{ "Guatemala", "Guatemala City" } });

プログラマは{...}のネスト数で期待されるユーザー定義の暗黙的変換("conversion")数を明示できる(N2640, p.5)。暗黙的変換ではexplicitコンストラクタは候補から外される。

struct Array {
explicit Array(int size);
// ...
};
void print(Array); // (1)
struct Number {
Number(long long);
};
void print(Number); // (2)
print({100}); // Calls (2); (1) is not a candidate
// because it doesn't have a converting
// constructor.

N2640は暗黙的変換とexplicitコンストラクタとの関係で別方法も紹介して(N2640, pp.14-15)、そちらではexplicitコンストラクタは候補に含むが選択された場合は不適格とした。N2640は前者を提案したが規格化の過程で後者が選択され、つまりサンプルコードはC++11以降でも曖昧エラーでコンパイルできない。いずれにせよ暗黙的変換でexplicitコンストラクタはコールできないという原則は変わらない。

N2215やN2640などからC++11の{...}が規格化された。

  • {...}を統一された初期化機構として採用する
  • 初期化子{...}と初期化子={...}は異なり、後者がexplicitコンストラクタを選択すると不適格とする

規格は初期化子{...}("ctor-call")による初期化を直接リスト初期化、初期化子={...}("conversion")による初期化をコピーリスト初期化と定義する。後者の{...}は初期化節の一つとして、変数定義以外のコピー初期化の文脈(例えば関数の実引数値渡しなど)でもターゲットをコピーリスト初期化する。コピーリスト初期化は集約で無いクラスでexplicitコンストラクタをコールできないが、これを除けば直接リスト初期化と変わらないとしてほぼ問題ない(厳密ではないが)。つまりコピーリスト初期化はコピーセマンティクスを持たずコピーコンストラクタを必要としない。これはC++17でコピー省略の保証として純右辺値式一般に拡張される。以上は初期化とオーバーロード(2)で詳説している。

N2640は{...}による縮小変換の抑止を提案し(N2640, p.2,p.6)C++最厄構文も言及する(N2640, p.7)。結論として主張するアドバイスをサイト作成者訳でここに示す(N2640, p.15)。

  • もしコンストラクタの仮引数がその型の値を直接決定する(characterize)ものでなければ"explicit"でなければならない。例えば文字列の長さは値を直接決定するものでなく、仮引数に受けるコンストラクタは"explicit"でなければならない。一方で文字列をリテラルから構築するならばリテラルはまさしく値を直接決定して、"char const*"を受けるコンストラクタは非"explicit"で良い。
  • コンストラクタがコールに見えるならコンストラクタコールと解釈して、あらゆるコンストラクタを候補とする。さもなければ値の変換と見なし、非"explicit"なコンストラクタ(変換コンストラクタ)あるいは変換オペレータ(変換関数)を候補とする。
  • コンストラクタコールであれば丸括弧(...)は波括弧{...}に置き換えることができる。これは二つの優位点を持ち、(a)びっくり構文(surprising parsing)(C++最厄構文)を避けることができて、(b)不用意な縮小変換を避けることができる。

初期化リストコンストラクタと通常コンストラクタ

N2215はstd::vectorが用意する初期化リストコンストラクタとそれ以外のコンストラクタ(通常コンストラクタ)が曖昧となる場合にどうするかを議論している(N2215, p.52)。

vector<int> v2 { 1, 2 }; // one or two elements?

ストロヴストルップ的議論が6ページ続いた結論をサイト作成者訳で示す(N2215, p.58)。

  • 全てのコンストラクタの曖昧性を確認すれば多くの偽陽性(false positive)、すなわち本来無関係なコンストラクタ同士が曖昧エラーとなる事態をもたらす。そこで初期化リストコンストラクタの方を優先とする。
  • 全要素が同型だが要素数が任意となるリストを同じ構文で扱う事のほうが重要であり、曖昧さはリストのパターンが不規則となる通常コンストラクタの方が解決するべき。

N2215は通常コンストラクタ側で曖昧さを解決するための新たな構文を提案せず、古い初期化子(...)で事足りるとした(N2215, p.29)。仮に{...}は初期化リストコンストラクタのみを候補として(...)は通常コンストラクタのみを候補とすれば曖昧さに関する問題を完全に回避するが、{...}の目的から大きく外れてしまう(N2215, p.53)。N2640を含めてまとめれば、{...}は初期化リストコンストラクタと通常コンストラクタを合わせて候補とするが、初期化リストコンストラクタが必ず優先される。(...)は常に通常コンストラクタを候補とする。コピー初期化の文脈ではexplicitコンストラクタをコールできず、変換コンストラクタのみがコールできる。

vector<int> v2 { 1, 2 }; // two elements
vector<int> v22 ( 1, 2 ); // one element

全ての初期化を{...}で統一するという野望は2007年に早くも潰えていた。

波括弧初期子の問題点

本記事はC++11以降の世代に属するあなたには無用かもしれない。ともかく{...}で初期化さえしていればC++最厄構文など存在しないのだから。進取に富むあなたはメイヤーズサンプルをさらにC++17以降のクラステンプレート実引数推定(CTAD)してコンパイルOKを確認するだろう。しかしそのdataがlist<int>型でなくなるのに驚くかもしれない。

#include <fstream>
#include <list>
#include <iterator>
using namespace std;
int main()
{
ifstream dataFile{"data.txt"};
// Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, p.34
// Replaced all parentheses with curly brackets.
// Removed <template-parameter-list> with CTAD. Required C++17 or later.
list data{istream_iterator<int>{dataFile},
istream_iterator<int>{}};
// コンパイルOK、しかしdataはlist<int>型ではない
data.size();
}

CTADを維持したいあなたは仕方なくC++最厄構文を回避しつつ(...)の初期化に戻さざるを得ない。

#include <fstream>
#include <list>
#include <iterator>
using namespace std;
int main()
{
ifstream dataFile{"data.txt"};
// Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, p.34
// Made the first argument avoid the most vexing parse.
// Removed <template-parameter-list> with CTAD. Required C++17 or later.
list data((istream_iterator<int>(dataFile)),
istream_iterator<int>());
// コンパイルOK、dataはlist<int>
data.size();
}

{...}はC++最厄構文に限らず多くの古い問題を解決するが、それ自体がもたらす新たな問題を認識したい。

問題の定式化

std::vectorを例に議論を進める。{...}の問題点とは初期化リストコンストラクタと通常コンストラクタが曖昧となって、後者を意図しながら前者を選択してしまう可能性である。両者が曖昧になるかどうかはテンプレート実引数型に依存する。vectorのコンストラクタ宣言(N4659 26.3.11.1/p2)を要素型T以外のテンプレート仮引数をデフォルトに固定するなどで簡略化して示しておく。

namespace std {
template <class T>
class vector {
public:
...
vector(); // デフォルトコンストラクタ
explicit vector(size_t); // 要素数をデフォルト構築(explicit)
vector(size_t, const T&); // 要素数と値を指定
template <class InputIterator> // イテレータ指定範囲をコピー
vector(InputIterator, InputIterator);
vector(const vector&); // コピーコンストラクタ
vector(vector&&) noexcept; // ムーブコンストラクタ
vector(initializer_list<T>); // 初期化リストコンストラクタ
...
};

{...}中の縮小変換は不適格だがmingw-w64はデフォルトでその一部をエラーとせず警告表示に留め、規格もそれを許容する(4.1/p2.2)。以降のサンプルコードではこれを全てエラーと見なす#pragmaオプションを加えている。

曖昧とならない事例を最初に挙げる。クラスXが数値型からの変換コンストラクタを持たなければ、初期化リストコンストラクタと通常コンストラクタは曖昧とならない。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
struct X{};
int main()
{
X a{};
vector<X> v00{}; // vector()、要素数0
//vector<X> v1n{-1}; // vector(size_t)、縮小変換エラー
vector<X> v10{0}; // vector(size_t)、要素数0
vector<X> v11{1}; // vector(size_t)、要素数1
vector<X> v20{0,a}; // vector(size_t,const T&)、要素数0
vector<X> v21{1,a}; // vector(size_t,const T&)、要素数1
vector<X> v2a{a,a}; // vector(initializer_list<T>)、要素数2
vector<X> v3a{a,a,a}; // vector(initializer_list<T>)、要素数3
}

全ての実引数がTと特定の通常コンストラクタ仮引数型の両方に暗黙変換できる場合に、初期化リストコンストラクタと通常コンストラクタが曖昧となる。以下の場合について検討する。

  • Tが数値型から暗黙変換できる
  • Tが型消去ラッパー型
  • Tがポインタ型
  • クラステンプレート実引数推定(CTAD)

vector<T>のTが数値型から暗黙変換できる

vector<T>のTが数値型から暗黙変換できる場合は常に初期化リストコンストラクタとvector(size_t)/vector(size_t,const T&)が曖昧となり、初期化リストコンストラクタが選択される。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
struct Y {Y(int){}};
int main()
{
Y a{0};
vector<Y> v00{}; // vector()、要素数0
vector<Y> v1n{-1}; // vector(initializer_list<T>)、要素数1
vector<Y> v10{0}; // vector(initializer_list<T>)、要素数1
vector<Y> v11{1}; // vector(initializer_list<T>)、要素数1
vector<Y> v20{0,a}; // vector(initializer_list<T>)、要素数2
vector<Y> v21{1,a}; // vector(initializer_list<T>)、要素数2
vector<Y> v2a{a,a}; // vector(initializer_list<T>)、要素数2
vector<Y> v3a{a,a,a}; // vector(initializer_list<T>)、要素数3
}

T自体が数値型でも同様となる。縮小変換でエラーとなるとしても必ず初期化リストコンストラクタが優先される。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
int main()
{
int a{0};
vector<int> v00{}; // vector()、要素数0
vector<int> v1n{-1}; // vector(initializer_list<T>)、要素数1
vector<int> v10{0}; // vector(initializer_list<T>)、要素数1
vector<int> v11{1}; // vector(initializer_list<T>)、要素数1
vector<int> v20{0,a}; // vector(initializer_list<T>)、要素数2
vector<int> v21{1,a}; // vector(initializer_list<T>)、要素数2
vector<int> v2a{a,a}; // vector(initializer_list<T>)、要素数2
vector<int> v3a{a,a,a}; // vector(initializer_list<T>)、要素数3
// 縮小変換があったとしても初期化リストコンストラクタが選択される
size_t b{0};
//vector<char> w1b{b}; // vector(initializer_list<T>)、縮小変換エラー
vector<char> w1bx(b); // vector(size_t)
}

vector<T>のTが型消去ラッパー型

vector<T>のTが型消去ラッパー型の場合、常に初期化リストコンストラクタとデフォルトを除く通常コンストラクタが曖昧となり、初期化リストコンストラクタが選択される。型消去ラッパー型とはあらゆる型を保持するクラスでstd::anyクラスあるいはwxWidgetsライブラリのwxVariant/wxAnyクラスが相当する。一般にはバリアント型と呼ばれるが本記事はstd::variantとの誤解を避けるため型消去ラッパー型と呼ぶ。std::variantは型消去ラッパー型ではない。

#include <vector>
#include <any>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
using std::any;
int main()
{
any a{0};
vector<any> v00{}; // vector()、要素数0
vector<any> v1n{-1}; // vector(initializer_list<T>)、要素数1
vector<any> v10{0}; // vector(initializer_list<T>)、要素数1
vector<any> v11{1}; // vector(initializer_list<T>)、要素数1
vector<any> v20{0,a}; // vector(initializer_list<T>)、要素数2
vector<any> v21{1,a}; // vector(initializer_list<T>)、要素数2
vector<any> v2a{a,a}; // vector(initializer_list<T>)、要素数2
vector<any> v3a{a,a,a}; // vector(initializer_list<T>)、要素数3
// 以下はvector(const vector&)/vector(vector&&)をコールしない
vector<any> v1c{v00}; // vector(initializer_list<T>)、要素数1
vector<any> v1m{std::move(v00)}; // vector(initializer_list<T>)、要素数1
// 以下はvector(InputIterator, InputIterator)をコールしない
vector<any> v2i{v00.begin(),v00.end()}; // vector(initializer_list<T>)、要素数2
}

vector<T>のTがポインタ型

vector<T>のTがポインタ型の場合、通常なら初期化リストコンストラクタとvector(size_t)/vector(size_t,const T&)が曖昧とならない。ただし0リテラルのみポインタ型へ暗黙変換可能なので曖昧となって、初期化リストコンストラクタが選択される。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
int main()
{
int* a{nullptr};
vector<int*> v00{}; // vector()、要素数0
//vector<int*> v1n{-1}; // vector(size_t)、縮小変換エラー
vector<int*> v10{0}; // 0はTへ変換可能、vector(initializer_list<T>)、要素数1
vector<int*> v11{1}; // vector(size_t)、要素数1
vector<int*> v20{0,a}; // 0はTへ変換可能、vector(initializer_list<T>)、要素数2
vector<int*> v21{1,a}; // vector(size_t,const T&)、要素数1
vector<int*> v2a{a,a}; // vector(initializer_list<T>)、要素数2
vector<int*> v3a{a,a,a};// vector(initializer_list<T>)、要素数3
}

Tがvoid*の場合でvector(InputIterator,InputIterator)のInputIteratorがTのポインタ型すなわちvoid**の場合、void**はvoid*へ暗黙変換可能なので初期化リストコンストラクタとvector(InputIterator,InputIterator)が曖昧となり、初期化リストコンストラクタが選択される。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
int main()
{
// vector<int*>でコピー元イテレータにint**
int* ai3[3]={nullptr,nullptr,nullptr};
vector<int*> vii{ai3,ai3+3}; // vector(InputIterator,InputIterator)、要素数3
// vector<void*>とvoid**ではvector(InputIterator,InputIterator)をコールしない
void* av3[3]={nullptr,nullptr,nullptr};
vector<void*> viv{av3,av3+3}; // vector(initializer_list<T>)、要素数2
}

クラステンプレート実引数推定(CTAD)

リスト初期化とCTADを同時使用すれば二つのややこしい規格が絡み合って予測の難しい結果をもたらす事は想像に難くない。最もシンプルなクラスXで検討する。あたりまえの話だが、実引数にX型インスタンスを与えない限りvector<X>は型推定できないし、整数リテラルのみを与えていればintと解釈してvector<int>(initializer_list<int>)をコールする。

using std::vector;
struct X{};
int main()
{
X a{};
//vector v00{}; // 型推定できない、エラー
vector v1n{-1}; // vector<int>(initializer_list<int>)、要素数1
vector v10{0}; // vector<int>(initializer_list<int>)、要素数1
vector v11{1}; // vector<int>(initializer_list<int>)、要素数1
vector v20{0,a}; // vector<X>(size_t,const X&)、要素数0
vector v21{1,a}; // vector<X>(size_t,const X&)、要素数1
vector v2a{a,a}; // vector<X>(initializer_list<X>)、要素数2
vector v3a{a,a,a}; // vector<X>(initializer_list<X>)、要素数3
// CTADコピー推定候補で適切にコピー/ムーブコンストラクタがコールされる
vector v1c{v20}; // vector<X>(const vector<X>&)
vector v1m{std::move(v20)}; // vector<X>(vector<X>&&)
}

vector(InputIterator,InputIterator)のCTADによる型推定は推論補助により(N4659 26.3.11.1/p2)、それを説明に不要なテンプレート仮引数を省いて示しておく。iterator_traits型特性クラスで適切に推定されるのが理解できる。

template<class InputIterator>
vector(InputIterator, InputIterator)
-> vector<typename iterator_traits<InputIterator>::value_type>;

しかし常に初期化リストコンストラクタとvector(InputIterator,InputIterator)が曖昧となり初期化リストコンストラクタが選択される。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
struct X{};
int main()
{
X a{};
vector<X> v3a{a,a,a};
X a3a[3]={a,a,a};
// いずれもvector<X>(InputIterator,InputIterator)をコールしない
vector v2i{v3a.begin(),v3a.end()}; // vector<vector<X>::iterator>(initializer_list<vector<X>::iterator>)、要素数2
vector v2p{a3a,a3a+3}; // vector<X*>(initializer_list<X*>)、要素数2
// (...)ならvector<X>(InputIterator,InputIterator)をコールする
vector v2ix(v3a.begin(),v3a.end());// vector<X>(InputIterator,InputIterator)、要素数3
vector v2px(a3a,a3a+3); // vector<X>(InputIterator,InputIterator)、要素数3
}

回避方法

あなたは以上を十分理解した上で{...}が使えるかどうかを判断しなければならない。最も安全な回避方法は{...}を初期化リストコンストラクタをコールする場合に限定し、通常コンストラクタをコールする場合は(...)とする。それは{...}の利点を全て放棄してしまう事なのだが。縮小変換の抑止だけなら(...)も実引数2個以上で各実引数を{...}で囲めば良く、しかし1個の場合は初期化リストコンストラクタコールとなってやはり混乱の極みに落ち込む。

#include <vector>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
struct Y{Y(int) {}};
int main()
{
Y a{0};
vector<Y> v2mx(-1,a); // vector(size_t,const T&)、要素数0xFFFFFFFF(恐らく実行時エラーになる)
//vector<Y> v2my({-1},{a}); // vector(size_t,const T&)、縮小変換エラー
vector<Y> v1my({-1}); // vector(list_initializer<T>)、要素数1
}

C++は厳密な型チェックをするがプログラマに実際の型が隠されている事が多い。ウィンドウズAPIはウィンドウ操作にHWND型ハンドル、カーネルオブジェクト操作にHANDLE型ハンドルを用いるが、それが実際は何であるかは気に留めない。C++標準ライブラリもコンテナのイテレータ型を実装定義に任せる。{...}の問題点はあなたが考える以上にあちらこちらにトラップを仕掛けている。あなたはウィンドウズプログラミングしているとしよう。HWND型とHANDLE型をstd::arrayに収めて、イテレータ範囲でstd::vectorへ{...}でコピーする。mingw-w64でHWND型は意図通りにコピーされるが、HANDLE型はそうならない。

#include <cassert>
#include <vector>
#include <array>
//#define NO_STRICT
#include <windows.h>
#pragma GCC diagnostic error "-Wnarrowing"
using std::vector;
using std::array;
int main()
{
array<HWND,3> aHwnd{0,0,0};
vector<HWND> vHwnd{aHwnd.begin(),aHwnd.end()}; // (*1)
assert(aHwnd.size()==vHwnd.size()); // OKだがNO_STRICTを#defineするとアサートエラー
array<HANDLE,3> aHandle{0,0,0};
vector<HANDLE> vHandle{aHandle.begin(),aHandle.end()}; // (*2)
assert(aHandle.size()==vHandle.size()); // アサートエラー
}

HWND型は実際には要素ゼロの構造体__HWNDへのポインタ__HWND*で、HANDLE型はvoid*である。しかしそれぞれは識別値を直接格納して、どこかのメモリアドレスを格納しているわけではない。mingw-w64実装でarray<T>::iteratorはT*で、規格はコンテナのメモリ領域が連続であればそういった実装を許している。vector<T>::iteratorもそういった実装が多かったが最近は見なくなった。vector<HANDLE>は実際はvector<void*>で、array<HANDLE>::iteratorは実際にはvoid**であり、(*2)はvector(InputIterator,InputIterator)でなく初期化リストコンストラクタをコールする。ライブラリが供給する型や規格が実装定義とする型が、実際は何であるかを知らないとこういった問題を完全に理解できない。そういった型が将来にわたって不変である保証は無く(そうであって欲しいと願うが)、少なくともこういった状況では最も安全な回避方法を採るべきかもしれない。CTADの場合も型推定に十分な情報が実引数リストに与えられていれば事情は変わらない。

結論

本記事はC++最厄構文の理解から出発して広く{...}初期化を議論する結果となった。C++11以降、規格は初期化子を独自の構文表記(N4659 4.3)を用いたEBNFで以下に記述する(11.6/p1)。

    initializer:
            brace-or-equal-initializer
            ( expression-list )
    brace-or-equal-initializer:
            = initializer-clause
            braced-init-list
    initializer-clause:
            assignment-expression
            braced-init-list
    initializer-list:
            initializer-clause ...opt
            initializer-list , initializer-clause ...opt
    braced-init-list:
            { initializer-list ,opt }
            { }

initializerすなわち初期化子はbrace-or-equal-initializerと( expression-list )すなわち(...)に分類される。brace-or-equal-initializerは= initializer-clausebraced-init-listすなわち{...}に分類される。{...}はinitializer-clauseすなわち初期化節にも使える。つまりは初期化子をbrace-or-equal-initializerで再定義して、その定義に漏れる(...)を後方互換に残したと言って良い。しかし{...}が初期化リストコンストラクタと通常コンストラクタに両対応するため、今でも(...)を避ける事ができない場合がある。以下にまとめる。

  • リスト初期化コンストラクタを持つクラスを通常コンストラクタで初期化する場合は(...)が安全
  • リスト初期化コンストラクタを持つクラスを初期化リストコンストラクタで初期化する場合は{...}でなければならない
  • リスト初期化コンストラクタを持たないクラスを初期化する場合は{...}であるべき

クラスを作るときは以下に配慮する。

  • コンストラクタの仮引数が値を直接決定するものであればexplicitとしない
  • コンストラクタの仮引数が値を直接決定するものでなければexplicitとする
  • リスト初期化コンストラクタを与えるなら、通常ならそれは値を直接決定するものであるからexplicitとしない

CTADしたメイヤーズサンプルはstd::listを(...)で初期化し、それ以外を{...}で初期化するとして、最終回答とする。

#include <fstream>
#include <list>
#include <iterator>
using namespace std;
int main()
{
ifstream dataFile{"data.txt"};
// Scott Meyers, Effective STL, Boston, Addison-Wesley, 2001, p.34
// list<int> is initialized with (...) and the others are with {...}.
// Removed <template-parameter-list> with CTAD. Required C++17 or later.
list data(istream_iterator<int>{dataFile},
istream_iterator<int>{});
// コンパイルOK、dataはlist<int>
data.size();
}