返回值优化

返回值优化在C++里面主要是通过RVO(Return Value Optimization)以及NVRO(Named Return Value Optimization)来完成。其中NVRO是在C++11中被引入,并且在C++17中作为强制要求。本质上是一种Copy Elision, 用来减少按返回临时对象时所发生的拷贝。

具体来讲,我们分析下面这个例子

1
2
3
4
5
std::vector<int> func() {
std::vector<int> data = {1, 2, 3, 4, 5};
return data;
}
std::vector<int> t = func();

第一眼看上去,99%的人都会觉得这东西性能一定有大问题,毕竟直接返回了一个数组,那不得每次都得拷贝一大堆数据。但是实际上如果我们打开类似于cpp insight之类的分析工具,我们会看到以下代码。

1
2
3
4
5
std::vector<int, std::allocator<int> > func()
{
std::vector<int, std::allocator<int> > data = std::vector<int, std::allocator<int> >{std::initializer_list<int>{1, 2, 3, 4, 5}, std::allocator<int>()} /* NRVO variable */;
return std::vector<int, std::allocator<int> >(static_cast<std::vector<int, std::allocator<int>>&&>(data));
}

可以看到编译器并没有像我们所想的那样傻傻的按值返回,而是做了对应的优化,编译器将临时变量作为右值引用来进行返回,从而避免了拷贝。并且cpp insight还给写了注释/ NRVO variable /(可能你以为这注释是我写的,但是并不是😋)。

于是我们自然就想着去了解一下这个返回值优化到底是什么东西,然后当你google过后,你就会发现C++的返回值优化有两种类型,也就是开头所讲的RVO(Return Value Optimization)以及NVRO(Named Return Value Optimization)

RVO

让我们先了解下RVO吧,这个东西的官方解释如下:

当一个未具名且未绑定到任何引用的临时变量被移动或复制到一个相同的对象时,拷贝和移动构造可以被省略。当这个临时对象在被构造的时候,他会直接被构造在将要拷贝/移动到的对象。当未命名临时对象是函数返回值时,发生的省略拷贝的行为被称为RVO(返回值优化)。

1
2
3
4
5
6
7
8
9
# obj is some custom class
Obj func() {
return Obj(1);
}

int main() {
Obj obj = func();
return 0;
}

上面这种情况下,如果发生了RVO,我们就可以看到以下的等效代码。

1
2
3
4
5
6
7
8
9
10
11
# obj is some custom class
void func(Obj& obj) {
obj.Obj::Obj(1);
return;
}

int main() {
Obj obj; // 并没有调用构造函数,是来自编译器的魔法
func(obj);
return 0;
}

原先的函数变成了一个void函数,然后我们将要赋值的变量通过参数传递进去,这样的话,我们就可以完成拷贝消除,也就是所谓的copy elision

NVRO

顾名思义,这个优化主要是针对具名,也就是有名字的临时变量所进行的优化。而优化后的结果其实与上面RVO的并无大异。

1
2
3
4
5
6
7
8
9
10
# obj is some custom class
Obj func() {
Obj temp(1); // 区别就是有个具名的临时变量
return temp;
}

int main() {
Obj obj = func();
return 0;
}

不过在没有NVRO的编译器上面,上面这段代码如果执行RVO的话,实际上会多一次拷贝构造,这点要注意, 因为RVO会优化为以下代码:

1
2
3
4
5
void func(Obj &obj_) {
Obj obj(1);
obj_.Obj::Obj(obj); // 拷贝构造函数
return;
}

优化失效问题

虽然返回值优化很棒,不禁让人感叹现代编译器的强大,但是还是存在一些特殊情况会阻碍这种优化的发生。

  1. 函数中返回全局变量或者函数参数的情况下,优化不会发生。也就是说只要值不是在函数内创建,优化就不会发生。

    1
    2
    3
    4
    5
    6
    7
    Obj obj;
    void func() {
    return obj;
    }
    void func(Obj obj) {
    return obj;
    }
  1. 运行时依赖,也就是函数里面存在分支,编译器无法判断返回什么对象的情况下,优化也不会发生。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Obj fun(bool flag) {
    Obj o1;
    Obj o2;
    if (flag) {
    return o1;
    }
    return o2;
    }

    int main() {
    Obj obj = fun(true);
    return 0;
    }
  2. 使用std::move()的情况下,这个应该是重灾区,有很多人认为返回一个临时变量的右值引用就可以解决返回值优化,虽然这种做法看上去非常合理,但是实际上会强制调用移动构造函数,并且编译器优化上其实会隐式的帮你调用std::move(),所以自己是不用这么做的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Obj fun() {
    Obj obj;
    return std::move(obj); // 不要这么做,会妨碍编译器优化
    }

    int main() {
    Obj obj = fun();
    return 0;
    }

总结

返回值优化本身是非常有用的技术,能够大幅减少程序中的无用拷贝,在了解优化发生的时间以及禁忌事项后,我们可以非常高效的利用这个特性。但是另一方面其实把优化全都交给编译器也不太好,嗯,说到底还是自己做优化没准才是出路?