性能翻倍!揭秘编译器如何偷偷加速你的 C++代码:RVO/NRVO 详解

开发
你有没有好奇过,为什么有时候 C++ 代码明明应该很慢,却跑得飞快?今天咱们就一起来扒一扒编译器背后的那些小动作!

前段时间我在调试一段代码时,发现了一个有趣的现象:我写了一个函数,它返回了一个超大的对象(几G那种),按理说这玩意复制一次得花不少时间,可实际运行起来却快得出奇。

当时我就纳闷了:这不科学啊!

直到我深究了"RVO"和"NRVO",这才恍然大悟。原来编译器早就偷偷帮我们做了优化,只是我们不知道而已!

今天,就让我们一起来扒一扒这些编译器背后的小动作,看看它们是如何在你不经意间就帮你的代码提速的。不管你是刚入门的小白,还是已经写了几年代码的老鸟,相信都能从中有所收获。

一、什么是返回值优化(RVO)?

1. 先来聊聊没有优化时会发生什么

想象一下这个场景:你写了一个函数,它需要返回一个大对象,比如说这样:

class BigObject {
    // 假设这个类很大,有一大堆数据
    char *data;
    // ...其他成员
public:
    BigObject() { 
        cout << "构造函数被调用" << endl; 
    }
    
    BigObject(const BigObject& other) { 
        cout << "复制构造函数被调用" << endl; 
        // 复制数据
    }
    
    ~BigObject() { 
        cout << "析构函数被调用" << endl; 
    }
};

BigObject createBigObject() {
    // 直接返回一个临时对象
    return BigObject(); // 返回一个无名临时对象
}

int main() {
    BigObject myObj = createBigObject(); // 调用函数并接收返回值
    // 使用myObj...
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.

按照 C++ 的基本规则,这段代码的执行过程应该是这样的:

  • 在createBigObject()函数内部创建一个临时的BigObject对象
  • 当函数返回时,把这个临时对象复制一份到main()函数的myObj变量中
  • 销毁函数内的临时对象

所以按道理说,这里至少会调用一次构造函数和一次复制构造函数,对吧?

但是!如果你实际运行这段代码并打印出构造和复制构造的调用情况,你很可能会惊讶地发现:复制构造函数根本没被调用!

这是为什么呢?这就是今天的主角——返回值优化(Return Value Optimization, RVO)在默默发挥作用。

2. RVO是什么鬼?

RVO,全称 Return Value Optimization,中文叫"返回值优化",是一种编译器优化技术。简单来说,它可以消除函数返回时的对象复制操作。

回到刚才的例子,使用 RVO 后,编译器会直接在main()函数的myObj变量所在的内存位置上构造对象,而不是先在createBigObject()函数内构造,再复制出来。这样就完全省去了复制的开销!

是不是很神奇?明明我们写的代码逻辑上需要复制,但编译器却偷偷帮我们优化掉了。这种优化在 C++11 标准中被称为"复制省略"(copy elision),是少数几个允许编译器改变程序可观察行为的优化之一。

二、NRVO:RVO的近亲兄弟

说完了RVO,我们再来看看它的"近亲兄弟"——NRVO。

NRVO  全称是 Named Return Value Optimization,中文可以叫做"具名返回值优化"。这名字听起来有点绕,但其实很好理解:它就是针对有名字的局部变量的返回值优化。

看下面这个例子:

BigObject createBigObject() {
    BigObject result; // 创建一个具名对象
    // 对result做一些处理...
    return result; // 返回这个具名对象
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

这种情况下,我们创建了一个名为result的局部变量,并在最后返回它。这就是 NRVO 的应用场景。

相比之下,我们前面已经看到了RVO的例子,它是针对返回无名临时对象的优化:

BigObject createBigObject() {
    // 直接返回一个临时对象
    return BigObject();
}
  • 1.
  • 2.
  • 3.
  • 4.

虽然两者有细微差别,但目的都是一样的:避免不必要的对象复制,提高程序性能。

三、深入理解:RVO和NRVO如何实现?

好了,现在我们知道了 RVO 和 NRVO 是什么,但它们是如何实现的呢?编译器到底在背后做了什么魔法?让我们揭开谜底!

1. 编译器的巧妙把戏

传统情况下,当函数返回一个对象时,会经历这样的过程:

  • 在函数内创建一个局部对象
  • 复制这个对象到返回值位置
  • 销毁函数内的局部对象

但使用 RVO/NRVO 时,编译器耍了个聪明的把戏:

  • 在调用者的栈上直接分配返回值的空间
  • 将这个空间的地址偷偷传给被调用函数
  • 被调用函数直接在这个地址上构造对象

就这么简单!没有复制,没有移动,对象直接在它最终应该在的位置上诞生。

我们来看看这在汇编代码中是什么样子的,以我们前面的RVO例子为例:

BigObject createBigObject() {
    return BigObject(); // 返回一个无名临时对象
}

int main() {
    BigObject myObj = createBigObject();
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

让我们来对比一下开启 RVO 和未开启 RVO 时的汇编代码差异,这样对比会更有说服力。

未开启RVO优化时(使用 -fno-elide-constructors的编译选项):

createBigObject:
    ; rdi包含返回值的地址
    
    ; 在返回地址构造BigObject
    call BigObject::BigObject()  ; 调用构造函数
    ret                           ; 返回
    
main:
    ; 为myObj分配空间
    sub rsp, 40000        ; 假设BigObject占用40000字节
    
    ; 为临时返回值分配空间
    sub rsp, 40000        ; 再分配一块空间存储函数返回值
    
    ; 调用createBigObject
    lea rdi, [rsp]        ; 传递临时返回值的地址
    call createBigObject
    
    ; 现在需要把临时返回值复制到myObj
    lea rdi, [rsp+40000]  ; 目标地址(myObj)
    lea rsi, [rsp]        ; 源地址(临时返回值)
    call BigObject::BigObject(BigObject const&)  ; 调用复制构造函数
    
    ; 释放临时返回值
    lea rdi, [rsp]
    call BigObject::~BigObject  ; 调用临时对象的析构函数
    
    add rsp, 40000        ; 释放临时返回值的空间
    add rsp, 40000        ; 释放myObj的空间
    xor eax, eax          ; 返回0
    ret
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.

开启RVO优化时(默认就开启):

createBigObject:
    ; rdi中已经包含了目标对象的地址
    
    ; 直接在目标地址上构造BigObject
    mov QWORD PTR [rdi], 0    ; 初始化部分数据
    mov QWORD PTR [rdi+8], 0  ; 初始化更多数据
    ; ...更多初始化代码...
    
    ; 返回(对象已经构造在调用者提供的内存中)
    ret
    
main:
    ; 为myObj分配空间
    sub rsp, 40000        ; 假设BigObject占用40000字节
    
    ; 调用createBigObject,并传递myObj的地址作为隐藏参数
    lea rdi, [rsp]        ; 将myObj的地址加载到rdi寄存器(第一个参数)
    call createBigObject
    
    ; myObj已经构造好了,清理并返回
    add rsp, 40000
    xor eax, eax          ; 返回0
    ret
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

看一眼这两段汇编代码,差异显而易见。未优化的版本明显更复杂:它要分配两块内存空间,而不是一块;它调用了构造函数,然后又调用复制构造函数和析构函数;它需要进行内存复制,还有更多的栈操作。

相比之下,RVO优化版本简洁明了:只分配一块内存空间,只调用一次构造函数,没有复制,没有析构,也没有额外的栈操作。对于大对象来说,这种差异带来的性能提升是相当可观的!

2. NRVO与RVO有何不同?

那 NRVO 呢?它与 RVO 在实现上有什么区别?

在 RVO 中,编译器一看到return BigObject()就知道这是个临时对象,直接在目标位置构造它很容易。

而 NRVO 要复杂一些。当编译器看到BigObject obj;时,它不确定这个对象是否只用于返回。只有分析整个函数后,确认 obj 没有被多次修改或以复杂方式使用,才能将它直接构造在返回位置。

举个例子:

BigObject createComplex(bool condition) {
    BigObject obj1;
    BigObject obj2;
    // ...
    if (condition) {
        obj1 = obj2;  // obj1被修改了!
        return obj1;
    }
    return obj2;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

这种情况下,编译器可能无法应用NRVO,因为:

  • 可能返回不同的对象(obj1或obj2)
  • 对象在返回前被修改了
  • 函数逻辑依赖运行时条件

简单来说:

  • RVO:直接明了,容易实现,优化成功率高
  • NRVO:需要更全面的代码分析,实现更复杂

虽然原理有差异,但成功应用后的效果是相同的:对象都直接在最终位置上构造,完全避免了复制。

3. 来看个实际例子

让我们用实际代码来验证一下 RVO 和 NRVO 的效果:

#include <iostream>
#include <chrono>
usingnamespacestd;
usingnamespacestd::chrono;

class BigObject {
private:
    int* data; // 指针,而不是数组
public:
    BigObject() {
        data = newint[1000000]; // 在堆上分配
        for (int i = 0; i < 1000000; i++) {
            data[i] = i;
        }
        cout << "构造函数被调用" << endl;
    }

    BigObject(const BigObject& other) {
        data = newint[1000000]; // 在堆上分配
        for (int i = 0; i < 1000000; i++) {
            data[i] = other.data[i];
        }
        cout << "复制构造函数被调用" << endl;
    }

    ~BigObject() {
        delete[] data; // 记得释放内存
    }
};

// RVO示例
BigObject createWithRVO() {
    return BigObject(); // 返回临时对象
}
// NRVO示例
BigObject createWithNRVO() {
    BigObject obj;
    return obj; // 返回具名对象
}

int main() {
    // 测试RVO
    auto start = high_resolution_clock::now();
    BigObject obj1 = createWithRVO();
    auto end = high_resolution_clock::now();
    cout << "RVO耗时: " << duration_cast<microseconds>(end - start).count() << "us" << endl;

    // 测试NRVO
    start = high_resolution_clock::now();
    BigObject obj2 = createWithNRVO();
    end = high_resolution_clock::now();
    cout << "NRVO耗时: " << duration_cast<microseconds>(end - start).count() << "us" << endl;

    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.

运行这段代码,我们可以得到明显不同的结果,这取决于编译器是否启用了 RVO/NRVO 优化。

  • 禁用RVO优化时(使用编译选项:g++ -fno-elide-constructors -o run test.cpp -std=c++11):
构造函数被调用
复制构造函数被调用
复制构造函数被调用
RVO耗时: 14428us
构造函数被调用
复制构造函数被调用
复制构造函数被调用
NRVO耗时: 9674us
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 启用RVO优化时(默认选项:g++ -o run test.cpp -std=c++11):
构造函数被调用
RVO耗时: 4413us
构造函数被调用
NRVO耗时: 4424us
  • 1.
  • 2.
  • 3.
  • 4.

看到没?差别蛮大!

  • 禁用优化时,每个函数调用都要复制两次对象,耗时挺长。
  • 启用优化后,复制构造函数直接消失了!只需构造一次对象,速度整整快了2-3倍多。

即使是在禁用优化时,你可能注意到 NRVO 比 RVO 稍快 —— 这可能只是测试误差,但确实有趣。不过重点是:开启优化后,两者性能基本一致,完全符合我们的理论分析。

这就是 RVO 和 NRVO 的威力!它们不是魔法,而是实实在在的性能提升,特别是当你的函数需要返回大对象时。

四、什么时候会失效?RVO 和 NRVO 的限制条件

前面我们了解了 RVO 和 NRVO 这两个强大的优化技术,但它们也不是万能的。什么情况下这些优化会失效呢?让我们一起来看看几种常见情况。

1. 多个返回语句指向不同对象

当函数里有多个返回语句,并且返回的是不同的对象时,编译器就无法确定应该为哪个对象应用优化:

BigObject createObject(bool condition) {
    BigObject obj1;
    BigObject obj2;
    
    if (condition) {
        return obj1;  // 返回第一个对象
    } else {
        return obj2;  // 返回第二个对象
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

在这种情况下,编译器通常无法应用NRVO,因为它不能确定是obj1还是obj2会被返回。这完全取决于运行时的condition值。

2. 返回的对象是函数参数

如果函数返回的是一个参数,编译器通常无法应用RVO:

BigObject returnParameter(BigObject param) {
    return param;  // 返回的是函数参数
}
  • 1.
  • 2.
  • 3.

这里的param已经在调用者那里构造好了,函数只是返回了它的一个副本。编译器无法在调用者的栈上"预先"构造这个对象,因为它已经存在了。

3. 返回的是类成员变量

当函数返回类的成员变量时,这个变量已经作为对象的一部分存在了,编译器通常也无法应用RVO:

class Container {
    BigObject member;
public:
    BigObject getMember() {
        return member;  // 返回的是类成员变量
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

因为member的生命周期与函数调用无关(它是Container对象的一部分),编译器无法将它直接构造在返回值位置。

4. 复杂控制流

当函数中有复杂的控制流(如多层嵌套的条件语句、循环、异常处理等)时,编译器可能难以分析并应用RVO/NRVO:

BigObject complexFunction() {
    BigObject obj;
    try {
        // 一些可能抛出异常的代码
        if (someCondition) {
            throw SomeException();
        }
    } catch (...) {
        return obj;  // 在异常处理中返回
    }
    // 更多复杂控制流...
    return obj;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

复杂的控制流会使编译器难以确定返回路径和返回对象的情况,从而影响优化。

5. 如何确认优化是否生效?

想知道你的代码是否触发了 RVO/NRVO 优化?最简单的方法就是添加打印语句到构造函数和复制构造函数中,然后运行看看:

class Tracer {
public:
    Tracer() { cout << "构造函数" << endl; }
    Tracer(const Tracer&) { cout << "复制构造函数" << endl; }
    ~Tracer() { cout << "析构函数" << endl; }
};

Tracer getTracer() {
    return Tracer();
}

int main() {
    Tracer t = getTracer();
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

如果只看到"构造函数"和"析构函数"的输出,没有看到"复制构造函数",那么RVO就成功了!

6. 小贴士:如何提高优化成功率?

  • 尽量返回临时对象(RVO 比 NRVO 更容易被应用)
  • 一个函数只返回一个对象(避免多个返回语句返回不同对象)

记住这些小技巧,你的代码就能更好地利用这些强大的优化功能了!

五、C++17:强制的复制省略

前面我们讲了这么多 RVO 和 NRVO 的好处,但你知道吗?在 C++17 之前,这些优化其实只是编译器的"好心",并不是语言标准要求必须做的事情!

1. 从"可选"到"必选"

在 C++17 之前,编译器可以选择是否应用 RVO 和 NRVO 优化。也就是说,即使你的代码写得再完美,满足了所有优化条件,编译器也可以说:"不,我就是不想优化。"当然,实际上大多数编译器都会尽可能地进行这些优化,因为它们确实能带来很大的性能提升。

但从 C++17 开始,对于 RVO 这种情况(即返回临时对象),标准明确要求 编译器必须省略复制/移动操作。这就是所谓的"强制的复制省略"(mandatory copy elision)。

2. 这意味着什么?

用大白话说,就是 C++17 把"情分"变成了"本分"。编译器不再能偷懒,必须为临时对象的返回做优化。

最有趣的变化是,以下代码在 C++17 之前可能无法编译,但在 C++17 中一定能编译并正常工作:

class NonCopyable {
public:
    NonCopyable() = default;
    // 禁止复制
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
    // 禁止移动
    NonCopyable(NonCopyable&&) = delete;
    NonCopyable& operator=(NonCopyable&&) = delete;
};

NonCopyable createNonCopyable() {
    return NonCopyable(); // C++17前可能报错,C++17一定没问题
}

int main() {
    NonCopyable obj = createNonCopyable(); // 同上
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

这段代码看起来很矛盾:我们创建了一个既不能复制也不能移动的类,然后却试图返回它的一个临时对象。按理说,既然不能复制也不能移动,这个对象就不应该能够从函数返回到调用者那里。

但在 C++17 中,这段代码是完全合法的!因为标准要求在这种情况下,编译器必须直接在main函数的obj变量的内存位置上构造这个NonCopyable对象,完全跳过任何复制或移动操作。

3. 为什么这个变化很重要?

  • 代码行为更可预测:无论使用哪个编译器,优化效果都是一样的
  • 使用不可复制类型更灵活:如上例所示,即使类禁止了复制和移动,也能轻松返回
  • 性能保证更强:标准保证临时对象返回时不会有额外开销

不过要注意,NRVO(返回具名对象)在 C++17 中仍然是可选的优化,编译器可以自行决定是否应用。只有RVO(返回临时对象)是强制的。

所以,如果你希望代码在所有 C++17 编译器上都能获得优化,返回临时对象会是更安全的选择:

// 在所有C++17编译器上都会被优化
BigObject getBigObject() {
    return BigObject();  // 返回临时对象,强制优化
}

// 可能会被优化,取决于编译器
BigObject getBigObject2() {
    BigObject obj;
    return obj;  // 返回具名对象,优化是可选的
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

六、实战应用:如何充分利用 RVO 和 NRVO

好了,了解了这么多理论知识,现在该谈谈怎么在日常编码中实际运用这些技巧了!下面我们就来看看如何写出能够充分利用RVO和NRVO的代码。

1. 尽可能使用返回值,而不是输出参数

在C++中,有两种常见的方式向调用者传递新创建的对象:通过返回值或通过输出参数。

// 方式1:使用输出参数
void createBigObject(BigObject& outObj) {
    // 初始化outObj...
    outObj.setData(42);
}

// 方式2:使用返回值
BigObject createBigObject() {
    BigObject obj;
    obj.setData(42);
    return obj;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

哪种更好? 毫无疑问是第二种!

使用返回值不仅代码更加清晰(表明函数的目的是"创建"和"返回"某物),而且能够利用 RVO/NRVO 优化性能。而第一种方式无法利用这些优化。

在现代 C++ 中,你完全不需要担心返回大对象会影响性能。相反,你应该拥抱返回值风格!

2. 在函数末尾直接返回局部变量

看看下面两种写法:

// 不好的写法
BigObject createBigObject() {
    BigObject result;
    // 初始化result...
    BigObject temp = result; // 多余的复制
    return temp;
}

// 更好的写法
BigObject createBigObject() {
    BigObject result;
    // 初始化result...
    return result; // 直接返回,可能触发NRVO
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

第一种写法中,我们创建了一个多余的temp对象,并做了一次不必要的复制。这不仅增加了代码的复杂性,还破坏了NRVO优化的条件。

第二种写法简单直接,而且更有可能触发 NRVO 优化。记住:直接返回你想要返回的局部变量,不要绕弯子!

3. 小心使用std::move

初学 C++11 的同学可能会有一个常见误区:认为给所有返回的对象都加上std::move会提高效率。实际上,这通常是一个巨大的错误!

// 错误示范!会破坏RVO/NRVO
BigObject createBigObject() {
    BigObject obj;
    // ...
    return std::move(obj); // ❌ 不要这样做!可能会阻止NRVO!
}

// 正确做法:直接返回局部变量
BigObject createBigObject() {
    BigObject obj;
    // ...
    return obj; // ✅ 让编译器做优化
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

为什么std::move反而会降低性能?因为它告诉编译器:"我要移动这个对象",这就阻止了编译器直接在目标位置构造对象的优化路径。记住:在返回局部变量时,不要使用 std::move!

唯一应该使用 std::move的情况是当你确定 RVO/NRVO 无法应用,而你又想避免复制的时候:

BigObject createBigObject(bool condition) {
    BigObject obj1;
    BigObject obj2;
    
    // 多返回路径情况下,NRVO可能失效
    // 此时使用移动语义作为"备胎"
    if (condition) {
        return std::move(obj1); // 这里使用move是合理的
    } else {
        return std::move(obj2); // 这里也是
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

4. 使用右值引用和移动构造函数作为后备

从 C++11 开始,我们有了移动语义。即使在 RVO/NRVO 无法应用的场景,移动语义也能提供比复制更高效的方案:

class BigObject {
private:
    vector<int> data; // 可能很大的数据
public:
    // 移动构造函数
    BigObject(BigObject&& other) noexcept
        : data(std::move(other.data)) { // 只是转移指针,不复制数据
        cout << "移动构造" << endl;
    }
    
    // 常规复制构造函数
    BigObject(const BigObject& other)
        : data(other.data) { // 复制所有数据,可能很慢
        cout << "复制构造" << endl;
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

通过实现移动构造函数,即使在RVO/NRVO失效的情况下,编译器也会选择调用移动构造而不是复制构造,这能显著提升性能。

5. 实战小贴士总结

  • 优先使用返回值风格,而不是输出参数
  • 直接返回局部变量,不要创建临时副本
  • 不要对返回的局部变量使用std::move,除非你确定 RVO/NRVO 无法应用
  • 实现移动构造函数作为后备优化
  • 阅读编译器生成的汇编代码(如果你想确认优化是否生效)

掌握了这些技巧,你就能写出既清晰又高效的C++代码,充分利用编译器为你提供的这些免费的性能优化!

七、实际测量:验证优化效果

理论讲得再多,不如亲自验证一下。下面是一个更全面的基准测试代码,你可以用它来测量不同情况下的性能差异:

#include <iostream>
#include <chrono>
#include <vector>
#include <string>
usingnamespace std;
usingnamespace std::chrono;

// 一个足够大的类,使性能差异明显
class BigObject {
private:
    vector<int> data;
    string name;
public:
    BigObject(size_t size = 1000000) : data(size) {
        for (size_t i = 0; i < size; i++) {
            data[i] = static_cast<int>(i);
        }
        name = "BigObject";
    }
    
    BigObject(const BigObject& other) : data(other.data), name(other.name) {
        cout << "复制构造: 复制了 " << data.size() << " 个元素" << endl;
    }
    
    BigObject(BigObject&& other) noexcept : 
        data(std::move(other.data)), name(std::move(other.name)) {
        cout << "移动构造被调用" << endl;
    }
    
    BigObject& operator=(const BigObject& other) {
        if (this != &other) {
            data = other.data;
            name = other.name;
            cout << "复制赋值: 复制了 " << data.size() << " 个元素" << endl;
        }
        return *this;
    }
    
    BigObject& operator=(BigObject&& other) noexcept {
        if (this != &other) {
            data = std::move(other.data);
            name = std::move(other.name);
            cout << "移动赋值被调用" << endl;
        }
        return *this;
    }
    
    ~BigObject() {
        // 析构函数
    }
    
    size_t getSize() const { return data.size(); }
};

// 使用RVO(返回临时对象)
BigObject createWithRVO(size_t size) {
    return BigObject(size);
}

// 使用NRVO(返回具名对象)
BigObject createWithNRVO(size_t size) {
    BigObject obj(size);
    return obj;
}

// 故意阻止RVO/NRVO
BigObject createWithDisabledOptimization(size_t size, bool flag) {
    BigObject obj1(size);
    BigObject obj2(size);
    
    if (flag) {
        return obj1;
    } else {
        return obj2;
    }
}

// 使用移动语义
BigObject createWithMove(size_t size, bool flag) {
    BigObject obj1(size);
    BigObject obj2(size);
    
    if (flag) {
        return std::move(obj1);
    } else {
        return std::move(obj2);
    }
}

// 运行基准测试
template<typename Func>
long long runBenchmark(Func func, int iterations) {
    auto start = high_resolution_clock::now();
    
    for (int i = 0; i < iterations; i++) {
        BigObject obj = func();
        // 做一些操作以防止编译器过度优化
        if (obj.getSize() < 0) cout << "不可能发生" << endl;
    }
    
    auto end = high_resolution_clock::now();
    return duration_cast<milliseconds>(end - start).count();
}

int main() {
    constint iterations = 10;
    constsize_t objSize = 1000000;
    
    cout << "测试RVO优化..." << endl;
    auto rvoTime = runBenchmark([objSize]() { 
        return createWithRVO(objSize); 
    }, iterations);
    
    cout << "\n测试NRVO优化..." << endl;
    auto nrvoTime = runBenchmark([objSize]() { 
        return createWithNRVO(objSize); 
    }, iterations);
    
    cout << "\n测试无优化情况..." << endl;
    auto noOptTime = runBenchmark([objSize]() { 
        return createWithDisabledOptimization(objSize, rand() % 2); 
    }, iterations);
    
    cout << "\n测试移动语义..." << endl;
    auto moveTime = runBenchmark([objSize]() { 
        return createWithMove(objSize, rand() % 2); 
    }, iterations);
    
    cout << "\n性能比较:" << endl;
    cout << "RVO: " << rvoTime << "ms" << endl;
    cout << "NRVO: " << nrvoTime << "ms" << endl;
    cout << "无优化: " << noOptTime << "ms" << endl;
    cout << "移动语义: " << moveTime << "ms" << endl;
    
    return0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.
  • 112.
  • 113.
  • 114.
  • 115.
  • 116.
  • 117.
  • 118.
  • 119.
  • 120.
  • 121.
  • 122.
  • 123.
  • 124.
  • 125.
  • 126.
  • 127.
  • 128.
  • 129.
  • 130.
  • 131.
  • 132.
  • 133.
  • 134.
  • 135.
  • 136.

在 Visual Studio 2022 上的测试结果:

测试RVO优化...

测试NRVO优化...

测试无优化情况...
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用

测试移动语义...
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用
移动构造被调用

性能比较:
RVO: 127ms
NRVO: 118ms
无优化: 241ms
移动语义: 243ms
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.

从结果可以看出:

  • RVO 和 NRVO 的性能几乎相同,都非常优秀
  • 有趣的是,"无优化情况"和"显式使用移动语义"的性能也几乎相同
  • 最令人惊讶的是,即使在"无优化情况"下,也调用了移动构造函数,而不是复制构造函数!

1. 编译器和平台的影响

不过,值得注意的是,测试结果会受到编译器、编译选项和平台的影响。我是在Visual Studio 2022上进行的测试,发现了一些有趣的现象:

关于移动构造函数的重要发现:

(1) 如果注释掉BigObject类的移动构造函数,测试结果会有显著变化:

  • "无优化情况"和"移动语义"测试都会调用复制构造函数
  • 两者的性能几乎完全相同

(2) 反之,如果定义了移动构造函数:

  • 两种情况都会调用移动构造函数
  • 性能同样会非常接近

这个现象解释了为什么在某些测试环境中,"无优化"和"移动语义"的性能差异不明显。它说明:

  • C++编译器非常智能:即使在无法应用RVO/NRVO的情况下,如果有移动构造函数可用,现代编译器会自动选择移动而非复制
  • 添加std::move并不总是必要的:在多返回路径的情况下,即使不显式使用std::move,编译器也可能自动应用移动语义
  • 但定义移动构造函数很重要:要让编译器能够选择移动而不是复制,必须定义移动构造函数

这个测试提醒我们:在进行性能优化时,务必在自己的实际环境中测试,因为不同编译器和不同编译选项可能导致不同的优化结果。

这也进一步强调了 C++ 标准库中"Rule of Five"(五法则)的重要性:如果你定义了任何一个复制构造、复制赋值、移动构造、移动赋值或析构函数,通常应该考虑定义所有五个函数,以确保类的行为一致且性能最优。

八、总结与最佳实践

讲了这么多,是时候把重点内容简单总结一下了!

1. RVO与NRVO:不再是"大对象别返回"

以前我们常被告诫:"C++返回大对象很慢,尽量用指针或引用传递"。现在看来,这个说法已经过时啦!

有了RVO和NRVO这两个强大的优化技术,返回对象不再是性能瓶颈:

  • RVO处理临时对象返回:return BigObject();
  • NRVO处理局部变量返回:BigObject obj; return obj;
  • C++17让RVO成为必选项:编译器必须优化临时对象返回
  • 移动语义是不错的备胎:当RVO/NRVO失效时的保底方案

最佳编码实践包括:直接返回对象而非用输出参数、直接返回局部变量不做额外复制、不对返回局部变量使用std::move、实现移动构造函数作为后备、使用现代编译器并开启优化等。

2. 别被"过早优化"困住

有句名言:"过早优化是万恶之源"。但利用 RVO/NRVO 并非过早优化 — 这些写法本身就是现代C++的自然表达,代码更清晰,还能获得更好性能,何乐而不为?

九、结语:不只是一个优化技巧

RVO 和 NRVO 代表了 C++ 的一个重要理念:零开销抽象。通过它们,我们可以写出既清晰又高效的代码。这正是C++的魅力所在!

希望这篇文章能帮你更好理解和利用这两个强大的优化技术。C++的优化技巧还有很多,后续我会继续分享更多实用的 C++ 性能优化知识。

责任编辑:赵宁宁 来源: 跟着小康学编程
点赞
收藏

51CTO技术栈公众号