4 minute read

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

Tags:

Categories:

Updated:

Comments