Item 7:當創建對象的時候,區分()和{}的使用

本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!

博客已經遷移到這裏啦

從不同的角度來看,在C++11中,對象初始化擁有多種語法選擇,這體現了語法豐富造成的尷尬或者爛攤子。一般情況下,初始化的值可以用圓括號,等號,花括號來確定:

int x(0);   //用圓括號初始化

int y = 0;  //用"="初始化

int z{ 0 }; //用花括號初始化

在很多情況下,也可以使用等號加花括號的形式:

int z = { 0 };  //用"="和花括號初始化

在這個Item中,對於剩下的這種情況我通常忽略“等號加花括號”的語法,因爲C++通常把它和“只使用花括號”的情況同樣對待。

”爛攤子“指的是使用等號來初始化常常誤導C++初學者,這裏發生了賦值操作(儘管不是這樣的)。對於built-in類型比如int,只是學術上的不同,但是對於user-defined類型,把初始化和賦值區分開來很重要,因爲它們涉及不同的函數:

Widget w1;  //調用默認構造函數

Widget w2;  //不是賦值,調用拷貝構造函數

w1 = w2;    //是賦值,調用operator=

儘管這裏有好幾個初始化的語法了,C++98仍然沒有辦法去做到一些想得到的初始化。舉個例子,我們沒有辦法直接指示一個STL容器使用特定的集合來創建(比如1,3,和5)。

爲了解決多種初始化語法之間的混亂,也爲了解決他們沒有覆蓋所有的初始化情況,C++介紹了一種標準初始化:至少在概念上,它是一個單一的初始化語法,可以用在任何地方,做到任何事情。它基於花括號,所以因爲這個原因,我更喜歡用術語花括號初始化來形容它。”標準初始化“是一個想法,”花括號初始化“是一個語法概念。

花括號初始化讓你做到之前你做不到的事,使用花括號,明確容器的初始內容是很簡單的:

std::vector<int> v{ 1, 3, 5};   //v的初始內容是1,3,5

花括號同樣可以用來明確non-static成員變量的初始值。這是C++11的新能力,也能用”=“初始化語法做到,但是不能用圓括號做到:

class Widget{
    ...

private:
    int x{ 0 };     //對的,x的默認值爲0
    int y = 0;      //同樣是對的
    int z(0);       //錯誤!

另外,不能拷貝的對象(比如,std::atomics—看 Item 40)能用花括號和圓括號初始化,但是不能用”=“初始化:

std::atomic<int> ai1{ 0 };  //對的

std::atomic<int> ai2(0);    //對的

std::atomic<int> ai3 = 0;   //錯誤

因此這很容易理解爲什麼花括號初始化被稱爲”標準“。因爲,C++中指定初始化值的三種方式中,只有花括號能用在每個地方。

花括號初始化有一個新奇的特性,它阻止在built-in類型中的隱式收縮轉換(narrowing conversation)。如果表達式的值不能保證被初始化對象表現出來,代碼將無法通過編譯:

double x, y, z;

...

int sum1{ x + y + z };  //錯誤!doubles的和不能表現爲int

用圓括號和”=“初始化不會檢查收縮轉換(narrowing conversation),因爲這麼做的話,會讓歷史遺留的代碼無法使用:

int sum2(x + y + z);    //可以(表達式的值被截斷爲int)

int sum3 = x + y + z;   //同上

花括號初始化的另外一個值得一談的特性是它能避免C++最令人惱火的解析。一方面,C++的規則中,所有能被解釋爲聲明的東西肯定會被解釋爲聲明,最令人惱火的解析常常折磨開發者,當開發者想用默認構造函數構造一個對象時,他常常會不小心聲明瞭一個函數。問題的根本就是你想使用一個參數調用一個構造函數,你能這麼做:

Widget w1(10);  //使用參數10調用Widget的構造函數

但是如果你使用類似的語法,嘗試使用0個參數調用Widget的構造函數,你會聲明一個函數,而不是一個對象:

Widget w2();    //最令人惱火的解析!聲明一個
                //名字是w2返回Widget的函數

函數不能使用花括號作爲參數列表來聲明,所以使用花括號調用默認構造函數構造一個對象不會有這樣的問題:

Widget w3{};    //不帶參數調用Widget的默認構造函數

因此這裏對於花括號初始化有很多可以說的。這種語法能用在最寬廣的範圍,它能阻止隱式收縮轉換(narrowing convertions),並且它能避免C++最讓人惱火的解析。三個優點!但是爲什麼這個條款的標題不是”優先使用花括號初始化語法“呢?

花括號初始化的缺點是它會伴隨一些意外的行爲。這些行爲來自於花括號初始化與std::initializer_lists的異常糾結的關係,以及構造函數的重載解析。它們的常常導致代碼看來應該是這麼做的,單事實上卻做了別的事。舉個例子,Item 2解釋了當auto聲明的變量使用花括號初始化時,它的類型被推導爲std::initializer_list, 而使用一樣的初始化表達式,別的方式聲明的變量能產生更符合實際的類型。結果就是,你越喜歡auto,你就越不喜歡花括號初始化。

在調用構造函數時,圓括號和花括號是一樣的只要參數不涉及std::initializer_list:

class Widget{
public:
    Widget(int i, bool b);      //不聲明帶std::initializer_list
    Widget(int i, double d);    //參數的構造函數
    ...
};

Widget w1(10, true);        //調用第一個構造函數

Widget w2{10, true);        //同樣調用第一個構造函數

Widget w3(10, 5.0);         //調用第二個構造函數

Widget w4{10, 5.0};         //也調用第二個構造函數

但是,如果一個或更多個構造函數聲明瞭一個類型爲std::initializer_list的參數,使用花括號初始化語法將強烈地偏向於調用帶std::initializer_list參數的函數。強烈意味着,當使用花括號初始化語法時,編譯器只要有任何機會能調用帶std::initializer_list參數的構造函數,編譯器就會採用這種解釋。舉個例子,如果上面Widget類增加一個帶std::initializer_list參數的構造函數:

class Widget {
public:
    Widget(int i, bool b);  
    Widget(int i, double d);
    Widget(std::initializer_list<long double> il);

    ...
};

儘管std::initializer_list元素的類型是long double,Widget w2和w4還將使用新的構造函數來構造對象,比起non-std::initializer_list構造函數,兩個參數都是更加糟糕的匹配。看:

Widget w1(10, true);    //使用圓括號,和以前一樣,調用
                        //第一個構造函數

Widget w2{10, true};    //使用花括號,但是現在調用
                        //std::initializer_list版本的
                        //構造函數(10和true轉換爲long double)

Widget w3(10, 5.0);     //使用圓括號,和以前一樣,調用
                        //第二個構造函數

Widget w4{10, 5.0};     //使用花括號,但是現在調用
                        //std::initializer_list版本的
                        //構造函數(10和5.0轉換爲long double)

甚至普通的拷貝和移動構造函數也會被std::initializer_list構造函數所劫持:

class Widget {
public:
    Widget(int i, bool b);  
    Widget(int i, double d);
    Widget(std::initializer_list<long double> il);

    operator float() const;     //轉換到float
    ...
};                  

Widget w5(w4);              //使用圓括號,調用拷貝構造函數

Widget w6{w4};              //使用花括號,調用
                            //std::initializer_list構造函數
                            //(w4 轉換到float,然後float
                            //轉換到long double)

Widget w7(std::move(w4));   //使用圓括號,調用移動構造函數

Widget w8{std::move(w4)};   //使用花括號,調用
                            //std::initializer_list構造函數
                            //(同w6一樣的原因)

編譯器使用帶std::initializer_list參數的構造函數來匹配花括號初始化的決心如此之強,就算最符合的std::initializer_list構造函數不能調用,它還是能勝出。舉個例子:

class Widget {
public:
    Widget(int i, bool b);  
    Widget(int i, double d);
    Widget(std::initializer_list<bool> il); //元素類型
                                            //現在是bool

    ...
};  

Widget w{10, 5.0};  //錯誤!需要收縮轉換(narrowing conversion)

這裏,編譯器將忽視前兩個構造函數(第二個構造函數提供了最合適的兩個參數類型)並且嘗試調用帶std::initializer_list參數的構造函數。調用這個構造函數需要把一個int(10)和一個double(5.0)轉換到bool。兩個轉換都要求收縮(narrowing)(bool不能顯式地代表這兩個值),並且收縮轉換(narrowing conversions)在花括號初始化中是被禁止的,所以這個調用是無效的,然後代碼被拒絕了。

在花括號初始化中,只有當這裏沒有辦法轉換參數的類型爲std::initializer_list時,編譯器纔會回到正常的重載解析。舉個例子,如果我們把std::initializer_list構造函數替換爲帶std::initializer_list參數的構造函數,non-std::initializer_list構造函數才能重新成爲候選人,因爲這裏沒有任何辦法把bools轉換爲std::string:

class Widget {
public:
    Widget(int i, bool b);  
    Widget(int i, double d);

    //元素類型現在是std::string
    Widget(std::initializer_list<std::string> il);  

    ...
};  

Widget w1(10, true);    //使用圓括號,和以前一樣,調用
                        //第一個構造函數

Widget w2{10, true};    //使用花括號,現在調用第一個構造函數

Widget w3(10, 5.0);     //使用圓括號,和以前一樣,調用
                        //第二個構造函數

Widget w4{10, 5.0};     //使用花括號,現在調用第二個構造函數

我們已經接近花括號初始化和構造函數重載的尾聲了,但是這裏還有一種有趣的邊緣情況需要解決。假設你使用空的花括號來構造對象,這個支持默認構造函數並且支持std::initializer_list構造函數。那麼你的空的花括號意味着什麼呢?如果它們意味着“沒有參數”,你得到默認構造函數,但是如果它們意味着“空的std::initializer_list”你得到不帶元素的std::initializer_list構造函數。

規則是你會得到默認構造函數,空的花括號意味着沒有參數,不是一個空的std::initializer_list:

class Widget {
public:
    Widget();       //默認構造函數

    //std::initializer_list構造函數
    Widget(std::initializer_list<int> il);  

    ...
};  

Widget w1;      //調用默認構造函數

Widget w2{};    //也調用默認構造函數

Widget w3();    //最令人惱火的解析!聲明一個函數!

如果你想使用元素爲空的std::initializer_list來調用一個std::initializer_list函數,你需要用空的花括號作爲參數來調用—把空的花括號放在圓括號或者花括號中間:

Widget w4({});      //使用空的list調用
                    //std::initializer_list構造函數

Widget w5{{}};      //同上

在這種情況下,看起來很神祕的大括號初始化,std::initializer_list,和構造函數重載等東西在你腦袋中旋轉,你可能會擔心,在日常編程中這些信息會造成多大的麻煩。比你想的更多,因爲一個直接被影響的class就是std::vector。std::vector有一個non-std::initializer_list構造函數允許你明確初始的容器大小以及每個元素的初始值,但是它也有一個std::initializer_list構造函數允許你明確初始的容器值。如果你創建一個數值類型的std::vector(比如std::vector )並且傳入兩個參數給構造函數,根據你使用的是圓括號或者花括號,會造成很大的不同:

std::vector<int> v1(10, 20);    //使用non-std::initializer_list
                                //構造函數,創建一個10元素的
                                //std::vector,所有元素的值都是20

std::vector<int> v2{10, 20};    //使用std::initializer_list
                                //構造函數沒創建一個2元素的
                                //std::vector,元素的值分別是
                                //10,20

但是,讓我們退一步來看std::vector,圓括號,花括號以及構造函數重載解析規則的細節。在這個討論中有兩個重要的問題。第一,作爲一個class的作者,你需要意識到如果你設置了一個或多個std::initializer_list構造函數,客戶代碼使用花括號初始化時將會只看見std::initializer_list版本的重載函數。因此,最好在設計你的構造函數時考慮到,客戶使用圓括號或者花括號都不會影響到構造函數的調用。換句話說,就像你現在看到的std::vector接口設計是錯誤的,並且在設計你的類的時候避免這個錯誤。

另一個問題是,如果你有一個類,這個類沒有std::initializer_list構造函數,然後你想增加一個,客戶代碼中使用花括號初始化的代碼會找到這個構造函數,並把從前本來解析爲調用non-std::initializer_list構造函數的代碼重新解析爲調用這個構造函數。當然,這種事情經常發生,只要你增加一個新的函數來重載:原來解析爲調用舊函數的代碼可能會被解析爲調用新函數。std::initializer_list構造函數重載的不同之處在於它不同別的版本的函數競爭,它直接佔據領先地位以至於其它重載函數幾乎不會被考慮。所以只有經過仔細考慮過後你才能增加這樣的重載(std::initializer_list構造函數)。

第二個要考慮是,作爲類的客戶,你必須在選擇用圓括號或花括號創建對象的時候仔細考慮。很多開發者使用一種符號作爲默認選擇,只有在必要的時候才使用另外一種符號。默認使用花括號的人被它的大量優點(無可匹敵的應用寬度,禁止收縮轉換,避免C++最令人惱火的解析)所吸引。這些人知道一些情況(比如,根據容器大小和所有初始化元素值創建std::vector時)圓括號是必須的。另外一方面,一些向圓括號看齊的人使用圓括號作爲他們的默認符號。他們喜歡的是,圓括號的同C++98傳統語法的一致性,避免錯誤的auto推導問題,以及創建對象時不會不小心被std::initializer_list構造函數所偷襲。他們有時候會只考慮花括號(比如,使用特定元素值創建一個容器)。這裏沒有一個標準,沒有說用哪一種誰好,所以我的建議是選擇一種,並堅持使用它。

如果你是template的作者,在創建對象時選擇使用圓括號還是花括號時會尤其沮喪,因爲通常來說,這裏有沒辦法知道哪一種會被使用。舉個例子,假設你要創建一個對象使用任意類型任意數量的參數。一個概念上的可變參的template看起來像這樣:

template<typename T,        //對象的類型是T
         typename...Ts>     //參數的類型
void doSomeWork(TS&&... params)
{

    使用params創建局部T對象...
    ...
}

這裏有兩種形式把僞代碼轉換成真正的代碼(要知道std::forward的信息,請看Item 25):

T localObject(std::forward<Ts>(params)...); //使用圓括號

T localObject{std::forward<Ts>(params)...}; //使用花括號

所以考慮下面的代碼:

std::vector<int> v;
...
doSomeWork<std::vector<int>>(10, 20);

如果doSomeWork使用圓括號來創建localObject,最後的std::vector將有10個元素。如果doSomeWork使用花括號,最後的std:vector將有兩個元素。哪一種是對的?doSomeWork的作者不知道,只有調用者知道。

這正是STL標準庫函數std::make_unique和std::make_shared(看Item21)面臨的問題。這些函數解決問題的方式是:在內部使用圓括號並且在接口文檔中說明這個決定。

            你要記住的事
  • 花括號初始化是用途最廣的初始化語法,它阻止收縮轉換,能避免C++最令人惱火的解析
  • 在構造函數重載解析中,只要有可能,花括號初始化都對應std::initializer_list構造函數,就算其他的構造函數看起來更好。
  • 選擇圓括號或花括號創建對象能起到重大影響的例子是用兩個參數創建一個std::vector的對象。
  • 在template內部,創建對象時選擇圓括號或花括號是一個難題。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章