资讯动态

C++继承中的拷贝构造与赋值重载:原理、陷阱与最佳实践

发布时间:2026/10/13 9:06:27 来源:尧图企业网站定制
1. 从一个真实翻车现场说起先讲个我前阵子帮人排查的Bug。某同学写了个简单的字符串包装类成员就一个char*指针加一个长度字段构造、析构都写得好好的结果程序跑起来偶尔崩溃偶尔打印出乱码偶尔又完全正常。他反复检查构造和析构逻辑看着都对最后把代码发给我一看——类里压根没写拷贝构造函数也没写赋值运算符重载。问题就出在这编译器默默给他生成了默认版本而默认版本干的是“逐成员按位复制”指针被原样抄了一份两个对象指向同一块堆内存析构时同一块内存被释放两次程序不崩才怪。这个场景在C里太经典了经典到几乎每个写过带资源管理类的人都会踩一次。标题里说的“继承关系中的拷贝构造函数和赋值操作重载函数分析”其实是在这个经典问题之上又加了一层难度一旦类之间有了继承关系拷贝构造和赋值重载的行为会变得更微妙基类子对象和派生类成员分别怎么处理、赋值运算符要不要返回引用、派生类的赋值要不要显式调用基类的赋值这些细节稍不注意就会埋雷。这篇内容我打算把这件事从头到尾捋一遍。适合谁看如果你写过C类、知道构造函数和析构函数是干嘛的但对“为什么拷贝构造要传引用”“派生类赋值为什么要调基类赋值”这些问题还模棱两可那这篇就是写给你的。如果你已经能熟练写出Rule of Three也可以看看继承场景下那些容易被忽略的边界情况当作一次系统复盘。全文我会用“先讲原理、再上代码、最后给排查表”的方式推进尽量让每个结论都能落到可复现的操作上。2. 拷贝构造与赋值重载的基础机制拆解2.1 编译器默认生成了什么为什么它是把双刃剑要理解继承场景下的问题得先把单个类里的机制说透。C编译器在你没显式声明的时候会“好心”地帮你生成四个函数默认构造函数、拷贝构造函数、赋值运算符重载、析构函数。这四个函数在C11之后还有移动版本但那是后话这里先聚焦拷贝这条线。默认拷贝构造函数的语义是“逐成员拷贝”对每个非静态成员用它的拷贝构造来初始化新对象的对应成员。默认赋值运算符的语义是“逐成员赋值”对每个非静态成员用它的赋值运算符来赋值。听起来很合理问题在于当成员里有指针、文件句柄、socket描述符这类“资源型”成员时逐成员拷贝只是把资源句柄的值抄了一份并没有复制资源本身。结果就是两个对象共享同一份资源谁先析构谁把资源释放掉另一个对象手里的句柄就成了悬空引用。这就是所谓的“浅拷贝”陷阱。编译器生成的版本永远是浅拷贝它不懂你的成员里哪个是“拥有所有权”的哪个只是“借用”。所以判断标准很简单只要你的类直接管理了某种需要手动释放的资源就必须自己写拷贝构造和赋值重载这就是Rule of Three的由来——析构、拷贝构造、赋值重载要么都不写要么都写。注意判断“是否管理资源”不能只看有没有指针。一个持有std::vector的类成员里没有裸指针但vector自己管理堆内存它的拷贝构造是深拷贝所以外层类用默认版本反而是安全的。真正危险的是裸指针、裸句柄这类需要你手动delete或close的成员。2.2 拷贝构造为什么必须传引用传值会怎样这个问题面试常问但很多人只背了答案没想过原因。拷贝构造函数的参数如果是传值比如MyClass(MyClass other)那么调用拷贝构造时实参要先拷贝一份给形参other而拷贝形参这个动作本身又要调用拷贝构造函数——于是无限递归编译器直接报错拒绝编译。所以拷贝构造的标准签名是MyClass(const MyClass other)传const引用。加const是因为拷贝一个对象不应该修改原对象而且加了const之后const对象和临时对象都能被拷贝。这一点在继承场景里同样成立派生类的拷贝构造签名是Derived(const Derived other)参数是派生类自己的引用。赋值运算符重载的签名则是MyClass operator(const MyClass other)返回引用是为了支持链式赋值a b c返回*this让表达式结果还能继续参与赋值。参数传const引用同样是避免无谓拷贝并兼容const对象。2.3 深拷贝的经典实现骨架在讲继承之前先把单类的深拷贝骨架立起来后面继承场景都是在这个骨架上做文章。以一个管理动态数组的类为例class IntArray { public: IntArray(int n) : size_(n), data_(new int[n]()) {} // 拷贝构造先分配自己的资源再复制内容 IntArray(const IntArray other) : size_(other.size_), data_(new int[other.size_]) { std::copy(other.data_, other.data_ size_, data_); } // 赋值重载先处理自赋值再释放旧资源再分配新资源 IntArray operator(const IntArray other) { if (this other) return *this; // 自赋值保护 int* newData new int[other.size_]; // 先分配异常安全 std::copy(other.data_, other.data_ other.size_, newData); delete[] data_; // 再释放旧的 data_ newData; size_ other.size_; return *this; } ~IntArray() { delete[] data_; } private: int size_; int* data_; };这段代码里有三个关键点值得展开。第一是自赋值保护a a这种操作如果不判断会先delete自己的data_然后去拷贝已经被释放的内存直接崩。第二是“先分配后释放”的顺序如果先delete再new而new抛异常对象就处于资源已释放但指针未更新的坏状态先new成功再delete即使new抛异常原对象依然完好。第三是返回*this的引用这是链式赋值的必要条件。实操心得自赋值保护除了用this other判断还有一种写法是先拷贝一份临时对象再交换copy-and-swap那种写法天然免疫自赋值而且异常安全性更好代价是多一次拷贝。对性能不敏感的类我一般推荐copy-and-swap代码更短更不容易出错。3. 继承关系带来的新问题基类子对象怎么处理3.1 派生类对象的内存布局决定了拷贝顺序要理解继承下的拷贝行为得先知道派生类对象在内存里长什么样。一个派生类对象实际上包含两部分基类子对象 派生类自己的成员。基类子对象在内存的前部单继承情况下派生类成员紧随其后。这个布局直接决定了拷贝构造和赋值的执行顺序。当你写派生类的拷贝构造函数时如果不在初始化列表里显式调用基类的拷贝构造编译器会自动调用基类的默认构造函数——注意是默认构造不是拷贝构造。这是个巨大的坑。也就是说派生类拷贝构造时基类部分会被“默认初始化”而不是“拷贝初始化”基类里的资源根本没被复制过来。正确的做法是在派生类的拷贝构造初始化列表里显式调用基类的拷贝构造class Base { public: Base(const Base other) { /* 拷贝基类资源 */ } Base operator(const Base other) { /* 赋值基类资源 */ return *this; } // ... 其他成员 }; class Derived : public Base { public: Derived(const Derived other) : Base(other), // 显式调用基类拷贝构造 member_(other.member_) // 再拷贝自己的成员 {} Derived operator(const Derived other) { if (this other) return *this; Base::operator(other); // 显式调用基类赋值 member_ other.member_; return *this; } private: SomeType member_; };这里Base(other)之所以能匹配到基类拷贝构造是因为派生类对象可以隐式转换为基类引用切片所以传other给Base的拷贝构造是合法的。赋值那边则必须显式写Base::operator(other)因为赋值运算符不会被自动“向上调用”——派生类的赋值运算符会隐藏基类的同名运算符不显式调用的话基类部分根本不会被赋值。3.2 为什么基类赋值不会自动被调用这是继承场景里最容易出错的地方值得单独说清楚。拷贝构造和赋值重载在继承下的行为是不对称的拷贝构造如果你不显式调用基类拷贝构造编译器会调用基类的默认构造。它不会报错但结果是错的——基类部分没被拷贝。赋值重载如果你不显式调用基类赋值编译器什么都不做基类部分保持原样。它也不会报错结果同样是错的——基类部分没被更新。为什么赋值不像拷贝构造那样至少调用个默认版本因为赋值运算符在C里就是普通的成员函数派生类定义了自己的operator之后就把基类的operator隐藏了名字隐藏规则除非你用using Base::operator引入否则基类版本在派生类作用域里根本不可见。而拷贝构造是构造函数构造函数的调用规则由语言规定基类子对象的构造是强制的只是选哪个构造函数的问题。这个不对称性导致一个常见Bug派生类赋值运算符写得好好的自己的成员都赋值了但基类成员纹丝不动程序跑起来数据不一致排查半天才发现漏了Base::operator(other)这一行。注意如果基类的赋值运算符返回类型不是Base而是void或其他派生类里调用它虽然语法上可行但会破坏链式赋值的语义。写基类时坚持返回Base是个好习惯。3.3 一个完整的继承深拷贝实例把上面的规则拼成一个可运行的例子。假设基类管理一个动态字符串派生类额外管理一个动态数组#include cstring #include algorithm class Base { public: Base(const char* s ) { len_ std::strlen(s); str_ new char[len_ 1]; std::strcpy(str_, s); } Base(const Base other) { len_ other.len_; str_ new char[len_ 1]; std::strcpy(str_, other.str_); } Base operator(const Base other) { if (this other) return *this; char* newStr new char[other.len_ 1]; std::strcpy(newStr, other.str_); delete[] str_; str_ newStr; len_ other.len_; return *this; } virtual ~Base() { delete[] str_; } protected: char* str_; size_t len_; }; class Derived : public Base { public: Derived(const char* s , int n 0) : Base(s), size_(n), arr_(new int[n]()) {} Derived(const Derived other) : Base(other), // 关键拷贝基类部分 size_(other.size_), arr_(new int[other.size_]) { std::copy(other.arr_, other.arr_ size_, arr_); } Derived operator(const Derived other) { if (this other) return *this; Base::operator(other); // 关键赋值基类部分 int* newArr new int[other.size_]; std::copy(other.arr_, other.arr_ other.size_, newArr); delete[] arr_; arr_ newArr; size_ other.size_; return *this; } ~Derived() override { delete[] arr_; } private: int* arr_; size_t size_; };这段代码可以直接编译运行。你可以自己加打印语句验证注释掉Base(other)那行基类的字符串就不会被拷贝注释掉Base::operator(other)那行赋值后基类字符串保持旧值。这两个实验能让你对“显式调用”的必要性有肌肉记忆。4. 实操过程与关键环节的完整复现4.1 从零搭建一个可验证的测试工程光看代码不够得动手跑一遍才能记住。我建议按下面的步骤搭一个最小验证工程用打印语句把每个函数的调用顺序暴露出来。第一步建三个文件base.h、derived.h、main.cpp。基类和派生类各加一个静态计数器或者打印语句标记“基类拷贝构造被调用”“派生类拷贝构造被调用”“基类赋值被调用”“派生类赋值被调用”。第二步在main里构造几个场景用一个已有对象拷贝构造新对象、用一个对象赋值给另一个已存在对象、自赋值、通过基类指针操作派生类对象。每个场景跑完打印对象内容肉眼确认数据是否正确。第三步故意制造错误版本做对比。把派生类拷贝构造里的Base(other)删掉重新编译运行观察基类数据变成什么。再把Base::operator(other)删掉观察赋值后基类数据是否更新。这种“正确 vs 错误”的对照实验比看十遍文档都管用。我实测下来最容易观察到的现象是删掉Base(other)后派生类拷贝出来的对象基类字符串变成空串因为调用了基类默认构造默认参数是空字符串。这个现象非常直观一眼就能看出问题。4.2 参数选择与异常安全的权衡写深拷贝时资源分配的顺序直接关系到异常安全。前面提过“先分配后释放”这里展开说一下为什么。假设赋值运算符里先delete[] data_再new int[n]如果new因为内存不足抛出std::bad_alloc此时data_已经被释放但指针还指向那块已释放的内存对象处于“析构会二次释放”的坏状态。而如果先new成功拿到新指针再delete旧指针即使new抛异常对象依然持有有效的旧资源处于合法状态异常传播出去后对象还能正常析构。这个顺序在继承场景下同样适用而且因为涉及基类和派生类两部分资源顺序更要注意。我的习惯是在派生类赋值里先调用Base::operator(other)完成基类部分再处理派生类自己的资源。如果派生类资源分配失败抛异常基类部分已经被更新对象处于“基类新、派生类旧”的不一致状态。要彻底解决这个问题就得用copy-and-swap先完整拷贝一份临时对象全部成功后再交换任何一步失败原对象都不受影响。Derived operator(Derived other) { // 注意参数传值触发拷贝构造 swap(other); // 交换不抛异常 return *this; }这种写法把拷贝和赋值合并了参数传值那一步就完成了深拷贝如果拷贝失败异常在进入函数体前就抛出了原对象毫发无损。函数体内只剩swap而swap只交换指针和整数不会抛异常。代价是每次赋值多一次拷贝构造对性能敏感的类要权衡。实操心得copy-and-swap写起来短但有个前提——你得先写好一个正确的拷贝构造和一个不抛异常的swap。如果swap里还有资源分配那这套就白搭了。所以根子还是在拷贝构造要写对。4.3 用打印语句验证调用链理论说再多不如让程序自己“说话”。下面这段main函数把几个关键场景都覆盖了int main() { std::cout 场景1拷贝构造 std::endl; Derived d1(hello, 3); Derived d2(d1); // 触发派生类拷贝构造 std::cout 场景2赋值 std::endl; Derived d3(world, 5); d3 d1; // 触发派生类赋值 std::cout 场景3自赋值 std::endl; d3 d3; // 触发自赋值保护 std::cout 场景4基类指针 std::endl; Base* pb d1; Base b2(*pb); // 切片只拷贝基类部分 return 0; }场景4特别值得注意。通过基类指针拷贝构造得到的是一个纯基类对象派生类部分被“切掉”了这叫对象切片。这是继承体系里另一个经典陷阱虽然不属于拷贝构造本身的语法问题但在设计基类时要想清楚如果这个基类会被按值传递或拷贝切片就会发生。避免的方法是把基类设计成抽象类含纯虚函数让它无法被实例化从而杜绝切片。5. 常见问题与排查技巧实录5.1 问题速查表把继承场景下拷贝和赋值相关的典型症状整理成表方便对照排查症状可能原因排查方向程序析构时崩溃报double free派生类或基类缺深拷贝默认浅拷贝导致双重释放检查基类和派生类是否都实现了拷贝构造和赋值拷贝后基类数据为空或为默认值派生类拷贝构造没在初始化列表调用基类拷贝构造检查初始化列表是否有Base(other)赋值后基类数据没变派生类赋值没显式调用基类赋值检查是否有Base::operator(other)自赋值后对象数据损坏赋值运算符缺自赋值保护加if (this other) return *this;链式赋值abc结果不对赋值运算符返回类型不是引用确认返回Derived且return *this;通过基类指针拷贝后派生数据丢失对象切片基类设计为抽象类或避免按值传递基类赋值过程中抛异常后对象状态错乱资源释放和分配顺序不当改用先分配后释放或copy-and-swap5.2 几个容易忽略的边界情况第一个边界是虚析构函数。如果基类没有虚析构函数通过基类指针delete派生类对象时派生类的析构不会被调用派生类管理的资源泄漏。这个问题和拷贝构造经常一起出现因为都是资源管理类要处理的。写带继承的资源管理类时基类析构函数加virtual基本是标配。第二个边界是移动语义的干扰。C11之后如果你只写了拷贝构造没写移动构造编译器不会自动生成移动构造因为拷贝构造的存在抑制了移动构造的隐式生成。这时候用std::move反而会退化成拷贝。如果你希望类支持移动得显式写移动构造和移动赋值并且记得在移动版本里把源对象的指针置空避免析构时释放已转移的资源。第三个边界是继承链超过两层。三层继承时中间层的拷贝构造和赋值也要正确调用它自己的基类形成一条完整的调用链。任何一层漏掉那一层以上的基类部分就不会被正确拷贝。排查时从最顶层基类往下逐层检查确认每层都有显式调用。第四个边界是赋值运算符的返回类型协变问题。基类返回Base派生类返回Derived这是合法的协变返回但要注意派生类的operator会隐藏基类的如果想让基类版本在派生类作用域可见得用using Base::operator;引入。不过引入后可能带来重载解析的复杂性一般资源管理类不太需要知道有这回事即可。5.3 我踩过的两个真实坑第一个坑是早期写代码时以为派生类拷贝构造会自动调用基类拷贝构造因为“拷贝构造嘛当然是把整个对象拷贝一遍”。结果基类部分被默认构造数据丢失排查了半天才发现初始化列表里少写了一行。这个认知偏差的根源是把“拷贝构造”理解成了“拷贝整个对象”实际上它只负责派生类自己声明的那部分成员基类部分得你手动点名。第二个坑是赋值运算符里忘了自赋值保护测试时用a a跑了一次程序直接崩。当时还纳闷“自己赋值给自己怎么会崩”后来才明白是先delete了自己的资源再去读已经释放的内存。自赋值在实际业务里不常见但一旦出现就是崩溃级Bug而且很难通过常规测试发现所以自赋值保护这一行不能省。提示如果你用的是copy-and-swap写法自赋值天然安全因为参数传值那一步已经拷贝了一份独立的副本交换后原对象拿到副本自己释放自己的旧资源也没问题。这也是我推荐copy-and-swap的原因之一。6. 设计层面的取舍与建议6.1 什么时候该自己写什么时候该用智能指针前面所有讨论都建立在“手动管理裸资源”的前提下。但现代C里更省心的做法是用智能指针把资源管理委托出去。比如把基类里的char* str_换成std::string把派生类里的int* arr_换成std::vectorint那么编译器生成的默认拷贝构造和赋值就自动是深拷贝你一行都不用写继承场景下的那些坑也全部消失。所以我的实际建议是能用标准库容器和智能指针的地方就别自己管裸资源。std::string、std::vector、std::unique_ptr、std::shared_ptr这些类型已经把拷贝、赋值、析构都处理好了你组合使用它们编译器生成的默认版本就是正确的。只有在性能极度敏感、或者需要管理标准库覆盖不到的资源比如某些系统句柄时才值得自己写深拷贝。但反过来说理解手动深拷贝的原理依然重要。因为一旦你用了std::unique_ptr作为成员这个类就不可拷贝了unique_ptr禁止拷贝这时候你要么改用shared_ptr要么自己实现拷贝构造来深拷贝unique_ptr指向的对象。这种场景下前面讲的原理就派上用场了。6.2 Rule of Three 到 Rule of Five 的演进C11之前讲Rule of Three析构、拷贝构造、赋值重载写一个就得写三个。C11引入移动语义后扩展成Rule of Five再加上移动构造和移动赋值。判断标准是如果你的类管理资源这五个函数要么都写要么用智能指针让编译器全包。在继承场景下Rule of Five的每一环都要考虑基类。移动构造里要调用基类的移动构造移动赋值里要调用基类的移动赋值。如果基类没写移动版本派生类的移动会退化成拷贝性能优势就没了。所以设计可继承的资源管理基类时五个函数最好都配齐。6.3 一个实用的检查清单每次写完带继承的资源管理类我会按这个清单过一遍基类析构函数是否加了virtual基类是否实现了拷贝构造、赋值重载或用了智能指针派生类拷贝构造初始化列表是否显式调用了基类拷贝构造派生类赋值是否显式调用了基类赋值赋值运算符是否有自赋值保护赋值运算符是否返回引用资源分配和释放的顺序是否异常安全如果支持移动移动后源对象是否被置为合法可析构状态这个清单看着简单但每次过一遍能挡掉大部分低级错误。尤其是前四条几乎覆盖了继承场景下90%的拷贝赋值Bug。最后分享一个我个人的习惯写这类类的时候我会先在纸上画出对象的内存布局标出哪些成员是“拥有资源”的哪些是“借用”的然后对着布局图写拷贝逻辑。这个习惯帮我省了很多调试时间因为拷贝的本质就是“把对方拥有的资源复制一份给自己”布局图画清楚了代码自然就写对了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑