C++指针成员类的拷贝控制:浅拷贝陷阱、深拷贝与Rule of Three

📅 发布时间:2026/10/15 17:14:17
C++指针成员类的拷贝控制:浅拷贝陷阱、深拷贝与Rule of Three
前些天我一位做客户端开发的朋友跑过来诉苦说线上一个模块随机崩溃崩溃栈指向free()查了大半天才发现是自定义的类里有个char*成员结果拷贝构造函数和拷贝赋值运算符没写对象一按值传参同一块堆内存被释放了两次。这个场景在 C 开发里太典型了类里有指针时默认拷贝行为远没有它看起来那么可靠甚至可以说是定时炸弹。这篇文章就把这件事彻底讲透——当类中有指针时拷贝构造函数为什么必须自己写、拷贝赋值运算符的真实写法是什么、背后涉及的内存语义怎么运作以及现代 C 里有哪些更省心的替代方案。不管你是刚接触 C 的学生还是写业务代码多年的老手这篇文章能帮你把这块基础彻底打牢。1. 一个让人抓狂的崩溃现场double free 是怎么发生的1.1 一段看起来没毛病的代码先看一个典型的反面教材。假设我们想封装一个简单的字符串类为了追求轻量选择用裸指针管理堆内存class MyString { public: MyString(const char* s) { if (s) { len_ strlen(s); data_ new char[len_ 1]; strcpy(data_, s); } else { len_ 0; data_ nullptr; } } ~MyString() { delete[] data_; } private: char* data_; size_t len_; };构造、析构都写了似乎很完整。然后业务代码里很自然地这么用MyString a(hello); MyString b(a); // 调用拷贝构造函数但没写啊 std::vectorMyString v; v.push_back(a); // 容器里要临时拷贝一份程序可能编译通过、运行几分钟也没事但当某个对象离开作用域时崩溃来了。用调试器一看上报的错误往往是double free or corruption。问题就出在MyString b(a)这一行——编译器自动生成了一个拷贝构造函数而它做的事情只有一个把a.data_这个指针的值原封不动地复制给b.data_。内存布局变成这样a.data_ -- [ h | e | l | l | o | \0 ] b.data_ -- [ h | e | l | l | o | \0 ] (同一个地址同一块堆内存)然后灾难开始b先析构delete[] data_把这块内存释放轮到a析构时data_指向的已经是悬挂指针再次delete[]堆管理器直接崩溃。1.2 内存里到底发生了什么展开讲一下这个堆内存的状态变化。第一次delete[]执行后那块内存被标记为可重用可能被系统回收也可能还躺在那但访问它就是未定义行为。第二次delete[]的时候堆管理器的元数据可能已经被破坏轻则报错重则静默地污染相邻内存块——这也是为什么这种 bug 有时表现成随机崩溃而不是每次都能稳定复现。更隐蔽的还有第三种场景两个对象共享同一块内存但都没有析构只是其中一个通过成员函数修改了内容MyString a(hello); MyString b(a); b.modify(0, H); // 假设实现了修改方法 // a 的字符串也变成了 Hello因为a.data_和b.data_指向同一个缓冲区改b等于改a两个对象之间产生完全意外的耦合。这种共享状态问题不光出现在拷贝构造在拷贝赋值里一样常见。从这些表现可以总结一句话当类中有指针成员时编译器默认生成的拷贝构造函数和拷贝赋值运算符几乎总是错的。它们拷贝的是地址而不是地址所指的内容这也就是著名的浅拷贝问题。2. 理解编译器默认拷贝按成员复制到底错在哪2.1 默认拷贝构造函数和默认赋值运算符的真实行为很多初学者以为类里没写拷贝构造那拷贝的时候应该什么也不做。这是彻头彻尾的误解。实际上只要类没有显式声明拷贝构造函数、拷贝赋值运算符或析构函数编译器就会自动生成它们。生成出的默认版本做的事情非常朴素对类中每一个非静态成员逐个调用该成员对应的拷贝构造函数或拷贝赋值运算符。展开到指针成员上就变成了// 编译器为我们生成的默认拷贝构造等价于 MyString(const MyString other) : data_(other.data_), // 指针类型按地址复制 len_(other.len_) // 内置类型按值复制 {}同理默认拷贝赋值运算符等价于MyString operator(const MyString other) { data_ other.data_; // 地址复制 len_ other.len_; return *this; }注意默认版本对内置类型的成员就是位复制对类类型成员才调用它的拷贝相关函数。如果成员本身是个std::string或者std::vector它们有自己的深拷贝逻辑那默认生成的拷贝就没问题——因为标准库容器已经替你处理好了复制数据这件事。2.2 为什么 int 安全、指针危险、浅拷贝的本质对比一下两类成员就清楚了int、double、bool这类内置类型复制就是复制值本身。两个对象的int成员各自独立互不影响你改我的我不改你的。指针类型复制的是地址值。本质上指针本身确实是一个值但这个值指向的资源只有一个。复制完地址后两个对象指向同一份资源这就是浅拷贝。用生活里的例子类比指针像一把钥匙。浅拷贝相当于把钥匙复刻了一份给别人但房子只有一栋。两个人拿同一把钥匙都能开门那么任何一个人把房子拆了另一个人手里拿着的钥匙就变成了废铁再想开门就会出事。深拷贝则是直接照原样另盖了一栋一模一样的房子把钥匙交出去各住各的互不干扰。需要特别强调的是危险不仅出现在同类对象之间互相拷贝还常常发生在容器操作里。std::vector扩容时会重新分配内存并把旧元素拷贝到新位置push_back、按值传参、函数返回对象这些操作都隐式调用拷贝构造。所以只要类里有裸指针从它被放进容器的瞬间开始雷就埋好了。std::vectorMyString之所以会产生两次析构同一块内存的情况就是因为容器内部拷贝出来的临时对象和原对象共享了data_。3. 拷贝构造函数先从长度为指针成员分配新内存开始3.1 第一个能跑的版本正确的拷贝构造核心思想是深拷贝让新对象拥有自己独立的堆内存内容与源对象一致但地址完全不同。以MyString为例MyString(const MyString other) { len_ other.len_; if (other.data_ ! nullptr) { data_ new char[len_ 1]; // 为 \0 留位置 strcpy(data_, other.data_); } else { data_ nullptr; } }这段代码的关键点有三个。第一len_必须先从other取来否则后面分配内存不知道要多大。第二new char[len_ 1]里的1是为字符串结尾的\0准备的如果少留这一个字节strcpy就会越界写进堆的相邻区域造成堆破坏。第三用strcpy而不是memcpy因为strcpy会把结尾的\0一起复制过去保证拷贝出来的字符串仍然是一个合法 C 风格字符串。如果追求更规范的写法可以放在初始化列表里。但要注意初始化顺序其实按成员声明顺序走为了可读性和安全性直接引用other的成员更稳妥MyString(const MyString other) : len_(other.len_), data_(other.data_ ? new char[other.len_ 1] : nullptr) { if (data_) { strcpy(data_, other.data_); } }这种写法把内存分配放在初始化列表中完成避免了函数体内先默认构造再赋值的双重开销也避免了一些场景下成员被短暂置于非法状态。data_的分配结果通过三目运算符处理源指针为空时不开辟内存全部初始化为nullptr。3.2 空指针合法性最容易被忽视的分支我见过不少人在写拷贝构造时只处理了data_非空的情况。测试用例里如果全是正常字符串代码跑得好好的一旦遇到用空字符串或者nullptr初始化的对象作为拷贝源程序立刻崩。原因是对nullptr调用strcpy或者strlen都是未定义行为有些实现会直接段错误。所以实现里必须保留nullptr分支。我的习惯是拷贝源指针为nullptr时目标指针也置为nullptr长度置为 0。这保证了两个对象的一致性也让析构函数的delete[] nullptr这是安全操作不会出错。千万不能图省事把空指针当成空字符串来 new 一块只含\0的内存——语义上两者的管理方式不同后续代码逻辑可能会依赖指针为空这个状态做判断。3.3 为什么拷贝构造不能先释放再拷贝有个容易混淆的点拷贝赋值运算符因为目标对象可能已有旧资源所以需要先释放旧资源于是有人把同样的思路套到拷贝构造上。拷贝构造是创建一个全新对象的过程构造函数执行之前对象的生命周期还没真正开始没有任何旧资源需要释放。手动去释放一个构造前对象的成员等于访问生命周期尚未开始的对象内部的垃圾值属于未定义行为。带指针成员的深拷贝拷贝构造就一句话分配新内存复制内容处理空分支。不需要也不应该做任何释放动作。4. 拷贝赋值运算符三个隐藏深坑逐一拆解4.1 第一版直觉写法为什么不行先看一个非常直觉、但没有经验的开发者容易写出来的第一版MyString operator(const MyString rhs) { delete[] data_; data_ new char[rhs.len_ 1]; strcpy(data_, rhs.data_); len_ rhs.len_; return *this; }看起来是先清空自己再照抄对方但这个版本存在三个致命问题。第一个是自赋值崩溃如果执行a adelete[] data_先销毁了a自己的内存此时rhs.data_其实和data_是同一个地址已经变成了悬挂指针。紧接着new char[...]和strcpy读到的全是已释放堆内存里的随机内容未定义行为已经发生。第二个问题是异常安全如果new char[...]因为内存不足抛出了std::bad_alloc目标对象data_已经被删除指向悬挂或空指针但对象本身还存活。一个空指针的字符串对象后续依然会被析构、被赋值类不变量不变量成员之间必须维持的约束关系被彻底破坏。第三个问题是先释放后分配的性能劣势不算最要紧最核心的还是每次赋值都多一次delete和new的往返还可能引发碎片化不过这不是正确性问题属于优化话题。4.2 自赋值检测的必要性自赋值不只是显式的a a还存在更隐蔽的形式。比如一个类的两个对象x和y某处代码通过别名MyString* p1 x; MyString* p2 y; MyString ref *p1; ref *p2;或者容器内部arr[i] arr[j]恰好i j本质都是自赋值。如果采用先释放再分配的写法就必须在开头加入if (this rhs) return *this;。以this指针判断是最精确的自赋值判断方式因为它比较的是对象地址。这是保命的一行但也是最容易被偷懒省略的一行。需要说明的是自赋值检测只是解决同一对象赋值给自己的问题如果两个不同的对象内部指针因为某种原因指向了同一块内存这本身就是浅拷贝带来的问题靠this ! rhs是挡不完的——根治方法仍然是让每个对象都拥有独立资源。4.3 copy-and-swap 惯用法同时解决自赋值和异常安全与其手动小心翼翼地处理先释放再分配C 社区更推荐一种优雅的写法叫作 copy-and-swap拷贝并交换void swap(MyString other) noexcept { std::swap(data_, other.data_); std::swap(len_, other.len_); } MyString operator(const MyString rhs) { MyString temp(rhs); // 先深拷贝一份利用的是我们写好的拷贝构造 swap(temp); // 把自己和副本整体交换 return *this; // temp 走出作用域析构时释放旧资源 }这段代码的精妙之处在于把资源管理全交给了拷贝构造和析构函数。执行流程拆开看先创建临时对象temp此时temp拥有rhs数据的独立副本swap把当前对象的旧内存交换给temp把temp里的新内存交换到当前对象函数结束时temp析构一次性释放原对象的旧资源。自赋值时temp也是先深拷贝当前对象swap交换的是两块内容相同但地址不同的内存完全不会访问已释放内存。异常安全方面如果MyString temp(rhs)这一步抛出异常说明深拷贝失败此时当前对象的数据原封未动仍然可用这达到了强异常保证。这一点非常关键——即便内存不足对象也不会处于半破坏状态。也可以进一步用传值方式简化MyString operator(MyString rhs) { // 传参时已经完成深拷贝 swap(rhs); return *this; }传值版本把深拷贝临时对象交给参数传递机制完成写法更简洁但代价是如果后面有移动语义这里会优先走移动构造而不是拷贝构造。两种版本我都在生产代码里用过传值版本配合移动语义在现代 C 里更常见但前提是你得先正确实现了移动构造。关于赋值运算符的返回值类型标准惯例是返回MyString返回*this。这保证了a b c这种链式赋值和(a b) c这类内建类型语义保持一致。虽然返回void也能完成赋值但会破坏语言惯例后续写模板或泛型代码时容易出问题。5. 指针类的完整配套析构、拷贝、移动必须一起处理5.1 Rule of Three 为什么是三条如果你发现类里有裸指针并且为此写了析构函数那你必须同时认真实现拷贝构造函数和拷贝赋值运算符。这个规则叫 Rule of Three三法则。它必须成三逻辑在于三件事互相关联析构函数负责的是释放资源拷贝构造函数负责的是从已有对象复制一份独立的资源拷贝赋值运算符负责的是把已有资源替换为副本并释放旧资源。只写析构不写拷贝默认拷贝就是浅拷贝对象析构时 double free。只写拷贝不写析构每个复制出来的对象都会丧失释放资源的通道内存泄漏。只写拷贝构造和析构但不写赋值赋值时刻还是会浅拷贝。这三者像一个三角形的三条边缺一条整个结构都会塌。判断是否需要手写三法则有一个简单标准问自己这个类是否直接管理一份独占资源如果答案是肯定的裸指针、文件句柄、socket 描述符等那三法则就逃不掉。如果资源交给标准库容器或智能指针管理那编译器生成的默认拷贝通常是安全的可以放心使用。5.2 Rule of Five移动构造和移动赋值是新时代的标配C11 引入移动语义后三法则升级为五法则Rule of Five。在已有析构、拷贝构造、拷贝赋值的基础上通常还应当实现移动构造函数和移动赋值运算符。移动的本质是偷走源对象的资源而不是深拷贝MyString(MyString other) noexcept : data_(other.data_), len_(other.len_) { other.data_ nullptr; // 关键一步把源对象置空 other.len_ 0; }移动构造的代码看起来几乎像浅拷贝但它和浅拷贝有本质区别移动之后源对象被赋予一个空状态它不再拥有任何资源所以析构它不会影响新对象。这就像把房子钥匙从别人手里拿过来同时把那个人的记忆清空让他再也想不起来自己有这栋房子——析构他时它什么都不会释放。移动赋值运算符通常写成MyString operator(MyString other) noexcept { if (this ! other) { delete[] data_; // 释放自己的旧资源 data_ other.data_; len_ other.len_; other.data_ nullptr; other.len_ 0; } return *this; }另一种更简洁的写法是直接swap把源对象的空容器和当前对象的旧资源互换由源对象析构时负责释放旧资源MyString operator(MyString other) noexcept { if (this ! other) { MyString temp(std::move(other)); swap(temp); } return *this; }五法则的意义在于性能。没有移动构造前return一个临时对象靠拷贝构造深拷贝有了移动构造后直接转移资源零拷贝成本。像std::vectorMyString扩容、push_back这类高频操作会因此快很多。我建议在新代码里把五法则当作完整标配除非有明确的理由主动关闭移动语义。5.3 哪些类其实不需要手写一套判断框架很多人被三法则五法则吓到觉得到处都是雷。其实可以反过来问什么情况下编译器默认生成的拷贝控制就是正确的第一类成员全是值类型的类。比如struct Point { int x, y; }默认拷贝完全正确。第二类成员全是标准库容器的类比如struct User { std::string name; std::vectorint scores; }string和vector的拷贝构造自己就是深拷贝默认拷贝得到的就是深拷贝。第三类用智能指针管理资源的类比如struct Handler { std::shared_ptrImpl impl; }默认拷贝把shared_ptr的引用计数增加一份这恰恰是我们想要的共享语义。需要手写的是那些直接持有裸指针、fd、句柄且资源所有权归属本对象的类。比如自己实现链表、字符串、内存池、外部 C 接口封装。在这些场景里不要偷懒用默认拷贝也不要只写析构不写拷贝老老实实把五法则补齐。6. 现代 C 的替代方案能不用裸指针就别用6.1 用标准库容器和智能指针从源头消灭问题裸指针不是目的只是手段。绝大多数业务代码里带指针的类完全可以用更安全的组件替代。最常见的场景是自封装字符串类和动态数组类。std::string和std::vector已经把这些活干得非常完美拷贝是深拷贝移动是高效的资源转移异常安全等级高内存管理自动化。直接替换掉裸指针成员后拷贝构造函数和拷贝赋值运算符都不用写了——编译器生成的默认版本就是对的。再看资源所有权语义。如果不希望对象之间共享资源希望一个人独占一份用std::unique_ptr管理堆对象。unique_ptr是只移动不可拷贝的类型它保证了资源独占性拷贝这种类会被编译器直接拒绝从源头杜绝浅拷贝struct Owner { std::unique_ptrImpl p_; }; Owner a; // Owner b(a); // 编译错误unique_ptr 不可拷贝 Owner b(std::move(a)); // 只能移动所有权转移安全如果确实需要共享用std::shared_ptr。它维护引用计数多个对象指向同一份资源但只有引用计数归零时最后一个对象析构才会真正释放资源。这解决了共享资源但不知道谁该释放的难题struct SharedOwner { std::shared_ptrImpl p_; }; SharedOwner a; SharedOwner b(a); // 安全两个对象共享一份资源引用计数为 2shared_ptr的拷贝默认就是正确的内部会原子递增引用计数不会出现 double free。当然shared_ptr有额外的性能开销引用计数的原子操作但在绝大多数业务场景里这点开销换来的安全性和开发效率完全值得。6.2 手写拷贝控制的合理场景与实战建议说了这么多替代方案是不是手写拷贝控制就没有价值了当然不是。标准库容器和智能指针解决不了所有问题你可能是为某个 C 接口封装句柄可能是在实现一个极低延迟的断言式数据结构也可能是旧代码里不得不维护的遗留接口。在这些场景里正确手写拷贝控制仍然是必须掌握的技能。我的实战建议有四条。第一优先把类设计成值语义成员能放栈上就放栈上不要一上来就想着堆分配。第二如果必须管理裸资源把析构、拷贝构造、拷贝赋值、移动构造、移动赋值五件套一起写出来并标记移动操作noexcept。第三拷贝赋值实测推荐 copy-and-swap 惯用法能少踩很多坑。第四写完以后用 sanitizer 工具验证比如开-fsanitizeaddress跑一轮单元测试或者用内存检测工具跑一遍——它能精准报告哪里 double free、哪里内存泄漏。我习惯在每次改动涉及资源管理的类时都跑一遍 sanitizer成本极低但能救命的次数不少。6.3 一个综合示例把前面所有内容串起来最后给一个完整示例把深拷贝拷贝构造、copy-and-swap 赋值、移动构造、移动赋值整合在一起供实际参考。这是一个简化版的动态字节缓冲区类#include cstring #include algorithm #include utility class Buffer { public: explicit Buffer(size_t size 0) : size_(size) { data_ size_ 0 ? new char[size_] : nullptr; } ~Buffer() { delete[] data_; } // 拷贝构造深拷贝 Buffer(const Buffer other) : size_(other.size_), data_(other.data_ ? new char[other.size_] : nullptr) { if (data_) { memcpy(data_, other.data_, size_); } } // 拷贝赋值copy-and-swap Buffer operator(const Buffer rhs) { Buffer tmp(rhs); swap(tmp); return *this; } // 移动构造转移资源 Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.data_ nullptr; other.size_ 0; } // 移动赋值 Buffer operator(Buffer other) noexcept { if (this ! other) { delete[] data_; data_ other.data_; size_ other.size_; other.data_ nullptr; other.size_ 0; } return *this; } void swap(Buffer other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); } private: char* data_; size_t size_; };注意几个细节拷贝构造里用memcpy而非strcpy因为这是字节缓冲区不是以\0结尾的字符串复制长度由size_决定。移动赋值里仍然保留了this ! other检查虽然移动操作本身正确处理了源对象置空但防御性检查成本极低写上更安心。swap函数带noexcept保证 copy-and-swap 过程中不会抛出异常——因为std::swap对指针和整数都是noexcept的。这套结构既满足了深拷贝正确性又兼顾了移动性能是我在新代码里最常用的一套模板。我在实际项目中踩过这些坑之后最大的体会是写资源管理类时不要指望编译器帮忙不要依赖巧合能正常工作。把五法则完整落实然后让 sanitizer 帮你验证内存安全这一步做完心里就踏实了。如果一定要给个总结性的建议那就是在能用标准库容器和智能指针的地方优先用它们在必须手写的地方用 copy-and-swap 和移动语义替换掉一切裸指针操作——这两条习惯能帮你避免绝大多数类指针相关的问题。