- 核心邏輯
- 構造函數的執行流
- 為什麼首選初始化列表?
- 構造函數的分類
- 默認構造函數(Default Constructor)
- 參數化構造函數(Parameterized Constructor)
- 拷貝構造函數(Copy Constructor)
- 移動構造函數(Move Constructor)
- 關鍵機制與陷阱
explicit關鍵字:拒絕隱式轉換- 委託構造(Delegating Constructors)
- 構造與虛函數
- RAII 與構造函數
- 相關關鍵字
- 控制編譯器行為
= default= delete(C++11)using(繼承構造函數)
- 性能優化
noexceptconstexpr(C++11/14)
- 邏輯控制與異常處理
explicittry(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 通常分為兩步:
- 分配內存(
malloc或棧上聲明) - 賦值(
init函數或手動賦值)
問題在於:如果在第 1 步和第 2 步之間使用該對象,就會導致災難(未定義行為)。或者,如果使用者忘記了第 2 步,系統就會處於 “非法狀態”。
C++ 引入構造函數就是為了保證:
如果一個對象存在,那麼它一定是合法的。
構造函數保證了初始化(Initialization)與定義(Defination)的不可分割性。
構造函數的執行流
當你寫下 T object(args); 時,編譯器實際執行了以下步驟:
- 分配內存:在棧或堆上找到一塊足夠容納
sizeof(T)的空間。此時,內存裏的數據是隨機的(Garbage)。 - 執行初始化列表(Initialization List):這是真正的初始化時刻。
- 執行函數體(Function Body):這實際上是後續的計算或賦值操作,而非初始化。
為什麼首選初始化列表?
因為 C++ 規定成員變量在進入構造函數體 {} 之前必須完成構建。
Class() : member(value) {} // 直接在內存位置上構造 member
使用初始化列表的成本僅為 1 次構造。
Class() { member = value; }
過程:
- 調用
member的默認構造函數(無參)。 - 調用
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的資源(指針指向新主,舊指針置空),而非複製數據 - 代價:極低(僅是指針賦值)
- 在 C++98 中,如果要將一個臨時對象(即將銷燬)放入容器,比如先複製再銷燬。這極度浪費性能(如複製一個巨大的
關鍵機制與陷阱
explicit 關鍵字:拒絕隱式轉換
C++ 默認允許單參數構造函數進行隱式類型轉換。
struct Buffer { Buffer(int size) { ... } };
void func(Buffer b);
func(42); // 編譯器偷偷執行了 Buffer(42),可能並不是你想要的
從安全角度(Safety First)出發,隱式類型轉換破壞了強類型系統。標記 explicit 禁止這種 “自作聰明” 的行為,強制顯式調用。
委託構造(Delegating Constructors)
允許一個構造函數調用同類的另一個構造函數。這是為了准許 DRY(Don'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)
- 語義:此路不通
有些對象在語義上是獨一無二的(例如:單例模式、硬件驅動句柄 Mutex、FileStream),它們絕不能被拷貝。
在 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 擴容時,它需要把舊數據搬到新內存。如果你的移動構造函數沒有標記 noexcept,std::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 訂閲我的最新動態!