大括弧之謎:C++的列表初始化語法解析
有朋友在使用std::array時發現一個奇怪的問題:當元素類型是複合類型時,編譯通不過。
struct S { int x; int y; }; int main() { int a1[3]{1, 2, 3}; // 簡單類型,原生數組 std::array<int, 3> a2{1, 2, 3}; // 簡單類型,std::array S a3[3]{{1, 2}, {3, 4}, {5, 6}}; // 複合類型,原生數組 std::array<S, 3> a4{{1, 2}, {3, 4}, {5, 6}}; // 複合類型,std::array,編譯失敗! return 0; }
按說std::array和原生數組的行為幾乎是一樣的,可為什麼當元素類型不同時,初始化語法還會有差別?更蹊蹺的是,如果多加一層括弧,或者去掉內層的括弧,都能讓程式碼編譯通過:
std::array<S, 3> a1{{1, 2}, {3, 4}, {5, 6}}; // 原生數組的初始化寫法,編譯失敗! std::array<S, 3> a2{{{1, 2}, {3, 4}, {5, 6}}}; // 外層多一層括弧,編譯成功 std::array<S, 3> a3{1, 2, 3, 4, 5, 6}; // 內層不加括弧,編譯成功
這篇文章會介紹這個問題的原理,以及正確的解決方式。
聚合初始化
先從std::array的內部實現說起。為了讓std::array表現得像原生數組,C++中的std::array與其他STL容器有很大區別——std::array沒有定義任何構造函數,而且所有內部數據成員都是public的。這使得std::array成為一個聚合(aggregate)。
對聚合的定義,在每個C++版本中有少許的區別,這裡簡單總結下C++17中定義:一個class或struct類型,當它滿足以下條件時,稱為一個聚合[1]:
- 沒有private或protected數據成員;
- 沒有用戶提供的構造函數(但是顯式使用=default或=delete聲明的構造函數除外);
- 沒有virtual、private或者protected基類;
- 沒有虛函數
直觀的看,聚合常常對應著只包含數據的struct類型,即常說的POD類型。另外,原生數組類型也都是聚合。
聚合初始化可以用大括弧列表。一般大括弧內的元素與聚合的元素一一對應,並且大括弧的嵌套也和聚合類型嵌套關係一致。在C語言中,我們常見到這樣的struct初始化語句。
解了上面的原理,就容易理解為什麼std::array的初始化在多一層大括弧時可以成功了——因為std::array內部的唯一元素是一個原生數組,所以有兩層嵌套關係。下面展示一個自定義的MyArray類型,它的數據結構和std::array幾乎一樣,初始化方法也類似:
struct S { int x; int y; }; template<typename T, size_t N> struct MyArray { T data[N]; }; int main() { MyArray<int, 3> a1{{1, 2, 3}}; // 兩層大括弧 MyArray<S, 3> a2{{{1, 2}, {3, 4}, {5, 6}}}; // 三層大括弧 return 0; }
在上面例子中,初始化列表的最外層大括弧對應著MyArray,之後一層的大括弧對應著數據成員data,再之後才是data中的元素。大括弧的嵌套與類型間的嵌套完全一致。這才是std::array嚴格、完整的初始化大括弧寫法。
可是,為什麼當std::array元素類型是簡單類型時,省掉一層大括弧也沒問題?——這就涉及聚合初始化的另一個特點:大括弧省略。
大括弧省略(brace elision)
C++允許在聚合的內部成員仍然是聚合時,省掉一層或多層大括弧。當有大括弧被省略時,編譯器會按照內層聚合所含的元素個數進行依次填充。
下面的程式碼雖然不常見,但是是合法的。雖然二維數組初始化只用了一層大括弧,但因為大括弧省略特性,編譯器會依次用所有元素填充內層數組——上一個填滿後再填下一個。
int a[3][2]{1, 2, 3, 4, 5, 6}; // 等同於{{1, 2}, {3, 4}, {5, 6}}
知道了大括弧省略後,就知道std::array初始化只用一層大括弧的原理了:由於std::array的內部成員數組是一個聚合,當編譯器看到{1,2,3}這樣的列表時,會挨個把大括弧內的元素填充給內部數組的元素。甚至,假設std::array內部有兩個數組的話,它還會在填完上一個數組後依次填下一個。
這也解釋了為什麼省掉內層大括弧,複雜類型也可以編譯成功:
std::array<S, 3> a3{1, 2, 3, 4, 5, 6}; // 內層不加括弧,編譯成功
因為S也是個聚合類型,所以這裡省略了兩層大括弧。編譯期按照下面的順序依次填充元素:數組0號元素的S::x、數組0號元素的S::y、數組1號元素的S::x、數組1號元素的S::y……
雖然大括弧可以省略,但是一旦用戶顯式的寫出了大括弧,那麼必須要和這一層的元素個數嚴格對應。因此下面的寫法會報錯:
std::array<S, 3> a1{{1, 2}, {3, 4}, {5, 6}}; // 編譯失敗!
編譯器認為{1,2}對應std::array的內部數組,然後{3,4}對應std::array的下一個內部成員。可是std::array只有一個數據成員,於是報錯:too many initializers for ‘std::array<S, 3>’
需要注意的是,大括弧省略只對聚合類型有效。如果S有個自定義的構造函數,省掉大括弧就行不通了:
// 聚合 struct S1 { S1() = default; int x; int y; }; std::array<S1, 3> a1{1, 2, 3, 4, 5, 6}; // OK // 聚合 struct S2 { S2() = delete; int x; int y; }; std::array<S2, 3> a2{1, 2, 3, 4, 5, 6}; // OK // 非聚合,有用戶提供的構造函數 struct S3 { S3() {}; int x; int y; }; std::array<S3, 3> a3{1, 2, 3, 4, 5, 6}; // 編譯失敗!
這裡可以看出=default的構造函數與空構造函數的微妙區別。
std::initializer_list的另一個故事
上面講的所有規則,都只對聚合初始化有效。如果我們給MyArray類型加上一個接受std::initializer_list的構造函數,情況又不一樣了:
struct S { int x; int y; }; template<typename T, size_t N> struct MyArray { public: MyArray(std::initializer_list<T> l) { std::copy(l.begin(), l.end(), std::begin(data)); } T data[N]; }; int main() { MyArray<S, 3> a{{{1, 2}, {3, 4}, {5, 6}}}; // OK MyArray<S, 3> b{{1, 2}, {3, 4}, {5, 6}}; // 同樣OK return 0; }
當使用std::initializer_list的構造函數來初始化時,無論初始化列表外層是一層還是兩層大括弧,都能初始化成功,而且a和b的內容完全一樣。
這又是為什麼?難道std::initializer_list也支援大括弧省略?
這裡要提一件趣事:《Effective Modern C++》這本書在講解對象初始化方法時,舉了這麼一個例子[2]:
class Widget { public: Widget(); // default ctor Widget(std::initializer_list<int> il); // std::initializer_list ctor … // no implicit conversion funcs }; Widget w1; // calls default ctor Widget w2{}; // also calls default ctor Widget w3(); // most vexing parse! declares a function! Widget w4({}); // calls std::initializer_list ctor with empty list Widget w5{{}}; // ditto <-注意!
然而,書里這段程式碼最後一行w5的注釋卻是個技術錯誤。這個w5的構造函數調用時並非像w4那樣傳入一個空的std::initializer_list,而是傳入包含了一個元素的std::initializer_list。
即使像Scott Meyers這樣的C++大牛,都會在大括弧的語義上搞錯,可見C++的相關規則充滿著陷阱!
連《Effective Modern C++》都弄錯了的規則
幸好,《Effective Modern C++》作為一本經典圖書,讀者眾多。很快就有讀者發現了這個錯誤,之後Scott Meyers將這個錯誤的闡述放在了書籍的勘誤表中[3]。
Scott Meyers還邀請讀者們和他一起研究正確的規則到底是什麼,最後,他們把結論寫在了一篇文章里[4]。文章通過3種具有不同構造函數的自定義類型,來揭示std::initializer_list匹配時的微妙差異。程式碼如下:
#include <iostream> #include <initializer_list> class DefCtor { int x; public: DefCtor(){} }; class DeletedDefCtor { int x; public: DeletedDefCtor() = delete; }; class NoDefCtor { int x; public: NoDefCtor(int){} }; template<typename T> class X { public: X() { std::cout << "Def Ctor\n"; } X(std::initializer_list<T> il) { std::cout << "il.size() = " << il.size() << '\n'; } }; int main() { X<DefCtor> a0({}); // il.size = 0 X<DefCtor> b0{{}}; // il.size = 1 X<DeletedDefCtor> a2({}); // il.size = 0 // X<DeletedDefCtor> b2{{}}; // error! attempt to use deleted constructor X<NoDefCtor> a1({}); // il.size = 0 X<NoDefCtor> b1{{}}; // il.size = 0 }
對於構造函數已被刪除的非聚合類型,用{}初始化會觸發編譯錯誤,因此b2的表現是容易理解的。但是b0和b1的區別就很奇怪了:一模一樣的初始化方法,為什麼一個傳入std::initializer_list的長度為1,另一個長度為0?
構造函數的兩步嘗試
問題的原因在於:當使用大括弧初始化來調用構造函數時,編譯器會進行兩次嘗試:
- 把整個大括弧列表連同最外層大括弧一起,作為構造函數的std::initializer_list參數,看看能不能匹配成功;
- 如果第一步失敗了,則將大括弧列表的成員作為構造函數的入參,看看能不能匹配成功。
對於b0{{}}這樣的表達式,可以直觀理解第一步嘗試是:b0({{}}),也就是把{{}}整體作為一個參數傳給構造函數。對b0來說,這個匹配是能夠成功的。因為DefCtor可以通過{}初始化,所以b0的初始化調用了X(std::initializer_list<T>),並且傳入含有1個成員的std::initializer_list作為入參。
對於b1{{}},編譯器同樣會先做第一步嘗試,但是NoDefCtor不允許用{}初始化,所以第一步嘗試會失敗。接下來編譯器做第二步嘗試,將外層大括弧剝掉,調用b1({}),發現可以成功,這時傳入的是空的std::initializer_list。
再回頭看之前MyArray的例子,現在我們可以分析出兩種初始化分別是在哪一步成功的:
MyArray<S, 3> a{{{1, 2}, {3, 4}, {5, 6}}}; // 在第二步,剝掉外層大括弧後匹配成功 MyArray<S, 3> b{{1, 2}, {3, 4}, {5, 6}}; // 第一步整個大括弧列表匹配成功
綜合小測試
到這裡,大括弧初始化在各種場景下的規則就都解析完了。不知道讀者是否徹底掌握了?
不妨來試一試下面的小測試:這段程式碼里有一個僅含一個元素的std::array,其元素類型是std::tuple,tuple只有一個成員,是自定義類型S,S定義有默認構造函數和接受std::initializer_list<int>的構造函數。對於這個類型,初始化時允許使用幾層大括弧呢?下面的初始化語句有哪些可以成功?分別是為什麼?
struct S { S() = default; S(std::initializer_list<int>) {} }; int main() { using MyType = std::array<std::tuple<S>, 1>; MyType a{}; // 1層 MyType b{{}}; // 2層 MyType c{{{}}}; // 3層 MyType d{{{{}}}}; // 4層 MyType e{{{{{}}}}}; // 5層 MyType f{{{{{{}}}}}}; // 6層 MyType g{{{{{{{}}}}}}}; // 7層 return 0; }
章節附註
[1] //en.cppreference.com/w/cpp/language/aggregate_initialization
[2] 位於書的 Item 7: Distinguish between () and {} when creating objects. 第55頁
[3] //www.aristeia.com/BookErrata/emc++-errata.html
[4] //scottmeyers.blogspot.com/2016/11/help-me-sort-out-meaning-of-as.html
本文分享自華為雲社區《大括弧之謎——C++的列表初始化語法解析》,原文作者:飛得樂。