时间:2026-01-04 01:12
人气:
作者:admin
本文首发于我的个人博客: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)。因为 C++ 规定成员变量在进入构造函数体 {} 之前必须完成构建。
Class() : member(value) {} // 直接在内存位置上构造 member
使用初始化列表的成本仅为 1 次构造。
Class() { member = value; }
过程:
member 的默认构造函数(无参)。member 的赋值运算符 operator=。在这个过程中的成本为:1 次构造 + 1 次赋值(还可能设计旧内存释放和新内存申请)。
初始化列表不仅是效率优化,对于
const成员或reference(引用)成员,它是唯一的初始化方式,因为它们创建后不可修改(不可赋值)。
根据对象资源管理的不同需求,构造函数演化出了四种主要形态。我们将用资源所有权的视角来区分它们。
T()int* p = nullptr;)T(args...)radius > 0,这就是维护 “不变量”T(const T& other)移动构造函数是 C++11 提出的革命性进步。
T(T&& other)std::vector)&&),识别出 other 是一个即将消亡的对象。other 的资源(指针指向新主,旧指针置空),而非复制数据explicit 关键字:拒绝隐式转换C++ 默认允许单参数构造函数进行隐式类型转换。
struct Buffer { Buffer(int size) { ... } };
void func(Buffer b);
func(42); // 编译器偷偷执行了 Buffer(42),可能并不是你想要的
从安全角度(Safety First)出发,隐式类型转换破坏了强类型系统。标记 explicit 禁止这种 “自作聪明” 的行为,强制显式调用。
允许一个构造函数调用同类的另一个构造函数。这是为了准许 DRY(Don't Repeat Yourself)原则,防止初始化逻辑碎片化。
永远不要在构造函数中调用虚函数。
vtalbe)指针指向基类表,多态失效。将上述所有内容串联起来的概念就是 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 订阅我的最新动态!