【C++ 面试真题】聊聊 C++ 的拷贝构造与拷贝赋值
上一篇我们讲了构造析构的"生与死"。本篇聚焦其中最坑的一对------拷贝构造和拷贝赋值。看似就是"复制一份",可一旦你的类管着资源(指针、文件句柄、socket),浅拷贝就会给你送上 double free、悬空指针、内存泄漏三连。还有那个面试必问的"三五法则"。本文把拷贝这件事一次讲透。
一、先说结论:拷贝 = "复制一份独立的"
❓ 拷贝构造和拷贝赋值有什么区别?
✅ 一句话:都是"复制一份",但触发的时机和对象状态不同。
| 维度 | 拷贝构造 | 拷贝赋值 |
|---|---|---|
| 形式 | T(const T&) |
T& operator=(const T&) |
| 触发对象 | 一个还没诞生的对象 | 一个已经存在的对象 |
| 典型场景 | 按值传参/返回、用同类型初始化 | a = b; 赋值 |
cpp
string s1 = "hi"; // 构造(非拷贝)
string s2(s1); // 拷贝构造:s2 新生
string s3 = s1; // 也是拷贝构造(语法糖)
s2 = s3; // 拷贝赋值:s2 已存在
💡 判别口诀 :等号左边是新对象 → 拷贝构造;左边是老对象 → 拷贝赋值 。
string s2 = s1;看着是=,但s2刚诞生,所以是构造,不是赋值。
二、拷贝何时被触发?
❓ 哪些代码会触发拷贝?
✅ 主要三类:
cpp
void f(string s); // 1. 按值传参
string g() {
string t = "x";
return t; // 2. 按值返回
}
string a = "y";
string b(a); // 3. 用同类型初始化
⚠️ 注意 :现代 C++ 里第 1、2 类经常被优化掉(拷贝消除 RVO/NRVO、C++17 起部分场景强制省略)。本文先讲清"拷贝本该发生时该怎么写对",第 8 节会专门提优化。
三、浅拷贝的灾难(核心考点)
❓ 编译器默认合成的拷贝,为什么常常出事?
✅ 因为默认拷贝是"逐成员 bitwise 复制" ------成员是指针时,它只复制地址,不复制指向的内容。两个对象就指向同一块内存,析构时 double free。
经典翻车现场:
cpp
struct Buffer {
Buffer(int n)
: data_(new int[n]), n_(n) {}
~Buffer() { delete[] data_; }
// 没写拷贝构造/赋值
// 用编译器合成的(逐成员复制)
int* data_;
int n_;
};
cpp
{
Buffer a(10);
Buffer b = a; // 拷贝构造
// a.data_ == b.data_,指向同一块!
}
// a、b 离开作用域
// 析构 b:delete[] data_;
// 析构 a:delete[] 同一块
// ❌ double free,程序崩溃
⚠️ 更隐蔽的版本 :不止崩溃------两个对象里一个改了
data_,另一个也跟着变(它们共享同一块内存)。这种"幽灵修改"在大型项目里极难排查。只要类管着裸资源(指针、句柄),就不能用默认拷贝。
四、深拷贝怎么写?
❓ 浅拷贝这么坑,正确的拷贝该怎么做?
✅ 深拷贝 :不只复制指针,而是重新分配一块新内存,把内容复制过去,让两个对象各持一份。
cpp
struct Buffer {
Buffer(int n)
: data_(new int[n]), n_(n) {}
// 拷贝构造:分配 + 复制
Buffer(const Buffer& o)
: data_(new int[o.n_]), n_(o.n_) {
// 把内容逐个复制过去
for (int i = 0; i < n_; ++i)
data_[i] = o.data_[i];
}
~Buffer() { delete[] data_; }
int* data_;
int n_;
};
cpp
Buffer a(10);
Buffer b = a; // ✅ b 有自己的 10 个 int
// 改 b.data_[0] 不影响 a
💡 核心区别:浅拷贝复制的是"门牌号"(地址),深拷贝复制的是"房子"(内容)+ 给你盖一栋新门牌号。
五、拷贝赋值:自赋值与三步走
❓ 拷贝赋值比拷贝构造多了什么坑?
✅ 多两件事:目标对象已存在(要先释放旧资源) ,还要防自赋值。
cpp
Buffer& operator=(const Buffer& o) {
if (this == &o) return *this; // 1. 自赋值检查
delete[] data_; // 2. 释放旧的
data_ = new int[o.n_]; // 3. 分配新的
n_ = o.n_;
for (int i = 0; i < n_; ++i)
data_[i] = o.data_[i]; // 复制内容
return *this;
}
cpp
Buffer a(10);
a = a; // 没有自赋值检查的话:
// delete[] data_; 然后 new ...
// 但 o.data_ 就是 a.data_
// 复制的是已释放的内存!
⚠️ 自赋值不写? 正常代码里
a = a很少直接出现,但通过别名间接发生 很常见:a[i] = a[j]、两个引用指向同一对象。所以拷贝赋值永远要先防自赋值。
💡 加分写法------copy-and-swap:把"赋值"拆成"先拷贝构造一份临时对象,再和自己的内部资源交换",自赋值、异常安全一次性解决。这个写法也是移动语义的天然搭档(移动构造配合 swap 尤其优雅)。
六、三五法则(Rule of Three/Five)
❓ 面试总听到的"三五法则"是什么?
✅ 一句话:一旦你需要自定义析构、拷贝构造、拷贝赋值三者之一,另外两个通常也得自己写。
cpp
struct Buffer {
~Buffer() { delete[] data_; }
// 既然你要管析构(释放资源)
// 拷贝构造、拷贝赋值几乎肯定也要管
// 否则就用默认的 bitwise 复制 → 出事
};
| 法则 | 涉及函数 | 适用 |
|---|---|---|
| Rule of Three | 析构 + 拷贝构造 + 拷贝赋值 | C++98 |
| Rule of Five | 上面三个 + 移动构造 + 移动赋值 | C++11 起 |
🎯 本质 :这三个函数都和"资源管理 "绑定。你需要自定义其中一个,说明这个类管着需要特殊处理的资源------那"复制它"、"移动它"、"释放它"自然都得特殊处理。反过来 :如果类全是普通成员(int、string、vector 这些会自己管资源),就一个都别写,让编译器合成的更好、更安全。
七、什么时候默认拷贝就够用?
❓ 那些用着指针的类才要写拷贝,那普通类呢?
✅ 只要类里没有"自己裸管的资源",默认拷贝就完全够用------标准库的容器、string、智能指针都自己管好了自己,把它们当成员,类就"白嫖"了它们的正确拷贝。
cpp
struct Good {
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:返回一个有名字的局部对象。
cpp
string make() {
string t = "hello";
return t; // 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 扩容为什么会偷偷退回拷贝,敬请关注 👋