类和对象讲到中期,最绕不开的两个成员函数就是构造函数和析构函数。你可以把类当成一张图纸,对象是按照图纸盖起来的房子,构造函数是开工时的地基浇筑和剪彩仪式,析构函数是拆除前的安全检查和管线回收——没有这两样,盖房和拆房全靠运气,这在C++里是绝对走不远的。构造函数决定了对象出生时带什么数据、资源从哪来;析构函数决定了对象消失时资源怎么还回去。很多人学完“类的基本语法”就卡在这里:为什么编译器偶尔报“没有合适的构造函数”?为什么同一个对象析构两次?为什么明明delete了还泄漏?这篇就集中把构造与析构这两个成员函数讲透。适合刚学完对象基础语法、想把类写得“能用又安全”的同学;被深浅拷贝坑过的老手也值得扫一遍常见错误清单。1. 构造函数:对象出生前的“第一件事”1.1 构造函数到底在解决什么问题一个类除了成员变量和成员函数,编译器还悄悄给每个类都准备了一组“默认成员函数”:默认构造、析构、拷贝构造、拷贝赋值,C++11之后又多了移动构造和移动赋值。这六个函数不写,编译器在需要时会自动生成;但写错其中一个,往往比不写还危险。构造函数的名字和类名完全相同,没有返回值——不是void,是真的什么都不返回。它可以重载,所以你才能写出这种写法:class Student { public: Student() { // 什么都不干 } Student(const char* name, int id) { // 带两个参数 } };构造函数最核心的任务只有一个:把对象里每一个成员变量设置成合理状态。这句话说起来很轻,做起来要命——因为绝大多数BUG都来自“对象出生时状态就不对”。我见过新手写一个类,成员是int age,构造函数忘了初始化,打印出的年龄直接是垃圾值。在Debug版里可能还能稳定复现,Release版里直接时好时坏,非常难查。一个重要的坑先放在这:只要你手动写了任何一个构造函数,编译器就不再替你合成默认构造函数。也就是说,你定义了一个带参构造后,再写Student s;,编译器直接报错“没有默认构造函数”。很多人刚学的时候在这里卡一晚上。解决办法是显式补一个无参构造,或者给带参构造的参数全部用缺省值。1.2 初始化列表:这才是“初始化”的正确姿势接上面的Student,更专业的写法是用初始化列表:class Student { public: Student() : name("unknown"), id(0) {} Student(const char* nm, int nid) : name(nm ? nm : "unknown"), id(nid) {} private: const char* name; int id; };这里有两个细节值得讲清楚。其一,初始化列表是在进入构造函数体之前执行的,成员在这里直接被初始化;而在构造体内写name = ...,本质是先默认初始化成员,再赋值,多一次操作。对int、指针这种廉价类型差别不大,但对string、vector这类带资源的类型,先默认构造再赋值,白白多一次构造和析构的开销。其二,有些成员根本不能靠体内赋值,比如const成员和引用成员:它们必须在初始化列表里初始化,体内赋值是违法的,因为const一旦初始化就不可变,引用一旦绑定就不可换。还有更隐蔽的一点:初始化顺序由成员声明顺序决定,不是由初始化列表里谁先谁后决定。假设你声明了a和b两个成员,但列表里写b先、a后,编译器按a再b初始化,如果你在初始化a时依赖b的初始值,就会拿到半成品。这类问题编译器不警告,运行结果又常常“碰巧对”,等换编译器或换平台就翻车。经验法则:初始化列表的书写顺序和成员声明顺序保持一致,不要靠记忆,要用代码风格约束。class Demo { int a; int b; public: Demo(int x) : b(x), a(b) {} // 危险:a比b先初始化,此时b还没被赋值 };我把上面这个代码放到某个聊天群里问过,一半人第一眼看不出问题——这正好说明顺序问题有多容易被忽略。2. 拷贝构造与深浅拷贝:C++新手最容易栽跟头的地方2.1 拷贝构造函数在哪些时候被偷偷调用拷贝构造的名字是类名(const 类名& other),它根据一个已有对象创建一个新对象。哪些场景会触发它?三个高频场景:T t2 = t1;——用一个对象初始化另一个对象,走拷贝构造;函数传值,形参是T类型,实参传进来时会用实参拷贝一份临时形参;函数返回T类型,把局部对象返回给调用方时,需要用拷贝生成返回值。注意第三点很容易被混淆:C++编译器普遍做返回值优化(RVO/NRVO),常见写法下明明看到构造函数只调了一次,不代表它没这个能力。写代码时不能依赖“反正编译器会优化”来偷懒,资源管理必须按最坏情况设计。同样一个等号,还要区分拷贝构造和拷贝赋值:Student s1; Student s2 = s1; // 拷贝构造:s2还不存在,从s1构造出来 Student s3; s3 = s1; // 拷贝赋值:s3已存在,把s1的状态赋给s3新手搞不清这两个概念,于是出现了很经典的“对象还没创建好就operator=”,编译器一脸懵地报错。2.2 默认的浅拷贝到底坑在哪里重点来了。类里如果只有int、double这类内部类型,编译器生成的默认拷贝构造完全够用。可一旦成员里有裸指针,默认拷贝构造是逐字节复制指针的值。换句话说,两个对象的指针成员指向同一块内存。这就是浅拷贝。看这个最简单的类:class StringBuf { public: StringBuf(const char* s) { len = strlen(s); data = new char[len + 1]; strcpy(data, s); } ~StringBuf() { delete[] data; } private: char* data; int len; };你写StringBuf a("hello"); StringBuf b = a;之后,a和b里的data都指向同一段堆内存。等a和b各自析构,delete[]执行两次。同一块内存被释放两次,这在大部分平台上直接崩,少部分平台表现为“内存被系统回收后再被写坏”,各种诡异崩溃都来了。解决办法是显式写深拷贝构造,把指针指向的数据复制一遍,而不是复制指针本身:StringBuf(const StringBuf& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); }深拷贝的代价是新分配一份内存,但语义上完全独立,两个对象各管各的,谁析构都不影响另一个。这是“值语义”在C++里最重要的体现。2.3 拷贝赋值运算符与“三/五法则”有拷贝构造,自然还要有拷贝赋值运算符,否则你写完StringBuf a; StringBuf b; b = a;,又回到浅拷贝的路子。赋值和构造的差异决定了它的写法:被赋值的对象已经存在,里面可能还持有旧资源,所以要先释放旧的,再拷贝新的。StringBuf& operator=(const StringBuf& other) { if (this == &other) { return *this; } delete[] data; len = other.len; data = new char[len + 1]; strcpy(data, other.data); return *this; }四件事缺一不可:判断自赋值、释放旧资源、深拷贝新资源、返回*this。自赋值判断看起来多余,但b = a;这种写法你永远不知道哪天会出现a自身赋值的循环;先delete再new的顺序也不要换,否则自赋值时你删了对方唯一的资源,然后还没东西可拷。三条法则说到这里就成型了:如果你要写析构函数(说明有需要释放的资源),那拷贝构造和拷贝赋值通常也必须一起写,这就是经典的三法则。C++11之后加入移动构造和移动赋值,扩展成五法则。移动语义后面再展开,现在先把三条记住,能帮你躲开一大半内存错误。3. 析构函数:对象离场时的收尾工作3.1 析构函数的基本规则和调用时机析构函数名字是~类名(),没有参数、没有返回值、不能重载、一个类最多一个。它在对象生命周期结束时被自动调用:栈上对象:离开作用域时调用;堆上对象:delete时调用;临时对象:所在的完整表达式结束时调用;程序正常退出:全局对象和静态对象的析构在main之后执行。析构函数最常见的用途就是释放资源:delete掉new出来的内存、关闭打开的文件、断开网络连接、归还锁。不看不知道,一看吓一跳,很多人写第一个LinkedList的时候,析构函数就是一片空白,然后每次程序跑完,系统报内存泄漏一大堆。手动调用析构函数几乎是要避免的。唯一合理的场景是placement new配合手动析构,那属于内存池这类底层玩法,业务代码里基本碰不到。日常写代码,只需要保证每个栈对象自动析构,堆对象在delete时析构,其他情况交给语言规则。3.2 资源管理与RAII:用对象生命周期管理资源如果只让我选一个C++最重要的理念,我选RAII:资源获取即初始化。意思是把资源的获取放在构造函数里,把资源的释放放在析构函数里,让资源生命周期与对象生命周期完全绑定。文件、锁、网络句柄、内存,都该包进这样的对象里。比如一个简化版的锁管理类:class LockGuard { public: LockGuard(SomeMutex& m) : mutex_(m) { mutex_.lock(); } ~LockGuard() { mutex_.unlock(); } private: SomeMutex& mutex_; };在函数里写LockGuard guard(mutex);,函数任何分支return、抛异常,析构函数都会执行,锁一定被释放。这就是为什么现代C++反复强调“不要裸new、裸delete,交给容器和智能指针",背后全是RAII:把资源放进一个对象里,然后忘记释放这件事,它自己会管好自己。从这个角度看,析构函数不是“对象销毁前的清理仪式”,而是“对象作为资源管理器的退出机制”。想通这一点,设计类时的思路会清晰很多:先把成员变量想明白,谁拥有资源、谁负责释放,构造函数和析构函数自然水到渠成。3.3 析构函数里的三个高频坑第一个坑:析构函数不释放私有资源。类里new了一块缓冲区,析构却留空,跑几次程序就泄漏。我有段时间排查一个进程内存持续增长,最后发现是某个日志输出类析构没释放,每输出一次泄漏几百字节,压力测试跑半天内存就越界。教训是:类里有哪些new,析构里就要有对应的delete。第二个坑:多态场景下析构函数不是virtual。基类有虚函数,派生类里也持有资源,但基类析构没有virtual。delete基类指针指向的派生类对象时,因为对象的静态类型是基类,编译器只调用基类析构,派生类的资源不释放,直接泄漏一半。写法上:只要这个类设计成要被继承(有virtual函数),就顺手把析构写成virtual。这条建议是强制性的,不是可选项。第三个坑:析构函数抛异常。两个对象的析构如果都抛异常,程序会直接触发异常处理规则终止运行。更麻烦的是,一个对象析构抛异常时,其余成员可能没法正常析构。我的原则:析构函数里永远不要抛异常,遇到错误要么记录日志,要么吞掉,实在不行就让进程终止。C++11之后的默认行为是析构函数隐式noexcept,你要是不小心让它抛出来,编译器会直接告诉你:析构默认不能抛,除非你显式声明。4. 话分两头:构造顺序、析构顺序与移动构造很多人的理解停留在“构造和析构就是两个函数,写出来就行”,但实际工程里还有几个细节很容易漏,漏了就要踩坑。我把构造顺序、析构顺序、委托构造、移动构造逐一提出来讲。4.1 构造顺序与析构顺序对象析构顺序这一点,许多新手第一次写多个对象时就懵了。假设类里有两个成员,编译器构造时按声明顺序构造成员,再执行构造函数体;析构时则是先执行析构函数体,再按声明逆序析构成员。多个对象在同一个作用域时,遵循“后构造的先析构”规律。class Order { public: Order() = default; ~Order() = default; private: std::string s1; // 先构造 std::string s2; // 后构造 }; // 析构顺序:s2先,s1后继承场景则按“基类先构造、派生类再构造;派生类先析构、基类后析构”的顺序。这也解释了为什么析构函数必须virtual这件事:基类指针delete时,语法确保会沿着派生类到基类调用整条析构链。4.2 委托构造函数C++11之后,构造函数可以写在另一个构造函数里调用另一个构造函数,叫委托构造。典型场景是多个构造函数做同样的初始化逻辑:class Student { public: Student() : Student("unknown", 0) {} Student(const std::string& nm, int i) : name(nm), id(i) { // 只有这个真实构造在这里工作 } };优点是把重复代码收拢,缺点是稍微增加理解成本。一个细节:委托目标先执行完,当前构造函数体再执行;多个构造函数之间不能互相委托成环,否则编译期就报错。我见过有人试图在三个构造函数间互相委托,最后连自己都绕晕了,不知道为什么构造不出来。4.3 移动构造:资源转移的特殊构造函数移动构造解决的是“返回大对象、传播大对象时还深拷贝一份,实在太浪费”的问题。它的参数是右值引用,语义是从源对象“偷走”资源而不是复制:StringBuf(StringBuf&& other) noexcept : data(other.data), len(other.len) { other.data = nullptr; other.len = 0; }核心操作是把源对象的指针直接拿走,然后把源对象置为空,确保源对象的析构函数不会重复delete。这里藏着两个高价值的细节。第一,必须把源对象的data置空,否则源对象析构时会把已经属于新对象的内存释放掉,新对象再析构时double free。第二,最好标记noexcept:标准库容器比如vector扩容时,如果移动构造是noexcept的就用移动,否则宁可退回拷贝,因为移动可能抛异常会导致容器不具备强异常安全。这个标记在性能敏感的代码里差异非常显著。移动语义展开其实够写单独一篇,这里先把方向立住:移动构造是五法则成员之一,写之前想清楚你到底要“值语义深拷贝”还是“资源转移”。5. 常见问题与排查技巧实录5.1 我见过的高频报错速查表写个小抄方便直接查:现象大概率原因对策对象声明就报“没有默认构造函数”手动写了带参构造,编译器不再自动生成默认构造显式补默认构造,或给参数全加缺省值指向同一块内存的两个指针同一时刻被释放,崩溃默认浅拷贝造成只拷贝了指针写深拷贝构造 + 赋值析构函数执行后再次访问对象成员悬空引用/悬空指针检查返回值和生命周期多态delete后派生类资源没释放基类析构不是virtual基类析构加virtual容器扩容后对象数量莫名其妙变多/性能明显变慢移动构造未标noexcept或未提供给移动构造加noexcept拷贝过去后又改了某对象数据,另一个对象也变裸指针浅拷贝语义转深拷贝或改用标准库容器这表里面每一条我都见过不止一个真实案例,本质上都是对默认成员函数的生成规则和资源所有权理解不到位。5.2 构造析构的调试技巧调试构造和析构,最高效的经验是“打日志 + 计数”。在构造函数和析构函数里分别打印进入和退出,再配合一个静态计数器统计当前存活对象个数,很多资源管理问题一眼就看穿:static int alive = 0; Foo::Foo() { ++alive; std::cout << "Foo() alive=" << alive << "\n"; } Foo::~Foo() { --alive; std::cout << "~Foo() alive=" << alive << "\n"; }如果alive一直涨,说明某处泄漏;如果alive变化符合“先进后出”曲线,说明析构顺序是正常的;如果某个对象析构函数被调用了两次,那基本就是浅拷贝问题。我在排查一个长期运行的服务端程序时就是靠这种计数,把泄漏的类从几百个候选里快速筛出来。5.3 几条避免踩坑的实操建议最后给几条实际经验:成员优先用标准库容器和智能指针,能不自管裸指针就不自管。一个类只要手动写了析构函数,就先停下来想想:拷贝构造和拷贝赋值是不是也要一起写?如果不想写,干脆用= delete禁用拷贝,避免意外复制。new和delete要配对,哪里new哪里delete,尽量缩小裸指针的作用域。开始动手写类之前,先画一下成员资源的归属:谁创建、谁释放、对象间是否允许共享。排查内存崩溃,优先怀疑“两个对象共享同一块资源”,而不是怀疑内存被写坏,因为后者出现的概率远低于前者。这样养成习惯后,构造与析构这两个函数写起来会越来越有把握,踩坑概率直线下降。我个人实际操作中的体会是:构造函数和析构函数表面上是两个函数,实质上是类和对象资源管理的一体两端。把“对象出生时数据如何初始化、对象销毁时资源如何释放”想明白,C++里很多看起来吓人的崩溃,其实都是这两个函数没有写对。再多说一个小技巧:给类写构造函数时,我习惯先把所有成员变量列一遍,逐个标注是“值类型、引用还是指针、是否需要独占资源”,然后按列表决定要不要写析构和拷贝;这个动作花不了两分钟,却能避免掉绝大多数资源泄漏和double free。下一篇再聊运算符重载与类型转换也不迟——你会发现,今天的构造与析构基本功,就是那天所有“奇怪语法”的地基。
类和对象讲到中期,最绕不开的两个成员函数就是构造函数和析构函数。你可以把类当成一张图纸,对象是按照图纸盖起来的房子,构造函数是开工时的地基浇筑和剪彩仪式,析构函数是拆除前的安全检查和管线回收——没有这两样,盖房和拆房全靠运气,这在C++里是绝对走不远的。构造函数决定了对象出生时带什么数据、资源从哪来;析构函数决定了对象消失时资源怎么还回去。很多人学完“类的基本语法”就卡在这里:为什么编译器偶尔报“没有合适的构造函数”?为什么同一个对象析构两次?为什么明明delete了还泄漏?这篇就集中把构造与析构这两个成员函数讲透。适合刚学完对象基础语法、想把类写得“能用又安全”的同学;被深浅拷贝坑过的老手也值得扫一遍常见错误清单。1. 构造函数:对象出生前的“第一件事”1.1 构造函数到底在解决什么问题一个类除了成员变量和成员函数,编译器还悄悄给每个类都准备了一组“默认成员函数”:默认构造、析构、拷贝构造、拷贝赋值,C++11之后又多了移动构造和移动赋值。这六个函数不写,编译器在需要时会自动生成;但写错其中一个,往往比不写还危险。构造函数的名字和类名完全相同,没有返回值——不是void,是真的什么都不返回。它可以重载,所以你才能写出这种写法:class Student { public: Student() { // 什么都不干 } Student(const char* name, int id) { // 带两个参数 } };构造函数最核心的任务只有一个:把对象里每一个成员变量设置成合理状态。这句话说起来很轻,做起来要命——因为绝大多数BUG都来自“对象出生时状态就不对”。我见过新手写一个类,成员是int age,构造函数忘了初始化,打印出的年龄直接是垃圾值。在Debug版里可能还能稳定复现,Release版里直接时好时坏,非常难查。一个重要的坑先放在这:只要你手动写了任何一个构造函数,编译器就不再替你合成默认构造函数。也就是说,你定义了一个带参构造后,再写Student s;,编译器直接报错“没有默认构造函数”。很多人刚学的时候在这里卡一晚上。解决办法是显式补一个无参构造,或者给带参构造的参数全部用缺省值。1.2 初始化列表:这才是“初始化”的正确姿势接上面的Student,更专业的写法是用初始化列表:class Student { public: Student() : name("unknown"), id(0) {} Student(const char* nm, int nid) : name(nm ? nm : "unknown"), id(nid) {} private: const char* name; int id; };这里有两个细节值得讲清楚。其一,初始化列表是在进入构造函数体之前执行的,成员在这里直接被初始化;而在构造体内写name = ...,本质是先默认初始化成员,再赋值,多一次操作。对int、指针这种廉价类型差别不大,但对string、vector这类带资源的类型,先默认构造再赋值,白白多一次构造和析构的开销。其二,有些成员根本不能靠体内赋值,比如const成员和引用成员:它们必须在初始化列表里初始化,体内赋值是违法的,因为const一旦初始化就不可变,引用一旦绑定就不可换。还有更隐蔽的一点:初始化顺序由成员声明顺序决定,不是由初始化列表里谁先谁后决定。假设你声明了a和b两个成员,但列表里写b先、a后,编译器按a再b初始化,如果你在初始化a时依赖b的初始值,就会拿到半成品。这类问题编译器不警告,运行结果又常常“碰巧对”,等换编译器或换平台就翻车。经验法则:初始化列表的书写顺序和成员声明顺序保持一致,不要靠记忆,要用代码风格约束。class Demo { int a; int b; public: Demo(int x) : b(x), a(b) {} // 危险:a比b先初始化,此时b还没被赋值 };我把上面这个代码放到某个聊天群里问过,一半人第一眼看不出问题——这正好说明顺序问题有多容易被忽略。2. 拷贝构造与深浅拷贝:C++新手最容易栽跟头的地方2.1 拷贝构造函数在哪些时候被偷偷调用拷贝构造的名字是类名(const 类名& other),它根据一个已有对象创建一个新对象。哪些场景会触发它?三个高频场景:T t2 = t1;——用一个对象初始化另一个对象,走拷贝构造;函数传值,形参是T类型,实参传进来时会用实参拷贝一份临时形参;函数返回T类型,把局部对象返回给调用方时,需要用拷贝生成返回值。注意第三点很容易被混淆:C++编译器普遍做返回值优化(RVO/NRVO),常见写法下明明看到构造函数只调了一次,不代表它没这个能力。写代码时不能依赖“反正编译器会优化”来偷懒,资源管理必须按最坏情况设计。同样一个等号,还要区分拷贝构造和拷贝赋值:Student s1; Student s2 = s1; // 拷贝构造:s2还不存在,从s1构造出来 Student s3; s3 = s1; // 拷贝赋值:s3已存在,把s1的状态赋给s3新手搞不清这两个概念,于是出现了很经典的“对象还没创建好就operator=”,编译器一脸懵地报错。2.2 默认的浅拷贝到底坑在哪里重点来了。类里如果只有int、double这类内部类型,编译器生成的默认拷贝构造完全够用。可一旦成员里有裸指针,默认拷贝构造是逐字节复制指针的值。换句话说,两个对象的指针成员指向同一块内存。这就是浅拷贝。看这个最简单的类:class StringBuf { public: StringBuf(const char* s) { len = strlen(s); data = new char[len + 1]; strcpy(data, s); } ~StringBuf() { delete[] data; } private: char* data; int len; };你写StringBuf a("hello"); StringBuf b = a;之后,a和b里的data都指向同一段堆内存。等a和b各自析构,delete[]执行两次。同一块内存被释放两次,这在大部分平台上直接崩,少部分平台表现为“内存被系统回收后再被写坏”,各种诡异崩溃都来了。解决办法是显式写深拷贝构造,把指针指向的数据复制一遍,而不是复制指针本身:StringBuf(const StringBuf& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); }深拷贝的代价是新分配一份内存,但语义上完全独立,两个对象各管各的,谁析构都不影响另一个。这是“值语义”在C++里最重要的体现。2.3 拷贝赋值运算符与“三/五法则”有拷贝构造,自然还要有拷贝赋值运算符,否则你写完StringBuf a; StringBuf b; b = a;,又回到浅拷贝的路子。赋值和构造的差异决定了它的写法:被赋值的对象已经存在,里面可能还持有旧资源,所以要先释放旧的,再拷贝新的。StringBuf& operator=(const StringBuf& other) { if (this == &other) { return *this; } delete[] data; len = other.len; data = new char[len + 1]; strcpy(data, other.data); return *this; }四件事缺一不可:判断自赋值、释放旧资源、深拷贝新资源、返回*this。自赋值判断看起来多余,但b = a;这种写法你永远不知道哪天会出现a自身赋值的循环;先delete再new的顺序也不要换,否则自赋值时你删了对方唯一的资源,然后还没东西可拷。三条法则说到这里就成型了:如果你要写析构函数(说明有需要释放的资源),那拷贝构造和拷贝赋值通常也必须一起写,这就是经典的三法则。C++11之后加入移动构造和移动赋值,扩展成五法则。移动语义后面再展开,现在先把三条记住,能帮你躲开一大半内存错误。3. 析构函数:对象离场时的收尾工作3.1 析构函数的基本规则和调用时机析构函数名字是~类名(),没有参数、没有返回值、不能重载、一个类最多一个。它在对象生命周期结束时被自动调用:栈上对象:离开作用域时调用;堆上对象:delete时调用;临时对象:所在的完整表达式结束时调用;程序正常退出:全局对象和静态对象的析构在main之后执行。析构函数最常见的用途就是释放资源:delete掉new出来的内存、关闭打开的文件、断开网络连接、归还锁。不看不知道,一看吓一跳,很多人写第一个LinkedList的时候,析构函数就是一片空白,然后每次程序跑完,系统报内存泄漏一大堆。手动调用析构函数几乎是要避免的。唯一合理的场景是placement new配合手动析构,那属于内存池这类底层玩法,业务代码里基本碰不到。日常写代码,只需要保证每个栈对象自动析构,堆对象在delete时析构,其他情况交给语言规则。3.2 资源管理与RAII:用对象生命周期管理资源如果只让我选一个C++最重要的理念,我选RAII:资源获取即初始化。意思是把资源的获取放在构造函数里,把资源的释放放在析构函数里,让资源生命周期与对象生命周期完全绑定。文件、锁、网络句柄、内存,都该包进这样的对象里。比如一个简化版的锁管理类:class LockGuard { public: LockGuard(SomeMutex& m) : mutex_(m) { mutex_.lock(); } ~LockGuard() { mutex_.unlock(); } private: SomeMutex& mutex_; };在函数里写LockGuard guard(mutex);,函数任何分支return、抛异常,析构函数都会执行,锁一定被释放。这就是为什么现代C++反复强调“不要裸new、裸delete,交给容器和智能指针",背后全是RAII:把资源放进一个对象里,然后忘记释放这件事,它自己会管好自己。从这个角度看,析构函数不是“对象销毁前的清理仪式”,而是“对象作为资源管理器的退出机制”。想通这一点,设计类时的思路会清晰很多:先把成员变量想明白,谁拥有资源、谁负责释放,构造函数和析构函数自然水到渠成。3.3 析构函数里的三个高频坑第一个坑:析构函数不释放私有资源。类里new了一块缓冲区,析构却留空,跑几次程序就泄漏。我有段时间排查一个进程内存持续增长,最后发现是某个日志输出类析构没释放,每输出一次泄漏几百字节,压力测试跑半天内存就越界。教训是:类里有哪些new,析构里就要有对应的delete。第二个坑:多态场景下析构函数不是virtual。基类有虚函数,派生类里也持有资源,但基类析构没有virtual。delete基类指针指向的派生类对象时,因为对象的静态类型是基类,编译器只调用基类析构,派生类的资源不释放,直接泄漏一半。写法上:只要这个类设计成要被继承(有virtual函数),就顺手把析构写成virtual。这条建议是强制性的,不是可选项。第三个坑:析构函数抛异常。两个对象的析构如果都抛异常,程序会直接触发异常处理规则终止运行。更麻烦的是,一个对象析构抛异常时,其余成员可能没法正常析构。我的原则:析构函数里永远不要抛异常,遇到错误要么记录日志,要么吞掉,实在不行就让进程终止。C++11之后的默认行为是析构函数隐式noexcept,你要是不小心让它抛出来,编译器会直接告诉你:析构默认不能抛,除非你显式声明。4. 话分两头:构造顺序、析构顺序与移动构造很多人的理解停留在“构造和析构就是两个函数,写出来就行”,但实际工程里还有几个细节很容易漏,漏了就要踩坑。我把构造顺序、析构顺序、委托构造、移动构造逐一提出来讲。4.1 构造顺序与析构顺序对象析构顺序这一点,许多新手第一次写多个对象时就懵了。假设类里有两个成员,编译器构造时按声明顺序构造成员,再执行构造函数体;析构时则是先执行析构函数体,再按声明逆序析构成员。多个对象在同一个作用域时,遵循“后构造的先析构”规律。class Order { public: Order() = default; ~Order() = default; private: std::string s1; // 先构造 std::string s2; // 后构造 }; // 析构顺序:s2先,s1后继承场景则按“基类先构造、派生类再构造;派生类先析构、基类后析构”的顺序。这也解释了为什么析构函数必须virtual这件事:基类指针delete时,语法确保会沿着派生类到基类调用整条析构链。4.2 委托构造函数C++11之后,构造函数可以写在另一个构造函数里调用另一个构造函数,叫委托构造。典型场景是多个构造函数做同样的初始化逻辑:class Student { public: Student() : Student("unknown", 0) {} Student(const std::string& nm, int i) : name(nm), id(i) { // 只有这个真实构造在这里工作 } };优点是把重复代码收拢,缺点是稍微增加理解成本。一个细节:委托目标先执行完,当前构造函数体再执行;多个构造函数之间不能互相委托成环,否则编译期就报错。我见过有人试图在三个构造函数间互相委托,最后连自己都绕晕了,不知道为什么构造不出来。4.3 移动构造:资源转移的特殊构造函数移动构造解决的是“返回大对象、传播大对象时还深拷贝一份,实在太浪费”的问题。它的参数是右值引用,语义是从源对象“偷走”资源而不是复制:StringBuf(StringBuf&& other) noexcept : data(other.data), len(other.len) { other.data = nullptr; other.len = 0; }核心操作是把源对象的指针直接拿走,然后把源对象置为空,确保源对象的析构函数不会重复delete。这里藏着两个高价值的细节。第一,必须把源对象的data置空,否则源对象析构时会把已经属于新对象的内存释放掉,新对象再析构时double free。第二,最好标记noexcept:标准库容器比如vector扩容时,如果移动构造是noexcept的就用移动,否则宁可退回拷贝,因为移动可能抛异常会导致容器不具备强异常安全。这个标记在性能敏感的代码里差异非常显著。移动语义展开其实够写单独一篇,这里先把方向立住:移动构造是五法则成员之一,写之前想清楚你到底要“值语义深拷贝”还是“资源转移”。5. 常见问题与排查技巧实录5.1 我见过的高频报错速查表写个小抄方便直接查:现象大概率原因对策对象声明就报“没有默认构造函数”手动写了带参构造,编译器不再自动生成默认构造显式补默认构造,或给参数全加缺省值指向同一块内存的两个指针同一时刻被释放,崩溃默认浅拷贝造成只拷贝了指针写深拷贝构造 + 赋值析构函数执行后再次访问对象成员悬空引用/悬空指针检查返回值和生命周期多态delete后派生类资源没释放基类析构不是virtual基类析构加virtual容器扩容后对象数量莫名其妙变多/性能明显变慢移动构造未标noexcept或未提供给移动构造加noexcept拷贝过去后又改了某对象数据,另一个对象也变裸指针浅拷贝语义转深拷贝或改用标准库容器这表里面每一条我都见过不止一个真实案例,本质上都是对默认成员函数的生成规则和资源所有权理解不到位。5.2 构造析构的调试技巧调试构造和析构,最高效的经验是“打日志 + 计数”。在构造函数和析构函数里分别打印进入和退出,再配合一个静态计数器统计当前存活对象个数,很多资源管理问题一眼就看穿:static int alive = 0; Foo::Foo() { ++alive; std::cout << "Foo() alive=" << alive << "\n"; } Foo::~Foo() { --alive; std::cout << "~Foo() alive=" << alive << "\n"; }如果alive一直涨,说明某处泄漏;如果alive变化符合“先进后出”曲线,说明析构顺序是正常的;如果某个对象析构函数被调用了两次,那基本就是浅拷贝问题。我在排查一个长期运行的服务端程序时就是靠这种计数,把泄漏的类从几百个候选里快速筛出来。5.3 几条避免踩坑的实操建议最后给几条实际经验:成员优先用标准库容器和智能指针,能不自管裸指针就不自管。一个类只要手动写了析构函数,就先停下来想想:拷贝构造和拷贝赋值是不是也要一起写?如果不想写,干脆用= delete禁用拷贝,避免意外复制。new和delete要配对,哪里new哪里delete,尽量缩小裸指针的作用域。开始动手写类之前,先画一下成员资源的归属:谁创建、谁释放、对象间是否允许共享。排查内存崩溃,优先怀疑“两个对象共享同一块资源”,而不是怀疑内存被写坏,因为后者出现的概率远低于前者。这样养成习惯后,构造与析构这两个函数写起来会越来越有把握,踩坑概率直线下降。我个人实际操作中的体会是:构造函数和析构函数表面上是两个函数,实质上是类和对象资源管理的一体两端。把“对象出生时数据如何初始化、对象销毁时资源如何释放”想明白,C++里很多看起来吓人的崩溃,其实都是这两个函数没有写对。再多说一个小技巧:给类写构造函数时,我习惯先把所有成员变量列一遍,逐个标注是“值类型、引用还是指针、是否需要独占资源”,然后按列表决定要不要写析构和拷贝;这个动作花不了两分钟,却能避免掉绝大多数资源泄漏和double free。下一篇再聊运算符重载与类型转换也不迟——你会发现,今天的构造与析构基本功,就是那天所有“奇怪语法”的地基。