复制消除

首先来看一段代码:

class Cls
{
public:
    Cls(int num = 1)
    {
        cout << "Default constructor" << endl;
    }
    Cls(Cls&)
    {
        cout << "Copy constructor" << endl;
    }
    Cls(Cls&&)
    {
        cout << "Move constructor" << endl;
    }

    int _num;
};

Cls func()
{
    return Cls();
}

int main()
{
    Cls obj = func();

    return 0;
}

首先定义一个 Cls 类,其中的默认构造函数、拷贝构造函数和移动构造函数都会打印一些帮助信息来方便我们观察对应的函数是何时被调到的。

那现在就来看看这个程序最终的打印会是什么。首先 func 函数被调到,其中先用 Cls() 构造了一个临时对象,然后将这个临时对象返回。我们知道如果一个函数返回一个类对象,其实是用返回的这个类对象在主调函数(也就是这里的 main 函数)中先用移动构造函数构造出一个临时的类对象,然后再对这个临时的类对象做其他的操作,在这个例子中也就是这个临时的类对象再去构造本地的这个 obj 对象,因为临时类对象是一个右值所以还是会调用到移动构造函数,所以我们可以推测最终的输出应该是:

Default constructor
Move constructor
Move constructor

那么真实情况是不是这样呢,我们来看一看:

什么,居然只输出了 Default constructor,这个程序只调用了一次默认构造函数,移动构造函数一次都没调到,这怎么可能。

原来,C++ 标准允许编译器在某些情况下将拷贝构造函数和移动构造函数的调用消除掉,这样可以一定程度上提高程序执行的效率。这个动作也被称作复制消除(copy elision)。而在我们这里,后两次移动构造函数的调用在编译阶段就直接被编译器优化掉了,所以这里只看到了一次默认构造函数的输出。为了看到不经优化的结果,gcc 编译器需要加上 -fno-elide-constructors 选项。

可以看到,我们的分析其实是没错的,就是会调两次移动构造函数。如果一个新手在学习这些构造函数的时候不知道编译器的这个特性,可能会直接怀疑人生吧xd

 非强制拷贝/移动消除

编译器被允许(但不强制)在下面介绍的这些情况中消除拷贝/移动构造函数的调用,哪怕消除掉这些拷贝/移动构造函数以及对应的析构函数会产生可观察到的影响(例如我们的例子中的输出)。

  • 在一条 return 语句中,如果返回值是一个有变量名的、不加 volatile 修饰的、具有自动生命周期的、非当前函数参数的、非 catch 语句参数的对象,并且这个对象和返回类型是同一个类类型,则可以进行复制消除。这种情况下的复制消除也被称为 NRVO(named return value optimization)

  • 在一条对象初始化的语句中,如果源对象是一个无名的临时对象,且这个对象和被初始化对象是同一个类类型,则可以进行复制消除。如果这个临时对象又是一条 return 语句的返回值(就是我们上面例子中那种情况),这种复制消除也被称为RVO(return value optimization)

同一条语句中可以连锁进行多次复制消除,以消除多次拷贝:

Cls obj = Cls(Cls(Cls(Cls())));  // Default constructor

 强制拷贝/移动消除

在 C++17 中,编译器被强制要求在下面这些情况中消除拷贝/移动构造函数的调用,哪怕消除掉这些拷贝/移动构造函数以及对应的析构函数会产生可观察到的影响。

  • 在一条 return 语句中,如果返回值是一个和返回类型相同的纯右值类对象,则必须进行复制消除。

  • 在一条对象初始化的语句中,如果源对象是一个和被初始化对象类型相同的纯右值对象,则必须进复制消除。

Last modification:November 28th, 2021 at 11:56 pm