C++ 中的构造函数
对于 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 必定会再次抛出异常,因为对象构造函数失败了,必须通知外界
}
Comments