C++资源管理核心:构造、析构、拷贝构造与赋值运算符重载详解

发布时间:2026/10/24 7:15:33
C++资源管理核心:构造、析构、拷贝构造与赋值运算符重载详解 1. 从一次内存泄漏排查说起为什么这四个函数总是一起出现刚入行那会儿我写了一个管理动态数组的类功能测试全过跑了一晚上压力测试内存曲线像爬楼梯一样往上走。用工具一查泄漏点全在对象的复制和销毁环节。那次经历让我彻底明白一件事构造、析构、拷贝构造、赋值运算符重载这四个函数不是四个独立的知识点而是一套完整的资源生命周期管理契约。你只要有一个没写对整套机制就会漏风。很多教材把它们拆成四个章节讲导致初学者以为这是四个不相干的东西。实际上它们共同回答一个问题一个对象从出生到死亡它的资源内存、文件句柄、网络连接、锁该由谁负责、在什么时机、以什么方式处理。构造函数负责出生时怎么拿资源析构函数负责死亡时怎么还资源拷贝构造函数负责复制一个对象时资源怎么跟着复制赋值运算符重载负责把一个已有对象覆盖成另一个对象的样子时旧资源怎么处理、新资源怎么来。这套机制在C里叫Rule of Three三法则后来演进成Rule of Five五法则加上移动构造和移动赋值。但不管规则怎么变核心逻辑没变只要你手动管理了资源这四个或六个函数就必须作为一个整体来考虑。只写构造和析构不写拷贝构造和赋值编译器生成的默认版本会做浅拷贝两个对象指向同一块内存析构时double free程序直接崩溃。只写拷贝构造不写赋值赋值时旧资源泄漏。只写赋值不写拷贝构造用另一个对象初始化新对象时又是浅拷贝。这篇文章我会从实际工程角度出发把这四个函数的触发时机、默认行为、手写要点、常见陷阱、现代C的替代方案全部串起来讲清楚。不管你是刚学C的学生还是工作几年但一直靠编译器默认版本混日子的开发者看完之后应该能建立起一套完整的资源管理直觉。文章里所有代码都是可编译运行的完整示例你可以直接抄到本地验证。提示本文假设你至少写过C类知道指针和引用的基本概念。如果你对栈对象堆对象还没概念建议先补一下基础再回来。2. 四个函数的触发时机编译器到底在什么时候调用谁要理解这四个函数第一步不是背语法而是搞清楚编译器在什么场景下会调用哪一个。很多人写代码时凭感觉结果调试半天才发现调用路径和自己想的不一样。我整理了一张触发时机对照表这张表我建议你贴在显示器旁边写代码时随时对照。代码写法触发函数备注MyClass a;默认构造函数栈对象无参数MyClass a(10);带参构造函数栈对象有参数MyClass* p new MyClass();默认构造函数堆对象MyClass b a;拷贝构造函数用已有对象初始化新对象MyClass b(a);拷贝构造函数同上函数式写法void f(MyClass x)传参拷贝构造函数值传递产生临时副本return a;返回对象拷贝构造或移动构造取决于是否有移动语义b a;赋值运算符重载b已存在用a覆盖bb a c;赋值运算符重载两次链式赋值注意返回引用作用域结束}析构函数栈对象自动销毁delete p;析构函数堆对象手动销毁临时对象表达式结束析构函数如f(MyClass())中的临时对象这张表里最容易混淆的是拷贝构造和赋值的区分。判断标准很简单看左边对象是不是已经存在。如果左边对象是正在被创建的新对象那就是拷贝构造如果左边对象早就存在了现在要用右边的值覆盖它那就是赋值。用生活类比拷贝构造是照着别人的身份证新办一张赋值是把旧身份证上的信息擦掉重新写上别人的信息。还有一个高频误区MyClass b a;看起来像赋值实际是拷贝构造。因为b是第一次出现正在被创建。这个坑我见过太多人踩包括我自己早期。编译器在这里不会调用赋值运算符因为赋值运算符要求左边对象已经构造完成。你可以写个测试代码验证#include iostream class Tracker { public: Tracker() { std::cout 默认构造\n; } Tracker(const Tracker) { std::cout 拷贝构造\n; } Tracker operator(const Tracker) { std::cout 赋值运算符\n; return *this; } ~Tracker() { std::cout 析构\n; } }; int main() { Tracker a; // 默认构造 Tracker b a; // 拷贝构造不是赋值 Tracker c; // 默认构造 c a; // 赋值运算符 return 0; }运行结果会明确告诉你Tracker b a;走的是拷贝构造。这个细节在写资源管理类时至关重要因为如果你只在赋值运算符里释放旧资源拷贝构造里没做正确处理用初始化语法创建对象时就会出问题。另外析构函数的调用时机也值得单独说。栈对象在离开作用域时自动调用析构调用顺序和构造顺序相反后构造的先析构。堆对象必须手动delete才调用析构不delete就不调用这就是内存泄漏的根源。临时对象在完整表达式结束时析构比如f(MyClass())中MyClass的临时对象在f返回后立即析构。理解这些时机你才能在调试时准确预测每一步发生了什么。3. 默认版本干了什么浅拷贝的甜蜜陷阱编译器很聪明当你没写这四个函数时它会自动生成默认版本。但默认不等于正确它只做最机械的事情。理解默认行为是判断我到底要不要手写的前提。默认构造函数如果你没声明任何构造函数编译器生成一个无参的默认构造函数它什么都不做成员变量保持未初始化状态对于内置类型或调用成员自己的默认构造对于类类型成员。注意只要你写了任何一个构造函数哪怕是有参的编译器就不再生成默认构造函数。这就是为什么很多类写了带参构造后MyClass a;会编译报错。默认析构函数编译器生成的析构函数会依次调用每个成员变量的析构函数但不会delete任何指针成员指向的堆内存。这是浅拷贝问题的另一半指针成员只是被销毁了指针本身指向的内存没人管。默认拷贝构造函数执行逐成员拷贝member-wise copy。对于内置类型成员int、double、指针直接复制值对于类类型成员调用它们各自的拷贝构造。问题出在指针成员上默认拷贝构造复制的是指针的值地址而不是指针指向的内容。结果就是两个对象的指针成员指向同一块堆内存。默认赋值运算符同样执行逐成员赋值对指针成员也是复制地址。而且默认赋值运算符不会先释放左边对象原有的资源直接覆盖指针值导致左边对象原来指向的内存泄漏。我用一个完整的例子演示浅拷贝的灾难#include iostream #include cstring class String { public: String(const char* str ) { m_data new char[strlen(str) 1]; strcpy(m_data, str); } // 没写析构、拷贝构造、赋值运算符 void print() const { std::cout m_data \n; } private: char* m_data; }; int main() { String s1(hello); String s2 s1; // 默认拷贝构造s2.m_data 和 s1.m_data 指向同一块内存 s1.print(); s2.print(); return 0; // 析构时两次delete同一块内存程序崩溃 }这段代码在main返回时会崩溃因为s1和s2的m_data指向同一块堆内存析构时第一次delete释放了内存第二次delete访问已释放内存行为未定义。更隐蔽的情况是程序不崩溃但数据被意外修改修改s2的内容s1也跟着变因为它们是同一块内存。注意浅拷贝问题不是偶尔出错而是必然出错。只要类里有指针成员且指向堆内存不手写拷贝构造和赋值就一定会出问题。区别只是崩溃得早还是晚、明显还是隐蔽。那什么时候可以依赖默认版本答案是当类里没有管理任何资源时。比如一个只包含int、double、std::string、std::vector等成员的类这些成员自己管理自己的资源默认的逐成员拷贝就是正确的。std::string和std::vector都正确实现了拷贝构造和赋值所以包含它们的类不需要手写。这就是Rule of Zero能用标准库容器就别自己管裸指针让编译器默认版本干活。判断标准可以总结成一句话如果你的类需要手写析构函数来释放资源那它几乎一定也需要手写拷贝构造和赋值运算符。反过来如果析构函数什么都不用做那默认版本大概率够用。这个判断法则我用了很多年几乎没出过错。4. 手写这四个函数的完整实战一个动态数组类的演进光看理论不够我带你从零写一个动态数组类一步步把四个函数补齐每一步都解释为什么这么写。这个类比std::vector简单但包含了所有核心要点。4.1 第一版只有构造和析构暴露浅拷贝问题class IntArray { public: IntArray(int size) : m_size(size), m_data(new int[size]) {} ~IntArray() { delete[] m_data; } int operator[](int i) { return m_data[i]; } int size() const { return m_size; } private: int* m_data; int m_size; };这个版本单独使用没问题但一旦发生拷贝就会double free。我们加上拷贝构造和赋值运算符。4.2 第二版补上拷贝构造实现深拷贝IntArray(const IntArray other) : m_size(other.m_size), m_data(new int[other.m_size]) { for (int i 0; i m_size; i) { m_data[i] other.m_data[i]; } }拷贝构造的关键点先分配自己的内存再逐个复制元素。初始化列表里完成m_size和m_data的初始化函数体里做数据复制。这里有个细节初始化列表的顺序应该和成员声明顺序一致m_size在前m_data在后因为m_data的分配依赖m_size的值。虽然初始化列表的书写顺序不影响实际初始化顺序实际顺序由声明顺序决定但保持一致能避免误读。4.3 第三版补上赋值运算符处理自赋值和旧资源释放IntArray operator(const IntArray other) { if (this other) { // 自赋值检查 return *this; } delete[] m_data; // 释放旧资源 m_size other.m_size; m_data new int[m_size]; // 分配新资源 for (int i 0; i m_size; i) { m_data[i] other.m_data[i]; } return *this; }赋值运算符有四个必须注意的点自赋值检查a a时如果不检查先delete[] m_data就把自己的数据释放了后面复制的是已释放的内存。自赋值检查用this other判断地址是否相同。释放旧资源赋值是覆盖操作左边对象原来持有的资源必须先释放否则泄漏。分配新资源并复制和拷贝构造一样分配自己的内存再复制数据。返回引用返回*this的引用支持链式赋值a b c。返回类型是IntArray而不是IntArray避免不必要的拷贝。4.4 第四版用拷贝并交换惯用法简化赋值上面那个赋值运算符有个隐患如果new int[m_size]抛异常内存不足m_data已经被delete了对象处于无效状态。虽然实际项目中很少遇到但严谨的写法应该保证异常安全。经典方案是拷贝并交换copy-and-swapIntArray operator(IntArray other) { // 注意参数是值传递不是引用 swap(other); return *this; } void swap(IntArray other) noexcept { std::swap(m_data, other.m_data); std::swap(m_size, other.m_size); }这个写法的精妙之处参数other是值传递调用时自动调用拷贝构造生成一份副本。函数体里只做swap把当前对象的资源换给otherother在函数结束时析构自动释放旧资源。整个过程不需要手动delete也不需要自赋值检查因为other是独立副本swap自己也没问题而且如果拷贝构造抛异常当前对象完全没被修改强异常安全保证。代价是多了一次拷贝构造值传递参数但现代编译器有拷贝省略优化实际开销很小。我个人的选择是对性能不敏感的类用拷贝并交换对性能极度敏感的类用传统写法加自赋值检查。两种写法都要会看场景选。4.5 第五版加上移动构造和移动赋值Rule of FiveC11之后如果类管理资源还应该加上移动构造和移动赋值让临时对象可以窃取资源而不是复制IntArray(IntArray other) noexcept : m_size(other.m_size), m_data(other.m_data) { other.m_data nullptr; other.m_size 0; } IntArray operator(IntArray other) noexcept { if (this ! other) { delete[] m_data; m_size other.m_size; m_data other.m_data; other.m_data nullptr; other.m_size 0; } return *this; }移动构造和移动赋值的核心是转移所有权把other的指针拿过来把other的指针置空这样other析构时不会释放已经被转移的资源。标记noexcept很重要因为标准库容器在扩容时如果移动构造不是noexcept会退化成拷贝构造以保证异常安全。到这里一个完整的资源管理类就成型了。四个核心函数加上两个移动版本构成了现代C资源管理的标准骨架。5. 那些年我踩过的坑从自赋值到异常安全理论讲完了这一节我专门讲踩坑经验。这些坑有些是我自己踩的有些是review别人代码时发现的都是文档里不会重点强调但实际项目中一定会遇到的情况。5.1 自赋值不一定长得像a a很多人以为自赋值就是a a写了自赋值检查就万事大吉。但实际代码里自赋值可能以很隐蔽的形式出现IntArray arr(10); IntArray ref arr; arr ref; // 自赋值但看起来像两个不同对象 // 更隐蔽的 std::vectorIntArray vec; vec.push_back(vec[0]); // 如果vector扩容可能触发自赋值还有一种情况通过指针或引用间接自赋值。比如*p *q如果p和q指向同一对象就是自赋值。所以自赋值检查不能省而且要用this other判断地址不能靠比较内容。5.2 赋值运算符里delete和new的顺序有讲究我见过有人这样写赋值IntArray operator(const IntArray other) { delete[] m_data; // 先释放 m_data new int[other.m_size]; // 再分配 // ... }如果new抛异常比如内存不足m_data已经被delete了但新内存没分配成功对象处于指针悬空状态。后续任何操作都是未定义行为。正确做法要么用拷贝并交换要么先分配新内存再释放旧内存int* newData new int[other.m_size]; // 先分配可能抛异常 delete[] m_data; // 分配成功后再释放 m_data newData;这个顺序调整很小但异常安全性天差地别。我现在的习惯是只要函数里有可能抛异常的操作就先做可能失败的事再做不可逆的事。5.3 拷贝构造里成员初始化顺序的坑拷贝构造的初始化列表顺序应该和成员声明顺序一致。看这个例子class Buffer { int* m_data; int m_size; public: Buffer(const Buffer other) : m_size(other.m_size), // 初始化列表写的是m_size在前 m_data(new int[m_size]) { // 但实际m_data先初始化 // ... } };因为成员声明顺序是m_data在前、m_size在后实际初始化时m_data先初始化此时m_size还是未初始化的垃圾值new int[m_size]就用了垃圾值。编译器通常会警告但很多人忽略警告。解决办法要么调整声明顺序让依赖关系正确要么在初始化列表里用other.m_size而不是m_sizeBuffer(const Buffer other) : m_data(new int[other.m_size]), // 直接用other的size m_size(other.m_size) { }5.4 析构函数里不该抛异常析构函数默认是noexcept的如果析构函数里抛异常且没被捕获程序直接terminate。更麻烦的是如果析构是在栈展开异常传播过程中被调用的此时再抛异常会导致程序崩溃。所以析构函数里如果要调用可能抛异常的操作比如关闭文件、释放网络连接必须用try-catch包起来吞掉异常或记录日志。~ResourceHolder() { try { cleanup(); // 可能抛异常的操作 } catch (...) { // 记录日志但不重新抛出 } }5.5 基类析构函数必须是virtual这个坑和四个函数本身不直接相关但只要是继承体系就绕不开。如果基类指针指向派生类对象delete基类指针时如果基类析构不是virtual只会调用基类析构派生类的资源泄漏。规则很简单只要类可能被继承析构函数就声明为virtual。如果一个类不想被继承可以用final关键字标记这样析构函数不需要virtual还能省一个虚表指针。6. 现代C的替代方案Rule of Zero为什么更香讲了这么多手写四个函数的细节但实际项目中我越来越倾向于不手写。C11之后标准库提供了足够的工具让你可以完全避免手动管理资源。这就是Rule of Zero如果你的类不需要手动管理资源就不要写析构、拷贝构造、赋值运算符让编译器默认版本干活。6.1 用智能指针替代裸指针std::unique_ptr和std::shared_ptr自动管理堆内存拷贝和移动语义都正确实现。用它们作为成员你的类自动获得正确的拷贝和移动行为class IntArray { std::unique_ptrint[] m_data; int m_size; public: IntArray(int size) : m_data(new int[size]), m_size(size) {} // 不需要析构、拷贝构造、赋值运算符 // unique_ptr不可拷贝所以IntArray也不可拷贝只能移动 };如果确实需要拷贝语义用std::shared_ptr或者自己写拷贝构造。但大多数场景下移动语义就够了。6.2 用标准容器替代手写数组std::vector、std::string、std::map这些容器已经正确实现了所有资源管理函数。你的类只需要包含它们作为成员默认版本就正确class DataHolder { std::vectorint m_data; std::string m_name; public: // 什么都不用写默认版本全部正确 };这个类可以拷贝、可以赋值、可以移动全部由编译器生成的默认版本完成而且性能不差。我现在的项目里90%以上的类都是这种零手写风格。6.3 什么时候还必须手写Rule of Zero虽好但有些场景绕不开手写管理非内存资源文件句柄、socket、锁、数据库连接等标准库没有现成的RAII包装虽然可以用unique_ptr加自定义删除器但有时不够灵活。性能极度敏感需要精细控制内存分配策略比如对象池、内存映射文件。与C API交互需要把资源句柄封装成C类提供RAII语义。实现底层容器比如你自己写一个vector那四个函数必须手写。判断标准如果你需要写析构函数那四个函数就都要考虑。如果析构函数是空的或者默认的那大概率可以Rule of Zero。6.4 移动语义带来的性能红利现代C里移动构造和移动赋值让临时对象的传递几乎零开销。比如函数返回一个大对象编译器会优先调用移动构造而不是拷贝构造。标准库容器扩容时如果元素的移动构造是noexcept会移动而不是拷贝性能提升明显。我做过一个简单测试一个包含100万个int的vector拷贝构造耗时约4毫秒移动构造耗时约0.001毫秒差了三个数量级。所以在设计资源管理类时只要可能就提供移动构造和移动赋值并标记noexcept。7. 一套可复用的检查清单与调试技巧最后这部分我把自己多年积累的检查清单和调试技巧分享出来。每次写完一个资源管理类我都会对照这个清单过一遍基本能拦住绝大多数问题。7.1 四函数检查清单检查项判断标准处理方式类是否管理资源有裸指针、文件句柄、socket等是则必须手写或Rule of Zero析构函数是否释放资源每个new都有对应delete用智能指针替代拷贝构造是否深拷贝指针成员是否重新分配内存逐个成员检查赋值是否处理自赋值this other检查或用拷贝并交换赋值是否释放旧资源delete旧指针再分配新内存注意异常安全顺序是否提供移动语义有移动构造和移动赋值标记noexcept基类析构是否virtual类是否可能被继承是则加virtual或final析构是否可能抛异常析构里有抛异常操作try-catch吞掉7.2 用打印语句追踪函数调用调试资源管理问题时最有效的方法是在四个函数里加打印语句观察实际调用路径。我前面给的Tracker类就是干这个用的。你可以把它作为成员嵌入到你的类里或者直接在你的类里加打印。运行一遍测试用例看看构造、拷贝、赋值、析构的调用次数和顺序是否符合预期。很多时候问题就出在你以为调用了拷贝构造实际调用了赋值这种认知偏差上。7.3 用工具检测内存问题手工检查总有遗漏工具能帮你兜底。常见的内存检测工具能发现内存泄漏、double free、越界访问等问题。在Linux下可以用valgrindWindows下可以用Visual Studio自带的内存诊断工具或者AddressSanitizer编译时加-fsanitizeaddress。我现在的习惯是写完资源管理类先跑一遍AddressSanitizer再跑单元测试。AddressSanitizer能在运行时捕获double free和内存泄漏比人工检查可靠得多。7.4 一个容易忽略的细节移动后的对象状态移动构造和移动赋值之后被移动的对象处于有效但未指定状态。你可以析构它、可以赋值给它但不应该假设它的内容。我见过有人在移动后还去读被移动对象的数据结果拿到的是nullptr或空值。正确做法是移动后立即把源对象的指针置空并确保析构函数能处理空指针。比如delete[] nullptr是安全的所以析构函数不需要额外检查。7.5 关于拷贝省略和返回值优化现代编译器有拷贝省略copy elision优化很多情况下临时对象的拷贝构造会被省略。比如IntArray f() { return IntArray(10); }编译器可能直接在调用者的栈上构造对象一次拷贝都不发生。C17之后某些拷贝省略是强制性的。这意味着你写的拷贝构造可能在某些场景下根本不被调用。但这不代表你可以不写正确的拷贝构造因为编译器优化不是在所有场景都生效而且不同编译器行为可能不同。正确性不能依赖优化。我在实际项目中的体会是这四个函数是C资源管理的基石理解它们比记住语法重要得多。刚开始写的时候每个类都老老实实把四个函数写全哪怕有些是多余的。写多了之后你会形成直觉什么时候该手写什么时候该用Rule of Zero什么时候该用智能指针。这个直觉不是看书看来的是踩坑踩出来的。希望这篇文章能帮你少踩几个我踩过的坑。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询