我见过很多“深拷贝 / 浅拷贝”的事故现场。
它们的开头都挺朴素。
你写下一行。
看着很正派。
甚至还有点洁癖。
std::vector<T*> copy = v;
编译过了。
单测也过了。
然后你就把它合了。
等到某个夜里。
压测一上来。
日志里开始出现你不想看的那种句子。
double free detected
或者更阴险一点。
它不崩。
它只是悄悄把数据写坏。
第二天你去定位。
发现罪魁祸首就是这行“看起来很无辜的复制”。
这时候我一般会对新人说一句老话。
拷贝在 C++ 里从来不是一个动作。 它是一份契约。
你写下 copy = v。
你其实是在问编译器:
“兄弟。
我想要一份新的东西。
你觉得我想要的是新的值。
还是新的所有权?”
编译器不会猜。
它只会按规则办事。
而默认规则。
说白了。
就是把每个成员按字面抄一遍。
这在很多类型上很美。
在裸指针上很要命。
1. 先把图画出来:对象本体,和资源本体
我喜欢把“拥有资源的对象”拆成两层看。
一层是对象本体。
它在栈上。
也可能在另一个对象的成员区里。
另一层是资源本体。
它通常在堆上。
或者在系统里。
对象本体:普通字段 + 资源句柄(指针 / fd / handle)
资源本体:真正的数据(堆 / 系统)
所谓浅拷贝。
多半就是“把句柄也抄一份”。
于是两个对象。
指向同一份资源。
a(对象本体) b(对象本体)
+---------+ +---------+
| data_ |----+ => | data_ |----+
+---------+ | +---------+ |
v v
同一块资源本体 同一块资源本体
所谓深拷贝。
则是“句柄抄一份没用”。
你得给新对象再分配一份资源。
把内容也复制过去。
于是两个对象看起来一样。
但背后各养各的。
2. 再问一句:你到底想拷贝什么
C 时代没有这个烦恼。
大家写结构体。
拷贝就是拷贝字节。
memcpy 一把梭。
谁 malloc 谁 free。
靠纪律。
靠口口相传。
后来 C++ 站出来。
Bjarne 当年押了两个赌。
一个赌叫 RAII。
资源要跟着对象走。
对象死了。
资源也得体面退场。
另一个赌更大。
它坚持值语义。
你可以像拷贝 int 一样拷贝一个对象。
问题也从这里长出来。
对象里要是藏了资源。
那“拷贝对象”是拷贝值。
还是拷贝所有权。
这俩目标。
看起来都叫 copy。
工程结果却完全不同。
先看“纯值”的世界。
你复制配置。
复制参数。
你想要的就是状态的副本。
struct JobConfig {
int threads = 8;
int timeoutMs = 5000;
};
JobConfig base;
JobConfig cfg = base;
cfg.timeoutMs = 8000;
你不会纠结谁释放。
因为压根没资源。
再看“有资源”的世界。
同样的语法。
味道立刻就变了。
struct RawBuffer {
std::size_t n;
int* data;
};
int* p = new int[4];
RawBuffer a{4, p};
RawBuffer b = a;
这里 b.data == a.data。
你拷贝到的是地址。
不是数据。
这时候你必须回答一个很现实的问题。
这块 p。
到底谁来 delete[]。
你不回答。
程序会替你回答。
回答方式通常不太礼貌。
要么双重释放。
要么悬空指针。
要么默默泄漏。
你如果真的想要“每个对象都有自己一份数据”。
那所谓深拷贝,大概就是这样。
RawBuffer c{a.n, new int[a.n]};
std::copy(a.data, a.data + a.n, c.data);
你如果想要的是“共享同一份数据,但别重复释放”。
那就别用裸指针演戏。
把共享写进类型里。
比如 std::shared_ptr。
深拷贝也好。
浅拷贝也好。
本质都不是技巧。
是你给类型立的规矩。
3. 浅拷贝:默认规则,专治“我以为”
这一节你只要记住一件事。
默认拷贝很诚实。
它不做决定。
它只干体力活。
你写。
T b = a;
你心里多半想的是。
“我复制了一个对象。”
编译器的理解更朴素。
“行。
我把每个成员抄一遍。”
这就叫默认拷贝。
逐成员。
照字面。
不猜你的业务。
也不替你选语义。
有些类型。
你这么抄。
一点问题都没有。
比如最朴素的点。
struct Point {
int x;
int y;
};
Point a{1, 2};
Point b = a;
两个 int。
抄一遍。
各过各的日子。
到这里为止。
世界都挺温柔。
问题是。
你很容易把这份温柔当成默认。
然后你就会对着一根裸指针。
写出同样的拷贝。
struct Raw {
int* p;
};
int x = 7;
Raw a{&x};
Raw b = a;
b.p == a.p。
这行如果你看着不紧张。
那说明你还没被它坑过。
它的意思很简单。
你没有拿到第二份数据。
你只是把“地址”又抄了一遍。
两个人。
拿着同一把钥匙。
进同一间屋子。
这不怪编译器。
它从头到尾都很诚实。
指针。
就是按值复制。
它从来没答应你“帮你把屋子也复制一份”。
所以你接下来写。
*b.p = 42;
*a.p 也会变。
这也不是 bug。
这是语义。
只不过是那种“没写清楚就会要命”的语义。
如果你真的想共享。
那你就该把共享写到类型里。
让别人一眼看出来。
如果你不想共享。
那你就别让默认拷贝替你做决定。
容器也一样。
std::vector<int> 看起来很像“深”。
因为元素是 int。
它确实是在拷值。
std::vector<int> a{1, 2, 3};
auto b = a;
b[0] = 42;
a[0] 不变。
这很像“各养各的”。
但你把元素换成指针。
味道立刻就变了。
它又回到刚才那根裸指针的世界。
int x = 1;
std::vector<int*> v{&x};
auto w = v;
w[0] == v[0]。
容器拷贝的是一堆地址。
不是 x 的副本。
所以我一直说。
浅拷贝真正阴险的地方不在“浅”。
在“你以为它深”。
4. 最小复现:double free 这件事怎么来的
写个能炸的例子。
我不想写复杂。
就写那种你在古董代码里常见的类。
自己 new[]。
自己 delete[]。
struct Buffer {
std::size_t n_ = 0;
int* data_ = nullptr;
explicit Buffer(std::size_t n) : n_(n), data_(new int[n]) {}
~Buffer() { delete[] data_; }
};
到这里为止。
它看起来还挺守规矩。
资源跟着对象走。
对象死了。
资源也走。
戏从这行开始。
Buffer a(4);
Buffer b = a;
你没写拷贝构造。
编译器就给你一个。
它做的事情也很朴素。
把 n_ 抄过去。
把 data_ 也抄过去。
于是 a.data_ 和 b.data_ 指向同一块堆内存。
这时候程序还不炸。
它会等。
等到析构。
a 析构。
delete[] 一次。
b 析构。
再 delete[] 一次。
第二次释放。
就看你运气了。
glibc 有时候会直接告诉你。
“double free detected”。
也有时候它不吭声。
你以为没事。
其实堆已经开始腐烂。
这就是为什么老程序员看到“拥有裸指针的可拷贝类型”。
第一反应不是惊喜。
是紧张。
5. 深拷贝:内容也得搬家
你说你要深拷贝。
翻译成工程人能听懂的话。
就是这句。
两个对象可以长得一样。
但家当不能共用。
对 Buffer 来说。
家当就是那块 new[] 出来的内存。
所以深拷贝的关键动作只有一个。
给新对象再申请一块。
把旧内容抄过去。
Buffer(const Buffer& other)
: n_(other.n_), data_(new int[other.n_]) {
std::copy(other.data_, other.data_ + other.n_, data_);
}
到这里。
a 和 b 的内容可以一样。
但 a.data_ 和 b.data_ 不再是同一个地址。
析构各管各的。
这一步很多人写得出来。
真正让人写到皱眉的。
是下一件事。
赋值。
6. Rule of 3 / 5 / 0:这不是教条,是事故统计
我第一次听到 Rule of 3。
是在一个老前辈嘴里。
他说得很难听。
“你写了析构。
就等于承认你在手动养资源。
那你就别指望编译器替你把拷贝这事也养好。”
这话糙。
但在 C++98 那个年代。
确实是救命的经验。
因为当时的库不如今天这么强。
很多人为了省事。
喜欢把资源塞进一个裸指针。
再写个析构释放。
然后觉得自己已经很 RAII 了。
结果对象一拷贝。
不是 double free。
就是泄漏。
Rule of 3 说的其实很朴素。
你既然决定自己管释放。
那你也得决定“复制时怎么算”。
拷贝构造要管。
拷贝赋值也要管。
你不管。
默认逐成员拷贝就会来接手。
接手方式你已经见过了。
同一把钥匙复制两份。
两个人都觉得房子是自己的。
到关门那一刻。
就打起来。
后来 C++11 来了。
社区又补了一刀。
Rule of 5。
因为你除了“怎么拷贝”。
还得想“怎么移动”。
移动这事本来是好东西。
它让很多以前必须拷贝的地方。
可以只是转交所有权。
但代价是。
你手写资源管理类的时候。
要照顾的函数更多了。
再后来。
大家终于忍不住了。
开始推 Rule of 0。
意思也很直。
别手写。
别硬抗。
把资源交给标准库类型。
你自己别写析构。
也别写拷贝。
让编译器生成的默认行为。
落在 std::vector、std::string、std::unique_ptr 这些“早就把契约写清楚”的类型上。
这不是偷懒。
这是现代 C++ 的正道。
当然。
你现在读这篇文章。
大概率就是因为你手里还真有一个 Buffer。
你没法立刻把它换成 std::vector<int>。
那也行。
我们继续往下讲。
7. copy-and-swap:别在半成品状态下动自己
拷贝赋值这个东西。
书上写起来像一行。
b = a;
但在老项目里。
它经常是事故的起点。
原因也不玄学。
大多数人第一版都会写成“先删再拷”。
很像 C。
也很顺手。
先放一个最典型的版本。
Buffer& operator=(const Buffer& other) {
delete[] data_;
n_ = other.n_;
data_ = new int[n_];
std::copy(other.data_, other.data_ + n_, data_);
return *this;
}
这段代码的问题。
不在“能不能跑”。
它当然能跑。
它的问题在于。
它会让对象短暂地变成半成品。
而 C++ 最爱拿半成品开玩笑。
先说第一个坑。
自赋值。
Buffer a(4);
a = a;
你可能会说。
“我不会这么写。”
对。
你不会。
但模板和容器有时候会替你走到这一步。
你那句 delete[] data_ 一旦先执行。
你就把 a 自己的数据删了。
然后你又要从 other.data_ 里拷回来。
可 other 就是 a。
你等于在问一块已经被释放的内存。
“兄弟。
把你刚才的内容再给我一份。”
这就是经典的“烧完房子再找钥匙”。
第二个坑。
异常。
Buffer a(4);
Buffer b(8);
b = a;
你盯着 std::copy 看。
会觉得。
拷贝 int 不会抛。
所以应该安全。
但真正会抛的。
往往是 new。
比如内存紧了。
它就给你一个 std::bad_alloc。
注意。
异常一旦发生在 new int[n_]。
你已经把 b 的旧资源删了。
但新的资源还没到手。
b 这时候是什么。
一个被你掏空到一半的对象。
更要命的是。
你上面那段代码。
删完以后没有把 data_ 置空。
如果 new 抛了。
b.data_ 还留着一个悬空指针。
b 之后析构。
就可能再 delete[] 一次。
你看。
又回到了我们开头说的那句。
double free detected。
这就是“半成品”的可怕之处。
它不一定当场崩。
它会把雷埋在未来。
copy-and-swap 这招。
就是专门用来对付这个的。
它的口号很简单。
先把新的一套在旁边做出来。 做不出来。 就别动旧的。
你可以先写一个 swap。
它只交换句柄。
不复制内容。
void swap(Buffer& other) noexcept {
std::swap(n_, other.n_);
std::swap(data_, other.data_);
}
然后赋值运算符写成按值传参。
Buffer& operator=(Buffer other) {
swap(other);
return *this;
}
到这里。
很多人还是觉得像魔术。
那我们拿一行最普通的赋值。
把它按时间顺序拆开。
Buffer a(4);
Buffer b(8);
b = a;
第一件事。
operator= 还没进门。
参数 other 先被构造出来。
它是用 a 来构造的。
也就是调用拷贝构造。
如果这一步里 new 抛了。
那就抛在门外。
b 完全没动过。
这就是强异常安全。
翻译成人话。
失败就当没发生。
第二件事。
门外的副本造好了。
我们进门。
只做一次 swap。
交换句柄。
不搬家。
这一步也不该抛。
所以我们把它写成 noexcept。
第三件事。
函数返回。
other 这个临时对象析构。
你问的“删旧的逻辑去哪里了”。
就在这里。
它没有消失。
只是换了个人来做。
swap 之后。
other 手里拿到的是 b 原来的那份旧资源。
所以它一析构。
就会走 ~Buffer()。
也就会执行那句你熟悉的 delete[]。
~Buffer() { delete[] data_; }
注意。
此时 other 手里拿着的。
已经是 b 原来的那份旧资源。
它析构。
就把旧资源顺手带走。
所以你在 copy-and-swap 里看不到显式的 delete[]。
但旧资源确实被删了。
只是删的时机被推迟到函数收尾。
而且删的人是那个临时副本。
像把旧钥匙交给前台。
人走。
钥匙也走。
整个过程里。
b 从来没有进入过“半成品状态”。
你再回头看自赋值。
a = a;
同样成立。
先在门外造一个副本。
副本是独立资源。
然后交换。
不会出现“先删自己再从自己拷回来”的荒唐戏码。
最后补一句高手会在意的现实。
很多人听说。
按值形参还能“顺便吃到 move”。
这话大方向没错。
但它不是免费午餐。
你得先真的“有得可 move”。
很多人栽在这里。
比如这种老派资源类。
struct Buf {
int* p = new int[1];
~Buf() { delete[] p; }
};
你一写析构。
编译器就会很谨慎。
这时候你得先想清楚。
你要的是值。
还是所有权。
你要共享。
就把共享写进类型。
比如 std::shared_ptr。
这样别人一眼就知道你在干什么。
这里不是值语义。
这里是共享所有权。
你再狠一点。
把共享的数据做成不可变。
比如共享 std::shared_ptr<const Data>。
你把“谁能改”这件事从习惯变成类型系统。
这才是高手会点赞的地方。
最后还有一种误会。
也是 C++11 之后才慢慢变少的。
你以为你需要拷贝。
其实你需要的是移动。
早些年没有 move。
很多代码只能硬拷。
copy-and-swap 才会那么流行。
它是在一个“没有移动语义”的时代里。
尽可能把赋值写得不崩。
现在有 move 了。
能转交所有权就转交。
别把“搬家”写成“复印整栋房子”。
说到底。
深拷贝。
浅拷贝。
移动。
都不是技巧。
它们是你的类型对外做出的承诺。
承诺写清楚。
语言就会帮你。
承诺写含糊。
默认逐成员拷贝就会帮你“做决定”。
而它做决定的方式。
你已经见过了。
小结
如果你只想带走一句话。
那就带走这句。
在 C++ 里。 copy 不是一个动作。 它是一份契约。
你写 T b = a; 的时候。
别急着问“怎么实现”。
先问“我到底要什么”。
我要的是值。
还是所有权。
我要的是隔离。
还是共享。
我要的是复制。
还是搬家。
这些问题你问清楚了。
深拷贝就只是其中一个答案。
不是默认答案。