引言 要是世上不曾存在C++14和C++17該有多好! 是好東西,但是讓編譯器開發者痛不欲生;新標準庫的確好用,但改語法細節未必是明智之舉,尤其是3年一次的頻繁改動。C++帶了太多歷史包袱,我們都是為之買賬的一員。 我沒那麼多精力考慮C++14/17的問題,所以本文基於C++11標準。 知其所以然, ...
引言
要是世上不曾存在C++14和C++17該有多好!constexpr
是好東西,但是讓編譯器開發者痛不欲生;新標準庫的確好用,但改語法細節未必是明智之舉,尤其是3年一次的頻繁改動。C++帶了太多歷史包袱,我們都是為之買賬的一員。
我沒那麼多精力考慮C++14/17的問題,所以本文基於C++11標準。
知其所以然,是學習C++越發複雜的語法的最佳方式。因此,我們從列表初始化的動機講起。
動機
早在2005年,Bjarne Stroustrup就提出要統一C++中的初始化語法。這是因為在C++11以前,初始化存在一系列問題,包括:
-
4種初始化方式:
X t1 = v;
、X t2(v);
、X t3 = { v };
、X t4 = X(v);
; -
聚合(aggregate)初始化;
-
default
與explicit
; -
……
雖然每一個都有辦法解決,但加在一起將會變得非常複雜,對編譯器和開發者都是負擔。換句話說,唯一的需求就是一種統一的初始化語法,其適用範圍能涵蓋先前的各種問題。
於是,列表初始化誕生了。
語法
正因為列表初始化是為解決初始化問題而生,列表初始化的適用範圍是任何初始化。你能想到的都寫寫看,寫對就是賺到。
當然,全憑感覺是行不通的,還是得講點道理。列表初始化分為兩類:直接初始化與拷貝初始化。
在直接初始化中,無論構造函數是否explicit
,都有可能被調用:
-
T object { arg1, arg2, ... };
,用arg1, arg2, ...
構造T
類型的對象object
——參數可以是一個值,也可以是一個初始化列表,下同; -
Class { T member { arg1, arg2, ... }; };
,構造member
成員對象——花括弧的優勢在這裡體現出來,因為如果是圓括弧的話member
會被看作一個函數; -
T { arg1, arg2, ... }
,構造臨時對象; -
new T { arg1, arg2, ... }
,構造heap上的對象; -
Class::Class() : member{arg1, arg2, ...} {...
,成員初始化列表——除了2以外,其餘都與用()
初始化沒有區別。
在拷貝初始化中,無論構造函數是否explicit
都會被考慮,但是如果重載決議為一個explicit
函數,則此調用錯誤:
-
T object = {arg1, arg2, ...};
,與直接初始化中的1
類似,除了explicit
以外都相同,operator=
不會被調用; -
object = { arg1, arg2, ... }
,賦值語句,調用operator=
; -
Class { T member = { arg1, arg2, ... }; };
,與直接初始化中的2
類似,explicit
同理; -
function( { arg1, arg2, ... } )
,構造函數參數; -
return { arg1, arg2, ... } ;
,構造返回值; -
object[ { arg1, arg2, ... } ]
,構造operator[]
的參數; -
U( { arg1, arg2, ... } )
,構造U
構造函數的參數。
4~7可以概括為,在該有一個對象的地方,可以用一個列表來構造它。這句話不是很嚴謹,因為除了operator()
和operator[]
以外,其他運算符的參數都不能用列表初始化。
還有一個要註意的地方,是列表初始化不允許窄化轉換(narrowing conversion),即可能丟失信息的轉換,如float
轉換為int
。
#include <iostream>
#include <utility>
struct Test
{
Test(int, int)
{
std::cout << "Test(int, int)" << std::endl;
}
explicit Test(int, int, int)
{
std::cout << "explicit Test(int, int, int)" << std::endl;
}
void operator[](std::pair<int, int>)
{
std::cout << "void operator[](std::pair<int, int>)" << std::endl;
}
void operator()(std::pair<int, int>)
{
std::cout << "void operator()(std::pair<int, int>)" << std::endl;
}
};
Test test()
{
return { 1, 2 };
}
int main()
{
Test t{ 1, 2 };
Test t1 = { 1, 2 };
Test t2 = { 1, 2, 3 }; // error
t[{ 1, 2 }];
t({ 1, 2 });
}
initializer_list
列表不是表達式,更不屬於任何類型,所以decltype({1, 2})
是非法的,這還適用於模板參數推導。但是在以下幾種情況中,列表可以轉換成std::initializer_list<T>
實例:
-
直接初始化中,對應構造函數參數類型為
std::initializer_list<T>
; -
拷貝初始化中,對應參數類型為
std::initializer_list<T>
; -
綁定到
auto
上(列表元素類型必須嚴格一致),包括範圍for
(range for)迴圈——當綁定auto&&
時,變數的實際類型為std::initializer_list<T>&&
,這是轉發引用的特例。
std::initializer_list
是為列表初始化提供的特殊的工具,是一個輕量級的數組代理(proxy),其元素類型為const T
。雖然你能在<initializer_list>
中看到std::initializer_list
類模板的實現,但它實際上是與編譯器內部綁定的,你無法用一個自己寫的相似的類替換它(除非改編譯器)。
std::initializer_list
有構造函數、size
、begin
和end
函數,用法與其他STL順序容器類似。迭代器解引用得到const T&
類型,元素是不能修改的。
std::initializer_list
帶來的最明顯的進步就是STL容器可以用列表來初始化,無需再寫那麼多push_back
了。
重載決議
struct Test
{
Test(int, int)
{
std::cout << "Test(int, int)" << std::endl;
}
Test(std::initializer_list<int>)
{
std::cout << "Test(std::initializer_list<int>)" << std::endl;
}
};
如果我寫Test{1, 2}
,哪個構造函數會被調用呢?回答這個問題,需要對與列表相關的重載決議有所瞭解。
對於涉及到構造函數的列表初始化(不涉及到的包括聚合初始化等),各構造函數分兩個階段考慮:
-
如果有構造函數第一個參數為
std::initializer_list
,沒有其他參數或其他參數都有預設值,則匹配該構造函數(這裡似乎允許窄化轉換,我測試起來也是如此)——std::initializer_list
優先順序高; -
否則,所有構造函數參與重載決議,除了窄化轉換不允許,以及拷貝初始化與
explicit
的衝突依然有效。
所以上面那段程式中Test{1, 2}
會匹配第二個構造函數。
如果有多個std::initializer_list
重載呢?眾所周知,重載決議中參數轉換有完美、提升、轉換三個等級,std::initializer_list
參數的轉換等級定義為所有元素中最差的(不允許窄化轉換),然後找出等級最高的調用,如果有多個則為二義調用。
如果沒有std::initializer_list
重載呢?由於從列表到參數本身就是轉換,屬於最差的等級,如果有多個函數可以通過參數轉換後匹配,則該調用就是二義調用;只有當只有一個函數可行時才合法。
總結
列表初始化是一種萬能的初始化語法,適用範圍廣導致其規則比較複雜,我們應當結合其動機來理解標準規定的行為。
列表初始化包括直接初始化與拷貝初始化,後者涵蓋了參數與返回值等情形。當我們不想要隱式拷貝初始化時,要用explicit
關鍵字來拒絕。
列表不屬於任何類型,但一些情況下可以轉換成std::initializer_list
。在重載決議中,std::initializer_list
有更高的優先順序。