C++ 構造函數的執行過程(一) 無繼承


 

引言

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 目前的構造函數執行順序

  1. 開辟內存空間.
  2. 按照成員變量聲明的順序開始構造成員變量.
  3. 進入函數體, 執行語句.

 

二. 成員變量如何被構造

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_;
};

主要做了幾個改動

  1. Collar添加了一個帶參構造函數. 便於和缺省構造函數進行區分.
  2. 添加一個拷貝構造函數.
    // todo 還沒有解釋
  3. 添加一個拷貝賦值運算符.
    拷貝賦值運算符其實就是我們常用的"="(更准確的說是"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_;
};

主要做了以下改動:

  1. 修改了Dog自身的構造函數聲明, 添加了一個參數.
  2. 在構造函數的函數體內, 將參數collar賦值給成員變量collar_.
  3. 由於本構造函數內, 會調用其他函數, 所以我們在函數體內最上方和最下方都打印了一條日志, 便於分析函數調用鏈.

修改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:

  1. 帶參構造
  2. 缺省構造
  3. 拷貝賦值運算符
  4. 析構"缺省構造"
  5. 析構"帶參構造"

 

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_;
};

主要做了以下改動:

  1. Dog構造函數中, 添加初始化列表, 直接用myCollar來初始化collar_.
  2. 既然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.

 

三 構造函數執行順序

  1. 開辟內存空間.
  2. 按照成員變量聲明的順序開始構造成員變量.
    • 如果成員變量在初始化列表中, 就會執行該變量類型的拷貝構造函數.
    • 如果成員變量沒有在初始化列表中, 就會執行該變量類型的缺省構造函數.
  3. 進入函數體, 執行語句.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM