ARTICLE DETAIL

资讯详情

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

C++构造函数与析构函数:生命周期、资源管理与避坑指南

C++构造函数与析构函数:生命周期、资源管理与避坑指南 咱们直接说结论C 里如果一个类不写构造函数编译器也会给你一个默认的但如果类里有指针成员、动态分配的资源还傻乎乎用默认析构那迟早要出事。构造函数和析构函数是 C 面向对象里最基础、也最容易被低估的两个特殊成员函数。很多初学者会背“构造函数用来初始化析构函数用来清理”但真到写代码的时候要么忘了处理拷贝要么析构里抛异常要么多态删除对象时直接未定义行为。这篇东西我打算从实际项目角度把这些讲透目标是让你不仅能看懂还能直接照着写。不整太多理论玄学重点放在为什么需要这些函数、什么时候会触发、写好它们需要注意什么以及踩过哪些坑。对刚入门 C 的同学、准备面试的或者写了两三年还在靠“试错”来管理内存的人都应该有点用。1. 构造函数和析构函数从对象生命周期说起1.1 生命周期与“初始化/清理”的对称性每个对象都有生命周期从内存里分配出来到使用再到销毁。C 用构造函数和析构函数把生命周期的两端包住构造函数在对象创建时自动调用负责把对象从“一堆随机字节”变成“一个可用的对象”析构函数在对象销毁时自动调用负责把资源交还系统把对象体面地送走。这两个动作天然对称一进一出一开一关一借一还。我经常用文件句柄来举例子。你写一个日志类打开文件写日志如果忘了关闭文件进程一直跑文件描述符就会耗尽程序后面什么都打不开。有了析构函数这些琐碎操作就不用你手动调用一个close()去保证每次都能执行到只要对象出了作用域析构函数就会被调用这种“编译器帮你在固定时机做清理”的设计就是资源管理的基本盘。很多人把构造函数理解成“初始化函数”这个说法不严谨。构造函数不是普通函数它没有返回类型名字必须和类名相同而且它主要的目标是完成类的不变量建立。比如说一个vector对象构造完成后size()必须返回 0capacity()也必须是一个合法值。构造函数结束后对象应该处于一个“可被正常使用”的状态析构函数执行完毕后对象的内存会被回收所以析构里绝对不能访问已经释放的资源。1.2 构造/析构在 C 中的调用时机搞清楚触发时机比背语法重要得多。我简单列一下常见的场景栈对象声明一个局部对象执行到该行时构造离开作用域时析构。堆对象用new创建时构造用delete删除时析构。数组对象new[]会依次构造每个元素delete[]会依次析构每个元素。临时对象表达式求值过程中产生的临时对象在完整表达式结束时析构。拷贝/赋值拷贝构造和拷贝赋值也是构造/析构体系的一部分后面专门讲。继承体系构造时先调基类构造再调派生类构造析构时先析构派生类再析构基类顺序恰好相反。这些顺序不是“规定出来”的而是由资源的所有权关系决定的。基类成员是派生类对象的一部分派生类对象是使用者所以必须先有基类部分派生类才能使用析构时候反过来先清理派生类自己的资源再拆基类部分不然基类析构把派生类依赖的资源提前释放了就乱套了。我见过一些人把对象生命周期管理完全寄托在“记得写delete”上这种思路在 C 里面早就过时了。真正可靠的做法是让析构函数成为资源释放的唯一出口配智能指针和 RAIIResource Acquisition Is Initialization资源在构造函数里拿在析构函数里放这样怎么调用、调没调用都不再是你的负担。这个思想是整个 C 资源管理的灵魂后面每一节其实都在围绕它展开。2. 构造函数默认、重载与初始化列表2.1 默认构造函数和“空构造”的区别默认构造函数指的是不需要传参就能调用的构造函数。你写一个类只要没声明任何构造函数编译器会帮你生成一个默认构造。但这里有个经典误区一旦你声明了任何带参构造函数编译器就不会再隐式生成默认构造。举个例子class Foo { public: Foo(int x) {} }; Foo f; // 编译错误不存在默认构造函数很多人第一次碰到这个编译错误时会懵明明没写任何构造函数啊为什么不给我默认的因为你的Foo(int)已经“宣告”了这个类构造时必须带参数编译器认为你的初始化意图就是“没有参数不应该能构造”所以它拒绝帮你补一个默认构造。如果你确实需要一个无参版本就得自己写Foo() default;。还有一种情况是成员变量没有默认构造。比如类里有一个成员是std::mutex或者某个没有默认构造的自定义类型那你的类默认构造也得想办法把成员初始化好否则编译器生成的默认构造没法调用成员的默认构造直接报错。平时写代码时注意只要类里有引用成员、const成员或者没有默认构造的成员默认构造就不可能是“空转”的你必须用初始化列表把它们安排好。2.2 初始化列表不是风格问题是正确性问题我看很多新手写构造函数喜欢在函数体内直接赋值class Point { public: Point(int x, int y) { this-x x; this-y y; } private: int x, y; };这段代码能跑但对内置类型来说它其实做了两步先让x、y默认初始化对int来说可能啥都没做然后再赋一次值。问题在于如果成员是const、引用或者没有默认构造的类类型这种写法直接编译不过class Wrapper { public: Wrapper(int v) : ref(v), c(v) {} // 正确 // Wrapper(int v) { ref v; c v; } // 错误引用和const不能赋值 private: int ref; const int c; };为什么必须用初始化列表因为引用和const变量一旦创建就不能再绑定或修改它们只能在“初始化”的阶段赋值这个阶段对应的就是初始化列表。对于类成员也一样成员对象的构造函数参数必须在初始化列表里传进去如果先默认构造再赋值等于白白多构造一次浪费性能不说有些类根本不允许默认构造。所以我建议能放初始化列表的成员全部放初始化列表这不仅是效率问题更是正确性要求。还有一个容易被忽略的点初始化列表的求值顺序不是按你写的顺序而是按成员在类中声明的顺序。类里先声明a再声明b那无论初始化列表里怎么写都是先初始化a。如果你用a(b)这样的表达式而b还没初始化那a拿到的就是未初始化的垃圾值。编译器通常还会给警告但很多人不看警告这个坑我踩过一次排查了半天才明白是顺序问题。2.3 重载构造函数与默认参数的坑构造函数可以重载这一点和普通函数一样。你可以提供多个版本让调用者按需选择。但要注意重载和默认参数混用经常引发二义性class Foo { public: Foo(int a) {} Foo(int a, int b 0) {} }; Foo f(1); // 编译错误两个构造函数都能匹配这类错误在构造函数里特别常见因为调用者觉得传一个参数理所当然编译器却不知道该选哪一个。我的建议是构造函数要么用默认参数提供“便捷版本”要么用重载明确区分不要两个都上。一般来说参数语义差距大就用重载只是个别参数可省就用默认参数。另外构造函数的重载要格外小心“全能构造”。比如有个类既有Foo(std::string)又有Foo(const char*)调用Foo(hello)时两个都能匹配最后还是编译错误。字符串字面量在函数重载里的匹配规则本身就容易出问题平时写库的时候std::string_view、const char*、std::string这几个别随便堆在一起重载优先级判断会把人绕晕。2.4 转换构造和 explicit单个参数的构造函数还有一个特殊身份它定义了一个从参数类型到本类类型的隐式转换。比如class String { public: String(const char* s) {} }; void func(String s) {} func(hello); // 编译器自动构造一个临时 String这在某些场合很方便但很危险。如果你不希望这种隐式转换发生就给构造函数加上explicit。explicit的意思很直白这个构造函数只能用来“直接初始化”不能做隐式类型转换。C 社区目前的共识是单参数构造函数默认都应该加explicit除非你的类确实要支持隐式转换比如自定义一个数值包装类想让它像内置类型一样参与运算。不加explicit的典型翻车场景是容器类。如果你写了一个Array(int size)构造函数没加explicit那么一个不注意就会把int隐式转成Array。函数签名写的是void process(Array a)你传了一个数字进去编译器不报错代码能跑但逻辑全变了。这种 bug 很难查因为出错的不是语法是语义。2.5 委托构造函数简化代码C11 之后构造函数可以委托另一个构造函数来执行初始化避免重复代码class Foo { public: Foo() : Foo(0, ) {} Foo(int id, const std::string name) : id_(id), name_(name) {} private: int id_; std::string name_; };委托构造会把初始化的职责集中到一个主构造函数里其他构造函数做参数适配。这个特性看起来简单但它能减少“构造函数职责分裂”带来的维护问题。我见过一个类有六个构造函数每个都手工初始化五六行成员加上新成员后漏改了两个结果构造出来的对象部分成员是垃圾值。用委托构造之后只有主构造函数真正写初始化其他版本只需要关心怎么把参数转成主构造函数需要的形式。需要注意委托构造函数和目标构造函数不能再互相委托形成环编译器会拒绝这种写法。另外目标构造函数执行完之后委托构造函数函数体内不能再使用初始化列表这个限制本身也是为了保证初始化顺序清晰。3. 拷贝构造、移动构造与编译器生成的函数3.1 深浅拷贝最常见的经典事故C 的拷贝构造函数写法是T(const T other)它负责用另一个对象来构造当前对象。如果你不写编译器会生成一个逐成员拷贝的版本。对int、double这些内置类型逐成员拷贝没问题可一旦成员里有裸指针默认拷贝只是把指针的值地址复制了一份两个对象指向同一块内存。这就是俗称的浅拷贝。浅拷贝最典型的事故是 double free两个对象析构时都去delete ptr第一个释放了第二个再释放同一块内存行为未定义程序可能崩也可能不崩但堆已经被破坏了。解决方式就是写深拷贝构造函数重新分配内存复制数据class Buffer { public: Buffer(const Buffer other) : size(other.size) { ptr new int[size]; std::copy(other.ptr, other.ptr size, ptr); } private: int* ptr; size_t size; };很多人问为什么不直接让编译器默认浅拷贝就完事因为浅拷贝只适合那些不拥有资源的类。如果一个类里只有普通的int、double、std::string编译器默认生成的拷贝完全够用。真正要自己写拷贝构造的一定是那些手动管理了资源裸指针、文件句柄、网络连接的类。这就是为什么现代 C 强调“资源管理类”不要裸持有资源能交给std::unique_ptr、std::shared_ptr就交给它们这样编译器的默认拷贝规则才不会出错。3.2 移动语义为什么是性能关键C11 引进了移动构造和移动赋值。移动的意思是把一个对象的资源“偷”过来而不是复制一份。对包含动态内存的类一次深拷贝可能是 O(n)而一次移动往往是 O(1)就是把指针指向和大小赋值一下再把源对象置空。class Buffer { public: Buffer(Buffer other) noexcept : ptr(other.ptr), size(other.size) { other.ptr nullptr; other.size 0; } private: int* ptr; size_t size; };这里有个细节移动构造函数必须声明为noexcept。原因不是语法强制而是标准库容器的实现逻辑。比如std::vector在扩容时如果元素是noexcept移动构造就会直接用移动如果没有noexcept为了保证异常安全它会退化成拷贝构造因为你不能假设一个可能抛异常的移动会把源对象保持成什么状态。所以你的移动构造函数不标noexcept性能收益很可能直接消失。移动语义真正发挥作用的地方是函数按值返回、局部变量移入容器、临时对象直接构造等场景。如果你写了一个管理资源的类却只实现了拷贝构造和析构没实现移动构造代码照样能编译但运行时会多做很多不必要的拷贝。哪怕是新手项目只要有性能要求尽早补上移动构造和移动赋值是值得的。3.3 Rule of Five 和 Rule of Zero 怎么记C 里跟对象复制控制相关的特殊成员函数总共有六个默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。其中后四个直接和“复制/移动”绑定。经验的归纳很简单如果你要实现其中一个通常意味着资源所有权需要自定义那么其他几个也大概率需要。这就是 Rule of Three拷贝构造、拷贝赋值、析构和 Rule of Five再加移动构造、移动赋值的由来。Rule of Zero 则是现代 C 推崇的方向尽量不要自己写这五个函数让每个资源都由std::string、std::vector、std::unique_ptr这些专门管理资源的组件来持有编译器生成的默认版本就够了。我实际开发时的判断标准是类里如果没有裸指针、没有需要手动释放的句柄就一个字都不写全交给编译器如果有优先用智能指针替换替换不了再自己实现全套复制控制函数。写的时候还有一个容易忽略的函数拷贝赋值operator。它的实现要考虑自赋值、异常安全和返回引用。一个常见的写法叫 copy-and-swapBuffer operator(Buffer other) { swap(*this, other); return *this; }这个写法巧妙在参数是按值传入的调用时要么走拷贝构造要么走移动构造然后直接交换内部状态。它能同时解决自赋值和异常安全问题而且代码很简洁。不过它在某些高性能场景下有额外的一次交换开销要不要用具体情况具体看但作为正确性兜底是非常值得的。4. 析构函数常见陷阱和正确姿势4.1 析构函数与多态虚析构不得不写先说结论一个类如果打算作为基类被继承析构函数必须是virtual否则通过基类指针delete派生类对象行为未定义。原因是这样的delete一个对象时编译器需要知道该调用哪个析构函数。如果基类析构不是虚函数编译器就拿着静态类型基类指针去调用基类的析构派生类里自己释放的部分完全不执行。局部资源泄漏还算轻的如果析构逻辑里有关闭连接、写日志、解锁互斥量之类的操作全部漏掉程序状态直接坏掉。我见过一个老模块的内存泄漏查了一圈根因就是基类析构没写virtual派生类里delete了一堆堆内存。反过来如果一个类不被继承使用析构函数也不要随便加virtual。虚函数意味着对象里要存一个虚表指针对象体积变大访问成员稍微变慢。C 的哲学是“不用不付钱”没必要为潜在的多态付出额外代价。判断标准很简单这个类有没有被public继承的打算有就写virtual ~T() default;没有就不写。纯虚析构函数也是实际会遇到的写法。接口类可以把析构函数写成纯虚的用来强制“不能直接实例化”。注意纯虚析构也是析构它不能只有声明必须在类外给一个定义Base::~Base() {}因为派生类析构时最终还是会调用基类的析构没有定义就链接失败。这种技巧常用于设计抽象接口类。4.2 析构顺序反向析构是怎么发生的对象析构顺序是构造顺序的逆序这个规则适用于成员变量也适用于继承体系。成员变量的析构顺序是声明的逆序类里先声明file_再声明buffer_构造时先file_后buffer_析构时先buffer_后file_。这个逆序的设计意图是一个对象依赖的资源应该最后构造、最先释放。如果析构顺序和构造顺序一样先构造的资源在最前面反而可能在使用它的成员被销毁以后才轮到它释放那就容易访问到已经失效的内存。继承体系中派生类对象构造时先调用基类构造函数再构造派生类自己的成员最后执行派生类构造函数体析构时反过来先执行派生类析构函数体再析构派生类成员最后调用基类析构函数。所以在写析构函数时千万不要假设派生类部分还存在。比如基类析构里调用一个虚函数它调用的永远是这个对象“当前阶段”的版本也就是基类版本不是派生类版本。这个细节在面试里经常被问到很多答案说是“不要调用虚函数”更准确的说法是基类析构期间对象已经被降级成基类对象虚函数动态分发不会再走到派生类。4.3 析构函数里别抛异常在析构函数里抛异常是件非常危险的事。析构函数默认是noexcept一旦里面抛了异常程序会直接调用std::terminate也就是进程终止。这个行为在现代 C 标准里几乎是定死的。如果你确实需要在析构里执行可能失败的操作比如刷新文件、关闭连接最稳妥的做法是捕获所有异常并且记录错误而不是让它往外冒。C11 之后析构函数默认noexcept的设定让很多人吐槽“那我析构里就是想抛怎么办”答案是没法优雅地抛也不应该抛。析构函数的职责是清理资源清理要求的是可靠和简单。如果清理操作本身都可能失败那说明设计有问题资源应该在析构之外显式提交或者关闭析构只做兜底。比如一个网络请求类应该有一个send()方法返回错误码或者抛异常而不是等到析构时才去处理发送失败。还有一个讲究析构函数里调用其他可能抛异常的函数时也要包一层try/catch。很多第三方库的函数会在异常路径上有出其不意的行为如果这些函数在析构里被调用异常一旦逃逸就是 terminate。我自己的习惯是析构函数里只做裸资源释放什么日志、通知、回调一概尽量少放甚至可以放到一个单独的close()方法里让析构只负责兜底调用close()并吞掉所有可能异常。4.4 何时需要显式调用析构正常情况下析构函数不需要你手动调用编译器会在对象生命周期结束时自己做。但有两种情况例外一种是 placement new 构造出来的对象必须显式调用析构来结束它的生命周期另一种是你在处理非常底层的内存池对象在已分配但未构造的内存上构造释放前必须手动析构。void* mem ::operator new(sizeof(Foo)); Foo* foo new (mem) Foo(); // ... foo-~Foo(); ::operator delete(mem);这个写法在日常业务代码里很少出现但在内存池、对象池、嵌入式系统、游戏引擎里非常常见。它的原理是placement new不会分配内存它只在给定地址上调用构造函数所以对应的“释放”就分成两步先显式调用析构函数再释放内存。这里要特别小心如果对象是用new普通分配的再用显式析构和手动释放内存那是未定义行为不要随便混用。显式调用析构之后这块内存还能再重新 placement new 构造新对象这就是对象池复用的基本套路。设计对象池时要保证析构和构造严格配对否则对象池里的对象状态是乱掉的。在我维护过的游戏服务端项目里对象池的构造析构配对是每次 code review 的重点漏一处就是极难排查的内存错乱。5. 实际项目里的常见坑与排查心得5.1 new 和 delete 的配对是纪律问题C 语言时代malloc和free配不配对靠自觉。C 这边new和delete、new[]和delete[]也必须严格配对。new[]分配数组时会额外存储数组长度delete[]才知道要析构多少个元素如果你用delete去释放new[]出来的东西将是未定义行为最常见的现象是只析构了第一个元素剩下的资源全泄漏。我在项目里见过一个离谱的 bug某人用new int[size]创建数组用完了写delete ptr程序一直运行看起来没啥问题直到某个版本把所有元素都改成带资源的类对象内存泄漏立刻飙升。原因就是在内置类型上delete错对象没有立即崩溃掩盖了问题。这个经验告诉我们不要用内置类型去“试”错误行为未定义行为可能暂时无害但它始终是未定义。现在的做法应该是数组优先用std::vector单个对象优先用智能指针。只有当自己写底层容器、内存池时才碰裸new而且那时要格外谨慎每一处new都要能明确说出对应的delete在哪里、什么条件下执行。代码审查时如果看到裸new没有在构造函数里被封装住我一律要求改成智能指针或容器。5.2 悬空指针、重复释放和内存泄漏的定位思路悬空指针和重复释放是析构函数相关 bug 里最让人头疼的两类。悬空指针是指一个指针指向的内存已经被释放再访问就是访问无效内存可能读垃圾数据也可能段错误。重复释放则是两次delete同一块内存堆管理器的元数据被破坏崩溃现场通常是随机的很难复现。排查这类问题我的经验是优先用工具不要靠眼睛盯代码。Linux 下用 AddressSanitizerASan编译时加上-fsanitizeaddress -g -O0运行时一旦出现非法访问、double free、越界读写它立刻会报告精确的行号。Windows 下 Visual Studio 自带 CRT 检测也可以上 Application Verifier、Dr. Memory。C 的这类内存错误人工排查的成本非常高工具能直接帮你把范围缩到几行代码。如果手头没有工具可以临时在所有delete之后把指针置空。这一步不是标准要求的但能大大减少重复释放的概率因为对空指针delete是安全的。置空并不能解决所有问题比如两个指针指向同一块内存一个释放后置空另一个还是悬空但多做一步总比不做强。真正规范的做法是从设计上消除“多个所有者”的情况用std::unique_ptr明确所有权需要共享时用std::shared_ptr别让自己脑补所有权。5.3 用智能指针替掉手写裸指针资源管理我前面反复提到智能指针这里展开说。std::unique_ptr是独占所有权禁止拷贝只允许移动std::shared_ptr是共享所有权内部用引用计数管理生命周期最后一个持有者析构时释放资源。大部分业务类里“手写析构释放成员指针”的代码用std::unique_ptr替换后析构函数本身就不需要写了。比如class ConfigLoader { public: ConfigLoader(const std::string path) : file_(std::make_uniqueFile(path)) {} private: std::unique_ptrFile file_; };这个类没有自定义构造函数体的必要没有自定义析构的必要编译器生成的析构会自动调用unique_ptr的析构进而释放File。这就是 Rule of Zero 的标准实践。你可能会问那构造函数里拿资源失败怎么办unique_ptr的初始化在构造函数体之前完成如果抛异常成员会被自动销毁不会泄漏。手写裸指针时要考虑一堆异常路径下内存释放的细节换智能指针后编译器替你兜底。智能指针也不是万能药。shared_ptr的引用计数本身需要线程安全多线程环境下拷贝、析构shared_ptr是原子的但读对象里的数据仍然需要同步。另外shared_ptr有个经典的循环引用问题两个对象互相持有对方的shared_ptr引用计数永远到不了零对象永远不会析构。这个时候就需要weak_ptr打破环或者在设计上避免双向共享所有权。我在实际中更偏好让“所有者和被所有者”的关系保持单向尽量少用shared_ptr做长期持有的成员。5.4 一个小实验观察构造析构顺序一个帮助理解构造析构的经典实验就是写一个带输出的类然后在不同场景下创建、销毁它。观察输出的顺序能比读十篇文章都直观。#include iostream struct Obj { Obj() { std::cout 构造 this std::endl; } ~Obj() { std::cout 析构 this std::endl; } }; int main() { Obj a; // 栈对象 Obj* b new Obj(); // 堆对象 delete b; // 手动析构 return 0; }运行后会看到栈对象最后析构堆对象在delete时析构。再把Obj放进std::vector里观察扩容时元素的构造析构行为你会更深刻理解“拷贝/移动/析构”是怎么配合的。这个实验我建议每个学 C 的人都亲手跑一遍尤其是观察临时对象和容器操作触发的构造析构很多性能问题和崩溃都能在这个层面找到初因。6. 面试高频题与自查清单6.1 这几道题几乎每次都会被问到构造函数和析构函数是 C 面试的必考点我整理了出现频率很高的几个问题附上回答思路。第一个为什么基类析构函数要声明为虚函数。回答要点是删除派生类对象时只有虚析构才能保证动态绑定到派生类的析构函数否则派生类部分资源不会被释放属于未定义行为。第二个构造函数能不能是虚函数。不能。虚函数调用依赖虚表而虚表指针是在构造函数里初始化成员之后才设置的在构造函数执行期间虚表还没完全准备好所以构造函数不能被声明为虚函数。析构函数可以而且通常建议是虚的。第三个析构函数能不能抛出异常。不能。析构默认是noexcept抛出异常会调用std::terminate而且析构期间抛异常会导致后续对象的析构无法继续资源管理全乱。第四个C 的拷贝构造函数参数为什么必须是引用。因为如果是按值传参调用拷贝构造本身又需要一次拷贝形成无限递归编译不过。按const引用是最常见的方式它既能读取源对象又不会改动它。第五个为什么拷贝赋值运算符要考虑自赋值。因为如果不加判断先delete自己的资源再尝试从“源对象”拷贝而此时源对象就是自己数据已经被销毁了程序崩溃或数据损坏。虽然 copy-and-swap 写法天然兼顾了这个问题但理解自赋值风险本身也是面试官想听的。6.2 自查清单写一个新类前问自己三个问题我每次设计一个新类都会习惯性地过一遍清单能很大程度上避免构造析构相关的事故。第一个问题是这个类有没有资源需要释放如果没有所有特殊成员函数都别写用编译器默认如果有先想想能不能用智能指针、容器替代能替代就别手写。第二个问题是如果我自己写了析构函数拷贝构造和拷贝赋值写了没有写了析构代表这个类有特殊资源管理需求拷贝行为很可能也需要自定义。不写拷贝编译器默认的浅拷贝会把两个对象指向同一块资源后续 double free 概率极高。第三个问题是如果需要被继承析构是不是虚函数这个检查只需要一秒钟但能避免未来调用方通过基类指针删除对象时的灾难。总体来看构造函数和析构函数的本质就是让对象的生命周期从一开始到结束都有明确归属、完备照顾。你把它们当成“和内存、文件、锁、连接打交道时的守门员”很多用法和限制自然就顺理成章了。
返回列表