25. 拷贝省略与返回值优化:NRVO、RVO、C++17 的保证拷贝省略、对智能指针的影响
拷贝省略,说白了就是编译器偷偷帮你省掉一次拷贝或移动操作。你代码里写着“返回一个对象”,编译器一看:嘿,这对象反正也没人用原来的了,直接构造到目标位置得了,省得来回倒腾。
我刚开始接触这个特性时,觉得它就是个“锦上添花”的小优化。直到有一次,我在一个高性能网络库中遇到了严重的性能瓶颈——每次函数返回都要触发一次深拷贝,那个对象里有个 4MB 的缓冲区。嗯,从那以后,我再也不敢小看拷贝省略了。
25.1 什么是 RVO 和 NRVO?
RVO(Return Value Optimization),即返回值优化。它针对的是函数返回一个临时对象的情况。比如:
MyObject create() {
return MyObject(); // 临时对象
}
MyObject obj = create(); // 这里本该有一次拷贝,但被省略了
编译器直接把 MyObject() 构造到 obj 的位置上,省掉了中间那次拷贝。
NRVO(Named Return Value Optimization),即具名返回值优化。它针对的是函数返回一个具名局部变量的情况:
MyObject create() {
MyObject local;
// ... 对 local 做一些操作
return local; // 返回具名对象
}
MyObject obj = create(); // 这里本该有一次拷贝,但被省略了
RVO 和 NRVO 的区别,说白了就是一个是“匿名临时对象”,一个是“有名字的局部变量”。编译器对前者的优化几乎 100% 能做到,对后者则要看编译器的“心情”和代码的复杂程度。
核心要点:拷贝省略不是 C++ 标准强制要求的(C++17 之前),而是编译器的一种“允许行为”。不同编译器、不同优化级别下,结果可能不一样。
25.2 C++17 的保证拷贝省略
C++17 引入了一个重磅变化:在某些场景下,拷贝省略是强制性的,不再是“可选的优化”。
具体来说,以下两种情况是保证发生的:
- 返回一个临时对象(RVO 场景)
- 用临时对象初始化一个变量(比如
T obj = T();)
这意味着,即使你的类删除了拷贝构造函数和移动构造函数,上面的代码依然能编译通过。这在 C++14 及之前是不可能的。
struct MyObject {
MyObject() = default;
MyObject(const MyObject&) = delete; // 删除拷贝构造
MyObject(MyObject&&) = delete; // 删除移动构造
};
MyObject create() {
return MyObject{}; // C++17 保证拷贝省略,编译通过
}
int main() {
MyObject obj = create(); // 同样编译通过
}
我个人习惯在 C++17 及之后的项目中,充分利用这个特性。它让代码更安全,也减少了不必要的性能担忧。
注意:NRVO 在 C++17 中仍然不是强制性的。编译器可以选择优化,也可以不优化。如果你的代码依赖 NRVO,最好还是保留移动构造函数作为“保底方案”。
25.3 对智能指针的影响
智能指针(std::unique_ptr、std::shared_ptr)本身是“轻量级”对象,通常只有几个指针大小。但拷贝省略对它们的影响,主要体现在语义上,而不是性能上。
25.3.1 unique_ptr 与拷贝省略
std::unique_ptr 是只移类型,不能拷贝。如果没有拷贝省略,返回一个局部 unique_ptr 会触发移动构造。有了拷贝省略,移动构造也可能被省掉。
std::unique_ptr<int> create() {
auto ptr = std::make_unique<int>(42);
return ptr; // NRVO 可能发生,否则走移动构造
}
auto p = create(); // 要么拷贝省略,要么移动构造
这里有个坑:如果 NRVO 没有发生,编译器会尝试调用移动构造函数。unique_ptr 有移动构造,所以没问题。但如果你写了一个没有移动构造的只移类型,NRVO 失败就会导致编译错误。
我的建议:返回 unique_ptr 时,尽量写成 return std::make_unique<T>(...); 这种形式。这样触发的是 RVO(保证拷贝省略),而不是 NRVO(可能不优化)。
25.3.2 shared_ptr 与拷贝省略
std::shared_ptr 可以拷贝,但拷贝涉及引用计数的原子递增,有一定开销。拷贝省略能省掉这个开销。
std::shared_ptr<int> create() {
auto ptr = std::make_shared<int>(42);
return ptr; // NRVO 可能发生
}
auto sp = create(); // 如果 NRVO 发生,引用计数不变;否则先增后减
我记得有一次在调试一个多线程程序时,发现 shared_ptr 的引用计数异常波动。排查了半天,发现是 NRVO 没有生效,导致每次返回都多了一次拷贝和析构。虽然最终结果正确,但性能上多了几次原子操作。
25.4 知识体系图
25.5 实践中的注意事项
说了这么多理论,咱们聊聊实际项目中怎么用。
| 场景 | 建议做法 | 原因 |
|---|---|---|
| 返回临时对象 | return T{...}; |
C++17 保证省略,最安全 |
| 返回局部变量 | 保留移动构造函数 | NRVO 可能失败,移动构造保底 |
| 返回 unique_ptr | 优先用 make_unique |
触发 RVO,避免依赖 NRVO |
| 返回 shared_ptr | 同样优先用 make_shared |
减少引用计数操作 |
| 调试拷贝行为 | 在拷贝/移动构造中加日志 | 确认优化是否生效 |
我曾经踩过的坑:有一次我写了一个类,删除了拷贝构造函数,只保留了移动构造函数。然后在返回局部变量时,NRVO 没有生效,编译器尝试调用拷贝构造——编译失败。后来我加上了移动构造函数,问题解决。所以,永远不要完全依赖 NRVO,给编译器留条后路。
25.6 总结
拷贝省略是 C++ 中一个“看不见但很重要”的优化。C++17 之前,它是个“惊喜”;C++17 之后,部分场景成了“保证”。
我个人习惯在写返回对象的函数时,尽量用 return T{...} 这种形式,触发保证的 RVO。对于 NRVO,我会保留移动构造函数作为保底。这样既利用了优化,又不会因为编译器“心情不好”而翻车。
至于智能指针,拷贝省略更多是“锦上添花”。unique_ptr 的移动构造本身就很轻量,省掉它只是少了一次指针赋值;shared_ptr 的拷贝涉及原子操作,省掉它确实能带来可观的性能提升,尤其是在高频调用的场景下。
嗯,拷贝省略这个话题,说深了可以聊到编译器的中间表示和优化策略,但咱们作为工程实践者,记住一条就够了:写代码时假设优化可能发生,但不要依赖它一定发生。这样写出来的代码,既高效又健壮。