[C++]五花八門的C++初始化規則

2021-04-18 15:01:15

總結

  • 初始化的概念:建立變數時賦予它一個值(不同於賦值的概念)
  • 類別建構函式控制其物件的初始化過程,無論何時只要類的物件被建立就會執行建構函式
  • 如果物件未被使用者指定初始值,那麼這些變數會被執行預設初始化,預設值取決於變數型別和定義變數的位置
  • 無論何時只要類的物件被建立就會執行建構函式,通過顯式呼叫建構函式進行初始化被稱為顯式初始化,否則叫做隱式初始化
  • 使用等號(=)初始化一個類變數執行的是拷貝初始化,編譯器會把等號右側的初始值拷貝到新建立的物件中去,不使用等號則執行的是直接初始化
  • 傳統C++中列表初始化僅能用於普通陣列和POD型別,C++11新標準將列表初始化應用於所有物件的初始化(但是內建型別習慣於用等號初始化,類型別習慣用建構函式圓括號顯式初始化,vector、map和set等容器類習慣用列表初始化)

初始化不等於賦值

初始化的含義是建立變數時賦予其一個初始值,而賦值的含義是把物件的當前值擦去,並用一個新值替代它。

C++定義了初始化的好幾種不同形式,例如我們定義一個int變數並初始化為0,有如下4種方式:

int i = 0;
int i = {0};
int i{0};
int i(0);

預設初始化與值初始化

Tips:C不允許使用者自定義預設值從而提高效能(增加函數呼叫的代價),C++預設也不做初始化從而提高效能,但是C++提供了建構函式讓使用者顯式設定預設初始值。有個例外是把全域性變數初始化為0僅僅在程式啟動時會有成本,因此定義在任何函數之外的變數會被初始化為0。

如果定義變數時沒有指定初始值,則變數會被預設初始化或值初始化,此時變數被賦予了預設值,這個預設值取決於變數型別和定義位置。

#include <iostream>

class Cat {
 public:
    std::string name;
    Cat() = default;
};

int main() {
    Cat cat1;          // 預設初始化
    Cat cat2 = Cat();  // 顯式請求值初始化
}

1. 內建型別的預設初始化

Tips:建議初始化每一個內建型別的變數,原因在於定義在函數內部的內建型別變數的值是未定義的,如果試圖拷貝或者以其他形式存取此類值是一種錯誤的程式設計行為且很難偵錯。

如果內建型別的變數未被顯式初始化,它的值由定義的位置決定。定義於任何函數體之外的變數會被初始化為0,定義在函數體內部的內建型別變數將不被初始化(uninitialized),一個未被初始化的內建型別變數的值時未定義的,如果試圖拷貝或以其他形式存取此類值將引發錯誤。

#include <iostream>
int global_value;  // 預設初始化為0

int main() {
    int local_value;  // 使用了未初始化的區域性變數
    int* new_value = new int;
    std::cout << "new_value:" << *new_value << std::endl;       // 未定義
    std::cout << "global_value:" << global_value << std::endl;  // 0
    std::cout << "local_value:" << local_value << std::endl;    // 未定義, 且會報warning

    return 0;
}

2. 類型別的預設初始化

定義一個類變數但是沒有指定初始值時,會使用預設建構函式來初始化,所以沒有預設建構函式的類不能執行預設初始化。定義於任何函數體之外的類變數會先進行零初始化再執行預設初始化,定義在函數體內部的類變數會直接執行預設初始化。

#include <iostream>

// Cat類使用合成的預設建構函式
class Cat {
 public:
    int age;
};


// Dog類使用自定義的預設建構函式
class Dog {
 public:
    int age;
    Dog() {}  // 預設建構函式, 但是不會初始化age
};

// 在函數體外部定義的類會先執行零初始化, 再執行預設初始化, 因此雖然預設建構函式不會初始化age變數, 但age仍然是0
Cat global_cat;
Dog global_dog;

int main() {
    Cat local_cat;
    Dog local_dog;
    std::cout << "global_cat age:" << global_cat.age << std::endl;  // 0
    std::cout << "global_dog age:" << global_dog.age << std::endl;  // 0
    std::cout << "local_cat age:" << local_cat.age << std::endl;    // 隨機值
    std::cout << "local_dog age:" << local_dog.age << std::endl;    // 隨機值
    return 0;
}

沒有預設建構函式的類是不能執行預設初始化的:

#include <iostream>

// Cat類禁用預設建構函式, 無法預設初始化
class Cat {
 public:
    int age;
    Cat() = delete;
};

int main() {
    Cat local_cat;  // 編譯報錯: use of deleted function ‘Cat::Cat()’
    return 0;
}

從本質上講,類的初始化取決於建構函式中對資料成員的初始化,如果沒有在建構函式的初始值列表中顯式地初始化資料成員,那麼成員將在建構函式體之前執行預設初始化,例如:

// 通過建構函式初始值列表初始化資料成員: 資料成員通過提供的初始值進行初始化
class Cat {
 public:
    int age;
    explicit Cat(int i) : age(i) {}
};

// 資料成員先進行預設初始化, 再通過建構函式引數進行賦值操作
// 這種方法雖然合法但是比較草率, 造成的影響依賴於資料成員的型別
class Dog {
 public:
    int age;
    explicit Dog(int i) {
        age = i;
    }
};

3. 陣列的預設初始化

  1. 如果定義陣列時提供了初始值列表,那麼未定義的元素若是內建型別或者有合成的預設構造則會先進行零初始化,如果元素是類型別,再執行預設建構函式
  2. 如果定義陣列時未提供初始化列表,則每個元素執行預設初始化
class Cat {
 public:
    int age;
};


int main() {
    /* 內建型別在函數內部預設初始化, 隨機值 */
    int int_array[5];
    for (int i = 0; i < 5; i++) {
        std::cout << int_array[i] << std::endl;  // 全都是隨機值
    }

    /* 定義陣列使用初始值列表, 除了前兩個元素外都是0 */
    int int_array2[5] = { 22, 33 };
    for (int i = 0; i < 5; i++) {
        std::cout << int_array2[i] << std::endl;  // 22,33,0,0,0
    }

    /* 定義陣列使用初始值列表, 都是0 */
    int int_array3[5] = {};
    for (int i = 0; i < 5; i++) {
        std::cout << int_array3[i] << std::endl;  // 0,0,0,0,0
    }

    /* 陣列元素為類且使用初始值列表時 */
    Cat *my_cat = new Cat;
    Cat cat_array[5] = { *my_cat };
    for (int i = 0; i < 5; i++) {
        std::cout << cat_array[i].age << std::endl;  // 隨機值,0,0,0,0
    }

    return 0;
}

4. 內建型別的值初始化(不推薦)

對於類型別而言,不指定初始值下會呼叫它的預設建構函式,因此不存在預設初始化和值初始化的區別。但是對於內建型別值初始化和預設初始化不同,只不過實際開發中我們建議顯式初始化內建型別來避免產生未定義值的程式碼:

int *pi1 = new int;               // 預設初始化: *pi1的值未定義
int *pi2 = new int();             // 值初始化: *pi2的值為0

int *pia1 = new int[10];          // 10個預設初始化的int: 值未定義
int *pia2 = new int[10]();        // 10個值初始化的int: 值都為0

string *psa1 = new string[10];    // 10個預設初始化的string: 都為空
string *psa2 = new string[10]();  // 10個值初始化的string: 都為空

隱式初始化與顯式初始化

1. 概念

無論何時只要類的物件被建立就會執行建構函式,通過顯式呼叫建構函式進行初始化被稱為顯式初始化,否則叫做隱式初始化。

#include <iostream>

// Cat提供兩個建構函式
class Cat {
 public:
    int age;
    Cat() = default;
    explicit Cat(int i) : age(i) {}
};

int main() {
    Cat cat1;           // 隱式初始化: 呼叫預設建構函式
    Cat cat2(10);       // 隱式初始化: 呼叫一個形參的建構函式

    Cat cat3 = Cat();   // 顯式初始化: 呼叫預設建構函式
    Cat cat4 = Cat(5);  // 顯式初始化: 呼叫一個形參的建構函式

    // 建構函式還可以搭配new一起使用, 用於在堆上分配記憶體
    Cat *cat5 = new Cat();
    Cat *cat6 = new Cat(3);
    delete cat5;
    delete cat6;

    return 0;
}

還有一些操作不會顯式呼叫類別建構函式,比如:

  • 通過一個實參呼叫的建構函式定義了從建構函式引數型別向類型別隱式轉換的規則
  • 拷貝建構函式定義了用一個物件初始化另一個物件的隱式轉換
#include <iostream>

// Cat提供兩個建構函式
class Cat {
 public:
    int age;
    // 接收一個引數的建構函式定義了從int型向類型別隱式轉換的規則, explicit關鍵字可以組織這種轉換
    Cat(int i) : age(i) {}
    // 拷貝建構函式定義了從一個物件初始化另一個物件的隱式轉換
    Cat(const Cat &orig) : age(orig.age) {}
};

int main() {
    Cat cat1 = 10;    // 呼叫接收int引數的拷貝建構函式
    Cat cat2 = cat1;  // 呼叫拷貝建構函式

    std::cout << cat1.age << std::endl;
    std::cout << cat2.age << std::endl;
    return 0;
}

// 輸出:
10
10

2. explicit禁用建構函式定義的型別轉換

例如智慧指標就把建構函式宣告為explict,所以智慧指標只能直接初始化。我們也可以通過explicit禁用掉上面提到的兩種隱式轉換規則:

#include <memory>

class Cat {
 public:
    int age;
    Cat() = default;
    // 必須顯式呼叫拷貝建構函式
    explicit Cat(const Cat &orig) : age(orig.age) {}
};

int main() {
    Cat cat1;
    Cat cat2(cat1);      // 正確: 顯式呼叫拷貝建構函式
    // Cat cat3 = cat1;  // 錯誤: explicit關鍵字限制了拷貝建構函式的隱式呼叫

    // std::shared_ptr<int> sp = new int(8);    // 錯誤: 不支援隱式呼叫建構函式
    std::shared_ptr<int> sp(new int(8));        // OK
    return 0;
}

3. 只允許一步隱式型別轉換

編譯器只會自動執行一步隱式型別轉換,如果隱式地使用兩種轉換規則,那麼編譯器便會報錯:

class Cat {
 public:
    std::string name;
    Cat(std::string s) : name(s) {}  // 允許string到Cat的隱式型別轉換
};

int main() {
    // 錯誤: 不存在從const char[8]到Cat的型別轉換, 編譯器不會自動把const char[8]轉成string, 再把string轉成Cat
    // Cat cat1 = "tomocat";

    // 正確: 顯式轉換成string, 再隱式轉換成Cat
    Cat cat2(std::string("tomocat"));

    // 正確: 隱式轉換成string, 再顯式轉換成Cat
    Cat cat3 = Cat("tomocat");
}

直接初始化與拷貝初始化

如果使用等號(=)初始化一個類變數,實際上執行的是拷貝初始化,編譯器把等號右側的值拷貝到新建立的物件中區;如果不使用等號,那麼執行的是直接初始化。

以string為例:

string s1 = "tomocat";    // 拷貝初始化
string s2("tomocat");     // 直接初始化
string s3(10, 'c');       // 直接初始化, s3內容為cccccccccc

// s4拷貝初始化
string s4 = string(10, 'c');
// 等價於
string temp = string(10, 'c');
string s4 = temp;

列表初始化

1. C++98/03與C++11的列表初始化

在C++98/03中,普通陣列和POD(Plain Old Data,即沒有構造、解構和虛擬函式的類或結構體)型別可以使用花括號{}進行初始化,即列表初始化。但是這種初始化方式僅限於上述提到的兩種資料型別:

int main() {
    // 普通陣列的列表初始化
    int arr1[3] = { 1, 2, 3 };
    int arr2[] = { 1, 3, 2, 4 };  // arr2被編譯器自動推斷為int[4]型別
    
    // POD型別的列表初始化
    struct data {
        int x;
        int y;
    } my_data = { 1, 2 };
}

C++11新標準中列表初始化得到了全面應用,不僅相容了傳統C++中普通陣列和POD型別的列表初始化,還可以用於任何其他型別物件的初始化:

#include <iostream>
#include <string>

class Cat {
 public:
    std::string name;
    // 預設建構函式
    Cat() {
        std::cout << "default constructor of Cat" << std::endl;
    }
    // 接受一個引數的建構函式
    Cat(const std::string &s) : name(s) {
        std::cout << "normal constructor of Cat" << std::endl;
    }
    // 拷貝建構函式
    Cat(const Cat &orig) : name(orig.name) {
        std::cout << "copy constructor of Cat" << std::endl;
    }
};

int main() {
    /*
     * 內建型別的列表初始化
     */
    int a{ 10 };       // 內建型別通過初始化列表的直接初始化
    int b = { 10 };    // 內建型別通過初始化列表的拷貝初始化
    std::cout << "a:" << a << std::endl;
    std::cout << "b:" << b << std::endl;

    /*
     * 類型別的列表初始化
     */
    Cat cat1{};                 // 類型別呼叫預設建構函式的列表初始化
    std::cout << "cat1.name:" << cat1.name << std::endl;
    Cat cat2{ "tomocat" };        // 類型別呼叫普通建構函式的列表初始化
    std::cout << "cat2.name:" << cat2.name << std::endl;

    // 注意列表初始化前面的等於號並不會影響初始化行為, 這裡並不會呼叫拷貝建構函式
    Cat cat3 = { "tomocat" };     // 類型別呼叫普通建構函式的列表初始化
    std::cout << "cat3.name:" << cat3.name << std::endl;
    // 先通過列表初始化構造右側Cat臨時物件, 再呼叫拷貝建構函式(從輸出上看好像編譯器優化了, 直接呼叫普通建構函式而不會呼叫拷貝建構函式)
    Cat cat4 = Cat{ "tomocat" };
    std::cout << "cat4.name:" << cat4.name << std::endl;

    /*
     * new申請堆記憶體的列表初始化
     */
    int *pi = new int{ 100 };
    std::cout << "*pi:" << *pi << std::endl;
    delete pi;
    int *arr = new int[4] { 10, 20, 30, 40 };
    std::cout << "arr[2]:" << arr[2] << std::endl;
    delete[] arr;
}

// 輸出:
a:10
b:10
default constructor of Cat
cat1.name:
normal constructor of Cat
cat2.name:tomocat
normal constructor of Cat
cat3.name:tomocat
normal constructor of Cat
cat4.name:tomocat
*pi:100
arr[2]:30

2. vector中圓括號與花括號的初始化

總的來說,圓括號是通過呼叫vector的建構函式進行初始化的,如果使用了花括號那麼初始化過程會盡可能會把花括號內的值當做元素初始值的列表來處理。如果初始化時使用了花括號但是提供的值又無法用來列表初始化,那麼就考慮用這些值來呼叫vector的建構函式了。

#include <string>
#include <vector>

int main() {
    std::vector<std::string> v1{"tomo", "cat", "tomocat"};  // 列表初始化: 包含3個string元素的vector
    // std::vector<std::string> v2("a", "b", "c");          // 錯誤: 找不到合適的建構函式

    std::vector<std::string> v3(10, "tomocat");             // 10個string元素的vector, 每個string初始化為"tomocat"
    std::vector<std::string> v4{10, "tomocat"};             // 10個string元素的vector, 每個string初始化為"tomocat"

    std::vector<int> v5(10);     // 10個int元素, 每個都初始化為0
    std::vector<int> v6{10};     // 1個int元素, 該元素的值時10
    std::vector<int> v7(10, 1);  // 10個int元素, 每個都初始化為1
    std::vector<int> v8{10, 1};  // 2個int元素, 值分別是10和1
}

3. 初始化習慣

儘管C++11將列表初始化應用於所有物件的初始化,但是內建型別習慣於用等號初始化,類型別習慣用建構函式圓括號顯式初始化,vector、map和set等容器類習慣用列表初始化。

#include <string>
#include <vector>
#include <set>
#include <map>

class Cat {
 public:
    std::string name;
    Cat() = default;
    explicit Cat(const std::string &s) : name(s) {}
};

int main() {
    // 內建型別初始化(包括string等標準庫簡單類型別)
    int i = 10;
    long double ld = 3.1415926;
    std::string str = "tomocat";

    // 類型別初始化
    Cat cat1();
    Cat cat2("tomocat");

    // 容器型別初始化(當然也可以用圓括號初始化, 列表初始化用於顯式指明容器內元素)
    std::vector<std::string> v{"tomo", "cat", "tomocat"};
    int arr[] = {1, 2, 3, 4, 5};
    std::set<std::string> s = {"tomo", "cat"};
    std::map<std::string, std::string> m = {{"k1", "v1"}, {"k2", "v2"}, {"k3", "v3"}};
    std::pair<std::string, std::string> p = {"tomo", "cat"};

    // 動態分配物件的列表初始化
    int *pi = new int {10};
    std::vector<int> *pv = new std::vector<int>{0, 1, 2, 3, 4};

    // 動態分配陣列的列表初始化
    int *parr = new int[10]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
}

4. 列表初始化返回值

C++11新標準規定,函數可以通過列表初始化來對函數返回的臨時量進行初始化:

#include <string>
#include <vector>

std::vector<std::string> foo(int i) {
    if (i < 5) {
        return {};  // 返回一個空vector物件
    }
    return {"tomo", "cat", "tomocat"};  // 返回列表初始化的vector物件
}

int main() {
    foo(10);
}

5. initializer_list形參

前面提到C++11支援所有型別的初始化,對於類型別而言,雖然我們使用列表初始化它會自動呼叫匹配的建構函式,但是我們也能顯式指定接受初始化列表的建構函式。C++11引入了std::initializer_list,允許建構函式或其他函數像引數一樣使用初始化列表,這才真正意義上為類物件的初始化與普通陣列和 POD 的初 始化方法提供了統一的橋樑。

Tips:

  • 類物件在被列表初始化時會優先呼叫列表初始化建構函式,如果沒有列表初始化建構函式則會根據提供的花括號值呼叫匹配的建構函式
  • C++11新標準提供了兩種方法用於處理可變數量形參, 第一種是我們這裡提到的initializer_list形參(所有的形參型別必須相同),另一種是可變引數模板(可以處理不同型別的形參)
#include <initializer_list>
#include <vector>

class Cat {
 public:
    std::vector<int> data;
    Cat() = default;
    // 接受初始化列表的建構函式
    Cat(std::initializer_list<int> list) {
        for (auto it = list.begin(); it != list.end(); ++it) {
            data.push_back(*it);
        }
    }
};

int main() {
    Cat cat1 = {1, 2, 3, 4, 5};
    Cat cat2{1, 2, 3};
}

初始化列表除了用於物件建構函式上,還可以作為普通引數形參:

#include <initializer_list>
#include <string>
#include <iostream>

void print(std::initializer_list<std::string> list) {
    for (auto it = list.begin(); it != list.end(); ++it) {
        std::cout << *it << std::endl;
    }
}

int main() {
    print({"tomo", "cat", "tomocat"});
}

Reference

[1] https://blog.csdn.net/xiongya...

[2] https://my.oschina.net/u/9202...

[3] C++ Primer

[4] https://blog.csdn.net/linda_d...

[5] https://en.cppreference.com/w...