【C++ 面试真题】聊聊 C++ 的拷贝构造与拷贝赋值
上一篇我们讲了构造析构的"生与死"。本篇聚焦其中最坑的一对——拷贝构造和拷贝赋值。看似就是"复制一份",可一旦你的类管着资源(指针、文件句柄、socket),浅拷贝就会给你送上 double free、悬空指针、内存泄漏三连。还有那个面试必问的"三五法则"。本文把拷贝这件事一次讲透。
一、先说结论:拷贝 = “复制一份独立的”
❓ 拷贝构造和拷贝赋值有什么区别?
✅ 一句话:都是"复制一份",但触发的时机和对象状态不同。
| 维度 | 拷贝构造 | 拷贝赋值 |
|---|---|---|
| 形式 | T(const T&) | T& operator=(const T&) |
| 触发对象 | 一个还没诞生的对象 | 一个已经存在的对象 |
| 典型场景 | 按值传参/返回、用同类型初始化 | a = b;赋值 |
string s1="hi";// 构造(非拷贝)strings2(s1);// 拷贝构造:s2 新生string s3=s1;// 也是拷贝构造(语法糖)s2=s3;// 拷贝赋值:s2 已存在💡判别口诀:等号左边是新对象 → 拷贝构造;左边是老对象 → 拷贝赋值。
string s2 = s1;看着是=,但s2刚诞生,所以是构造,不是赋值。
二、拷贝何时被触发?
❓ 哪些代码会触发拷贝?
✅ 主要三类:
voidf(string s);// 1. 按值传参stringg(){string t="x";returnt;// 2. 按值返回}string a="y";stringb(a);// 3. 用同类型初始化⚠️注意:现代 C++ 里第 1、2 类经常被优化掉(拷贝消除 RVO/NRVO、C++17 起部分场景强制省略)。本文先讲清"拷贝本该发生时该怎么写对",第 8 节会专门提优化。
三、浅拷贝的灾难(核心考点)
❓ 编译器默认合成的拷贝,为什么常常出事?
✅ 因为默认拷贝是"逐成员 bitwise 复制"——成员是指针时,它只复制地址,不复制指向的内容。两个对象就指向同一块内存,析构时 double free。
经典翻车现场:
structBuffer{Buffer(intn):data_(newint[n]),n_(n){}~Buffer(){delete[]data_;}// 没写拷贝构造/赋值// 用编译器合成的(逐成员复制)int*data_;intn_;};{Buffera(10);Buffer b=a;// 拷贝构造// a.data_ == b.data_,指向同一块!}// a、b 离开作用域// 析构 b:delete[] data_;// 析构 a:delete[] 同一块// ❌ double free,程序崩溃⚠️更隐蔽的版本:不止崩溃——两个对象里一个改了
data_,另一个也跟着变(它们共享同一块内存)。这种"幽灵修改"在大型项目里极难排查。只要类管着裸资源(指针、句柄),就不能用默认拷贝。
四、深拷贝怎么写?
❓ 浅拷贝这么坑,正确的拷贝该怎么做?
✅深拷贝:不只复制指针,而是重新分配一块新内存,把内容复制过去,让两个对象各持一份。
structBuffer{Buffer(intn):data_(newint[n]),n_(n){}// 拷贝构造:分配 + 复制Buffer(constBuffer&o):data_(newint[o.n_]),n_(o.n_){// 把内容逐个复制过去for(inti=0;i<n_;++i)data_[i]=o.data_[i];}~Buffer(){delete[]data_;}int*data_;intn_;};Buffera(10);Buffer b=a;// ✅ b 有自己的 10 个 int// 改 b.data_[0] 不影响 a💡核心区别:浅拷贝复制的是"门牌号"(地址),深拷贝复制的是"房子"(内容)+ 给你盖一栋新门牌号。
五、拷贝赋值:自赋值与三步走
❓ 拷贝赋值比拷贝构造多了什么坑?
✅ 多两件事:目标对象已存在(要先释放旧资源),还要防自赋值。
Buffer&operator=(constBuffer&o){if(this==&o)return*this;// 1. 自赋值检查delete[]data_;// 2. 释放旧的data_=newint[o.n_];// 3. 分配新的n_=o.n_;for(inti=0;i<n_;++i)data_[i]=o.data_[i];// 复制内容return*this;}Buffera(10);a=a;// 没有自赋值检查的话:// delete[] data_; 然后 new ...// 但 o.data_ 就是 a.data_// 复制的是已释放的内存!⚠️自赋值不写?正常代码里
a = a很少直接出现,但通过别名间接发生很常见:a[i] = a[j]、两个引用指向同一对象。所以拷贝赋值永远要先防自赋值。
💡加分写法——copy-and-swap:把"赋值"拆成"先拷贝构造一份临时对象,再和自己的内部资源交换",自赋值、异常安全一次性解决。这个写法也是移动语义的天然搭档(移动构造配合 swap 尤其优雅)。
六、三五法则(Rule of Three/Five)
❓ 面试总听到的"三五法则"是什么?
✅ 一句话:一旦你需要自定义析构、拷贝构造、拷贝赋值三者之一,另外两个通常也得自己写。
structBuffer{~Buffer(){delete[]data_;}// 既然你要管析构(释放资源)// 拷贝构造、拷贝赋值几乎肯定也要管// 否则就用默认的 bitwise 复制 → 出事};| 法则 | 涉及函数 | 适用 |
|---|---|---|
| Rule of Three | 析构 + 拷贝构造 + 拷贝赋值 | C++98 |
| Rule of Five | 上面三个 + 移动构造 + 移动赋值 | C++11 起 |
🎯本质:这三个函数都和"资源管理"绑定。你需要自定义其中一个,说明这个类管着需要特殊处理的资源——那"复制它"、“移动它”、"释放它"自然都得特殊处理。反过来:如果类全是普通成员(int、string、vector 这些会自己管资源),就一个都别写,让编译器合成的更好、更安全。
七、什么时候默认拷贝就够用?
❓ 那些用着指针的类才要写拷贝,那普通类呢?
✅只要类里没有"自己裸管的资源",默认拷贝就完全够用——标准库的容器、string、智能指针都自己管好了自己,把它们当成员,类就"白嫖"了它们的正确拷贝。
structGood{std::vector<int>v;std::string name;std::unique_ptr<FILE>fp;// 一个拷贝函数都不用写!// vector/string/unique_ptr 各自正确处理};💡这就是现代 C++ 的方向:把资源托管给 RAII 类型(容器、智能指针),让你的类rule of zero(零法则)——一个特殊成员函数都不写,编译器合成的最优。需要手写三大/五大法则的,基本是"还在用裸 new/delete 的老式类"。
八、拷贝消除(RVO/NRVO)
❓ 我明明按值返回了局部对象,怎么没看到拷贝?
✅ 因为编译器把那次拷贝优化掉了——这叫拷贝消除(copy elision),分两种:
- RVO(Return Value Optimization):返回一个临时对象;
- NRVO:返回一个有名字的局部对象。
stringmake(){string t="hello";returnt;// NRVO:t 直接在调用方构造}string s=make();// 可能 0 次拷贝C++17 起,RVO 在特定条件下是强制的(guaranteed copy elision);NRVO 仍是可选优化,但几乎所有主流编译器都开。
⚠️重要推论:正因为拷贝常被消除,别为了"省拷贝"而乱写奇怪的代码(比如返回
std::move(局部对象)——这反而会阻止 NRVO!这是高频坑)。
九、面试高频追问
❓ Q1:拷贝构造的参数为什么必须是引用?
✅ 因为按值传参会触发拷贝构造——而你现在定义的正是拷贝构造,会造成无限递归。所以参数必须是引用(通常const T&),避免再次拷贝。
❓ Q2:拷贝构造为什么常加const?
✅ 两点:①不修改源对象(拷贝本应只读);②能接收常量对象和右值(非 const 引用绑不了右值)。
❓ Q3:三五法则反过来成立吗?写了析构就一定得写拷贝?
✅ 几乎一定。编译器看到你写了析构,说明类管着资源;默认的 bitwise 拷贝大概率是错的。虽然 C++11 后编译器看到自定义析构时会把合成拷贝标为 deprecated,但最稳妥还是要么三个都写,要么一个都不写(rule of zero)。
❓ Q4:= delete禁用拷贝和把拷贝设为 private 有什么区别?
✅= delete是编译期明确报错,任何代码(包括成员/友元)调都报;private只挡外部,类自己/友元还能调。C++11 后推荐= delete(std::mutex、std::unique_ptr就这么干)。
❓ Q5:拷贝赋值为什么不返回void,而返回*this?
✅ 为了支持链式赋值(a = b) = c和a = b = c。标准约定拷贝赋值返回T&(指向左操作数自己),遵循这个约定才能和内置类型、标准库混用不出错。
十、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 构造 vs 赋值 | 左边新对象→构造;左边老对象→赋值 |
| 默认拷贝 | 逐成员 bitwise 复制,指针成员会出事 |
| 深拷贝 | 重新分配 + 复制内容 |
| 拷贝赋值三步 | 自赋值检查 → 释放旧 → 分配+复制 |
| 三五法则 | 自定义析构/拷贝之一,其余通常都要写 |
| Rule of Zero | 用 RAII 成员,一个都不写 |
| RVO/NRVO | 按值返回的拷贝常被优化掉 |
| 参数为什么是引用 | 按值会无限递归 |
一句话回顾
管着裸资源的类,拷贝构造和拷贝赋值得自己写成深拷贝;尽量用容器/智能指针当成员,享受"零法则"——让编译器合成的又快又对。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续——讲拷贝的"升级版":移动语义。std::move到底 move 了什么、为什么移动构造要noexcept、vector 扩容为什么会偷偷退回拷贝,敬请关注 👋