C++11 列表初始化都做了什麼?

Torch_HXM發表於2023-11-02

類的成員變數的初始化細節

首先,來看兩個問題:

  • 類的建構函式中,成員變數的列表初始化是如何實現的?
  • 為什麼列表初始化效率上優於在建構函式中為成員變數賦值?

(後文中,將 “在建構函式中為成員變數賦值” 簡稱為 “構內賦值”。)

這兩個問題從何而來

通常,當你搜尋為什麼列表初始化優於構內賦值時,基本上所有的博文都會告訴你:“列表初始化使得成員變數在被定義時繫結初值;而在建構函式內賦值,成員變數會先在定義時被初始化為0,然後再被賦值為指定的初值。”總之,意思是,列表初始化相比於構內賦值少了一次對記憶體的寫入。至於這樣的說法是否正確?為什麼會這樣?列表初始化和構內賦值的實現細節是什麼樣的?很少有人分析。所以,本著“實踐出真知”的道理,我們在這本篇博文中做了一些列的實驗,並詳細的講述了為什麼初始化列表優於構內賦值?

列表初始化 和 構內賦值 的實現細節

首先,先將提出的兩個問題回答一下:

  • 類的列表初始化是透過成員變數的複製建構函式實現的。
  • 列表初始化相比於構內賦值,減少了一半的函式呼叫,減少了一半的記憶體寫入。

列表初始化和構內賦值的具體的流程圖如下:
列表初始化和構內賦值的具體的流程圖

構內賦值 的實現細節

構內賦值分為兩步實現:

  • 呼叫各級成員的預設建構函式
  • 呼叫各級成員的賦值函式

首先,我們來解釋一下什麼是“各級成員”?如下,類 A 中包含型別為 B 的成員變數 b,而 B 型別還可能包含型別為 C 的成員變數 c,如此遞推。直至遞推到基礎型別(比如 int)。對於類 A 而言,b, c, d ... 就是它的各級成員。

class C{
    D d;
};

class B{
    C c;
};

class A{
    B b;
};

我們透過如下程式碼來測試構內賦值是如何實現的:

#include<iostream>

class Test0{
    int a;
public:
    Test0():a(0){
        std::cout<< "0預設構造\n";
    }
    Test0(const Test0& t1):a(t1.a){
        std::cout<< "0複製構造\n";
    }
    Test0(Test0&& t1):a(t1.a){
        std::cout<< "0移動構造\n";
    }
    void operator= (const Test0& t1){
        std::cout<< "0賦值函式\n";
        a = t1.a;
    }
};

class Test1{
    Test0 t;
public:
    Test1(){
        std::cout<< "1預設構造\n";
    }
    Test1(const Test1& t1):t(t1.t){
        std::cout<< "1複製構造\n";
    }
    Test1(Test1&& t1):t(t1.t){
        std::cout<< "1移動構造\n";
    }
    void operator= (const Test1& t1){
        std::cout<< "1賦值函式\n";
        t = t1.t;
    }
};

class Test2{
private:
    Test1 t1;
public:
    Test2(const Test1& t1){
        std::cout<< "2構造\n";
        this->t1 = t1;
    }
};

int main(){
    Test1 t1;       \\ main 函式第一行程式碼 
    std::cout<< "---------------\n";
    Test2 t2(t1);   \\ main 函式第三行程式碼
}

在上面的程式碼中,我們定義了三個類,並依次包含,最底層的類 Test0 包含了一個基礎型別 int。在 main 函式中,我們首先預設構造了一個 Test1 型別的物件 t1,而後將 t1 傳入到 Test2 的建構函式中。Test2 的建構函式,是透過構內賦值實現的。我們執行上述程式碼,結果如下:

0預設構造
1預設構造
---------------
0預設構造
1預設構造
2構造
1賦值函式
0賦值函式

可以看到,main 函式的第一行程式碼透過預設建構函式構建物件 t1,從輸出的第 1、2 行可以看出,t1 及其各級成員的建構函式自下而上的執行,即:int 的預設構造 -> Test0 的預設構造 -> Test1 的預設構造。預設建構函式,會定義變數,並初始化為 0。

main 函式的第三行我們定義變數 t2,將 main 函式第一行定義的 t1 傳入其建構函式。從輸出的第 4、5、6 行可以看出,t2 的成員變數 t1 首先經過了預設構造,然後才進入到 t2 的建構函式中。而後在 t2 的建構函式中,我們將 main 函式第一行定義的 t1 賦值給 t2 的成員變數 t1。從輸出的 7、8 行可以看出,這個賦值操作自上而下的呼叫了成員的賦值函式,即: Test1 的賦值函式 -> Test0 的賦值函式 -> int 的賦值函式。

至此完成了構內賦值。整個過程的資源分析如下:

  • 呼叫了各級成員的預設構造
  • 向基礎型別成員的記憶體中寫入 0
  • 呼叫了各級成員的賦值函式
  • 向基礎型別成員的記憶體中寫入初值

列表初始化的實現細節

我們透過如下程式碼來觀察列表初始化的實現細節:

#include<iostream>

class Test0{
    int a;
public:
    Test0():a(0){
        std::cout<< "0預設構造\n";
    }
    Test0(const Test0& t1):a(t1.a){
        std::cout<< "0複製構造\n";
    }
    Test0(Test0&& t1):a(t1.a){
        std::cout<< "0移動構造\n";
    }
    void operator= (const Test0& t1){
        std::cout<< "0賦值函式\n";
        a = t1.a;
    }
};

class Test1{
    Test0 t;
public:
    Test1(){
        std::cout<< "1預設構造\n";
    }
    Test1(const Test1& t1):t(t1.t){
        std::cout<< "1複製構造\n";
    }
    Test1(Test1&& t1):t(t1.t){
        std::cout<< "1移動構造\n";
    }
    void operator= (const Test1& t1){
        std::cout<< "1賦值函式\n";
        t = t1.t;
    }
};

class Test2{
private:
    Test1 t1;
public:
    Test2(const Test1& t1):t1(t1){
        std::cout<< "2構造\n";
    }
};

int main(){
    Test1 t1;
    std::cout<< "---------------\n";
    Test2 t2(t1);
}

相比於構內賦值的實現細節中的程式碼,我們將 Test2 的建構函式改為列表初始化。程式碼的執行結果如下:

0預設構造
1預設構造
---------------
0複製構造
1複製構造
2構造

觀察輸出的 4,5 行,列表初始化透過呼叫各級成員的複製建構函式來完成。這種呼叫是自下而上的,即:int 的複製構造 -> Test0 的複製構造 -> Test1 的複製構造。基礎型別的成員經歷了一次記憶體寫入。

觀察輸出的 4, 5, 6 行,列表初始化在進入建構函式的函式體之前完成。

列表初始化的資源分析如下:

  • 呼叫了各級成員的複製建構函式
  • 向基礎型別成員的記憶體中寫入初值

列表初始化 與 構內賦值 所用資源比較

構內賦值的資源分析如下:

  • 呼叫了各級成員的預設構造
  • 向基礎型別成員的記憶體中寫入 0
  • 呼叫了各級成員的賦值函式
  • 向基礎型別成員的記憶體中寫入初值

列表初始化的資源分析如下:

  • 呼叫了各級成員的複製建構函式
  • 向基礎型別成員的記憶體中寫入初值

可見,列表初始化比構內賦值減少了一半的資源呼叫和一半的記憶體寫入。因此列表初始化由於構內賦值。

看來,大多數博文中說的不完全對,他們只說對了記憶體,卻沒有分析函式的呼叫次數。

相關文章