Effective Modern C++ 條款7 創建對象時區分( )和{ }

創建對象時區分( )和{ }

基於你的看法,C++11的對象初始化的語法選擇是不堪和混亂的。總的來說,初始值可以藉助大括號”{}”,等號”=”,圓括號”()”:

int x(0);   // 初始值在圓括號內

int y = 0;   // 初始值跟在等號後面

int z{0};  // 初始值在大括號內

在很多例子中,我們可以同時使用大括號和等號:
int z = {0};
在本條款剩餘的內容中,我會忽略這種語法,因爲C++通常會把這種語法當作只用大括號語法對待。

使用等號初始化經常會讓C++初學者認爲會進行一次賦值,但不是那樣的。對於內置類型,例如int,初始化和賦值操作的差別是模糊的,但是對於用戶定義的類,區分初始化和賦值操作是很重要的,,因爲這會導致不同的函數調用:

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

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

w1 = w2;   // 賦值操作,調用operator=函數

就算C++98有多種初始化語法,但是有些情況也無法得到想要的初始值,例如,STL容器無法直接用一組數來初始化。

因爲初始化的語法很混亂,而且有些情況無法實現,所以C++11提出了統一初始化(uniform initailization)語法:一種至少在概念上可以用於表達任何值的語法。它的實現基於大括號,所以我稱之爲大括號初始化(braced initialization)。統一初始化是一個想法,大括號初始化是句法表現。

大括號初始化可以讓你描述以前無法描述的初始值。使用大括號可以更容易的初始化容器:
std::vector<int> v{1, 3, 5};

大括號也可以用於類內成員的默認初始值,在C++11中,等號”=”也可以實現,但是圓括號”()”則不可以:

class Widget {
  ...
private:
  int x{ 0 };   // x的默認初始值爲0
  int y = 0;  // 同上
  int z( 0 ); // 報錯
}

另一方面,不可拷貝對象(例如,std::atomic)可以用大括號和圓括號初始化,但不能用等號:

std::atomic<int> ai1{ 0 };  // 可以

std::atomic<int> ai2( 0 );  // 可以

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

所以不難理解爲什麼大括號初始化會被稱爲”統一的“,因爲在C++三種初始化語法中,只有大括號初始化可以用於任何情況。

大括號初始化有一個奇怪的特性,就是它會禁止內值類型間的隱式的範圍窄化轉換(narrowing conversions,也就是精度降低,範圍變窄)。如果被初始化的變量的類型無法保證能被大括號內的值的類型所表述(本人理解是發生了類型截斷,溢出等情況),那麼代碼無法通過編譯(本人測試代碼可以編譯運行…):

double x, y, z;
...
int sum1{x + y + z};  // 報錯,double值的和可能在int中無法表述

使用圓括號和等號初始化不會檢測隱式的窄化轉換,這是爲了與舊代碼兼容:

int sum2(x + y + z);  //通過,值被截斷爲int

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

大括號初始化的另一個值得注意的特性是它會免疫C++中的most vexing parse(最讓人頭痛的歧義?)。當開發者想要一個默認構造的對象時,經常受到most vexing parse的折磨,因爲程序會不經意地聲明個函數代替構造對象。這根本原因是當你你想要調用一個帶參數的構造函數時,你可以這樣:

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

但當你嘗試用類似的語法調用無參構造時,你聲明瞭個函數,而不是創建對象:

Widget w2();   // most vexing parse,聲明瞭一個返回Widget的函數

使用大括號包含參數是無法聲明爲函數的,所以使用大括號默認構造對象不會出現這個問題:

Widget w3{};

我們講了很多大括號初始化的內容,這種語法可以用於多種場景,還可以避免隱式範圍窄化轉換,又免疫C++的most vexing parse問題。一舉多得,那麼爲什麼這條款不起名爲”用大括號初始化語法替代其他“呢?

大括號初始化的缺點是它有時會顯現令人驚訝的的行爲。這些行爲的出現是因爲與std::initializer_list混淆了。它們交互時會讓代碼不像表面上那樣運行。例如,在條款2中講述auto推斷帶大括號初始值時,會把類型推斷爲std::initializer_list

在構造函數中,只要形參不帶有std::initializer_list,圓括號和大括號行爲一致:

class Widget {
public:
  Widget(int i, bool b);
  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參數的重載構造函數。例如,Widget類的構造函數帶有參數類型是std::initializer_list<long double>

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

那麼w2和w4會使用新的構造構造函數,儘管std::initializer_list的元素是long double,比起另外兩個構造函數的參數來說是更差的匹配:

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

Widget w2{10, true};  // 使用大括號,調用第三個構造函數
                     // 10 和 true被轉換爲long double

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

Widget w4{10, 5.0};  // 使用大括號,調用第三個構造函數
                       // 10 和 true被轉換爲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};   // 使用大括號,調用第三個構造函數
                   // 原因是先把w4轉換爲float,再把float轉換爲long dobule

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

Widget w8{std::move(m4)};  // 使用大括號,調用第三個構造函數,理由同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);  // long double 改爲 bool
  ...
};

Widget w{10, 5.0};  // 報錯,因爲發生範圍窄化轉換

就算是這樣,編譯器也會忽略另外兩個構造函數(第二個還是參數精確匹配的),並且嘗試調用帶有std::initializer_list<bool>的構造函數。而調用第三個構造函數會讓一個int(10)值和一個double(5.0)值轉換爲bool類型。這兩個轉換都是範圍窄化轉換(bool的大小不能準確描述它們的值),然而窄化轉換在大括號初始化語法中是被禁止的,所以這個函數調用無效,代碼無法編譯通過。

只有當大括號內的值無法轉換爲std::initializer_list元素的類型時,編譯器纔會使用正常的重載選擇方法,例如把上面的std::initializer_list<bool>改爲std::initializer_list<std::string>,那麼那些非std::initializer_list構造函數會重新成爲候選函數,因爲沒有辦法從數值轉換爲std::string

class Widget {
public:
  Widget(int i, bool b);
  Widget(int i, double d);
  Widget(std::initializer_list<std::string> il);  // bool 改爲 std::string
  ...
};

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();
  Widget(std::initializer_list<int> il);
  ...
};

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

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

Widget w3();   // 出現most vexing parse,聲明瞭一個函數

如果你想要用一個空的std::initializer_list參數來調用帶std::initializer_list構造函數,那麼你需要把大括號作爲參數,即把空的大括號放在圓括號內或者大括號內:

Widget w4({});   // 用了一個空的list來調用帶std::initializer_list構造函數

Widget w5{{}};   // 同上

此時此刻,大括號初始化,std::initializer_list,構造函數重載之間的複雜關係在你的大腦中冒泡,你可能想要知道這些信息會在多大程度上關係到你的日常編程。可能比你想象中要多,因爲std::vector就是一個被它們直接影響的類。std::vector中有一個可以指定容器的大小和容器內元素的初始值的不帶std::initializer_list構造函數,但它也有一個可以指定容器中元素值的帶std::initializer_list函數。如果你想要創建一個數值類型的std::vector(例如std::vector),然後你要傳遞兩個值作爲構造函數的參數,那麼使用大括號與圓括號的行爲是不同的:

std::vector<int> v1(10, 20);   // 使用不帶std::initializer_list的構造函數
                        // 創建10個元素的vector,每個元素的值爲20

std::vector<int> v2{10, 20};   // 使用帶std::initializer_list的構造函數
                          // 創建2個元素的vector,元素值爲10和20

我們先忽視std::vector和圓括號,大括號,構造函數重載規則。這次討論不涉及兩個要素。首先,作爲一個類的作者,你需要知道如果你的構造函數集中包含一個帶std::initializer構造函數,客戶代碼中使用了大括號初始化的話看起來好像只有帶std::initializer構造函數。因此,你最好把構造函數設計得重載調用不受大括號和圓括號影響。換句話說,把上面std::vector出現的情況中當作錯誤,自己寫代碼時應該避免這樣。

一種糾紛,比如說,你的類一開始不含有帶std::initializer_list構造函數,後來你加了一個,那麼用戶會發現,原理使用大括號初始化時選擇的是不帶std::initializer_list構造函數,而現在全部都選擇帶std::initializer_list構造函數。當然,這種事情在你爲重載函數再添加一個實現時也有可能發生:本來是調用舊的重載函數可能會選擇新加入的函數。不過std::initializer_list構造函數的不同之處是,帶std::initializer_list構造函數不用與其它構造函數競爭,它直接遮蔽了其它的構造函數。所以加入帶std::initializer_list需要深思熟慮。

第二個要講的是作爲類的使用者(用戶),你創建對象時必須在使用大括號還是圓括號上仔細考慮。大多數開發者最後會選擇一種符號作爲默認使用,而使用另一種符號當它無可避免的時候。使用大括號作爲默認符號的人喜歡大括號的廣泛適用性,禁止窄化轉換,避免C++的most vexing parse。這些人知道在某種情況下(例如,上面的vector),圓括號是必須的。另一方便,使用圓括號作爲默認符合的人們,沿襲着C++98的語法傳統,又可以避免auto-deduced-a-std::initializer_list問題,還知道重載構造函數的選擇不受std::initializer_list構造函數的攔截,他們也會讓步當只有大括號才能實現時(例如用特殊的值來初始化容器)。這裏沒有哪個更好的說法,所以我的建議是選擇一種符號並且大部分情況下使用它。


如果你是模板的作者,那麼大括號和圓括號的關係會變得很難搞,因爲你太可能使用哪一種。例如,你想通過隨意的數值參數來創建一個數值類型對象。一個可變參數模板在概念上可以很直接的實現:

template <typename T, typename... Ts>
void doSomeWork(Ts&&... params)
{
  create local T object from params...  //僞代碼
  ...
}

在僞代碼中有兩種選擇:

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

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

然後考慮以下代碼:

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

如果doSomeWork使用的是圓括號的方式創建對象,那麼局部對象std::vector有10個元素。如果doSomeWork使用的是大括號的方式創建對象,那麼局部對象std::vector只有2個元素。是要使用哪一種方式呢,模板的作者是不知道的,只有用戶知道他想使用哪一種方式。

這正是標準庫函數std::make_uniquestd::make_shared所面臨的一個問題(條款21)。這些函數的解決辦法是強制要求把參數寫在圓括號內,然後在接口中說明這個決策。


總結

需要記住的3點:

  • 大括號初始化是適用範圍最廣泛的初始化語法,它可以防止範圍窄化轉換和免疫C++的most vexing parse問題。
  • 在選擇重載構造函數期間,大括號初始化會儘可能的匹配帶std::initializer_list構造函數,儘管看起來匹配其它的構造函數剛好。
  • 使用大括號和圓括號初始化導致巨大差異的一個例子是用兩個參數創建std::vector<numeric type>對象。
  • 在模板中選擇用大括號還是圓括號創建對象是具有挑戰性的。
發佈了18 篇原創文章 · 獲贊 43 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章