ARTICLE DETAIL

资讯详情

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

C++深拷贝与浅拷贝详解:从拷贝构造函数到智能指针与移动语义

C++深拷贝与浅拷贝详解:从拷贝构造函数到智能指针与移动语义 群里隔三差五就有人发一张崩溃截图代码里无非是“一个类带了个指针成员复制了一下程序就挂了”。这种问题十有八九出在拷贝构造函数没写对——编译器默认帮你做的“浅拷贝”在遇到指针、堆内存这些资源时根本不安全。今天这篇就把深拷贝和浅拷贝从头到尾掰扯清楚拷贝构造函数什么时候触发、默认拷贝到底做了什么、为什么浅拷贝会崩、怎么用手写深拷贝解决再到现代C工程里用智能指针和移动语义怎么彻底绕开这些坑。不管你是在校学生做课程设计还是工作里维护老项目这篇都适合放下手边的事好好读一遍。1. 对象被“复制”时编译器到底做了什么1.1 拷贝构造函数的真实触发时机很多初学者以为只有Test b(a);这种写法才是拷贝构造实际上它的触发点比你想象的隐蔽得多。我把最常见的几种场景列出来你对照着看看自己踩过几个MyString a(hello); MyString b a; // 拷贝构造不是赋值 MyString c(a); // 拷贝构造显式写法 void func(MyString s); // 按值传参时实参到形参会拷贝构造 func(a); std::vectorMyString v; v.push_back(a); // 容器里插入对象一般会经历拷贝构造这里最容易弄混的是MyString b a;。等号看起来和赋值一样但因为是新对象b的初始化走的是拷贝构造函数而不是拷贝赋值运算符。赋值运算符是给已经存在的对象用的b a;这个在语义上有本质区别后面写代码时一定要分清。还有一个隐藏触发点是函数按值返回局部对象。老编译器里会调用拷贝构造把局部对象复制给调用方不过现代编译器通常用返回值优化直接构造目标对象这一块我在第4章细说。1.2 默认拷贝构造“逐成员拷贝”的细节如果你没有自己写拷贝构造函数编译器会生成一个隐式的。这个隐式版本的行为非常朴素对每一个非静态成员原样复制。具体来说内建类型成员int、double、指针等直接按字节复制指针复制的是地址值类类型成员调用该成员自己的拷贝构造函数数组成员逐个元素复制引用成员正常绑定到同一个被引用的对象但不会因为复制而重新绑定新对象。这个行为在C里叫“逐成员拷贝member-wise copy”。表面上看很合理但问题恰恰出在“指针只复制地址值”这一点上。如果一个类有int* p这样的成员那么两个对象里的p都指向同一块内存两个对象对这块内存都有管理权这就是一切崩溃的源头。还需要注意如果类里存在const成员或引用成员默认的拷贝赋值运算符会被定义为删除的因为赋值时无法给const成员或引用成员重新赋值。这种情况编译期就会报错你会被迫自己写拷贝控制逻辑。1.3 浅拷贝的典型事故现场为了让你直观看到浅拷贝怎么出事我写一个最经典的例子class Widget { public: Widget(int val) : data_(new int(val)) {} ~Widget() { delete data_; } private: int* data_; }; int main() { Widget a(100); Widget b a; // 编译器默认浅拷贝b.data_和a.data_指向同一内存 return 0; // 先析构bdelete一次再析构a又delete一次 }这段代码运行起来你大概率会看到类似“double free detected”的报错或者程序直接崩掉。原因很简单a和b析构时都对同一块堆内存执行了delete第二次删除就是非法操作。就算你侥幸没崩浅拷贝还有个更隐蔽的危害修改一个对象会波及另一个。比如a里通过指针改了值b读到的数据也跟着变了。在很多业务场景里这种“意外共享状态”比崩溃更可怕因为它让程序的行为变得不可预测。所以只要类里出现了裸指针、文件句柄、网络连接这类资源继续依赖默认拷贝就是埋雷。2. 手写深拷贝从零实现一个可安全复制的MyString2.1 深拷贝到底“深”在哪深拷贝的核心思路就一句话对象复制时不是复制指针本身而是复制指针指向的数据并给新对象分配一份独立的内存。这样两个对象各自拥有一份完整的数据互不干扰析构时也各删各的不会出现重复释放。用表格对比最直观对比项浅拷贝深拷贝指针成员的处理只复制地址值重新分配内存并复制内容两个对象的指针关系指向同一块内存指向不同但内容相同的内存修改一个对象的数据另一个对象也会变互不影响析构时的风险容易double free或悬垂指针各自释放自己安全实现成本零成本编译器代劳需要手写分配内存有开销深拷贝本质上是把“资源所有权”也一并复制了。你可能听说过“值语义”这个词深拷贝就是值语义的关键实现手段让对象在用起来像个普通int一样复制后各过各的日子。2.2 一个完整且正确的MyString实现我以一个自定义的字符串类为例把深拷贝的完整代码写出来。这个类在功能上不需要多复杂重点看拷贝控制#include iostream #include cstring class MyString { public: // 构造函数 MyString(const char* str ) { size_ std::strlen(str); data_ new char[size_ 1]; std::memcpy(data_, str, size_ 1); } // 拷贝构造函数深拷贝 MyString(const MyString other) : size_(other.size_) { data_ new char[size_ 1]; std::memcpy(data_, other.data_, size_ 1); } // 拷贝赋值运算符深拷贝 MyString operator(const MyString other) { if (this other) { return *this; } delete[] data_; size_ other.size_; data_ new char[size_ 1]; std::memcpy(data_, other.data_, size_ 1); return *this; } // 析构函数 ~MyString() { delete[] data_; } const char* c_str() const { return data_; } private: size_t size_; char* data_; }; int main() { MyString s1(hello); MyString s2 s1; // 调用拷贝构造s2复制了一份完整数据 std::cout s2.c_str() std::endl; return 0; }看起来代码不多但每个函数都有讲究。构造函数里先算好长度再分配size_ 1的空间多出的1字节留给结尾的\0memcpy时把\0一起复制过去保证字符串安全和C接口兼容。拷贝构造函数的写法是先初始化size_再用new char[]分配一份独立内存最后把原对象里的字符数据整体搬过来。这一步是深拷贝的“灵魂”没有这个就退化成浅拷贝了。拷贝赋值运算符比拷贝构造要复杂因为目标对象已经存在可能已经持有内存。所以正确顺序是先判断自赋值再释放旧内存然后分配新内存并复制数据。第2.3节我会说这个顺序其实还有一个隐藏的坑。2.3 自赋值与异常安全两个容易翻车的细节拷贝赋值第一个要注意的是自赋值。如果别人写了个a a而你上来就delete[] data_那就把自己的内存给释放了紧接着去读other.data_就是操作悬垂指针。我上面的写法用if (this other)挡掉了这种情况这个判断看着简单但很多人会漏。第二个问题是异常安全。比如在拷贝赋值里先delete[] data_然后new char[...]分配失败抛异常这时对象已经被破坏旧内存没了新内存也没来。这在极端内存紧张时程序的行为难以预料。更稳的写法是“先复制再释放”或者用下面这种copy-and-swap模式class MyString { public: MyString(const char* str ) : size_(std::strlen(str)) { data_ new char[size_ 1]; std::memcpy(data_, str, size_ 1); } MyString(const MyString other) : size_(other.size_) { data_ new char[size_ 1]; std::memcpy(data_, other.data_, size_ 1); } // 注意参数是按值传递 MyString operator(MyString other) { swap(*this, other); return *this; } ~MyString() { delete[] data_; } friend void swap(MyString a, MyString b) noexcept { using std::swap; swap(a.size_, b.size_); swap(a.data_, b.data_); } private: size_t size_; char* data_; };这个写法的妙处在于传入参数时已经通过拷贝构造函数完成了一次深拷贝如果分配内存失败异常在进入函数体之前就抛出了this对象完全没被改动。进入函数体之后只要swap两个指针和大小旧资源就在函数退出时被other的析构函数自动释放了。自赋值也不用担心拷贝构造出来的临时对象本身就是当前对象的副本swap之后还是正确的数据。需要注意copy-and-swap通过按值传参引入了额外的一次拷贝/移动在性能敏感的代码里可能不是最优解。但对绝大多数场景来说安全性和简洁性远大于这点开销。我自己的建议是优先保证正确性再用性能分析工具说话。3. 什么时候该写拷贝构造基于三/五法则的判断3.1 三/五法则写代码前的判断标准C社区流传着一条经典经验叫“三/五法则”。三指早期C的三个特殊成员函数析构函数、拷贝构造函数、拷贝赋值运算符五则是C11之后又加上了移动构造函数和移动赋值运算符。这条法则的核心逻辑不是“你必须把五个都写出来”而是如果你发现必须自己写其中任何一个那几乎必然需要认真考虑其他几个。为什么这么讲因为你写析构函数通常说明类里管理了某种资源堆内存、句柄等此时编译器默认生成的拷贝行为几乎一定是浅拷贝两个对象会争夺同一份资源于是崩溃就成了必然。我见过很多新手只写了析构函数来释放内存拷贝构造和拷贝赋值完全没管结果程序一复制就挂。这就是没有把三/五法则当回事的代价。特殊成员函数触发场景不处理的后果析构函数对象生命周期结束时资源泄漏拷贝构造函数用已有对象初始化新对象浅拷贝导致double free拷贝赋值运算符给已存在的对象赋值浅拷贝导致double free移动构造函数用右值初始化新对象退化为拷贝性能损失移动赋值运算符给已存在的对象赋右值退化为拷贝性能损失3.2 浅拷贝天然安全的类别盲目深拷贝也不是所有类都要手写深拷贝。如果类里没有裸指针、没有外部资源、没有需要特殊处理的成员编译器生成的默认拷贝完全够用。比如class Point { public: Point(int x, int y) : x_(x), y_(y) {} private: int x_; int y_; };这种纯数据类浅拷贝就是字典里的“复制”复制出来的新对象和原对象完全独立不存在共享资源的问题。强行给每个类都写深拷贝反而是过度设计增加维护成本还白白损失性能。日常开发里你首先要做的是判断“这个类有没有资源所有权”有才需要认真设计拷贝语义。3.3 不该被拷贝的类怎么主动禁止拷贝有些类天生就不该被复制比如互斥锁、文件流、数据库连接。这类对象一旦被复制资源状态就会变得混乱前面说的浅拷贝问题会成倍放大。这时候最好的方案不是写深拷贝而是直接禁掉拷贝。C11之后禁拷贝的姿势非常优雅用 delete即可class MutexGuard { public: MutexGuard() default; MutexGuard(const MutexGuard) delete; // 禁止拷贝构造 MutexGuard operator(const MutexGuard) delete; // 禁止拷贝赋值 };也可以把拷贝声明为private但不实现这是老代码里常见的手法能在链接期报错。不过现代C里直接用 delete是最清晰的编译期就能给出明确提示。另一个思路是继承一个不可拷贝的基类比如Boost里的noncopyable效果一样。我觉得这个思路在业务代码里特别实用遇到不愿意被复制的资源类第一时间把拷贝操作删掉宁可编译报错也不能让浅拷贝的风险蔓延到整个项目。4. 现代C工程里的拷贝控制智能指针与移动语义4.1 用智能指针替代裸指针管理资源如果你还在用裸指针写深拷贝说明代码还停留在“手动挡”年代。现代C工程里绝大多数资源管理场景可以直接交给智能指针它们能从根本上绕过深拷贝的设计难题。先看std::unique_ptr。它的语义是独占所有权压根不允许拷贝只能移动。所以如果你的类里持有unique_ptr成员编译器很聪明地禁止了类被拷贝想要复制这个类会直接编译报错。这在很多场景下反而是好事因为有些类确实不应该被复制。再看std::shared_ptr。很多人第一次接触它时会困惑shared_ptr的拷贝也是复制指针这不就是浅拷贝吗没错它实现的确实是指针层面的“浅拷贝”但关键区别在于它同时拷贝了引用计数最后一次析构时才真正释放资源。也就是说多个shared_ptr可以安全地指向同一块内存不需要深拷贝也不必担心double free。代价是引用计数本身有线程安全的原子操作开销以及可能出现的循环引用问题。用智能指针改写之前的Widget类会变成这样class Widget { public: Widget(int val) : data_(std::make_sharedint(val)) {} private: std::shared_ptrint data_; };注意这里我都没写拷贝构造函数、析构函数、移动构造函数编译器生成的默认版本就是正确的。data_成员遇到拷贝时自动走shared_ptr的拷贝逻辑引用计数加一析构时减一一切自动完成。这就是现代C能让你“忘记”手写深拷贝的最大底气。4.2 移动语义深拷贝之外的第二条路深拷贝解决的是“两个对象各有一份数据”的问题但有些场景下源对象马上就要销毁了这时候再复制一份完整数据就纯属浪费。比如函数返回一个临时的MyString你完整深拷贝了一份数据临时对象随即销毁那拷贝出来的数据和原数据内容一模一样白白做了两次内存分配和一次大块数据复制。移动构造要做的就是把资源从源对象手里“偷”过来然后让源对象变成空壳。实现思路很简单关键是让源对象的指针指向nullptr防止析构时释放掉已经被偷走的内存MyString(MyString other) noexcept : size_(other.size_), data_(other.data_) { other.data_ nullptr; other.size_ 0; } MyString operator(MyString other) noexcept { if (this ! other) { delete[] data_; data_ other.data_; size_ other.size_; other.data_ nullptr; other.size_ 0; } return *this; }移动构造的代码跟浅拷贝长得有点像都是简单搬运指针但区别在于搬运完之后必须把源对象的指针置空。这也是为什么移动构造必须标记noexcept——如果移动构造抛异常标准容器和算法为了保证强异常安全会更倾向于退回到拷贝操作那样移动就失去意义了。我把这段代码贴出来就是想提醒你能写出正确的移动构造才算是从“会写C”进阶到“写得好C”。4.3 工程上传参/返回的优化习惯说完了类和对象的内部设计再聊聊日常调用习惯。我评审过不少代码明明类写得没问题性能却一塌糊涂一问全是传值传的。第一条铁律是能传const引用就别传值。// 下面这行实参传入时会触发一次拷贝构造 void func(MyString s); // 改成const引用实参零拷贝调用语义也足够清晰 void func(const MyString s);第二条是返回时不要过度优化。很多程序员写函数返回对象时心里都在纠结“会不会多拷贝一次”甚至为了省一次拷贝去写输出参数。现代编译器在C17标准下对很多返回场景做了强制省略直接构造目标对象根本没中间拷贝。std::vector、std::string这类标准库类型早就享受到了这个福利。我自己的习惯是返回值直接写对象编译器能优化就优化不能优化的场景它会自动调用移动构造深拷贝已经是最坏情况下的兜底方案。第三条是用代码验证优化效果别靠猜。给拷贝构造和移动构造加打印日志跑一遍程序看看到底调用了多少次一清二楚。这个方法我在第5章还会展开讲。5. 拷贝构造相关常见问题与排查技巧实录5.1 double free/段错误快速定位当你遇到程序崩溃、报错里出现double free detected或Segmentation fault时最可能的嫌疑就是浅拷贝导致同一份内存被释放了两次。定位的方法我按效率从高到低说几个。第一个是AddressSanitizerASan编译时加一行选项就行g -g -fsanitizeaddress -fno-omit-frame-pointer main.cpp -o main ./mainASan会在崩溃点输出非常详细的错误报告告诉你哪块内存被释放了两次、第一次在哪里释放、第二次在哪里释放基本可以顺着栈直接找到问题代码。第二个是Valgrind。它不需要重新编译直接跑可执行文件即可valgrind --toolmemcheck --track-originsyes ./mainValgrind运行速度慢但定位准确对堆内存的错误特别敏感。在Linux服务器上排查老代码时我经常留着它做兜底检查。第三个是gdb调试。如果崩溃比较随机就在gdb里拿到完整的调用栈gdb ./main (gdb) run (gdb) bt结合栈里的两层析构调用基本能确认是不是double free。用ASan和Valgrind还有一个额外好处它们不仅能查double free还能查越界访问、使用未初始化内存等问题拿来当日常开发工具很合适。5.2 为什么写了深拷贝程序还在不断拷贝代码明明写了正确的深拷贝和移动构造运行速度还是很慢打印日志发现拷贝构造老被触发这种情况多半出在你不容易察觉的边界上。有一种典型场景是函数参数传值时忘记加const了。我见过有人把原本传引用的函数签名改坏导致整个调用链上每次函数调用都要拷贝一份数据。排查方法也简单在拷贝构造和移动构造里各加一句打印然后观察哪些调用会触发拷贝MyString(const MyString other) : size_(other.size_) { std::cout copy\n; data_ new char[size_ 1]; std::memcpy(data_, other.data_, size_ 1); }如果是标准库容器场景比如std::vector扩容拷贝次数往往会比预期多。我会建议直接reserve预留容量减少扩容时的复制。另外C11之后容器会优先使用移动构造来扩容前提是你的类提供了noexcept的移动构造。如果有移动构造却不标noexcept标准库为了安全会乖乖走拷贝路线这又是一个很容易被忽视的性能杀手。5.3 派生类拷贝构造的坑继承体系里写拷贝构造有个细节很容易翻车。派生类的拷贝构造函数默认会调用基类的默认构造函数而不是基类的拷贝构造函数。这就意味着如果基类里管理了资源派生类拷贝时基类部分的数据可能压根没被复制资源全丢了。正确的写法是显式调用基类的拷贝构造class Derived : public Base { public: Derived(const Derived other) : Base(other), extra_(other.extra_) { } private: int extra_; };如果基类把拷贝构造删掉了那么派生类无论如何都不能被拷贝编译器会直接报错。这是好事说明设计上已经阻止了不安全操作。还有一个隐藏问题基类析构函数不是虚函数时派生类对象通过基类指针被delete会造成未定义行为。这和拷贝构造不是一回事但在设计带继承的类时两者往往一起出现。我建议写继承体系时先把拷贝控制和虚析构问题一起理清楚免得测试时才发现运行期诡异崩溃。5.4 常见问题速查表问题现象根本原因解决方法程序崩溃报double free浅拷贝导致同一内存被释放两次实现深拷贝或改用shared_ptr修改a对象b对象数据也跟着变浅拷贝指针共享同一块内存深拷贝分配独立内存拷贝构造写得对但仍大量拷贝传参、容器扩容触发额外拷贝传const、reserve、加移动构造派生类拷贝后基类数据丢失基类默认构造函数而非拷贝构造被调用显式调用Base(other)想在容器里移动元素却一直拷贝移动构造缺失或未标记noexcept补全移动构造并加noexcept本不该拷贝的类悄悄被复制没有禁用拷贝操作用 delete删除拷贝构造和拷贝赋值a a之后数据丢失拷贝赋值未处理自赋值判断this other或用copy-and-swap写代码时多一个心眼把这些问题在编译期堵住效率远高于运行时排查。编译器开上-Wall -Wextra把警告当错误看多少能在早期暴露一些拷贝相关的问题。更彻底的做法是在CI脚本里直接把ASan和Valgrind跑起来代码合入前把内存类错误全部扫一遍。最后分享一点个人经验我早期写C类时几乎不写拷贝构造函数全靠编译器默认行为。后来连续两次被同一个double free问题熬到半夜才真正把三/五法则刻在脑子里。现在写任何类之前我都会先问自己三个问题这个类管理资源吗允许被拷贝吗拷贝的成本可以接受吗这三个问题想清楚深拷贝浅拷贝的坑基本就绕开一大半了。推荐你也尝试在测试环境里给关键类加一段打印构造次数的临时日志跑一轮程序你会对自己代码的拷贝行为有完全不一样的认识。
返回列表