那会儿我们写 C++。
还不叫 C++11。
还叫 C++03。
大家对“拷贝构造”这件事。
有一种很天真的信任。
觉得它应该不贵。
反正代码看起来很干净。
直到某天线上延迟突然抬头。
你才发现自己在热路径上复印东西。
而且复印得特别认真。
当年还没有移动语义的时候,大家怎么干活
先把这篇文章里最常出现的两个词说成“人话”。
这里的“资源”,就是你不手动处理就会出事的东西,比如内存、文件句柄、锁。
这里的“所有权”,就是“谁负责最后把它收拾干净”。
当年你写一个函数返回一个对象,看起来是“把结果交出去”。但机器有时候会理解成“再拷一份交出去”。
std::vector<std::string> build() {
std::vector<std::string> v;
v.push_back(std::string(10000000, 'x'));
return v;
}
这段代码的语义很朴素:组装好 v,然后返回。
问题在 C++03 时代,“返回值优化”(RVO/NRVO)更像一种“可能发生的优化”,而不是你能拿来写 SLA 的保证。
说白了,它就是想办法让“返回值”直接在调用方那块内存里构造出来,省掉中间那次拷贝。
它很常见,但当年你不能把它当成语义去依赖。
struct Big {
std::string s;
Big() : s(10000000, 'x') {}
};
Big make() {
Big x;
return x;
}
如果编译器没做掉那次拷贝,你就会看到一次又一次的“大对象复制”。你写的是“返回”,机器做的是“复印”。
那些年社区怎么补洞(以及借鉴了谁)
当年的 C++ 社区并不是躺平。
大家只是用能用的工具,一层一层往上打补丁。
因为语言本身还没有“把资源搬走”这个动作。
最常见的补丁,是别返回,改成输出参数,再 swap 出去。
void build(std::vector<std::string>& out) {
std::vector<std::string> tmp;
tmp.push_back(std::string(10000000, 'x'));
out.swap(tmp);
}
它通常很快,因为 swap 经常只是交换几个指针。
代价也很明显:接口开始“像 C”,到处是引用参数。
读代码的人得额外确认:这个函数是不是会顺手改我传进去的对象。
还有一种更“硬核”的过渡方案,是用 std::auto_ptr 这种“拷贝即转移”的智能指针。
std::auto_ptr<int> a(new int(1));
std::auto_ptr<int> b = a;
// a 这时候被置空了
// 这就是当年的“伪移动”
它解决了“所有权转交”这个问题,但也把“拷贝”这件事搞得不再直觉。
所以后来它被 C++11 的 std::unique_ptr 取代,并且在 C++17 里被移除。
再往后,Boost 生态也做过很多铺垫。
比如 Boost.Move 让你在没有 C++11 的编译器上,也能写出“看起来像 move 的代码”。
这其实是在给标准库打样:我们需要一种被语言承认的“转交所有权”。
横向对比:别的语言/生态怎么干这件事
你会发现这个问题不只 C++ 有,只是别的生态绕坑的方式不一样。
在 Java 里,你把对象“赋值给另一个变量”,通常只是复制引用,所以“看起来像拷贝的代码”往往不拷贝数据;代价是你要接受 GC,以及共享可变状态带来的心智成本。
在 Rust 里,默认就是 move。你想复印,必须显式 clone();写起来啰嗦一点,但性能语义非常清晰。
C++11 的选择更像折中:保留拷贝语义,但给你一个“我允许你把资源搬走”的正规通道。
事情是怎么出问题的(线上啪一下)
我们做了个小服务,请求进来就拼一大坨响应,然后返回给框架发出去。
机器没宕,CPU 也不高,但 P99 延迟像被谁掐住了脖子。
最后你会在火焰图里看到一堆看起来很无辜的东西:std::string 拷贝,std::vector 重新分配。它们不坏,坏的是它们出现在热路径上,而且次数多得离谱。
用最短代码把坑复现出来
把“线上拼响应”这事缩小到最小:组装一个大对象,然后返回。
std::string reply() {
std::string s(10000000, 'x');
return s;
}
auto r = reply();
你以为这是“造好一个字符串交出去”。但在没有移动语义的年代,它可能变成“造好一个字符串,再复印一份交出去”。
当年的人是怎么想的
他们的选择并不蠢。
只是规则没把“临时对象快死了”这件事讲清楚。
你只能寄希望于编译器做优化;或者把接口写得更别扭一点,自己兜底性能。
大家踩过的坑
第一个坑,是把“优化”当成“语义”。你写得很干净,但只要换个编译器参数,性能就能换一张脸。
第二个坑,是输出参数滥用。它能快,但也更容易引入别名问题:同一个对象被多个地方拿着引用,谁改了谁都说不清。
第三个坑,是“我用指针就没拷贝了”。指针确实不拷贝数据,但你开始要处理所有权、异常安全、早退路径,项目里很容易出现“忘了释放”和“重复释放”。
怎么从坑里爬出来
输出参数和 swap 是朴素补丁。
智能指针是另一种补丁:把“释放资源”交给对象生命周期来做。
std::unique_ptr<std::string> reply() {
return std::unique_ptr<std::string>(
new std::string(10000000, 'x')
);
}
它绕开了“大对象拷贝”,并且“返回 unique_ptr”这件事本身就是靠移动来完成的。
代价是你把 API 形状改了:调用方要多一层解引用,而且你把“资源管理”暴露到了接口层。
所以大家一直在等一个更像“语言本来就该提供”的东西。
这时候,概念才登场:什么是右值引用
先别被术语吓到。
“左值”你可以先理解成“有名字、你还能继续用”的对象。
“右值”你可以先粗暴理解成“快死的临时对象”(这个说法不严谨,但够你先把移动语义想明白)。
“右值引用”就是“专门去接这个快死对象”的引用类型:T&&。
void sink(std::string& s);
void sink(std::string&& s);
sink(std::string("hi"));
同一个函数名,两个版本。
当你传入临时对象时,会落到 std::string&& 这个重载上。这在暗示库作者:这个对象大概率马上就要销毁了,你可以偷走它内部的资源。
但这里还有个新手很容易踩的反直觉点:只要它“有名字”,它就是左值。
哪怕这个名字的类型写着 T&&。
std::string&& x = std::string("hi");
// x 有名字,所以它是左值
// 想把它的资源再交出去,还是得 std::move 一下
std::string y = std::move(x);
这也解释了为什么 std::move 会这么常见:它经常只是把“有名字的对象”重新标记成“我允许你搬走”。
这时候,概念才登场:std::move 到底做了什么
std::move 的名字很会骗人。
它不搬家。
它只是把一个表达式强制变成“可以被当成右值”的样子,本质上接近一次 static_cast<T&&>。
std::string a = "hello";
std::string b = std::move(a);
真正“搬家”的,是 std::string 的移动构造。
它看到右值时,会倾向于接管内部指针,而不是把字符一字不漏复制一遍。
用很多小例子把移动语义拆碎
先看一个最常见的用法:按值接收,再 move 进成员或容器。
struct User {
std::string name;
void setName(std::string s) {
name = std::move(s);
}
};
s 在函数体里是个左值。
但它马上要离开作用域,所以你把它 move 进去,语义很诚实:你是在说“这个参数我就用到这里,用完你可以把里面的资源拿走”。
再看一个“新手特别容易踩”的点:对 const 做 move,经常并不会真的 move。
const std::string a = "hi";
std::string b = std::move(a);
因为移动通常要修改源对象(至少要把它置成一个可析构的空壳)。而 const 不让你改,所以很多类型会退回到拷贝。
如果你自己管理资源,移动构造和移动赋值一般就是“接管指针,再把对方置空”。
struct Buf {
char* p = nullptr;
explicit Buf(std::size_t n) : p(new char[n]) {}
Buf(Buf&& o) noexcept : p(o.p) { o.p = nullptr; }
Buf& operator=(Buf&& o) noexcept {
if (this != &o) { delete[] p; p = o.p; o.p = nullptr; }
return *this;
}
~Buf() { delete[] p; }
};
这就是“转交所有权”。
你保证两件事:资源不会泄漏,也不会被释放两次。
还有一个细节,会直接影响容器性能:noexcept。
struct X {
X() = default;
X(X&&) noexcept = default;
};
std::vector<X> v;
v.push_back(X{});
很多容器在扩容时,只有在“移动不会抛异常”时才敢用 move。
不然它为了强异常安全(中途出错也不破坏原容器内容),宁可退回去拷贝。
移动之后的对象还能用吗
能。
但你只能指望它处于“有效但未指定状态”。
std::string a = "hi";
std::string b = std::move(a);
a = "again";
它还能析构。
也还能重新赋值。
你只是别再依赖它“原来的内容”。
放回事故现场:用移动语义把问题收拾掉
同样是“组装然后返回”,C++11 以后这件事终于变得靠谱。
std::vector<std::string> build() {
std::vector<std::string> v;
v.push_back(std::string(10000000, 'x'));
return v;
}
返回的临时对象可以被 move(很多时候甚至会直接做拷贝省略)。
你不用把 API 写成输出参数,性能也能像你直觉里那样工作。
关键结论
移动语义不是让你到处写 std::move。
它是让“正常写法”终于也能快。
最后留个亮点
你可以把拷贝想成复印。
把移动想成搬家。
复印当然也有用。
但你只是要把东西交给下一个人时。
搬走往往更诚实。