C中的构造函数是做什么用的?

摘要:深入理解 C++ 构造函数的本质:从资源管理到性能优化。涵盖默认构造、拷贝构造、移动构造,以及 RAII、explicit、constexpr 等关键机制。
目录核心逻辑构造函数的执行流为什么首选初始化列表?构造函数的分类默认构造函数(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 的资源(指针指向新主,旧指针置空),而非复制数据 代价:极低(仅是指针赋值) 关键机制与陷阱 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 订阅我的最新动态!