1. 构造函数与析构函数对象生命周期里躲不开的两道门很多初学者写了几个月的C对类这个概念依然停留在结构体加函数的层面。这不奇怪因为如果你只在栈上声明几个普通对象编译器又帮你把默认构造、拷贝构造、析构都悄悄写好你确实感受不到它们的价值。但一旦你的类开始持有堆内存、文件句柄、网络连接、互斥锁这类外部资源局面立刻失控。最常见的一个现象是程序不报错但内存一点点涨或者程序一退出文件还在被占用又或者把一个对象塞进std::vector之后里面的指针全变成了悬空指针。这些问题的根源几乎都能追溯到构造和析构这两个环节没处理干净。所以这个中篇我打算把构造函数和析构函数一次讲透。它适合两类人第一类是刚学完类的基本语法、准备往深走的学生第二类是已经写了不少C但遇到资源管理问题时只能靠试错解决的开发者。读完你应该能回答一个问题一个对象从生到死编译器到底做了什么你又该做什么。2. 构造函数对象出生时被强制执行的初始化逻辑2.1 构造函数到底解决了什么问题先说一个最朴素的问题为什么C要专门搞一个构造函数早期C语言写结构体是这样的struct Person { char name[64]; int age; }; void init_person(struct Person* p, const char* name, int age) { strcpy(p-name, name); p-age age; }你得时刻记得先初始化再使用忘了调init_person就是未定义行为。这种靠程序员自律的方式在项目规模变大之后必然出漏子。构造函数的意义就是把初始化从可选操作变成强制操作只要对象被创建构造函数就必然执行语法层面剥夺了你忘记初始化的权利。这里有个关键认知构造函数的调用时机是对象的存储空间已经分配好之后、代码正式使用这个对象之前。内存分配和初始化是两回事。你需要先理解这一点才能理解后面初始化列表等细节。2.2 构造函数与普通成员函数的三点本质区别构造函数在形式上像一个没有返回值的成员函数但它的身份特殊第一函数名必须与类名完全一致。编译器通过名称识别它是构造函数。这意味着你没法主动调用它——析构函数和构造函数都是由编译器在合适的时机自动触发的虽然语法上你确实可以写obj.~Person()但真正决定调用时机的仍然是对象生命周期的结束点。第二构造函数允许重载且必须结合参数匹配规则选择版本。普通函数重载是传入什么调什么构造函数的重载则决定了这个对象以什么状态诞生。第三构造函数不能是虚函数。原因很直观虚函数机制依赖对象的虚表指针而虚表指针本身是在构造函数里被设置的。在对象还没有完全构造完之前虚表指针可能指向的是基类版本这时候调用虚函数会导致不可预测的行为。2.3 默认构造、带参构造与转换构造函数看一组实际例子#include iostream #include cstring class Logger { public: // 默认构造无参数 Logger() : level_(2), fd_(-1) { std::cout Logger() default construct std::endl; } // 带参构造 explicit Logger(int level) : level_(level), fd_(-1) { std::cout Logger(int) construct, level level std::endl; } // 带默认参数的构造 Logger(const char* filename, int level 1) { std::cout Logger(const char*, int) construct std::endl; // 实际项目中这里会打开文件 or 建立连接 } // 转换构造参数只有一个非默认类型时允许隐式转换 // 但加了 explicit 之后禁止隐式转换 explicit Logger(int a, int b) : level_(a) { std::cout Logger(int, int) construct std::endl; } private: int level_; int fd_; }; int main() { Logger log1; // 默认构造 Logger log2(3); // 带参构造 Logger log3(app.log); // 带默认参数的构造 Logger log4(1, 2); // 两个int形参的构造 // 注意这句如果没有 explicitLogger log5 5; 会调用 Logger(int) // 有了 explicit 之后下面这条语句编译不通过 // Logger log5 5; Logger log6 static_castLogger(5); // 显式转换才能调用 return 0; }这里有个非常实用的点单参数构造函数要养成加explicit的习惯。因为它会允许类似void print(Logger l); print(123);这种隐式转换发生——大多数时候这是你没料到的bug而不是你期待的功能。我自己曾经在一个配置解析器里就是因为漏了explicit导致一个整数参数被悄悄转换成了某个配置类查了两天才意识到是隐式转换在作祟。3. 初始化列表比赋值快还能绕开先默认后覆盖的坑3.1 初始化列表与赋值不只是效率差别不少人是这样写构造函数的class NetworkClient { public: NetworkClient(const std::string host, int port, std::shared_ptrConfig cfg) { host_ host; port_ port; cfg_ cfg; } private: std::string host_; int port_; std::shared_ptrConfig cfg_; };它能跑但本质上做了两步先由编译器调用成员变量的默认构造函数完成初始化然后在大括号里执行赋值操作。对于int这类内建类型这两步几乎没差别但对于std::string、std::vector、自定义类对象这意味着一次多余的默认构造加一次拷贝赋值。更关键的问题出在那些没有默认构造函数的成员对象上。比如class Connection { public: Connection(int timeout_ms) : timeout_ms_(timeout_ms) {} private: int timeout_ms_; }; class HttpClient { public: // 错误写法conn_ 没有默认构造函数这里编译失败 HttpClient() { conn_ Connection(1000); } // 正确写法在初始化列表里一步到位 HttpClient() : conn_(1000) {} private: Connection conn_; };Connection只有一个单参数的构造函数意味着它不存在无参初始化的可能性。你在函数体里赋值时编译器根本找不到默认构造来先创建conn_于是直接编译错误。初始化列表能解决的正是这个矛盾它不经过默认构造赋值而是直接用实参去匹配构造函数。3.2 const成员与引用成员必须在初始化列表里初始化还有一类成员是只能在初始化列表里初始化、在函数体里赋值必然编译失败的。const成员和引用成员的共同特点是它们在对象一生中只能被绑定一次。函数体内的赋值操作在语义上是修改已有对象的内容这和const/引用的只能绑一次天然冲突。所以如果你这样写class RequestContext { public: // 错误const 成员和引用成员不能放在函数体里赋值 RequestContext(int id, const std::string name) { id_ id; name_ name; // name_ 是引用这里是赋值给引用绑定的对象而不是重新绑定引用 } private: const int id_; std::string name_; };编译器会直接报错。正确的写法是class RequestContext { public: RequestContext(int id, std::string name) : id_(id), name_(name) {} private: const int id_; std::string name_; };3.3 初始化顺序的陷阱不按你写的顺序来这是初始化列表里最容易踩的坑几乎每隔几个月就能在代码评审里看到一次。成员变量的初始化顺序只由它们在类中声明的顺序决定跟初始化列表里的书写顺序没有任何关系。class Buffer { public: Buffer(size_t cap) : cap_(cap), buf_(new char[cap_]) { // 你以为是先 cap_ 再 buf_实际执行时 // 如果 buf_ 声明在 cap_ 前面buf_ 先被初始化 // 而 cap_ 此刻还是未初始化的垃圾值 } private: char* buf_; // 先声明 size_t cap_; // 后声明 };这个例子一旦buf_声明在cap_前面构造函数里的cap_就是未初始化值new char[垃圾值]大概率直接异常。我遇到过的最隐蔽的一种情况是两行语句一换在Debug模式下因为内存清零没暴露Release下偶现崩溃查了两天才定位到是初始化顺序问题。所以我的建议很简单初始化列表的书写顺序永远和成员声明顺序保持一致同时打开编译器警告选项。在GCC/Clang下加-WreorderMSVC下开启C5038警告顺序错了立刻提醒你。4. 拷贝构造与移动构造对象复制背后的深浅拷贝之争4.1 什么是拷贝构造什么时候会被调用拷贝构造就是用一个已有对象去构造另一个新对象。它的函数签名固定为T(const T)可能带有额外带默认值的参数。判断一个地方用的是拷贝构造还是赋值有一个快速心法看这一步里有没有新的对象产生。有就是构造没有就是赋值。class Image { public: Image(const Image other) : width_(other.width_), height_(other.height_) { // 深拷贝时代码在这里复制像素数据 } Image operator(const Image other) { // 赋值运算符返回引用对象本身已存在 } private: int width_, height_; }; void useImage(Image img) { } // 传参拷贝构造 Image createImage() { Image tmp; return tmp; // 理论上拷贝构造实际可能被省略或移动 } int main() { Image a; Image b(a); // 明确拷贝构造 Image c a; // 仍是拷贝构造因为 c 是新对象 c b; // 赋值运算符因为 c 已存在 useImage(a); // 传参拷贝构造 return 0; }4.2 浅拷贝与深拷贝悬空指针从哪来没有资源时编译器默认生成的拷贝构造够用——逐成员拷贝。但类里一旦出现裸指针浅拷贝就会出大事class StringBuffer { public: StringBuffer(const char* s) { size_ strlen(s); data_ new char[size_ 1]; strcpy(data_, s); } ~StringBuffer() { delete[] data_; } private: char* data_; int size_; }; int main() { StringBuffer s1(hello); StringBuffer s2(s1); // 浅拷贝s2.data_ 和 s1.data_ 指向同一块内存 // 函数结束时 s2 先析构delete[] 释放那块内存 // s1 析构时再次 delete[] 同一块内存——未定义行为大概率崩溃 return 0; }解决之道就是深拷贝在拷贝构造函数里为新对象分配独立的内存把数据复制过去而不是只复制指针值。很多现代库的做法是采用写时复制或者直接禁用拷贝、只允许移动比如C11之后的std::string在不同标准库实现下做过写时复制的取舍最终大多选择了移动语义和SSO小字符串优化。这提醒我们在自己设计类时最好把这个类到底允不允许拷贝想清楚。资源独占型类通常只允许移动。4.3 移动构造把别人的资源直接拿过来移动构造是C11引入的。它的核心是当源对象即将被销毁且它的资源可以被安全转移时不再做深拷贝直接偷走源对象的底层资源把源对象置为一个有效但空的状态。class StringBuffer { public: StringBuffer(StringBuffer other) noexcept : data_(other.data_), size_(other.size_) { other.data_ nullptr; other.size_ 0; } private: char* data_; int size_; };两个注意点第一移动构造最好标记noexcept。因为标准容器在扩容时如果移动构造不是noexcept它往往不敢用移动而是老老实实走拷贝因为拷贝失败还能保证强异常安全而移动搬一半出异常则整个容器都处于半损坏状态。这一点往深了说就是标准库对异常安全和性能的权衡。第二移动之后必须让源对象处于可析构状态。你把别人的指针拿走了一定要把源指针置空否则源对象析构时会二次释放。实际项目中我有一个习惯成员变量里有std::unique_ptr、std::thread、std::fstream这类移动型资源时优先考虑实现移动构造而不是拷贝构造。拿线程举例std::thread本身不可拷贝如果你的类里持有它想要支持返回对象就必须实现移动或者直接用编译器生成的默认移动构造。4.4 三/五法则要么不写要么一起写C社区有个脍炙人口的规则叫三法则如果你显式定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个那么另外两个也应该显式定义。C11之后引入移动构造和移动赋值升级为五法则。为什么因为这三个东西在语义上是绑定的。你写了析构函数delete[] data_说明你在管理资源这时候拷贝构造和拷贝赋值如果还是默认的逐成员复制就会有双重释放问题。反过来你定义了拷贝构造做深拷贝但析构函数什么都不做那内存泄漏就是家常便饭。我在代码评审里见过太多只写了一个析构函数没写拷贝构造也没禁拷贝的类。这类类只要被人拷贝一次运行期bug就来了而且非常难查。所以如果实在不确定怎么写完整一个更安全的做法是class SomeResource { public: SomeResource() default; ~SomeResource() default; SomeResource(const SomeResource) delete; SomeResource operator(const SomeResource) delete; SomeResource(SomeResource) default; SomeResource operator(SomeResource) default; };先明确这个类只允许移动不允许拷贝比留着一个有隐患的拷贝行为要稳妥得多。5. 析构函数对象消失时如何善后以及析构顺序的玄机5.1 析构函数的工作机制析构函数是构造函数在生命周期末端的镜像对象在销毁前编译器自动调用析构函数你在这个函数里释放所有在构造过程中获取的资源。class FileHandler { public: FileHandler(const char* path) { fp_ fopen(path, w); if (!fp_) { throw std::runtime_error(open failed); } } ~FileHandler() { if (fp_) { fclose(fp_); fp_ nullptr; } } private: FILE* fp_; };析构函数有几个特点值得单独说析构函数没有参数因此不能重载一个类只有一个析构函数。析构函数不能主动抛出异常。如果在析构过程中抛异常且没被捕获程序会直接调std::terminate。我见过有同事在析构函数里写日志、写数据库那些操作一旦失败就抛异常结果服务无征兆地退出。后来统一改成析构函数里只做最基础的资源释放不做可能失败的复杂操作。析构函数必须是虚函数的场景是当你要通过基类指针删除派生类对象时。如果基类析构函数不是虚函数delete base_ptr只会调用基类的析构函数派生类部分的资源就会泄漏。5.2 析构顺序构造的逆序派生类先走对象的析构顺序有一套严谨的规则这里挑最容易出错的两个场景说。第一个场景是成员变量与对象本身的析构顺序。规则是先执行类自己的析构函数体再按成员声明顺序的逆序析构成员变量。也就是说~A()函数体内的代码执行时所有成员变量还是活着的你可以放心使用它们但函数体结束之后成员变量才一个接一个进入析构。第二个场景是继承体系下的析构顺序。规则是派生类析构函数先执行然后基类析构函数再执行。这跟构造顺序正好相反。这意味着在基类析构函数里虚函数调用已经不会进入派生类的版本了。如果你在基类析构里调用一个虚函数它在运行时绑定的是基类版本——哪怕对象本身是派生类实例。有个经典的坑如下class Base { public: virtual ~Base() { cleanup(); // 这里调用的是 Base::cleanup()不是 Derived::cleanup() } virtual void cleanup() { std::cout Base cleanup std::endl; } }; class Derived : public Base { public: void cleanup() override { std::cout Derived cleanup std::endl; } };这个输出永远只会是Base cleanup。正确的做法是在派生类自己的析构函数里完成派生层级的清理而不是指望基类析构通过虚调用帮你完成。5.3 RAII析构函数的真正威力不在清内存很多人以为RAII就是用析构释放堆内存。其实内存只是资源的一种RAII的价值在于把任何需要用完必须归还的资源绑定到对象的生命周期上。锁、文件、数据库连接、线程、套接字都可以用同一个模式管理。举一个锁的例子class MutexLockGuard { public: explicit MutexLockGuard(std::mutex mtx) : mtx_(mtx) { mtx_.lock(); } ~MutexLockGuard() { mtx_.unlock(); } MutexLockGuard(const MutexLockGuard) delete; MutexLockGuard operator(const MutexLockGuard) delete; private: std::mutex mtx_; }; void critical_section() { MutexLockGuard guard(mtx); // 这里抛异常也没关系guard 析构时会自动解锁 do_something(); }注意std::lock_guard本身就是这么实现的。RAII最厉害的地方在于异常安全是自动获得的。函数中途抛出异常栈上所有对象都会被析构锁会被释放文件会被关闭资源不会泄漏。如果你习惯了裸lock、裸fopen、裸new那么一次异常路径上就会同时泄漏锁、句柄和内存。所以我的看法是析构函数最大的工程价值不是写两行清理代码而是让所有资源都遵循同一个生命周期模式。你写类的时候如果发现析构函数里需要做很多手工清理大概率是这个类设计得不够RAII。6. 实战中反复出现的构造与析构坑排查思路和修法记录这部分记录的是我在真实项目里踩过的、以及帮别人review代码时看到的高频问题。每一条我都给出了完整的排查思路而不是只给结论。6.1 vector扩容时发生的拷贝与移动问题现象一个持有裸指针的类放进std::vector后程序偶发崩溃而且通常在push_back多次之后才出现。排查思路在拷贝构造和析构函数里加日志观察调用频率。发现每次push_back时拷贝构造被调用的次数远超预期。进一步发现vector在容量不够时会重新分配一块更大的内存并把旧元素逐个拷贝或移动过去然后销毁旧元素。由于类的拷贝构造是浅拷贝旧元素析构时把内存释放了新元素手里的指针就成了悬空指针。修复方案把拷贝构造改成深拷贝。或者把拷贝禁用实现移动构造并标记为noexcept。或者一开始就用vectorunique_ptrT彻底避免大对象拷贝。我自己的偏好是第三种。因为vectorunique_ptrT语义清晰扩容时只移动指针既没有深拷贝的性能代价也不用担心字段多之后浅拷贝漏掉某个新成员。6.2 基类析构函数忘写virtual导致的派生类资源泄漏问题现象业务侧用基类指针统一管理一组插件对象程序退出时看起来正常但Valgrind报告still reachable和definitely lost。排查思路检查基类析构函数是否声明为virtual。如果不是delete base_ptr只会调用基类析构函数。派生类里持有的std::thread、数据库连接等资源全部没有机会释放。这类问题通常不会立刻暴露等对象多了、或连接资源被耗尽时才被发现。修复方案基类析构函数加virtual关键字。这里也提醒一下从C11开始建议析构函数统一写成virtual ~Base() default;如果基类本身是要被多态使用的。如果确定不需要多态删除可以考虑禁止该类被继承比如用C11之后不直接支持的方式如私有构造配合友元避免后人误用。6.3 构造函数中调用虚函数的误解问题现象某框架在派生类构造完成后希望通过基类构造函数调用一个在派生类里重写的初始化函数结果发现派生类版本根本没被执行。排查思路回顾构造函数中虚函数调用的绑定规则——构造函数执行期间对象的动态类型还是构造到哪一层就是哪一层。基类构造函数运行的时候派生类成员还没初始化虚表指针也指向基类的版本所以虚调用不会进入派生类覆盖函数。修复方案不依赖虚函数做跨层初始化。把初始化逻辑推迟到派生类构造结束后显式调用。或者用模板方法模式在基类非虚函数里定义骨架通过编译期绑定调用派生类实现的细节。注意后一种方案在不同编译器下的行为差异比较大优先选择延迟调用。6.4 析构函数里打了日志导致程序退出变慢问题现象压测时发现程序退出阶段要好几秒业务逻辑早就跑完了。排查思路栈展开时会逐个析构全局对象和局部对象如果在析构函数里做了耗时操作比如向日志系统写同步日志、访问远程服务整个退出流程会被拉得很长。修复方案析构函数里只做必要的资源释放。日志输出这种增值操作放到外部包装层不要放在资源类自身的析构里。6.5 成员对象没有默认构造的连环爆炸问题现象新接手一个模块编译后发现大量类似no matching function for call toSomeClass::SomeClass()的错误而且指向代码里并没有直接创建SomeClass对象的地方。排查思路错误往往发生在某个类的成员变量声明处。那些成员没有在初始化列表里被初始化而它们的类型又没有默认构造。修复方案为包含必须构造成员的类在构造函数初始化列表中显式初始化每一个成员或者给那些成员类型提供带默认值的参数构造。这个问题的间接教训是如果一个类的每个构造函数都必须在初始化列表里把成员全部覆盖可以考虑用std::optional包装那些可以晚一点初始化的成员。6.6 移动构造写了一半源对象状态不完整问题现象使用自己实现的移动构造函数后某个对象的析构函数崩溃。排查思路检查移动构造函数看是否所有指针成员都被置空。发现有个std::vector成员是通过std::move转移了但没有清理源对象的其他字符串成员。源对象析构时那些未清理的成员依然执行释放逻辑于是双重释放。修复方案移动构造完成后要把源对象的所有权型成员全部重置为有效空状态。写完后写一个简单的断言式测试移动构造之后源对象要么等于默认构造状态要么马上就要析构也不会出错。7. 两个我长期沿用的小建议最后再补充两个纯经验性质的建议都不是语法层面的但关键时刻能帮你省下大量排查时间。第一把构造函数和析构函数里的副作用通过日志提前暴露出来。我个人习惯在开发阶段给构造/析构加一行std::cout或轻量日志联调时打开发布时再关。很多人嫌日志吵但反过来想当你看到为什么这里多了一次构造、少了一次析构的时候往往就是追到深拷贝、返回值优化失效、临时对象滞留这类问题的突破口。构造和析构次数对不上几乎一定指向资源管理缺陷。第二默认情况下把拷贝操作禁掉需要时再加回来。不少新项目里的类都是从看起来不需要拷贝起步的结果某天有人在容器里把它传来传去浅拷贝问题立刻被引爆。与其事后修补不如在类定义时就用 delete禁掉拷贝构造和拷贝赋值等真正确定语义后再按需启用。移动操作则尽量保留并标noexcept这样标准容器在扩容、排序时都能获得最大的性能收益。构造函数和析构函数拿下了类和对象系列的核心部分也就拿下了大半。下一步就可以放心去冲运算符重载、继承和多态——有了这些基础后面那些内容学起来会快很多。
阅读完成 · 觉得有帮助?