你刚学完 C。
你知道 malloc 和 free。
你也听过一句老话。
谁申请。
谁释放。
然后你遇到 C++。
你听说“对象会自己析构”。
心里松了一口气。
但很快你会碰到另一件事。
拷贝。
那会儿还没有 C++11。
也没有“移动语义”这个词。
大家只是在一堆事故里慢慢学会。
代码跑起来以后。
“复制”这件事一点都不单纯。
当年没有 move 的世界:拷贝要么很贵,要么会炸
先写一个最朴素的“带资源”的类。 资源就是你得手动释放的东西,比如堆内存。
struct Buf {
int* p;
Buf() : p(new int[4]) {}
~Buf() { delete[] p; }
};
它看起来很讲道理。
构造时 new。
析构时 delete[]。
然后你写出这行代码。
Buf a;
Buf b = a;
如果你只学过 C,会很自然地以为这是“复制一份”。 但 C++98/03 默认的拷贝是“逐成员拷贝”。 也就是把指针地址抄一遍。
于是 a.p 和 b.p 指向同一块内存。
两个对象析构时都会 delete[]。
然后你就见到了那位老熟人:double free。
为了不炸,你只能自己写拷贝构造。
Buf(const Buf& o) : p(new int[4]) {
for (int i = 0; i < 4; ++i) p[i] = o.p[i];
}
这次安全了。 但代价也很诚实:每次拷贝都要分配内存,还要复制内容。
为了快,大家开始“绕开拷贝”
那几年你会在项目里看到很多“别拷贝”的写法。 比如把对象都放在堆上,到处传指针。
Buf* make() { return new Buf; }
Buf* p = make();
// ...
delete p;
它确实避开了拷贝。 也顺手把“谁负责 delete”这个问题塞回你手里。
这不是谁的错。 那是时代的现实。
标准库也试过救场:auto_ptr 的“拷贝=交接”
标准库那会儿给过一个选择:std::auto_ptr。
它想用 RAII 帮你自动释放。
std::auto_ptr<Buf> a(new Buf);
std::auto_ptr<Buf> b = a;
这行代码看起来像“拷贝”。 但它实际干的是“把所有权交给 b,然后把 a 掏空”。 有人当年吐槽得很准:语法像 A,语义却是 B。
读代码的人最怕的不是慢,是误判。
委员会想补一块拼图:让“交接”成为语言的一等公民
那时候大家已经看得很清楚了。 有些时候你就是想“复制一份”。 但也有很多时候,你只是想把资源交给下一个对象。
尤其是“临时对象”。 它本来就要在这句结束时被析构。 还要先复制一份再析构。 怎么想都别扭。
当一个对象马上就要销毁时,拷贝它通常是在浪费。
先把词掰开:资源、所有权、交接
资源,就是你得手动释放的东西。 所有权,就是“谁负责在合适的时候释放它”。 交接,就是“把释放责任从一个对象转到另一个对象”。
移动语义做的事,其实就一句话: 把“交接”这件事写进语言里。
右值:那种“用完这句就走”的对象
右值(rvalue)你可以先理解成“临时值”。 它没有稳定的名字。 通常也活不过这一句。
Buf make();
Buf x = make();
make() 的返回值就是典型的右值。
既然它马上要走了。
它手里的那块内存,其实可以直接交给 x。
右值引用 T&&:专门接“快走的”
传统引用是 T&。
它更愿意绑定到“有名字的对象”(左值)。
C++11 新增了 T&&。
右值引用。
它更愿意绑定到临时对象。
void sink(Buf&& b);
sink(make());
你可以把它理解成一种信号。 我接到的是个“快走的东西”。 我有资格尝试把资源拿走。
移动构造:把钥匙递过去
移动构造做的事很克制。 它不复制资源。 它只把指针接过来,然后把对方置空。
struct Buf {
int* p;
Buf() : p(new int[4]) {}
~Buf() { delete[] p; }
Buf(Buf&& o) noexcept : p(o.p) {
o.p = nullptr;
}
};
关键点只有一个: 被 move 走的对象仍然必须能安全析构。 否则你又会见到 double free。
不是搬家,是交接班。
std::move:它不搬运,它只是“我同意”
std::move(x) 本质上是一次类型转换。
它把 x 标记成“可以当右值用”。
Buf a;
Buf b = std::move(a);
真正干活的不是 std::move。
而是你写的移动构造/移动赋值。
moved-from:被 move 过的对象还能用吗
能。 但你别指望它里面还留下些什么。
标准的说法是:“有效但状态未指定”。 意思是: 它必须能析构。 也必须能再次被赋值。
为什么 move 经常写 noexcept
容器要搬家时很谨慎。
比如 std::vector 扩容,需要把元素搬到新内存。
std::vector<Buf> v;
v.push_back(make());
如果移动可能抛异常,容器就很难保证异常安全。 于是它会更保守:宁愿用拷贝。
想让容器放心用 move,就给 move 一个 noexcept。
放回到对象模型里:你到底省掉了什么
拷贝经常意味着:再申请一份资源,再复制内容。 移动意味着:只转移所有权,让资源原地继续活。
从对象模型看。 你省掉的往往是分配和复制。 也省掉了一堆“为了绕开拷贝而写的别扭代码”。
写得直觉一点,事故也会少一点。