博客 / 詳情

返回

C++ 中的構造函數

目錄
  • 核心邏輯
  • 構造函數的執行流
    • 為什麼首選初始化列表?
  • 構造函數的分類
    • 默認構造函數(Default Constructor)
    • 參數化構造函數(Parameterized Constructor)
    • 拷貝構造函數(Copy Constructor)
    • 移動構造函數(Move Constructor)
  • 關鍵機制與陷阱
    • explicit 關鍵字:拒絕隱式轉換
    • 委託構造(Delegating Constructors)
    • 構造與虛函數
  • RAII 與構造函數
  • 相關關鍵字
    • 控制編譯器行為
      • = default
      • = delete(C++11)
      • using(繼承構造函數)
    • 性能優化
      • noexcept
      • constexpr(C++11/14)
    • 邏輯控制與異常處理
      • explicit
      • try(Function-try block)

本文首發於我的個人博客:Better Mistakes

版權聲明:本文為原創文章,轉載請附上原文出處鏈接及本聲明。
由於技術迭代較快,文章內容可能隨時更新(含勘誤及補充)。為了確保您看到的是最新版本,並獲得更好的代碼閲讀體驗,請訪問:

🍭 原文鏈接:https://bfmhno3.github.io/note/constructor-in-cpp/](https://bfmhno3.github.io/note/constructor-in-cpp/)


對於 C++ 對象而言,我們認為:對象 = 內存 + 語義(不變量)。

  • 內存:僅僅是電子與硅晶體中狀態未知的比特位。
  • 語義:這段內存代表什麼含義(是 int、是 char 還是 float),以及它必須滿足的條件(“不變量”,Invariant)。

構造函數Constructor)的本質就是將 “原始、混沌” 的內存強制轉換為 “持有特定語義的、合法的對象” 的原子操作過程。

核心邏輯

在 C 語言中,創建一個 struct 通常分為兩步:

  1. 分配內存(malloc 或棧上聲明)
  2. 賦值(init 函數或手動賦值)

問題在於:如果在第 1 步和第 2 步之間使用該對象,就會導致災難(未定義行為)。或者,如果使用者忘記了第 2 步,系統就會處於 “非法狀態”。

C++ 引入構造函數就是為了保證:

如果一個對象存在,那麼它一定是合法的。

構造函數保證了初始化Initialization)與定義Defination)的不可分割性。

構造函數的執行流

當你寫下 T object(args); 時,編譯器實際執行了以下步驟:

  1. 分配內存:在棧或堆上找到一塊足夠容納 sizeof(T) 的空間。此時,內存裏的數據是隨機的(Garbage)。
  2. 執行初始化列表(Initialization List):這是真正的初始化時刻。
  3. 執行函數體(Function Body):這實際上是後續的計算或賦值操作,而非初始化。

為什麼首選初始化列表?

因為 C++ 規定成員變量在進入構造函數體 {} 之前必須完成構建。

Class() : member(value) {} // 直接在內存位置上構造 member

使用初始化列表的成本僅為 1 次構造。

Class() { member = value; }

過程:

  1. 調用 member 的默認構造函數(無參)。
  2. 調用 member 的賦值運算符 operator=

在這個過程中的成本為:1 次構造 + 1 次賦值(還可能設計舊內存釋放和新內存申請)。

初始化列表不僅是效率優化,對於 const 成員或 reference(引用)成員,它是唯一的初始化方式,因為它們創建後不可修改(不可賦值)。

構造函數的分類

根據對象資源管理的不同需求,構造函數演化出了四種主要形態。我們將用資源所有權的視角來區分它們。

默認構造函數(Default Constructor)

  • 語義:無中生有
  • 形式:T()
  • 視角:當對象被創建但外界未提供任何信息時,對象應處於什麼狀態?通常是 “空狀態” 或 “零狀態”
  • 注意:如果類中包含原始指針,編譯器生成的默認構造函數不會置空指針(由於 C 的遺留包袱),這會導致懸垂指針。因此現代 C++ 提倡顯式定義或使用成員默認初始化(int* p = nullptr;

參數化構造函數(Parameterized Constructor)

  • 語義:根據藍圖定製
  • 形式:T(args...)
  • 視角:將外部數據約束映射到內部不變量。例如,創建 “圓” 對象,參數是半徑。構造函數必須檢查 radius > 0,這就是維護 “不變量”

拷貝構造函數(Copy Constructor)

  • 語義:複製(細胞分裂、克隆)
  • 形式:T(const T& other)
  • 視角:
    • 如果對象時值語義(如整數、座標),直接按位拷貝(Shallow Copy)
    • 如果對象持有資源(如堆內存指針、文件句柄),必須進行深拷貝Deep Copy
    • 本質矛盾:如果只複製指針,兩個對象指向同一塊內存,析構時會發生 “Double Free” 錯誤。因此拷貝構造函數必須重新分配資源。

移動構造函數(Move Constructor)

移動構造函數是 C++11 提出的革命性進步。

  • 語義:所有權轉移(器官移植)
  • 形式:T(T&& other)
  • 視角:
    • 在 C++98 中,如果要將一個臨時對象(即將銷燬)放入容器,比如先複製再銷燬。這極度浪費性能(如複製一個巨大的 std::vector
    • 移動構造函數利用右值引用&&),識別出 other 是一個即將消亡的對象。
    • 偷走 other 的資源(指針指向新主,舊指針置空),而非複製數據
    • 代價:極低(僅是指針賦值)

關鍵機制與陷阱

explicit 關鍵字:拒絕隱式轉換

C++ 默認允許單參數構造函數進行隱式類型轉換。

struct Buffer { Buffer(int size) { ... } };
void func(Buffer b);

func(42); // 編譯器偷偷執行了 Buffer(42),可能並不是你想要的

從安全角度(Safety First)出發,隱式類型轉換破壞了強類型系統。標記 explicit 禁止這種 “自作聰明” 的行為,強制顯式調用。

委託構造(Delegating Constructors)

允許一個構造函數調用同類的另一個構造函數。這是為了准許 DRYDon't Repeat Yourself)原則,防止初始化邏輯碎片化。

構造與虛函數

永遠不要在構造函數中調用虛函數

  • 原理:在基類構造期間,派生類的部分尚未初始化。為了安全,C++ 此時將對象視為基類類型。虛函數表(vtalbe)指針指向基類表,多態失效。

RAII 與構造函數

將上述所有內容串聯起來的概念就是 RAII(Resource Acquisition Is Initialization),這是 C++ 的靈魂。

  • 資源獲取即初始化:資源的生命週期嚴格綁定對象的生命週期
  • 構造函數:資源的獲取點(鎖住互斥量、打開文件、分配內存)
  • 析構函數:資源的釋放點(解鎖、關閉、釋放)

C++ 的構造函數不僅僅是用來 “賦值” 的函數,它是類型系統安全性的守門人,是資源管理自動化的起點。

掌握構造函數,不僅僅是記住語法,而是要時刻思考:

這個對象誕生的一瞬間,我如何保證它擁有了所需的資源,且處於絕對合法的狀態?

相關關鍵字

控制編譯器行為

C++ 編譯器通常會 “自作聰明” 地為你生成默認構造、拷貝構造等。以下關鍵字則可以用於精確控制這種自動行為。

= default

  • 語義:出廠設置

當你手寫了一個參數化構造函數 T(int a) 後,編譯器認為你是一個有主見的人,於是不再自動生成無參的默認構造函數 T()。如果此時你又想要那個 “空” 的默認構造函數,不需要再手寫個空函數體 {}(這會導致它變成 “用户提供的”,從而失去某些 trivial/POD 特性),直接用 = default 讓編譯器恢復它的默認生成邏輯。

struct Example {
    Example(int a);         // 自定義構造
    Example() = default;    // 強制找回默認構造,且比手寫 {} 更高效
};

= delete(C++11)

  • 語義:此路不通

有些對象在語義上是獨一無二的(例如:單例模式、硬件驅動句柄 MutexFileStream),它們絕不能被拷貝。

在 C++11 之前,我們通過把拷貝構造函數設為 private 來防止拷貝。C++11 之後,可以直接在語法層面 “刪除” 這個函數的存在。

struct Mutex {
    // 任何嘗試拷貝代碼的操作,在編譯期間就會報錯
    Mutex(const Mutex&) = delete;
    Mutex& operator(const Mutex&) = delete;
};

using(繼承構造函數)

  • 語義:拿來主義

派生類通常不會繼承基類的構造函數。如果基類有 10 種構造方式,派生類想支持同樣的 10 種,以前得手動寫 10 個轉發函數。

using 關鍵字告訴編譯器:把基類的構造函數直接 “引入” 到當前作用域。

struct Base {
    Base(int); Base(std::string); Base(float);
};

struct Derived: Base {
    using Base::Base; // 一句話,擁有了上述三種構造方式
};

性能優化

這部分關鍵字主要服務於嵌入式開發和高性能計算,通過向編譯器提供更多信息來優化機器碼。

noexcept

  • 語義:我保證不惹麻煩(不拋出異常)

這是移動語義(Move Semantics)生效的關鍵。當 std::vector 擴容時,它需要把舊數據搬到新內存。如果你的移動構造函數沒有標記 noexceptstd::vector 為了內存安全(怕搬到一半拋異常,導致舊數據沒了,新數據也沒好),會放棄移動,強行降級為拷貝。

這在大數據量或高性能要求場景下會帶來極大的損耗。

class BigData {
public:
    // 承諾:移動操作絕不會失敗,編譯器看到這個才會大膽優化
    BigData(BigData&& other) noexcept { ... }
};

constexpr(C++11/14)

  • 語義:在編譯時就已經準備好了

如果一個對象的構造參數在編譯時就是確定的常量,那麼為什麼要等到程序運行(Runtime)才去分配內存、賦值呢?

constexpr 構造函數允許編譯器在編譯階段就計算出對象的內存佈局,並直接燒錄在二進制文件的只讀數據端(.rodata)或直接作為立即數嵌入指令中。

這對於嵌入式系統(節省運行時開銷、Flash/RAM 佈局)至關重要。

struct Point {
    int x, y;
    constexpr Point(int _x, int _y) : x(_x), y(_y) {}
};

// 編譯後,p 甚至可能不存在,直接被優化為立即數操作
constexpr Point p(10, 20);

邏輯控制與異常處理

explicit

在前文已經講到。同時,除了單參數構造函數,多參數構造函數(C++11 列表初始化)也需要注意。

struct Vector3 {
    explicit Vector3(float x, float y, float z);
};

void func(Vector3 v);

func({1.0, 2.0, 3.0}); // 錯誤!因為 explicit 禁止了 {list} -> Object 的隱式類型轉換
func(Vector3{1.0, 2.0, 3.0}); // 正確,顯式調用

try(Function-try block)

  • 語義:在進入內部前就能捕獲錯誤

構造函數分兩步:初始化列表 \(\rightarrow\) 函數體。如果在初始化列表階段(比如基類構造、成員對象構造)拋出了異常,普通的 try-catch 包裹函數體是抓不住的。必須把 try 寫在函數體外,這就是函數 try 塊

ResourceManager() try : core_resource(new core) {
    // ... 函數體
} catch (...) {
    // 能夠捕獲 core_resource 初始化時拋出的異常
    // 注意:構造函數裏的 catch 必定會再次拋出異常,因為對象構造函數失敗了,必須通知外界
}

📢 寫在最後

如果你覺得這篇文章對你有幫助,歡迎到我的個人博客 Better Mistakes 逛逛。

在那裏我歸檔了更多高質量的技術文章,也歡迎通過 RSS 訂閲我的最新動態!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.