【C++ 面试真题】聊聊 C++ 的拷贝构造与拷贝赋值

【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 后推荐 = deletestd::mutexstd::unique_ptr 就这么干)。

❓ Q5:拷贝赋值为什么不返回 void,而返回 *this

✅ 为了支持链式赋值 (a = b) = ca = b = c。标准约定拷贝赋值返回 T&(指向左操作数自己),遵循这个约定才能和内置类型、标准库混用不出错。


十、总结速查表

考点 一句话结论
构造 vs 赋值 左边新对象→构造;左边老对象→赋值
默认拷贝 逐成员 bitwise 复制,指针成员会出事
深拷贝 重新分配 + 复制内容
拷贝赋值三步 自赋值检查 → 释放旧 → 分配+复制
三五法则 自定义析构/拷贝之一,其余通常都要写
Rule of Zero 用 RAII 成员,一个都不写
RVO/NRVO 按值返回的拷贝常被优化掉
参数为什么是引用 按值会无限递归

一句话回顾

管着裸资源的类,拷贝构造和拷贝赋值得自己写成深拷贝;尽量用容器/智能指针当成员,享受"零法则"------让编译器合成的又快又对。

如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续------讲拷贝的"升级版":移动语义。std::move 到底 move 了什么、为什么移动构造要 noexcept、vector 扩容为什么会偷偷退回拷贝,敬请关注 👋

相关推荐
旖旎夜光2 小时前
LeetCode 3:无重复字符的最长子串(滑动窗口) —— 题解
数据结构·c++·算法·leetcode·滑动窗口
汉字萌萌哒2 小时前
2024CSP-J入门级C++真题详解
开发语言·c++
sakiko_2 小时前
OC学习笔记-面试题
面试·objective-c
极创信息2 小时前
系统安全隐患全方位排查方案:标准化渗透测试全流程
java·opencv·struts·数据挖掘·eclipse·语音识别·hibernate
hy.z_7772 小时前
【C++】13. 继承
c++
Heo3 小时前
大厂前端调试不能只会debugger
前端·javascript·面试
汉字萌萌哒3 小时前
2019CCF-CSP入门级C++试题解析
java·开发语言·c++
喜欢的名字被抢了3 小时前
程序出问题怎么查,以及如何让它不掉线
java·运维·数据库
2401_890603403 小时前
C++ 继承
c++