一文总结现代 C++ 中的初始化
本文嘗試回答:
- 現代 C++ 有哪幾種初始化形式?分別能夠用于什么場景?有什么限制?
-
MyClass obj();為什么沒有調用默認無參構造函數創建一個對象? -
new int和new int()有什么區別? - 直接初始化、拷貝初始化、列表初始化、默認初始化、值初始化、類內初始值、構造函數初始值列表的區別與聯系?
- 初始化和賦值的區別?
- 類成員有幾種初始化方式,其初始化順序是由什么決定的?
- 初始化相關的注意事項及最佳實踐?
1. 內置類型和類類型
正式開始介紹初始化之前,先要區分 C++ 中的兩種數據類型:內置類型和類類型。
- 內置類型:char、bool、short、int、float、double、指針等 C++ 語言支持的最基礎的數據類型
- 類類型:標準庫以及我們自己定義的各種類、模板類等,如
MyClass、std::vector<T>、std::string、std::unique_ptr<T>...
2. C++ 初始化的 4 種形式
初始化是指在創建對象(為特定類型的變量申請存儲空間)的同時賦予初始值。現代 C++ 中,一共有 4 種初始化形式:
- 等號
=... - 等號+花括號
={...} - 花括號
{...} - 圓括號
(...)
無論是內置類型還是類類型,都支持這 4 種形式的初始化:
int i1=0; // (1)
int i2={0}; // (2)
int i3{0}; // (3)
int i4(0); // (4)
std::string s1="hello"; // (1)
std::string s2={"hello"}; // (2)
std::string s3{"hello"}; // (3)
std::string s4("hello"); // (4)
3. 初始化和賦值
前兩種初始化雖然在形式上都用了等號 =,但初始化的等號和賦值的等號具有不同的含義。C++ 中賦值和初始化是兩種完全不同的操作,只是恰巧都用了等號 =。就好比乘法和解引用都用了 *,含義卻完全不同。
- 初始化:為變量申請存儲空間,創建新的變量。如果是類類型,將調用類的構造函數
-
賦值:把一個現有變量的值用另一個值替代,不創建新的變量。如果是類類型,將調用類的賦值運算符
operator=()
int a = 1; // 初始化
a = 2; // 賦值
MyClass obj1; // 初始化,調用 MyClass() 構造函數
MyClass obj2{42, "hello"}; // 初始化,調用 MyClass(int, string) 構造函數
obj1 = obj2; // 賦值,調用 operator=(const MyClass&)
4. 拷貝初始化和直接初始化
int i1=0; // (1) 拷貝初始化
int i2={0}; // (2) 拷貝初始化
int i3{0}; // (3) 直接初始化
int i4(0); // (4) 直接初始化
std::string s1="hello"; // (1) 拷貝初始化
std::string s2={"hello"}; // (2) 拷貝初始化
std::string s3{"hello"}; // (3) 直接初始化
std::string s4("hello"); // (4) 直接初始化
C++ 初始化的 4 種形式中,前兩種初始化形式 (1)(2) 使用了等號,叫做拷貝初始化,后兩種 (3)(4) 沒有等號,叫做直接初始化。無論是拷貝初始化,還是直接初始化,都是初始化,不是賦值!對于類類型,都是調用構造函數,不會調用賦值運算符!在絕大多數情況下(TODO:補充反例),拷貝初始化和直接初始化除了形式上多一個/少一個等號之外,底層代碼上沒有任何區別。
注意:雖然叫做拷貝初始化,但構造 s1、s2 的過程中,不存在“拷貝”!底層代碼和 s3、s4 完全相同,都是直接調用 string 的構造函數(不信可以去 cppinsights.io 自行驗證)。
5. 列表初始化
列表初始化(list initialization):使用花括號 {} 形式的初始化。C++ 的 4 種初始化形式中的 (2)(3) 都屬于列表初始化。列表初始化在 C++11 中得到全面應用,其最大的特點在于可以防止窄化轉換:如果列表初始化存在信息丟失的風險, 編譯器將報錯。不僅如此,列表初始化還能用于各種初始化場景,包括類內初始值以及 Most Vexing Parse 場景。
a. 防止窄化轉換
long double ld = 3.1415;
int a{ld}; // 無法編譯,轉換存在信息丟失的風險
int b = {ld}; // 無法編譯,轉換存在信息丟失的風險
int c(ld); // 可以編譯,但信息丟失
int d = ld; // 可以編譯,但信息丟失
b. 避免 Most Vexing Parse
class MyClass {
public:
MyClass();
MyClass(int x);
MyClass(int x, int y);
};
int main() {
MyClass obj1(1); // OK
MyClass obj2{1}; // OK,列表初始化
MyClass obj3(1,2); // OK
MyClass obj4(1,2); // OK,列表初始化
// 錯誤,obj5 被解析為函數聲明:參數為空,返回 MyClass
MyClass obj5();
MyClass obj6{}; // OK,列表初始化
MyClass obj7; // OK
}
注意:
obj5并不是創建一個默認構造的對象,而是被解析為一個函數聲明,參數為空,返回 MyClass。有的編譯期會給出警告 warning: empty parentheses were disambiguated as a function declaration [-Wvexing-parse]?
6. 默認初始化
默認初始化(default initialization):當對象未被顯式地賦予初值時執行的初始化行為。
默認初始化的例子:
int i;
std::string s;
MyClass* p = new MyClass;
double* pd = new double;
- 類類型:由類的默認(無參)構造決定
- 內置類型(指針、int、double、float、bool、char 等)及其數組:
- 全局(包括定義在任何函數之外、命名空間之內的)變量或局部靜態變量:初始化為 0(這種情況也叫值初始化)
- 局部非靜態變量或類成員:未定義(未初始化)
如果類沒有默認(無參)構造函數,則該類不支持默認初始化。
7. 值初始化
值初始化(value initialization):默認初始化的特殊情況,此時內置類型會被初始化為 0。
值初始化的場景:
- STL 容器只指定元素數量,而不指定初值時,就會執行值初始化,如
vector<int> vec(10);:10 個 int,初始化為 0 - 全局(包括定義在任何函數之外、命名空間之內的)變量或局部靜態變量:初始化為 0
- new 類型,后面帶括號,如:
new int(),new string{} - 初始值列表為空
{},如double d{};、int *p{};
類類型沒必要區分是默認初始化還是值初始化:類類型的初始化總是由類的構造函數決定,與在函數內/外、全局/局部/類成員、靜態/非靜態、默認初始化/值初始化無關!如果類不含默認(無參)構造,則該類無法進行默認初始化/值初始化!
8. new 的初始化
// 對于類類型,有無括號沒區別
string *ps1 = new string; // 默認初始化為空 string
string *ps2 = new string(); // 值初始化為空 string
string *ps3 = new string{}; // 值初始化為空 string
// 對于內置類型,有括號進行值初始化,沒有括號的值未定義!
int *pi1 = new int; // 默認初始化,*pi1 值未定義!
int *pi2 = new int(); // 值初始化,*pi2 為 0
int *pi3 = new int{}; // 值初始化,*pi3 為 0
const int *pci1 = new const int(1024); // 分配并初始化一個 const int
const int *pci2 = new const int{1024}; // 分配并初始化一個 const int
9. 類的初始化
類成員有兩種初始化方式:類內初始值(成員初始化器,in-class member initializer)以及構造函數初始值列表(constructor initialize list)。
不要在構造函數體內部初始化數據成員,因為只有當類的所有成員初始化完成之后才開始執行構造函數體,此時并不是真正意義上的初始化,而是重新賦值!也正是因為如此,引用成員、const 成員只能通過類內初始值或者構造函數初始值列表初始化,而不能在構造函數體內部“初始化”。不僅如此,在構造函數體內部進行賦值,相比于內類初始值/構造函數初始化列表的只調用一次構造函數,多了一次賦值操作,效率更低。
注意:對于內置類型的數據成員,如果沒有對其進行顯式初始化,其值未定義!
9.1 類內初始值/成員初始化器
在類中聲明類的數據成員同時提供初始值,初始值可以是字面值、表達式甚至是函數調用。形式上可以用等號或者花括號,但是不能用圓括號。C++11 之后首選的初始化類成員方式。
class SalesData {
unsigned unitsSold = 0;
double revenue {0.0};
std::string bookNo{"hello"};
shared_ptr<int> sp={make_shared<int>(5)};
};
9.2 構造函數初始值列表
如果需要根據傳入構造函數的參數來初始化類成員,可以使用構造函數初始值列表。構造函數初始值列表的形式是在構造函數的形參列表之后,使用冒號分隔,接著是成員名字,然后使用圓括號或花括號來包裹初始化的表達式,多個成員之間通過逗號分隔。
class SalesData {
public:
SalesData(const std::string &s) : bookNo(s) {}
SalesData(const std::string &s, unsigned n, double p) : bookNo(s), unitsSold(n), revenue(p*n) {}
};
注意:類的數據成員初始化順序和構造函數初始化列表中的順序無關,而是由成員在類中聲明的順序決定:
class X {
int x;
int y;
public:
// 先用未初始化的 y 初始化 x,再用 val 初始化 y
X(int val): y(val), x(y){}
};
上述 x 值未定義!一般編譯器會給出警告。
9.3 類成員的初始化順序
類的數據成員初始化順序由成員在類中聲明的順序決定,按照聲明的順序,依次構造每個成員,所有成員構造完成后才執行構造函數。
順便說一句,析構順序與初始化順序相反:先執行析構函數,再按照構造相反的順序依次析構每個成員。
10. 總結
現代 C++ 4 種初始化形式:
| 序號 | 形式 | 拷貝/直接初始化 | 可用于構造函數初始值列表 | 可用于類內初始值 | 備注 |
|---|---|---|---|---|---|
| 1 | 等號 =
|
拷貝初始化 | ? | ? | |
| 2 | 等號+花括號 ={}
|
拷貝初始化 | ? | ? | 列表初始化 |
| 3 | 花括號 {}
|
直接初始化 | ? | ? | 推薦!列表初始化,能用于各種初始化場景! |
| 4 | 圓括號 ()
|
直接初始化 | ? | ? | 存在 Most Vexing Parse 問題、不可用于類內初始值及提供多個初始元素值的列表 vector<string> v("a", "an", "the");
|
- 拷貝初始化:使用
=形式的初始化。 - 直接初始化:不使用
=形式的初始化(使用{}或()形式初始化) - 列表初始化:使用
{}形式的初始化,能夠用于各種初始化場景,也被稱為統一初始化 - 默認初始化:未顯式指定初始值的初始化行為。類類型將調用默認無參構造函數;而內置類型可能被值初始化為 0,也可能未被初始化(值未定義)!
- 值初始化:默認初始化的特殊情況,對于內置類型,其值將被初始化為 0。
- 類內初始值/成員初始化器:聲明類成員的同時直接提供初值,C++11 之后的首選初始化類成員的方式。
- 構造函數初始值列表:能夠根據傳入構造函數的參數進行初始類成員
11. 最佳實踐/核心指南
-
總是初始化內置類型的變量,如
int i{};。最好使用 auto,因為 auto 會強迫初始化:不提供初始值就無法推導類型。 -
推薦使用
{}統一列表初始化,形式統一,能用于各種場景。 -
對于類成員的初始化,優先考慮類內初始值。如果需要根據傳入構造函數的參數來初始化成員,可以使用構造函數初始值列表,不要在構造函數體內部對類成員進行賦值。
-
C++核心指南 C.45:如果只是初始化類的數據成員, 不需要專門定義構造函數,用類內初始值。
-
C++核心指南 NR.5:不要兩步初始化,類的構造函數應該直接完成類的初始化工作,不要把初始化的任務轉移/強加給類的用戶(例如要求用戶在創建一個類的對象后,再額外調用一個
Init()之類的函數)。
12. 擴展閱讀
- C++ 何時調用默認構造、拷貝構造、移動構造、拷貝賦值、移動賦值、析構以及對象析構順序
- C++ Primer 查漏補缺 —— C++ 中的各種初始化
總結
以上是生活随笔為你收集整理的一文总结现代 C++ 中的初始化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Gin 框架之jwt 介绍与基本使用
- 下一篇: 梳理拯救烂怂代码?我是这么做的