我第一次为“拷贝”焦虑。
是写一个很小的类。
就一个 buffer。
拷贝一次,就要分配一次。
热路径里 return 它。
性能像漏气。
然后我开始翻优化开关、编译器版本。
甚至去查“这个编译器会不会做 RVO”。
说白了,就是开始迷信。
当年:按值返回不是错,大家怕的是“多造一个对象”
C++ 从来就允许按值返回。
std::string make_name();
怕的是实现细节。
当年你很难确定:这个返回值是不是会在某个地方先“造出来”,再被拷贝/移动到你手里。
而你的那个 buffer。
一拷贝就分配。
多来一次,热路径就炸。
于是我开始打补丁。
第一层补丁:别按值返回,改成“返回指针”
我当时的第一反应也很直觉。
别 return 对象。
return 指针。
#include <string>
const std::string* make_name_bad() {
std::string s = "alice";
return &s;
}
这段代码看起来很顺。
也很危险。
s 是局部变量。
函数返回它就没了。
你拿到的是悬空指针。
它有时还能“看起来能用”。
但那是运气。
你很快会写出第二版补丁。
让对象活得更久。
比如 new。
#include <string>
std::string* make_name_also_bad() {
return new std::string("alice");
}
这次不悬空了。
但所有权开始飘。
谁 delete?
漏了就是泄漏。
多 delete 一次就是崩溃。
你省掉的只是一次“可能发生的拷贝”。
换来的却是必然要处理的生命周期问题。
第二层补丁:那就学 C,用 out 参数
再往后你会想。
我不搞指针了。
我让调用方把对象传进来。
#include <string>
void make_name_out(std::string& out) {
out = "alice";
}
能用。
也确实能绕开“按值返回会不会多一次构造”的恐惧。
但代价也很工程。
接口变别扭。
表达力变弱。
组合起来更痛苦(想写个链式调用、想写个函数式风格的表达式,你都得绕)。
但根问题还在。
你只是把“对象在哪构造”这件事,粗暴地推给了调用方。
我后来意识到:问题不是“拷贝”,是“对象到底在哪块内存里诞生”
按值返回之所以被怀疑。
是因为大家脑子里默认了一幅图:
函数里先构造一个临时对象。
再把它拷贝出去。
那如果我能不让这个“临时对象”存在呢?
如果我能让它直接在目标位置构造呢?
这就是 RVO 的直觉。
RVO/NRVO:编译器的那点“偷懒”其实很朴素
你写的是这样。
std::string make_name() {
return std::string("alice");
}
你想象的是“返回一个临时对象”。
编译器想象的是“我能不能让调用方先留一块位置出来”。
很多 ABI 里,调用方确实会把“返回值的地址”作为一个隐藏参数传进去。
于是函数体里不是先造临时,再拷贝。
而是直接在那块地址上构造。
拷贝自然就没了。
当 return 的是一个匿名的临时对象(上面这种),人们常叫它 RVO。
当 return 的是一个具名局部变量(下面这种),人们常叫它 NRVO。
std::string make_name() {
std::string s = "alice";
return s; // 这是 NRVO 的典型形态
}
注意这里的口气。
RVO/NRVO 很长时间都只是“编译器优化”。
优化就意味着:大多数时候会发生。
但你没法把它写进接口契约里。
先补两个词:lvalue / prvalue(别急着背定义)
第一次看到 prvalue,我也卡住过。
因为你会听到一个词:prvalue。
先记个直觉。
lvalue 更像“一个有身份的对象”。
prvalue 更像“一次计算出来的结果”。
#include <string>
std::string s = "alice";
auto t = std::string("bob");
s 是一个已经存在的对象。
你能反复用它。
std::string("bob") 更像“当场算出来的一个值”。
你当然也能把它接住,变成一个对象(比如上面的 t)。
但关键问题来了。
这个“接住”的过程。
会不会先造一个中间对象?
再补一个词:初始化时,会不会先冒出一个“中间对象”
我当时就把下面两句想成“完全不一样”。
#include <string>
std::string a("alice");
std::string b = std::string("alice");
直觉上,第二句看起来像:
先构造一个临时 std::string("alice")。
再把它拷贝/移动到 b。
如果你写的是热路径 buffer 类型。
你就会开始怀疑人生。
于是 RVO/NRVO 这类优化,最初就是在解决这件事:
能不能别造那个“中间对象”。
最小复现:用一个会打印的类型,看 copy/move 到底有没有发生
标准先别急着啃。
先把现象看见。
#include <cstdio>
#include <utility>
struct X {
X() { std::puts("X()"); }
X(const X&) { std::puts("X(copy)"); }
X(X&&) noexcept { std::puts("X(move)"); }
};
下面三种 return。
X make1() { return X{}; }
X make2() { X x; return x; }
X make3() { X x; return std::move(x); }
make1() 返回的是一个 prvalue。
你想要的最佳情况是:只打印一次 X()。
make2() 是具名变量。
这里如果编译器做了 NRVO,也可能只打印 X()。
但它不是承诺。
make3() 这种写法也很常见(以为“我都 move 了,肯定更快”)。
现实是:它通常会直接把 NRVO 机会拍死,只能走 move。
所以回到那句话。
在 C++17 之前,你经常只能靠经验猜。
你不敢把“不会多一次构造”当成语义。
更现实的一步:C++11 给了 move,但 move 也不是“零成本”
后来 C++11 有了 move。
它把很多“拷贝很贵”的对象,变成“移动很便宜”。
于是当 NRVO 没发生时,你至少还能期望 move。
std::string make_name() {
std::string s = "alice";
return s; // 大概率 move
}
但 move 不是免死金牌。
有些类型 move 也会做分配。
有些类型 move 不是 noexcept,容器里为了强异常安全还会退回 copy。
你写性能代码时,依旧会焦虑。
因为你追求的是语义保证。
不是“应该会被优化”。
C++17:把一部分“应该会被优化”写成了语言规则
C++17 做的事很实在。
它在一部分场景里直接规定:不会有那次拷贝/移动。
也就是说。
你连“临时对象”都不需要先构造出来。
它会直接在目标位置构造。
最常见的就是这种按值返回 prvalue 的写法。
#include <string>
std::string make_name() {
return std::string("alice");
}
在 C++17 里。
你可以把它当成:返回值对象直接在调用方那块内存里诞生。
函数栈上那个“临时对象”,从语义上就不存在。
这就是大家说的“保证拷贝省略”。
把“保证”翻译成可用的判断规则
新手最需要的是一条能落地的判断。
先记这一条就够用。
当你在初始化一个 T。
右边如果是一个“同类型的 prvalue”(比如 T{...})。
那么在 C++17 的这些场景里,copy/move 根本不会发生。
struct T { T(); T(const T&); T(T&&); };
T a = T{};
T b(T{});
T make() {
return T{};
}
这不是“编译器帮你优化了”。
这是语言规则:这里不会调用 copy/move。
换句话说。
T(copy) / T(move) 这类构造函数,就算你写了副作用(比如打日志),这里也不会被调用。
这也是为什么 move-only 类型在现代 C++ 里可以自然地按值返回。
于是 move-only 类型终于顺手了:你甚至不用关心 move 发生没发生
比如 std::unique_ptr。
它不能拷贝。
但你依旧可以很自然地按值返回。
#include <memory>
std::unique_ptr<int> make() {
return std::make_unique<int>(42);
}
你不需要写指针。
不需要 out 参数。
不需要为了“可能发生的开销”把接口搞得像 C。
你最容易误解的一点:保证不是“所有 return 都不会拷贝/移动”
C++17 保证的范围是有限的。
它主要覆盖的是:返回一个“同类型的 prvalue”。
所以这种具名局部变量的返回。
依旧属于 NRVO 的世界。
std::string make_name() {
std::string s = "alice";
return s; // 仍然是 NRVO(常见但不强制)
}
还有一个更常见的坑。
很多人以为“写上 std::move 更保险”。
结果刚好相反。
#include <string>
#include <utility>
std::string make_name() {
std::string s = "alice";
return std::move(s);
}
这个写法通常会让 NRVO 彻底没戏。
你只能期待一次 move。
对很多类型来说 move 已经很便宜了。
但它和“保证拷贝省略”不是一回事。
再比如这种需要做隐式转换的返回。
它也不在“强制保证”的那一小撮里。
struct Base {};
struct Derived : Base {};
Base make_base() {
return Derived{}; // 这里有类型转换
}
你当然仍然可以期待优化。
但如果你在写“必须稳定”的性能假设。
就别把这些边界条件当成同一回事。
关键结论
保证拷贝省略的价值,不是“快一点”。
而是它把按值返回从经验主义,拉回了更可靠的语言承诺。
小结:别为了躲拷贝把接口写坏
当年我们因为害怕按值返回。
写出了指针、new、out 参数这些补丁。
每个补丁都把复杂度挪到了接口层。
最后变成维护成本和事故概率。
C++17 做的事很朴素。
把一部分“你本来就希望编译器做到的优化”。
变成了规则。
所以你的工程直觉可以简单一点。
能按值返回,就按值返回。
别把生命周期问题留给未来的你。