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 引入了一个重磅变化:在某些场景下,拷贝省略是强制性的,不再是“可选的优化”。

具体来说,以下两种情况是保证发生的:

  1. 返回一个临时对象(RVO 场景)
  2. 用临时对象初始化一个变量(比如 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_ptrstd::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 知识体系图

拷贝省略与返回值优化知识体系 拷贝省略 RVO(返回值优化) NRVO(具名返回值优化) 返回临时对象(匿名) C++17 保证强制省略 返回具名局部变量 C++17 仍非强制 对智能指针的影响 unique_ptr:避免移动开销 shared_ptr:减少原子操作

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 的拷贝涉及原子操作,省掉它确实能带来可观的性能提升,尤其是在高频调用的场景下。

嗯,拷贝省略这个话题,说深了可以聊到编译器的中间表示和优化策略,但咱们作为工程实践者,记住一条就够了:写代码时假设优化可能发生,但不要依赖它一定发生。这样写出来的代码,既高效又健壮。


公众号:蓝海资料掘金营,微信 deep3321