拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++构造与析构深度解析:从初始化列表到RAII资源管理

C++构造与析构深度解析:从初始化列表到RAII资源管理 写C的人几乎没有人能绕开构造函数和析构函数这两个基础语法。我在最开始学C时最大的困惑是明明定义了一个类为什么对象刚创建时成员变量全是乱码为什么程序结束时某些资源没有自动释放非要手动去清理这些问题如果不搞明白后面写多态、写资源管理、写大型项目踩坑会踩到怀疑人生。这篇博文就是专门来拆解构造函数和析构函数的从底层原理、实际写法到常见Bug排查我会把能实操的经验都摊开来讲让初学者不再对着“默认构造”“深拷贝”这些词犯迷糊也让有经验的开发者回头重新审视一下自己的写法有没有潜在隐患。1. 构造函数对象从“创建”到“可用”的那一步1.1 默认构造函数与“脏数据”问题构造函数做的事情本质上是回答一个问题一个对象在被创建出来之后它的内部状态应该是什么C的设计思路是类的作者有责任定义好这个初始状态而构造函数就是定义初始状态的那个入口。举个例子如果你写了一个Student类#include iostream #include string class Student { public: void print() { std::cout name: name , age: age std::endl; } private: std::string name; int age; }; int main() { Student s; // 这里会发生什么 s.print(); return 0; }这段代码运行起来name通常会输出空字符串因为std::string本身有默认构造函数会把自己初始化为空。但age的输出结果就不一定了在某些编译器上可能是0在另一些环境下可能是随机值。这取决于栈上那个内存区域之前被什么数据覆盖过我把这种随机值叫作“脏数据”。原因很简单编译器生成的默认构造函数对于int、double、指针这些内置类型成员不做任何初始化动作。它只负责调用类类型成员比如std::string的默认构造函数剩下的内置类型就原样放着。如果你在写代码时依赖“默认构造后成员一定为0”这个假设那程序行为就是未定义的线上出问题只能自认倒霉。所以我的建议是只要自定义的类在逻辑上有“初始值”的概念就不要依赖系统帮你构造。无论是写一个全参数的构造函数还是在默认构造函数里用成员初始化列表给内置类型赋初值都比放任不管要安全得多。这一点在嵌入式开发、网络协议解析、游戏引擎这类对状态敏感的场景里尤为重要。1.2 初始化列表效率与必要性的双重考量构造函数有好几种写法最常用的是成员初始化列表它的形式是在函数签名后面加一个冒号然后逐个初始化成员。我见过不少初学者习惯在构造函数体内赋值class Person { public: Person(std::string name, int age) { m_name name; // 先构造再赋值 m_age age; } private: std::string m_name; int m_age; };从结果上看这段代码执行完之后m_name和m_age确实被赋值了但它和初始化列表有本质区别。如果写成初始化列表Person(std::string name, int age) : m_name(name), m_age(age) { }区别就在于体内赋值是“先用默认构造函数创建成员再调用拷贝赋值运算符重新赋值”等于一个成员被处理了两次。初始化列表则是“在成员创建的时候直接用传入的参数构造”只处理一次。对于std::string、std::vector这种重量级成员反复赋值会多出不少性能开销。等以后你写项目经手的对象多了这部分开销会被放大。更重要的是有些成员压根不支持“先默认构造再赋值”的路子。比如const成员它只能在初始化的时候确定值一旦构造完成就不能再改了。再比如引用类型成员引用必须在诞生时绑定目标不可能先空着再赋值。还有继承场景下的基类子对象必须在初始化列表里指定用哪个基类构造函数。如果你不写初始化列表这些编译就不会通过。所以我的习惯是能用初始化列表就用初始化列表这不是什么高深的性能优化技巧而是C的常规正确写法。这里还要提醒一个排序问题初始化列表的执行顺序不是按你写初始化列表的顺序来的而是按成员在类里面的声明顺序来的。举个经典踩坑例子class WrongOrder { public: WrongOrder(int a) : m_b(a), m_a(m_b) {} void print() { std::cout a m_a , b m_b std::endl; } private: int m_a; // 先声明 int m_b; // 后声明 };你看着初始化列表是先m_b再m_a可实际编译器是按m_a、m_b的顺序初始化的。m_a拿到的值是m_b还没初始化时的随机值然后m_b才被赋成参数a。运行结果很可能是a乱码ba。这个坑一旦踩了不打断点盯着根本发现不了。1.3 构造函数重载与隐式转换的“甜蜜陷阱”构造函数也支持重载你可以根据参数个数或类型不同提供多个版本。很多人写类时会提供默认构造函数、带参构造函数、拷贝构造函数这都很正常。但有一个问题很容易被忽视单参数构造函数会带来隐式类型转换。class Integer { public: Integer(int value) : m_value(value) {} private: int m_value; }; void printInteger(Integer i) { // 打印参数 } int main() { printInteger(42); // 不会报错编译器把42隐式转成了Integer return 0; }从某种意义上说这种隐式转换在某些场景很方便。但它也是一颗定时炸弹。比如你写了一个接受文件名作为参数的字符串类某处不小心把一个整数传进去编译器可能不报错而是悄悄构造一个奇怪的对象出来。为了杜绝这种情况C提供了explicit关键字。把构造函数声明为explicit后就只能显式构造比如Integer tmp(42)不再允许隐式转换。我个人的经验是非特殊情况单参数构造函数一律加explicit。这个习惯能避免大量本不该发生的隐式转换让类型系统更安全。等写到运算符重载时你会更能体会explicit的重要性。另外要说一下拷贝构造函数本身也可以带explicit不过这种用法很少见日常开发主力还是靠它来限制隐式复制虽然实际场景不多但知道有这回事总比不知道好。2. 析构函数资源回收的“最后一道防线”2.1 析构顺序与生命周期析构函数的用途和构造函数正好相反它负责在对象生命周期结束时把对象占用的资源释放掉。这个“生命周期结束”分两种情况。栈上的对象离开作用域就自动析构堆上通过new创建的对象只有你手动调用delete时才会析构。栈对象自动析构的顺序有个铁律先构造的后析构。也就是逆序。这个规则大家可能都听过但实际写代码时经常忽略它的影响。看个例子#include iostream class Sensor { public: Sensor(int id) : m_id(id) { std::cout Sensor m_id constructed std::endl; } ~Sensor() { std::cout Sensor m_id destroyed std::endl; } private: int m_id; }; int main() { Sensor s1(1); Sensor s2(2); { Sensor s3(3); } // 这里s3先析构 // 离开main函数s2后析构s1最后析构 return 0; }控制台输出的顺序是Sensor 1 constructed、Sensor 2 constructed、Sensor 3 constructed、Sensor 3 destroyed、Sensor 2 destroyed、Sensor 1 destroyed。这种顺序在很多场景下是被依赖的比如数据库连接、日志文件句柄、线程池资源如果依赖关系处理不好析构顺序错了程序直接崩溃。堆对象的情况要更加小心。new出来的对象不会自动析构必须配合delete调用。如果你new了一个对象但忘了delete析构函数就不会执行如果析构函数里负责释放其他资源那么那些资源也跟着泄漏。有个建议值得记住现代C中能用智能指针就别手动new/delete。std::unique_ptr和std::shared_ptr会把“在合适的时机自动调用delete”这件事管理好析构时机依然可控但你不必再亲自操作这正是RAII思想的核心体现。2.2 为什么基类析构函数必须是虚函数很多初学者写继承时会疑惑为什么基类的析构函数总要加一个virtual关键字。不加会怎样我们来演示一下class Base { public: ~Base() { // 释放Base的资源 } }; class Derived : public Base { public: Derived() { m_buffer new int[100]; } ~Derived() { delete[] m_buffer; // 释放派生类的资源 } private: int* m_buffer; }; Base* p new Derived(); delete p; // 这里会调用哪个析构实际结果是如果没有virtualdelete p只会调用Base的析构函数不会调用Derived的析构函数。因为编译器看到p是Base*类型它在静态类型上只知道Base的析构函数。这样一来Derived里new出来的m_buffer就泄漏了。更糟糕的是如果Derived的资源是一个文件句柄或网络连接这种泄漏带来的问题远不只是内存变大。所以只要一个类设计出来就是让人继承的它的析构函数就应该声明为virtual。即使基类没有资源要释放也建议把析构函数写成virtual或者写成纯虚析构函数来阻止别人直接实例化基类。纯虚析构函数有个反直觉的地方它虽然是纯虚函数但你必须为它提供函数体因为派生类析构时在析构自己之后还会调用基类的析构函数如果没有函数体链接阶段就会报找不到符号。我在做框架设计时经常用这个技巧来创建“只允许被继承、不允许直接实例化”的接口类。2.3 析构函数中不要抛出异常这一节值得单独强调。析构函数里一旦抛出异常后果非常严重。因为析构函数本身是在对象生命周期结束时被隐式调用的如果它在异常传播过程中又抛出新的异常C会直接调用std::terminate终止整个程序。简单说就是析构函数里抛出异常程序大概率直接崩掉。那如果析构函数里需要进行可能失败的操作怎么办比如要写日志、要关闭文件、要刷新缓冲区。我的办法是在析构函数内部try-catch住所有异常然后在catch块里记录错误或做降级处理绝不能让异常逃出析构函数。还有一种思路是把“可能失败”的操作单独抽成public的close()方法让调用方在正常流程里显式调用析构函数里只做“兜底释放”的事情。这样既保证了正常路径的可控性也保证了异常路径的底线安全。这个设计模式你在很多成熟项目中都能看到比如文件流对象的close()和它的析构函数就是这么配合的。3. 拷贝构造、拷贝赋值与移动语义3.1 浅拷贝vs深拷贝默认拷贝函数埋雷实录默认的拷贝构造函数和拷贝赋值运算符行为都是“逐成员拷贝”也就是浅拷贝。如果类里有一个指针成员浅拷贝只是把指针的值复制了一份结果就是两个对象指向同一块内存。这在程序结束时会导致灾难性的double free。看这段代码class StringBuffer { public: StringBuffer(const char* str) { m_size strlen(str) 1; m_data new char[m_size]; strcpy(m_data, str); } ~StringBuffer() { delete[] m_data; } private: char* m_data; int m_size; }; int main() { StringBuffer a(hello); StringBuffer b a; // 默认拷贝构造两个对象指向同一块内存 // 离开作用域时a和b都会执行delete[]同一块内存被释放两次 return 0; }这种double free在调试时很隐蔽因为释放同一块内存两次不一定每次都立刻崩溃有时候内存管理器还没被触发有时候却是随机崩溃。唯一可靠的解决方式就是实现深拷贝给目标对象分配自己的内存然后把内容复制过去。自定义深拷贝构造函数的写法大致是这样的在自己的构造函数体里new出一块新内存再用memcpy或者逐元素复制的方式把数据搬过来。拷贝赋值运算符的逻辑类似但要多做两件事第一是检测自赋值如果aa自己赋值自己要先释放自己的内存然后再从自己那里复制那岂不搬起石头砸自己的脚必须先判断一下this和传入参数是不是同一个对象第二是要记得先释放自己原有的资源否则会造成内存泄漏。我在实现拷贝赋值时通常遵循“copy and swap”范式这能一次性解决自赋值和异常安全两个问题比较省心。3.2 拷贝构造和拷贝赋值的区别与实现细节很多初学者搞不清拷贝构造函数和拷贝赋值运算符的区别。它们都是用一个已有的对象去初始化另一个对象但使用时机完全不同。拷贝构造发生在对象“从无到有”的阶段比如StringBuffer b(a); // 拷贝构造 StringBuffer c a; // 也是拷贝构造不是赋值拷贝赋值发生在对象“已经存在”之后比如StringBuffer b(a); b a; // 拷贝赋值区分的方法很简单如果那条语句的执行过程中有新对象被创建出来就是拷贝构造如果对象已经存在了只是改变它的值就是拷贝赋值。这个区别之所以重要是因为它们的实现要求不同。拷贝构造可以认为目标对象是“干净的”只需要分配资源并复制内容。拷贝赋值则要先处理掉目标对象身上已经占用的资源然后才复制还要考虑自赋值的情况。如果不够细心写错一个分支就会导致内存泄漏或double free。另外C11之后有了“移动语义”如果你用std::move把一个临时对象传给构造函数那么匹配到的是移动构造函数而不是拷贝构造函数。移动构造函数的核心思想很直接既然这个对象马上要销毁了我直接把它的资源指针拿过来用再把源对象指针置空不用再深拷贝一份。这对那些内部有大块数据、vector、string、容器等类型的性能影响非常明显。所以现代C里拷贝构造负责“复制”移动构造负责“转移”两者职责分明。3.3 三/五法则写了析构函数就要检查拷贝C里有一条广为人知的规则叫“三法则”如果你需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个那么通常三个都需要自定义。C11之后扩展成“五法则”把移动构造函数和移动赋值运算符也加进来了。这个规则的逻辑其实是假如你需要自定义析构函数说明类在管理某种资源。资源管理必然涉及怎样复制、怎样转移、怎样释放缺了任何一个环节资源管理都会出漏洞。我记得有一次评审别人的代码看到某个类只写了析构函数专门负责释放一块共享内存但拷贝构造函数和拷贝赋值运算符没写类内部还带着一个裸指针。我一问对方说“这个类不允许拷贝”。问题的关键是不写拷贝函数并不等于不能拷贝编译器会自动生成浅拷贝版本。于是这个类在无意中暴露了拷贝的入口两个对象同时共享同一块内存的风险立刻出现。正确的做法应该是把拷贝构造函数和拷贝赋值运算符显式删除delete或者用智能指针来管理这块内存。用一句话总结如果你写了析构请立刻检查另外几个函数是否需要显式处理这是C资源管理的基本素养。在实际项目中我更推荐优先用std::shared_ptr或std::unique_ptr替代裸指针成员。这样可以把资源管理的细节交给标准库自己只需要定义逻辑上的行为。只有在性能极其敏感或需要对内存布局精细控制的场合我才手动管理裸指针。这里想强调一下智能指针不是万能药它在单线程环境下好用在并发环境下还是要配合原子操作和锁但它们解决的是“生命周期”这个大问题省心的程度远超手动管理。4. RAII思想把“构造-析构”变成自己的工具4.1 用RAII管文件资源一个FileGuard小例子构造函数和析构函数的最大价值不只是管理内存而是实现RAIIResource Acquisition Is Initialization。这个术语看起来高深其实就是一句话资源在构造时获得在析构时释放。凡是符合这个思想的类用起来都特别令人安心。举个文件操作的场景。传统写法是这样的FILE* fp fopen(log.txt, w); // ... 一堆操作中间如果抛出异常或提前return fclose(fp);问题很明显如果函数中途有多个return分支或者中间代码抛出异常fclose(fp)可能永远执行不到文件句柄就泄漏了。用RAII类包装一下class FileGuard { public: FileGuard(const char* filename, const char* mode) { m_file fopen(filename, mode); if (!m_file) { throw std::runtime_error(open file failed); } } ~FileGuard() { if (m_file) { fclose(m_file); } } // 禁止拷贝 FileGuard(const FileGuard) delete; FileGuard operator(const FileGuard) delete; void write(const char* text) { fputs(text, m_file); } private: FILE* m_file; };你会发现无论函数中途走了哪个return分支也无论有没有异常抛出只要FileGuard对象是栈上的局部变量它的析构函数迟早会被调用文件最终都会被关闭。这比自己在每个分支里手写fclose靠谱太多。类似的模式可以扩展到所有“成对出现”的操作上比如数据库连接的开和关、互斥锁的加锁与解锁、动态内存的new和delete、socket的打开和关闭。凡是这样的场景我都建议用RAII类包一层。4.2 多线程里用RAII管锁避免死锁的通用招多线程编程里最常见的死锁原因之一是忘记解锁。std::mutex的lock和unlock必须成对调用中途如果代码抛异常unlock很可能执行不到其他线程就会一直卡在lock上。C标准库里的std::lock_guard就是典型的RAII封装。它在构造时接收一个mutex引用并调用lock在析构时自动调用unlock。std::mutex mtx; void safeUpdate() { std::lock_guardstd::mutex lock(mtx); // 这里的数据写操作是线程安全的 // 函数正常返回lock析构自动解锁 }用lock_guard之后你再也不用记着在每个return前手写unlock。代码只会在加锁的区域内执行出了作用域锁自然释放杜绝了因为分支处理不当而导致的死锁。这也是我写多线程代码时几乎不用裸lock/unlock的原因。C17还提供了std::scoped_lock可以一次管理多个互斥量在处理需要同时锁多个资源的场景时更安全、更简洁。4.3 成员变量的析构顺序与设计含义构造函数和析构函数的配合还有一个常被忽略的设计点成员变量的析构顺序。前面说过对象构造时成员按声明顺序构造对象析构时成员按声明顺序的逆序析构。这个规则意味着如果两个成员之间有依赖关系必须先构造的成员理论上应该被后析构以保障它在整个生命周期内一直被需要。举个例子如果你有一个线程池对象和一个任务队列对象任务队列可能在线程池运行期间被使用那么任务队列应该先声明线程池后声明。这样构造时先建任务队列再建线程池析构时先销毁线程池再销毁任务队列保证线程池在销毁时不会访问一个已经被销毁的任务队列。如果你把声明顺序反过来程序崩溃的时机可能非常随机而且很难从栈回溯中定位到原因。这种顺序依赖问题只有你深刻理解了“构造顺序声明顺序析构顺序声明逆序”这个铁律才能在写类时提前规避。我在设计一些复杂组件类时会在类头部的成员声明区域加注释说明每个成员的生命周期依赖关系。这个习惯曾经帮我排查过很多“偶发崩溃”的诡异问题强烈推荐给大家。5. 常见问题与排查技巧实录5.1 Bug最常见内存泄漏怎么定位内存泄漏是C老生常谈的痛点。我见过不少项目运行几天后内存占用一路飙升最后OOM。大部分情况下根源是new出来的对象没有对应delete或者对象持有的资源在析构时没释放。排查内存泄漏我推荐几个实用手段。在Visual Studio里可以用VLDVisual Leak Detector几乎零配置运行完就能在输出窗口看到泄漏点的调用堆栈。在Linux下用Valgrind的memcheck工具虽然速度和调试器差不多慢但它能帮你准确报告“哪一行new的内存没被释放”。还有一种更轻量的方式是自己在代码里加计数在构造函数里析构函数里--程序结束前打印一个值如果值不等于0说明有对象没被正确析构。虽然做不到逐行定位但至少能快速判断是哪个类出了泄漏。除了工具外更重要的是养成写代码时“一点一析构”的习惯。如果你新建了一个裸指针立刻想好它在哪个作用域被释放尽量不要让new和delete之间跨越太多控制流分支。5.2 诊断重灾区double free怎么定位double free的定位经常让人头疼因为崩溃点往往不在真正的bug处。你可能会在某个完全不相关的地方看到类似“pointer being freed was not allocated”的报错。这是因为同一块内存被释放两次后内存管理器的空闲链表被破坏下次分配或释放时才显露出来。我的经验是遇到double free先不要急着单步调试停下来检查类有没有涉及拷贝。看看类里有没有裸指针成员有没有默认拷贝行为暴露到外部。如果有第一步就在类的拷贝构造函数和拷贝赋值运算符上打断点观察它们被调用的位置。如果确实不需要拷贝就把拷贝构造函数和拷贝赋值运算符都声明为delete让编译器在编译期拦截所有拷贝行为。这一步能挡住绝大多数意外产生的浅拷贝。如果还需要拷贝就务必把深拷贝写完整。还有一点用std::shared_ptr时要注意循环引用两个对象互相持有对方的shared_ptr会导致两者的引用计数永远不为0析构函数永远不被调用但这属于“内存泄漏”而不会double free它和裸指针的double free形成两个相反的极端都需要警惕。5.3 容易误判构造/析构函数与虚函数的纠葛构造函数和析构函数内部可以调用虚函数但结果可能和很多人预期的不一样。当构造函数运行时对象的动态类型还是当前正在构造的这个类而不是最终的派生类。所以如果在构造函数里调用虚函数它调用的是当前类版本的虚函数而不是派生类重写后的版本。析构函数也类似C会先执行派生类的析构函数体然后执行基类的析构函数体在这个过程中调用虚函数绑定的同样是当前正在析构的这个类。这个设计有它的道理派生类对象在构造出来之前自己的成员还没初始化好此时冒然调用派生类的虚函数很可能会访问到未初始化的成员。在析构过程中派生类成员已经释放调用派生类虚函数也有同样的风险。因此C不让你踩这个坑。这里我不建议把编造一个“构造器里调用虚函数实现多态”的奇怪设计当作小技巧正确的做法是不要在构造函数和析构函数中依赖虚函数的多态行为。如果你确实需要在构造时触发一段动态行为更稳妥的办法是通过参数传递一个函数指针或std::function把真正的决策逻辑放到外层调用方。这也是很多框架在处理对象初始化时使用的模式。5.4 容易被忽略对象数组与new[]/delete[]的搭配创建对象数组时也有一个常见的坑。如果你用new[]创建对象数组释放时必须用delete[]不能直接用delete。反过来用new创建单个对象释放时用delete[]同样是未定义行为。虽然某些编译器在简单类型上可能不报错但一旦涉及到自定义类型delete和delete[]对应关系错了析构函数被调用次数就会出错。另一方面栈上的对象数组析构顺序是逆序的这点和先构造后析构的规则一致。如果你写了一个数组里面每个元素依赖前一个元素的存在那么在释放时就要小心后构造的元素会先析构流式依赖关系处理不好照样崩溃。更让我觉得省心的是标准库容器std::vector会把这些细节自动处理好。只要内存分配需求不太特殊就不要自己去管理数组。我自己除了在与C接口互操作等极少数场景外几乎不使用裸数组std::vector完全替代了大部分裸数组需求。5.5 构造失败时的异常处理构造函数里如果抛异常这个对象就算构造失败了。此时C会保证已经构造完成的成员变量会被自动析构但该对象自身的析构函数不会被调用。这个规则好理解——对象压根没构造出来何必调用析构函数但它带来了一个隐含问题如果你在构造函数里用new分配了一段内存然后紧接着某个步骤抛了异常new出来的那段内存没人能释放因为析构函数不会被调用。解决这个问题的常规思路是在构造函数体内尽量使用智能指针成员unique_ptr或shared_ptr而不是裸指针。因为智能指针成员本身是“成员”它是会正常析构的。或者把构造过程看成多个独立阶段先构造一个临时对象封装所有资源再用初始化列表转移给最终对象让异常安全得到保障。写过一段时间的生产级C之后你会发现构造函数是否具备异常安全几乎决定了一个类在复杂系统里能不能用得下去。这水很深但一开始只要记住了“构造函数里不用裸指针”这个原则就已经能避开很大一部分雷区。我个人在实际项目里有一个深切的体会构造函数和析构函数这两个看似基础的语言特性其实是C资源管理思想的根基。你写越多的代码越会发现所谓的内存安全、异常安全、RAII封装、智能指针语义底层全是在回答“对象何时初始化、何时销毁”这两个问题。记得在写代码时多问自己一句这个类的构造和析构函数的行为能被外部调用者预期到吗如果答案含糊那么即使代码编译运行过它也很可能潜伏着难以排查的问题。每次写完一个类建议花两分钟快速过一遍拷贝、赋值、析构这三个函数把它们显式安排明白长期来看一定是对代码质量的投资。希望这篇拆解对你有用哪怕只让你避开一个曾经踩过的坑那就算值得了。
返回列表