一文吃透:什么是浅拷贝?什么是深拷贝?默认拷贝做了什么?哪些类型需要你亲自写"深拷贝"?
Rule of Three/Five/Zero、copy-swap、移动语义、智能指针与标准容器的真实拷贝语义是什么?
目录
1. 两个术语的最短定义
-
浅拷贝(Shallow Copy) :只复制"指针值/句柄"等外壳 ,不复制其指向/管理的底层资源。两个对象共享同一块资源。
-
深拷贝(Deep Copy) :复制对象自身 的同时,也复制它所管理的底层资源(开辟新存储、逐元素拷贝等),两个对象彼此独立。
判断标准不是"代码写得多与少",而是拷贝后两个对象是否共享底层资源。
2. C++ 里"拷贝"发生在何处
以下场景会触发拷贝构造 或拷贝赋值:
-
以值传参/以值返回(未被移动/优化省略时)
-
用一个对象初始化另一个对象:
T b = a;/T b(a); -
赋值:
b = a; -
容器在扩容、插入时复制元素(若没有移动可用)
-
标准算法需要复制元素时
此外还有移动构造/移动赋值(C++11+),下文详解。
3. "默认拷贝"(编译器生成)的真实行为
当你没有显式定义 拷贝构造/拷贝赋值时,编译器会做成员逐一拷贝(memberwise copy):
-
内置类型 (
int/double/struct POD...)值拷贝 -
类成员:调用它们各自的拷贝构造/赋值
-
指针成员 :只是拷贝指针的值,不复制指针指向的数据(这就是浅拷贝)
这意味着:一旦你的类自己"管理资源"(裸指针指向堆内存、文件句柄、套接字、GPU 缓冲等),默认拷贝往往是危险的浅拷贝。
4. 浅拷贝的典型灾难:双重释放与悬垂指针
下面是反例:
cpp
#include <cstring>
#include <iostream>
class BufferBad {
public:
BufferBad(size_t n)
: size_(n), data_(new char[n]) { std::memset(data_, 0, n); }
// ❌ 没有自定义拷贝构造/拷贝赋值,编译器会生成"浅拷贝"
~BufferBad() { delete[] data_; }
void fill(char c) { std::memset(data_, c, size_); }
char* data() { return data_; }
private:
size_t size_;
char* data_;
};
int main() {
BufferBad a(8);
a.fill('A');
BufferBad b = a; // ⚠️ 浅拷贝:b.data_ 和 a.data_ 指向同一块内存
// 程序结束时 ~BufferBad() 被调用两次 → 同一指针 delete[] 两次 → 未定义行为(炸)
}
问题本质 :浅拷贝导致两个对象共享一份资源,而析构会释放两次;如果其中一个对象释放后另一个继续访问,这又变成悬垂指针。
5. 正确实现深拷贝:Rule of Three/Five、copy-swap
5.1 Rule of Three / Five / Zero
-
Rule of Three :如果类自己管理资源,你通常要同时自定义:
-
析构函数(
~T()) -
拷贝构造(
T(const T&)) -
拷贝赋值(
T& operator=(const T&))
-
-
Rule of Five (C++11+):在上述三者基础上再 加上:
-
移动构造(
T(T&&) noexcept) -
移动赋值(
T& operator=(T&&) noexcept)
-
-
Rule of Zero :如果你把资源交给标准库类型 (如
std::vector/std::string/智能指针)管理,那么不需要自己写上面这些特殊成员函数(默认生成就好)。
5.2 手写一个"深拷贝 + 移动"的安全类
cpp
#include <cstring>
#include <utility>
#include <stdexcept>
class Buffer {
public:
explicit Buffer(size_t n = 0) : size_(n), data_(n ? new char[n] : nullptr) {
if (data_) std::memset(data_, 0, n);
}
// 深拷贝:拷贝构造
Buffer(const Buffer& other) : size_(other.size_), data_(other.size_ ? new char[other.size_] : nullptr) {
if (data_) std::memcpy(data_, other.data_, size_);
}
// 深拷贝:拷贝赋值(强烈推荐 copy-swap 写法)
Buffer& operator=(Buffer other) { // ← 这里按值传参,构造一个临时副本(调用拷贝或移动构造)
swap(other); // 与临时副本交换 → 强异常安全 & 自然处理自赋值
return *this; // 临时副本析构时释放旧资源
}
// 移动构造(转移所有权,不分配/拷贝数据)
Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
// 移动赋值(同理,用 swap 即可)
Buffer& operator=(Buffer&& other) noexcept {
swap(other);
return *this;
}
~Buffer() { delete[] data_; }
void fill(char c) {
if (!data_) throw std::runtime_error("empty buffer");
std::memset(data_, c, size_);
}
void swap(Buffer& rhs) noexcept {
std::swap(size_, rhs.size_);
std::swap(data_, rhs.data_);
}
size_t size() const noexcept { return size_; }
const char* data() const noexcept { return data_; }
private:
size_t size_;
char* data_;
};
要点:
-
深拷贝 :在拷贝构造中重新
new,并memcpy原数据。 -
拷贝赋值 :用 copy-swap 惯用法 实现,天然处理异常安全与自赋值,代码简洁。
-
移动语义 :移动构造/赋值只做指针偷取与置空,避免分配与拷贝。
6. 移动语义与深/浅拷贝的关系
-
移动(move)既不是浅拷贝也不是深拷贝:它是**"转移资源所有权"**的第三种策略。
-
有了移动语义,容器扩容、返回局部对象等场景可避免昂贵的深拷贝。
-
当右值可用且你支持
T(T&&)/operator=(T&&)时,标准库会优先移动而非拷贝。
7. 标准库类型的拷贝语义(必须搞清)
-
std::string、std::vector<T>、std::array、std::map等容器/字符串 :深拷贝"自己的存储" (逐元素拷贝),但元素本身如何拷贝取决于元素类型的拷贝语义。-
std::vector<int>:拷贝就是复制每个int。 -
std::vector<char*>:只会浅拷贝指针值(指针指向的外部内存不会被复制)。
-
-
std::unique_ptr<T>:独占所有权 ,不可拷贝 (拷贝会编译失败),可移动。这能从语法上阻止"浅拷贝共享同一裸指针"的灾难。 -
std::shared_ptr<T>:共享所有权 ,拷贝会增加引用计数 ------这不是深拷贝数据本身,而是浅拷贝指针 + 引用计数管理 。多个shared_ptr指向同一对象,最后一个销毁时才释放。 -
std::span<T>:非拥有视图,拷贝只是复制"视图",绝不复制底层数据。
误区澄清:"容器拷贝是深拷贝"这句话要加前提------它只对容器自身所拥有的存储 成立;元素如果是指针/句柄,容器不会替你复制指针所指向的资源。
8. 何时选深拷贝,何时避免拷贝
-
你的类自己拥有并负责释放 底层资源(裸指针/句柄/文件/GPU 内存):要么实现深拷贝 + 移动 ,要么禁止拷贝(=delete)只允许移动。
-
性能敏感并且"复制"成本高:优先提供移动 ;必要时设计共享语义 (
shared_ptr/引用计数/写时复制 COW)。 -
能用 Rule of Zero 就不要手写特殊成员:把资源交给
std::vector、std::string、std::unique_ptr等来管理。
9. 高频面试/作业陷阱清单
-
默认拷贝 + 裸指针 → 双重释放/悬垂指针(经典坑)。
-
只写了析构或拷贝构造,却忘了拷贝赋值/移动成员 → 违反 Rule of Three/Five。
-
shared_ptr拷贝是共享所有权,不是深拷贝底层对象(很多人误会)。 -
容器里放指针:容器拷贝只是浅拷贝这些指针;要深拷贝请放对象本体或自定义克隆逻辑。
-
自赋值 未处理:
x = x;可能崩;用 copy-swap 最省心。 -
异常安全:先构造副本,再交换,保证强异常安全。
-
移动操作缺
noexcept:容器优化可能退化为拷贝,性能直线下降。
10. 完整示例:从踩坑到优雅
10.1 错误版:浅拷贝导致双删
cpp
class ImageBad {
public:
explicit ImageBad(size_t n) : n_(n), pixels_(new uint8_t[n]) {}
~ImageBad() { delete[] pixels_; }
// ❌ 未定义拷贝语义 → 默认浅拷贝(拷贝指针值)
private:
size_t n_;
uint8_t* pixels_;
};
10.2 正确版 A:深拷贝 + 移动 + copy-swap
cpp
#include <cstring>
#include <utility>
class Image {
public:
explicit Image(size_t n = 0) : n_(n), pixels_(n ? new uint8_t[n] : nullptr) {}
Image(const Image& rhs) : n_(rhs.n_), pixels_(rhs.n_ ? new uint8_t[rhs.n_] : nullptr) {
if (pixels_) std::memcpy(pixels_, rhs.pixels_, n_);
}
Image& operator=(Image rhs) { // 拷贝或移动进来
swap(rhs); // 与临时对象交换
return *this; // rhs 析构释放旧资源
}
Image(Image&& rhs) noexcept : n_(rhs.n_), pixels_(rhs.pixels_) {
rhs.n_ = 0; rhs.pixels_ = nullptr;
}
Image& operator=(Image&& rhs) noexcept {
swap(rhs);
return *this;
}
~Image() { delete[] pixels_; }
void swap(Image& other) noexcept {
std::swap(n_, other.n_);
std::swap(pixels_, other.pixels_);
}
private:
size_t n_ = 0;
uint8_t* pixels_ = nullptr;
};
10.3 正确版 B:Rule of Zero(推荐)
根本不自己管裸指针,资源交给标准库:
cpp
#include <vector>
#include <cstdint>
class ImageSafe {
public:
explicit ImageSafe(size_t n = 0) : pixels_(n) {} // vector 自管内存
// 无需写析构/拷贝/赋值/移动:默认全 OK(Rule of Zero)
private:
std::vector<uint8_t> pixels_;
};
优点:代码最短、异常安全、天然深拷贝元素、自动支持移动、性能友好。
11. 总结与实践建议
-
判断标准:拷贝后是否共享底层资源?共享 = 浅,不共享 = 深。
-
默认拷贝是成员逐一拷贝:一旦自己管理资源,默认拷贝几乎必错。
-
遵循 Rule of Three/Five/Zero:
-
能 Rule of Zero 就 Rule of Zero(容器/智能指针)。
-
必须手写就三/五件套 +
copy-swap+noexcept move。
-
-
移动语义优先:让昂贵对象在容器/返回值场景下移动而非拷贝。
-
别把裸指针放进容器,放对象或智能指针(并理解其所有权语义)。
-
shared_ptr拷贝不是深拷贝 ,而是共享所有权;如果需要克隆底层对象,请自定义clone()或自定义拷贝逻辑。
附:一个小型演示 main(可直接测试)
cpp
#include <iostream>
#include <vector>
#include <string>
// 放上面定义的 Buffer 或 Image 类...
int main() {
// 1) 深拷贝验证
Buffer a(4);
a.fill('X');
Buffer b = a; // 调用拷贝构造:深拷贝
Buffer c; c = a; // 调用拷贝赋值:深拷贝(copy-swap)
// 2) 移动验证
Buffer d = std::move(a); // a 资源被转移,a 置空
Buffer e; e = std::move(b);
// 3) Rule of Zero:vector 深拷贝元素
std::vector<int> v1 = {1,2,3};
std::vector<int> v2 = v1; // 拷贝了每个 int,互不影响
v1[0] = 99;
std::cout << v1[0] << " " << v2[0] << "\n"; // 99 1
// 4) 指针元素只是浅拷贝指针值(演示语义,勿在生产中这样做)
std::vector<const char*> p1 = {"abc", "def"};
std::vector<const char*> p2 = p1; // 只是拷贝了指针
std::cout << p1[0] << " " << p2[0] << "\n"; // 都指向同一字符串常量
return 0;
}
最后一句话:
在现代 C++ 中,能不用裸指针就不用 ,能交给
std::vector/std::string/ 智能指针 管理就交给它们。这样你几乎不再需要自己写"深拷贝",自然获得正确、简洁且高性能的代码。