引言
C++ 構造函數的執行過程(一) 無繼承
本篇介紹了在無繼承情況下, C++構造函數的執行過程, 即成員變量的構建先於函數體的執行, 初始化列表的數量和順序並不對構造函數執行順序造成任何影響.
還指出了初始化列表會影響成員變量的構造方式, 分析了為何要盡可能地使用初始化列表.
關於在繼承的情況下, C++構造函數的執行過程, 請期待第二篇.
本文所依賴的環境如下:
平台: Windows 10 64位
編譯器: Visual Studio 2019
一. 構造函數的執行順序
1.1 聲明一個類
首先我們聲明一個類:
// Dog.h
class Dog;
如果我們創建一個該類的實例:
// main.cpp
Dog myDog = Dog( );
那么編譯器會申請一塊內存空間, 並調用Dog
的構造函數, 構造這個實例.
1.2 添加構造函數
我們一點點補全這個類.
在這個類中, 添加一個構造函數, 一個析構函數.
在函數體內, 各打印一條日志, 方便我們在調試的過程中, 知道執行的順序.
// Dog.h
class Dog
{
public:
Dog( )
{
std::cout << "Dog構造函數函數體"<< std::endl;
}
~Dog( ) { }
};
現在再次執行:
// main.cpp
std::cout << "Dog構造函數 開始" << std::endl;
Dog myDog = Dog( );
std::cout << "Dog構造函數 結束" << std::endl;
std::cout << "程序即將結束" << std::endl;
程序會打印出日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Dog構造函數函數體
3. Dog構造函數 結束
4. 程序即將結束
1.3 添加成員變量
文明養狗, 每只狗都應該有自己的項圈.
我們給Dog
添加一個項圈collar
屬性.
注: 為了方便驗證, 我們讓collar
也是一個類的實例, 原因在於, 我們需要讓這個屬性在構造的時候, 打印出一條日志, 這樣我們才能判斷出它是在何時被構造的.
// Collar.h
class Collar
{
public:
// 缺省構造函數
Collar( )
{
std::cout << "Collar缺省構造函數" << std::endl;
}
};
現在我們在Dog
中添加整個成員變量:
// Dog.h
class Dog
{
public:
Dog( )
{
std::cout << "Dog構造函數函數體<< std::endl;
}
~Dog(){ }
private:
Collar collar_;
};
現在再次執行:
// main.cpp
std::cout << "Dog構造函數 開始" << std::endl;
Dog myDog = Dog(myCollar);
std::cout << "Dog構造函數 結束" << std::endl;
std::cout << "程序即將結束" << std::endl;
程序會打印出日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Collar缺省構造函數
3. Dog構造函數函數體
4. Dog構造函數 結束
5. 程序即將結束
目前的結論:
在創建一個類的實例的時候, 會先構造出它的成員變量, 然后才會執行它的構造函數函數體的語句.
觀察上面的代碼, 我們並沒有在任何地方, 顯式的調用Collar
的構造函數, 也就是說:
編譯器幫你完成了
Collar
構造函數的調用.
但是, 如果這個類, 不止有一個成員變量, 那么編譯器先構造哪個成員變量呢?
1.4 成員變量的構造順序
現在, 我們給狗狗一個玩具.
// Toy.h
class Toy
{
public:
// 缺省構造函數
Toy( )
{
std::cout << "Toy缺省構造函數" << std::endl;
}
};
在Dog
添加一個玩具Toy
屬性.
// Dog.h
class Dog
{
// 構造和析構與1.3相同, 在此省略
private:
Collar collar_;
Toy toy_;
};
現在執行程序, 得到日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Collar缺省構造函數
3. Toy缺省構造函數
4. Dog構造函數函數體
5. // 其余日志與1.3相同, 在此省略
可以看到, 我們在class Dog
的聲明中, 先聲明了Collar
, 再聲明了Toy
, 實際執行過程, 就是先調用了Collar
缺省構造函數, 再調用了Toy
缺省構造函數.
如果修改為:
// Dog.h
class Dog
{
// 構造和析構與1.3相同, 在此省略
private:
Toy toy_; // 調換了位置
Collar collar_; // 調換了位置
};
日志也會變成:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Toy缺省構造函數
3. Collar缺省構造函數
4. Dog構造函數函數體
5. // 其余日志與1.3相同, 在此省略
目前的結論:
類的成員變量, 是按照類的定義中, 成員變量的聲明順序進行構造的. 且構造都早於類構造函數的函數體.
1.5 初始化列表的順序, 不影響成員變量構造順序
我們將對初始化列表做3個測試.
測試1: 初始化列表的順序 和 成員變量聲明順序一致.
// Dog.h
class Dog
{
public:
Dog(const Collar& myCollar, const Toy& myToy)
: collar_(myCollar)
, toy_(myToy)
{
std::cout << "Dog構造函數函數體開始"<< std::endl;
std::cout << "Dog構造函數函數體結束" << std::endl;
}
private:
Collar collar_;
Toy toy_;
};
現在執行程序, 得到日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Collar缺省構造函數
3. Toy缺省構造函數
4. Dog構造函數函數體
5. // 其余日志與1.3相同, 在此省略
測試2: 初始化列表的順序 和 成員變量聲明順序不一致.
// Dog.h
class Dog
{
public:
Dog(const Collar& myCollar, const Toy& myToy)
: toy_(myToy)
, collar_(myCollar)
{
std::cout << "Dog構造函數函數體開始"<< std::endl;
std::cout << "Dog構造函數函數體結束" << std::endl;
}
private:
Collar collar_;
Toy toy_;
};
現在執行程序, 得到日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Collar缺省構造函數
3. Toy缺省構造函數
4. Dog構造函數函數體
5. // 其余日志與1.3相同, 在此省略
日志沒有任何變化.
測試3: 初始化列表中的數量少於成員變量的數量.
// Dog.h
class Dog
{
public:
Dog(const Collar& myCollar, const Toy& myToy)
: collar_(myCollar)
// 刪除了toy_(myToy)
{
std::cout << "Dog構造函數函數體開始"<< std::endl;
std::cout << "Dog構造函數函數體結束" << std::endl;
}
private:
Collar collar_;
Toy toy_;
};
現在執行程序, 得到日志:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Dog構造函數 開始
2. Collar缺省構造函數
3. Toy缺省構造函數
4. Dog構造函數函數體
5. // 其余日志與1.3相同, 在此省略
日志沒有任何變化.
目前的結論:
初始化列表的數量和順序, 均不影響成員變量構造順序.
構造順序仍然是按照類的定義中, 成員變量的聲明順序進行構造的. 且構造都早於類構造函數的函數體.
1.6 目前的構造函數執行順序
- 開辟內存空間.
- 按照成員變量聲明的順序開始構造成員變量.
- 進入函數體, 執行語句.
二. 成員變量如何被構造
2.1 在構造函數體內, 給成員變量賦值
現在, 我們顯示的指定collar
的構造, 給Collar
添加另一個構造函數:
// Collar.h
class Collar
{
public:
// 缺省構造函數
Collar( )
{
std::cout << "Collar缺省構造函數" << std::endl;
}
// 含參構造函數
Collar(std::string color)
{
std::cout << "Collar含參構造函數" << std::endl;
color_ = color;
}
// 拷貝構造函數, 這里直接使用了const引用, 是出於性能考慮. 如果用值拷貝, 會多構造一個collar出來, 然后再析構它.
Collar(const Collar& collar)
{
std::cout << "Collar拷貝構造函數" << std::endl;
this->color_ = collar.color_;
}
// 拷貝賦值運算符
Collar& operator = (const Collar& collar)
{
std::cout << "Collar拷貝賦值運算符" << std::endl;
this->color_ = collar.color_;
return *this;
}
// 析構函數
~Collar()
{
std::cout << "Collar析構函數" << std::endl;
}
private:
std::string color_;
};
主要做了幾個改動
- 給
Collar
添加了一個帶參構造函數. 便於和缺省構造函數進行區分. - 添加一個
拷貝構造函數
.
// todo 還沒有解釋 - 添加一個
拷貝賦值運算符
.
拷貝賦值運算符
其實就是我們常用的"=
"(更准確的說是"operator =
"), 它存在於所有的類中, 當你在執行dog1 = dog2;
的時候, 就是調用了這個函數來完成的賦值工作.
不管你在類的定義中, 有沒有定義這個"operator =
"函數, 你都可以使用它, 因為編譯器已經幫助你自動合成了它.
C++允許用戶自己對"operator =
"進行重載, 在這段代碼中, 我重載了這個函數, 額外添加了一條日志.
修改Dog
的構造函數:
// Dog.h
class Dog
{
public:
Dog(const Collar& myCollar)
{
std::cout << "Dog構造函數 函數體開始"<< std::endl;
// 將參數`collar`賦值給成員變量`collar_`
collar_= collar;
std::cout << "Dog構造函數 函數體結束" << std::endl;
}
~Dog(){ }
private:
Collar collar_;
};
主要做了以下改動:
- 修改了Dog自身的構造函數聲明, 添加了一個參數.
- 在構造函數的函數體內, 將參數
collar
賦值給成員變量collar_
. - 由於本構造函數內, 會調用其他函數, 所以我們在函數體內最上方和最下方都打印了一條日志, 便於分析函數調用鏈.
修改main.cpp
Collar myCollar = Collar("yellow");
std::cout << "Dog構造函數 開始" << std::endl;
Dog myDog = Dog(myCollar);
std::cout << "Dog構造函數 結束" << std::endl;
std::cout << "程序即將結束" << std::endl;
實際運行后打印的日志如下:
// 日志, 每行開頭的數字序號, 是我手動添加的, 數字后才是真實的日志.
1. Collar含參構造函數
2. Dog構造函數開始
3. ----Collar缺省構造函數
4. ----Dog構造函數函數體開始
5. --------Collar拷貝賦值運算符
6. ----Dog構造函數函數體結束
7. Dog構造函數結束
8. 程序即將結束
9. Collar析構函數"
10. Collar析構函數"
但是第二行日志指出, 編譯器還是幫你完成了Collar
缺省構造函數的隱式調用, 並且該調用早於Dog
構造函數的調用.
> 第一條日志, 調用`Collar`的含參構造函數, 構造出一個對象.
> 第二條日志, 標志着程序開始調用`Dog`構造函數.
> 第三條日志, 調用成員變量的`Collar`缺省構造函數, 將`collar_`構造出來.
> 第四條日志, 進入`Dog`的構造函數的函數體.
> 第五條日志, 調用拷貝賦值運算符, 將參數`myCollar`賦值給成員變量`collar_`;
> 第六條日志, `Dog`的構造函數的函數體結束.
> 第七條日志, 標志着`Dog`構造函數徹底結束.
> 第八條日志, 標志着程序即將結束, 開始進入析構階段.
> 第九條日志, 在析構`Dog`實例的過程中, 會析構成員變量`collar_`, 執行`Collar`的析構函數.
> 第十條日志, 仍然是程序結束階段, 會析構第一步建立的`myCollar`, 執行`Collar`的析構函數.
總結一下:
在構造Dog
實例的過程中, 總共有5個步驟涉及了Collar
:
- 帶參構造
- 缺省構造
- 拷貝賦值運算符
- 析構"缺省構造"
- 析構"帶參構造"
2.2 問題在哪里?
在剛才總結出的5個步驟中, 第2和3步, 存在浪費.
現在我們單獨看這兩步:
第一步: 先使用缺省構造, 構造出collar_
對象.
這個缺省構造過程中, 如果collar_
是一個很復雜的對象, 我們假設它包含了多個成員變量, 且每個成員變量要么是類的對象, 要么是結構體.
這個缺省構造, 將花費很多時間, 將每一個成員變量正確構造出來, 給它們一個默認值, 記住, 默認值通常都是沒用的, 比如是'0'或者'nullptr'.
緊接着, 進入第二步, 拷貝賦值運算符:
在這個步驟之前, 我們已經將myCollar
作為參數傳遞了進來, 這個myCollar
早就已經構造完成了, 它所有的成員變量的值都是正確的且有意義的, 現在我們把它復制給collar_
, 完成對collar_
的創建, 其中collar_
的默認值, 被一一覆蓋.
現在你可能意識到了問題:
第一步的默認值完全是多余的!
我們需要執行第一步的前半部分, 將collar_
對象構造出來.
但是我們不需要第一步的后半部分, 不需要默認值.
我們直接使用第二步, 將myCollar
的值, 拷貝給collar_
就行了.
2.3 使用初始化列表
我們僅僅對Dog.h
進行一些修改:
// Dog.h
class Dog
{
public:
Dog(const Collar& myCollar)
: collar_(myCollar)
{
std::cout << "Dog構造函數函數體開始"<< std::endl;
std::cout << "Dog構造函數函數體結束" << std::endl;
}
~Dog(){ }
private:
Collar collar_;
};
主要做了以下改動:
- 在
Dog
構造函數中, 添加初始化列表, 直接用myCollar
來初始化collar_
. - 既然
collar_
已經初始化了, 函數體內的拷貝賦值運算符就可以刪掉了.
其他內容保持不變, 執行:
1. Collar含參構造函數
2. Dog構造函數開始
3. Collar拷貝構造函數
4. Dog構造函數函數體開始
5. Dog構造函數函數體結束
6. Dog構造函數結束
7. 程序即將結束
8. Collar析構函數"
9. Collar析構函數"
對比上一次的日志可以發現:
本次運行使用了初始化列表, Collar拷貝構造函數
一個步驟, 替代了上次運行的Collar缺省構造函數
+拷貝賦值運算符
兩個步驟.
避免了Collar缺省構造
, 也就避免了多余的默認值.
目前的結論:
對於一個類的成員變量, 一定會在進入該類的構造函數之前構造完成.
如果成員變量在初始化列表中, 就會執行該變量類型的拷貝構造函數.
如果成員變量沒有在初始化列表中, 就會執行該變量類型的缺省構造函數.
2.4 盡可能地使用初始化列表
使用初始化列表, 首要原因是性能問題.
按照我們剛才的分析, 如果不使用初始化列表, 而是用構造函數函數體來完成初始化, 會額外調用一次缺省構造
.
對於內置類型, 如int
, double
, 在初始化列表和在構造函數函數體內初始化, 性能差別不是很大, 因為編譯器已經進行了優化.
但是對於類類型, 性能差別可能是巨大的, 數倍的.
另一個原因是, 有一些情況必須使用初始化列表:
-
常量成員, 因為常量只能初始化不能賦值, 所以必須放在初始化列表里面.
-
引用類型, 引用必須在定義的時候初始化, 並且不能重新賦值, 所以也要寫在初始化列表里面.
-
沒有默認構造函數的類類型, 因為使用初始化列表可以不必調用缺省構造函數來初始化, 而是直接調用拷貝構造函數初始化.
注: 對於還不知道具體值的變量, 使用零值或沒有具體含義的值, 比如int類型使用0
, std::string類型使用""
, 指針類型使用nullptr
.
三 構造函數執行順序
- 開辟內存空間.
- 按照成員變量聲明的順序開始構造成員變量.
- 如果成員變量在初始化列表中, 就會執行該變量類型的拷貝構造函數.
- 如果成員變量沒有在初始化列表中, 就會執行該變量類型的缺省構造函數.
- 進入函數體, 執行語句.