现代C++新特性 左值引用与右值引用

       文字版PDF文档链接:现代C++新特性(文字版)-C++文档类资源-CSDN下载

 左值和右值

左值和右值的概念早在C++98的时候就已经出现了,从 简单的字面理解,无非是表达式等号左边的值为左值,而表达式右边的值为右值,比如:

int x = 1;
int y = 3;
int z = x + y;

以上面的代码为例,x是左值,1是右值;y是左值,3是右值;z 是左值,x+y的结果是右值。用表达式等号左右的标准区分左值和右值虽然在一些场景下确实能得到正确结果,但是还是过于简单,有些情况下是无法准确区分左值和右值的,比如:

int a = 1;
int b = a;

按照表达式等号左右的区分方式,在第一行代码中a是左值,1是右值;在第二行代码中b是左值,而a是右值。这里出现了矛盾,在第一行代码中我们判断a是一个左值,它却在第二行变成了右值,很明显这不是我们想要的结果,要准确地区分左值和右值还是应该理解其内在含义。

在C++中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。还是以上面的代码为例,因为&a和&b都是符合语法规则的,所以 a和b都是左值,而&1在GCC中会给出“lvalue required as unary'&' operand”错误信息以提示程序员&运算符需要的是一个左值。

上面的代码在左右值的判断上比较简单,但是并非所有的情况都是如此,下面这些情况左值和右值的判断可能是违反直觉的,例如:

int x = 1;

int get_val()
{
    return x;
}

void set_val(int val)
{
    x = val;
}

int main(int argc, char** argv)
{
    x++;   
    ++x;
    int y = get_val();   
    set_val(6);
    return 0;
}

在上面的代码中,x++和++x虽然都是自增操作,但是却分为不同的左右值。其中x++是右值,因为在后置++操作中编译器首先会生成一份x值的临时复制,然后才对x递增, 后返回临时复制内容。而++x则不同,它是直接对x递增后马上返回其自身,所以++x是一个左值。如果对它们实施取地址操作,就会发现++x的取地址操作可以编译成功,而对x++取地址则会报错。但是从直觉上来说,&x++看起来更像是会编译成功的一方:

int* p = &x++;  // 编译失败 
int* q = &++x;  // 编译成功

接着来看上一份代码中的get_val函数,该函数返回了一个全局变量x,虽然很明显变量x是一个左值,但是它经过函数返回以后变成了一个右值。原因和x++类似,在函数返回的时候编译器并不会返回x本身,而是返回x的临时复制,所以int * p = &get_val();也会编译失败。对于set_val函数,该函数接受一个参数并且将参数的值赋值到x中。在main函数中set_val(6);实参6是一个右值,但是进入函数之后形参val却变成了一个左值,我们可以对val使用取地址符,并且不会引起任何问题:

void set_val(int val)
{
    int* p = &val;   
    x = val;
}

后需要强调的是,通常字面量都是一个右值,除字符串字面量以外:

int x = 1; set_val(6);
auto p = &"hello world";

这一点非常容易被忽略,因为经验告诉我们上面的代码中前两行的1和6都是右值,因为不存在&1和&6的语法,这会让我们想当然地认为"hello world"也是一个右值,毕竟&"hello world"的语法也很少看到。但是这段代码是可以编译成功的,其实原因仔细想来也很简单,编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会为其开辟内存空间,所以我们可以使用取地址符&来获取字符串字面量的内存地址。

​​​​​​​ 左值引用

左值引用是编程过程中的常用特性之一,它的出现让C++编程在一定程度上脱离了危险的指针。当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。非常量左值的引用对象很单纯,它们必须是一个左值。对于这一点,常量左值引用的特性显得更加有趣,它除了能引用左值,还能够引用右值,比如:

int& x1 = 7;          // 编译错误 
const int &x = 11;    // 编译成功

在上面的代码中,第一行代码会编译报错,因为int&无法绑定一个int类型的右值,但是第二行代码却可以编译成功。请注意,虽然在结果上const int &x = 11和const int x = 11是一样的,但是从语法上来说,前者是被引用了,所以语句结束后11的生命周期被延长,而后者当语句结束后右值11应该被销毁。虽然常量左值引用可以引用右值的这个特性在赋值表达式中看不出什么实用价值,但是在函数形参列表中却有着巨大的作用。一个典型的例子就是复制构造函数和复制赋值运算符函数,通常情况下我们实现的这两个函数的形参都是一个常量左值引用,例如:

class X {
public:
    X() {}
    X(const X&) {}
    X& operator = (const X&) { return *this; }
};

X make_x()
{
    return X();
}

int main(int argc, char** argv)
{
    X x1;
    X x2(x1);
    X x3(make_x());
    x3 = make_x();
    return 0;
}

以上代码可以通过编译,但是如果这里将类X的复制构造函数和复制赋值函数形参类型的常量性删除,则X x3(make_x());和x3 = make_x();这两句代码会编译报错,因为非常量左值引用无法绑定到 make_x()产生的右值。常量左值引用可以绑定右值是一条非常棒的特性,但是它也存在一个很大的缺点——常量性。一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用。

​​​​​​​ 右值引用

顾名思义,右值引用是一种引用右值且只能引用右值的方法。在语法方面右值引用可以对比左值引用,在左值引用声明中,需要在类型后添加&,而右值引用则是在类型后添加&&,例如:

int i = 0; 
int& j = i;    // 左值引用 
int &&k = 11;  // 右值引用

在上面的代码中,k是一个右值引用,如果试图用k引用变量i,则会引起编译错误。右值引用的特点之一是可以延长右值的生命周期,这个对于字面量11可能看不出效果,那么请看下面的例子:

#include <iostream> 
class X {
public:
    X() { cout << "X ctor" << endl; }
    X(const X& x) { cout << "X copy ctor" << endl; }
    ~X() { cout << "X dtor" << endl; }  
    void show() { cout << "show X" << endl; }
};

X make_x()
{
    X x1;  
    return x1;
}
int main(int argc, char** argv)
{
    X&& x2 = make_x();
    x2.show();
    return 0;
}

在理解这段代码之前,让我们想一下如果将X &&x2 =make_x()这句代码替换为X x2 = make_x()会发生几次构造。在没有进行任何优化的情况下应该是3次构造,首先make_x函数中x1会默认构造一次,然后return x1会使用复制构造产生临时对象,接着 X x2 = make_x()会使用复制构造将临时对象复制到x2, 后临时对象被销毁。

以上流程在使用了右值引用以后发生了微妙的变化,让我们编译运行这段代码。请注意,用GCC编译以上代码需要加上命令行参数-fno-elide-constructors用于关闭函数返回值优化(RVO)。因为GCC的RVO优化会减少复制构造函数的调用,不利于语言特性实验:

X ctor
X copy ctor
X dtor
show X
X dtor

从运行结果可以看出上面的代码只发生了两次构造。第一次是 make_x函数中x1的默认构造,第二次是return x1引发的复制构造。不同的是,由于x2是一个右值引用,引用的对象是函数make_x 返回的临时对象,因此该临时对象的生命周期得到延长,所以我们可以在X &&x2 = make_x()语句结束后继续调用show函数而不会发生任何问题。对性能敏感的读者应该注意到了,延长临时对象生命周期并不是这里右值引用的 终目标,其真实目标应该是减少对象复制,提升程序性能。

​​​​​​​ 右值的性能优化空间

通过6.3节的介绍我们知道了很多情况下右值都存储在临时对象中,当右值被使用之后程序会马上销毁对象并释放内存。这个过程可能会引发一个性能问题,例如:

#include <iostream> 
class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}

    ~BigMemoryPool()
    {
        if (pool_ != nullptr) {
            delete[] pool_;
        }
    }

    BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
    {
        cout << "copy big memory pool." << endl;
        memcpy(pool_, other.pool_, PoolSize);
    }
private:

    char* pool_;
};

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
    return pool;
}

BigMemoryPool make_pool()
{
    BigMemoryPool pool;
    return get_pool(pool);
}
int main(int argc, char** argv)
{
    BigMemoryPool my_pool = make_pool();
    return 0;
}

以上代码同样需要加上编译参数-fno-elide-constructors,编译运行程序会在屏幕上输出字符串:

copy big memory pool.
copy big memory pool.
copy big memory pool.

可以看到BigMemoryPool my_pool = make_pool();调用了3次复制构造函数。

1.get_pool返回的BigMemoryPool临时对象调用复制构造函数复制了pool对象。

2.make_pool返回的BigMemoryPool临时对象调用复制构造函数复制了get_pool返回的临时对象。

3.main函数中my_pool调用其复制构造函数复制make_pool返回的临时对象。

该代码从正确性上看毫无问题,但是从运行性能的角度上看却还有巨大的优化空间。在这里每发生一次复制构造都会复制整整4KB的数据,如果数据量更大一些,比如4MB或者400MB,那么将对程序性能造成很大影响。

​​​​​​​ 移动语义

仔细分析1.4节代码中3次复制构造函数的调用,不难发现第二次和第三次的复制构造是影响性能的主要原因。在这个过程中都有临时对象参与进来,而临时对象本身只是做数据的复制。如果有办法能将临时对象的内存直接转移到my_pool对象中,不就能消除内存复制对性能的消耗吗?好消息是在C++11标准中引入了移动语义,它可以帮助我们将临时对象的内存移动到my_pool对象中,以避免内存数据的复制。让我们简单修改一下BigMemoryPool类代码:

class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool()
    {
        if (pool_ != nullptr) {
            delete[] pool_;
        }
    }

    BigMemoryPool(BigMemoryPool&& other)
    {
        cout << "move big memory pool." << endl;
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

    BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
    {
        cout << "copy big memory pool." << endl;
        memcpy(pool_, other.pool_, PoolSize);
    }
private:
    char* pool_;
};

在上面的代码中增加了一个类BigMemoryPool的构造函数BigMemoryPool (BigMemoryPool&& other),它的形参是一个右值引用类型,称为移动构造函数。这个名称很容易让人联想到复制构造函数,那么就让我们先了解一下它们的区别。

从构造函数的名称和它们的参数可以很明显地发现其中的区别,对于复制构造函数而言形参是一个左值引用,也就是说函数的实参必须是一个具名的左值,在复制构造函数中往往进行的是深复制,即在不能破坏实参对象的前提下复制目标对象。而移动构造函数恰恰相反,它接受的是一个右值,其核心思想是通过转移实参对象的数据以达成构造目标对象的目的,也就是说实参对象是会被修改的。

进一步来说类BigMemoryPool的移动构造函数,在函数中没有了复制构造中的内存复制,取而代之的是简单的指针替换操作。它将实参对象的pool_赋值到当前对象,然后置空实参对象以保证实参对象析构的时候不会影响这片内存的生命周期。

编译运行这段代码,其输出结果如下:

copy big memory pool.
move big memory pool.
move big memory pool.

可以看到后面两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值(临时对象),对于右值编译器会优先选择使用移动构造函数去构造目标对象。当移动构造函数不存在的时候才会退而求其次地使用复制构造函数。在移动构造函数中使用了指针转移的方式构造目标对象,所以整个程序的运行效率得到大幅提升。

为了验证效率的提升,我们可以将上面的代码重复运行 100 万次,然后输出运行时间。请注意,在做实验前需要将构造函数中的打印输出语句删除,否则会影响实验数据:

#include <chrono> 
…
int main(int argc, char** argv)
{
    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; i++) {
        BigMemoryPool my_pool = make_pool();
    }
    auto end = chrono::high_resolution_clock::now();
    chrono::duration<double> diff = end - start;
    cout << "Time to call make_pool :" << diff.count() << " s" << endl;
    return 0;
}

以上代码在我的机器上运行结果是0.206474s,如果将移动构造函数删除,运行结果是0.47077s,可见使用移动构造函数将性能提升了1 倍多。

除移动构造函数能实现移动语义以外,移动赋值运算符函数也能完成移动操作,继续以BigMemoryPool为例,在这个类中添加移动赋值运算符函数:

class BigMemoryPool {
public:
    …
    BigMemoryPool& operator=(BigMemoryPool&& other)
    {
        cout << "move(operator=) big memory pool." << endl;
        if (pool_ != nullptr) {
            delete[] pool_;
        }
        pool_ = other.pool_;
        other.pool_ = nullptr;
        return *this;
    }
private:
    char* pool_;
};
int main(int argc, char** argv)
{
    BigMemoryPool my_pool;
    my_pool = make_pool();
    return 0;
}

这段代码编译运行的结果是:

copy big memory pool.
move big memory pool.
move(operator=) big memory pool.

可以看到赋值操作my_pool = make_pool()调用了移动赋值运算符函数,这里的规则和构造函数一样,即编译器对于赋值源对象是右值的情况会优先调用移动赋值运算符函数,如果该函数不存在,则调用复制赋值运算符函数。

后有两点需要说明一下。

1.同复制构造函数一样,编译器在一些条件下会生成一份移动构造函数,这些条件包括:没有任何的复制函数,包括复制构造函数和复制赋值函数;没有任何的移动函数,包括移动构造函数和移动赋值函数;也没有析构函数。虽然这些条件严苛得让人有些不太愉快,但是我们也不必对生成的移动构造函数有太多期待,因为编译器生成的移动构造函数和复制构造函数并没有什么区别。

2.虽然使用移动语义在性能上有很大收益,但是却也有一些风险,这些风险来自异常。试想一下,在一个移动构造函数中,如果当一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分发生了转移而另一部分没有,这就会造成源对象和目标对象都不完整的情况发生,这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常,与此同时,如果无法保证移动构造函数不会抛出异常,可以使用noexcept说明符限制该函数。

这样当函数抛出异常的时候,程序不会再继续执行而是调用 terminate中止执行以免造成其他不良影响。

​​​​​​​ 值类别

到目前为止一切都非常容易理解,其中一个原因是我在前面的内容中隐藏了一个概念。但是在进一步探讨右值引用之前,我们必须先掌握这个概念——值类别。值类别是C++11标准中新引入的概念,具体来说它是表达式的一种属性,该属性将表达式分为3个类别,它们分别是左值(lvalue)、纯右值(prvalue)和将亡值(xvalue),如图6-1所示。从前面的内容中我们知道早在C++98的时候,已经有了一些关于左值和右值的概念了,只不过当时这些概念对于C++程序编写并不重要。但是由于C++11中右值引用的出现,值类别被赋予了全新的含义。

可惜的是,在C++11标准中并没能够清晰地定义它们,比如在C++11的标准文档中,左值的概念只有一句话:“指定一个函数或一个对象”,这样的描述显然是不清晰的。这种糟糕的情况一直延续到C++17 标准的推出才得到解决。所以现在是时候让我们重新认识这些概念了。

表达式首先被分为了泛左值(glvalue)和右值(rvalue),其中泛左值被进一步划分为左值和将亡值,右值又被划分为将亡值和纯右值。理解这些概念的关键在于泛左值、纯右值和将亡值。

1.所谓泛左值是指一个通过评估能够确定对象、位域或函数的标识的表达式。简单来说,它确定了对象或者函数的标识(具名对象)。

2.而纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式。

3.将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用的转换产生的。剩下的两种类别就很容易理解了,其中左值是指非将亡值的泛左值,而右值则包含了纯右值和将亡值。再次强调,值类别都是表达式的属性,所以我们常说的左值和右值实际上指的是表达式,不过为了描述方便我们常常会忽略它。

是不是感觉有点晕。相信我,当我第一次看到这些概念的时候也是这个反应。不过好在我们对传统左值和右值的概念已经了然于心了,现在只需要做道连线题就能弄清楚它们的概念。实际上,这里的左值(lvalue)就是我们上文中描述的C++98的左值,而这里的纯右值(prvalue)则对应上文中描述的C++98的右值。 后我们惊喜地发现,现在只需要弄清楚将亡值(xvalue)到底是如何产生的就可以了。

从本质上说产生将亡值的途径有两种,第一种是使用类型转换将泛左值转换为该类型的右值引用。比如:

static_cast<BigMemoryPool&&>(my_pool)

第二种在C++17标准中引入,我们称它为临时量实质化,指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这也符合纯右值的概念,而这里的临时对象就是一个将亡值。

struct X {
    int a;
};

int main(int argc, char** argv)
{
    int b = X().a;
    return 0;
}

在上面的代码中,S()是一个纯右值,访问其成员变量a却需要一个泛左值,所以这里会发生一次临时量实质化,将S()转换为将亡值, 后再访问其成员变量a。还有一点需要说明,在C++17标准之前临时变量是纯右值,只有转换为右值引用的类型才是将亡值。

在本节之后的内容中,依然会以左值和右值这样的术语为主。但是读者应该清楚,这里的左值是C++17中的左值(lvalue),右值是C++17中的纯右值(prvalue)和将亡值(xvalue)。对于将亡值(xvalue),读者实际上只需要知道它是泛左值和右值交集即可,后面的内容也不会重点强调它,所以不会影响到读者对后续内容的理解。

​​​​​​​ 将左值转换为右值

在1.3节提到过右值引用只能绑定一个右值,如果尝试绑定,左值会导致编译错误:

int i = 0; 
int &&k = i;    // 编译失败

不过,如果想完成将右值引用绑定到左值这个“壮举”还是有办法的。在C++11标准中可以在不创建临时值的情况下显式地将左值通过 static_cast转换为将亡值,通过值类别的内容我们知道将亡值属于右值,所以可以被右值引用绑定。值得注意的是,由于转换的并不是右值,因此它依然有着和转换之前相同的生命周期和内存地址,例如:

int i = 0;
int&& k = static_cast<int&&>(i);    // 编译成功

读者在这里应该会有疑问,既然这个转换既不改变生命周期也不改变内存地址,那它有什么存在的意义呢?实际上它的 大作用是让左值使用移动语义,还是以BigMemoryPool为例:

BigMemoryPool my_pool1;
BigMemoryPool my_pool2 = my_pool1;
BigMemoryPool my_pool3 = static_cast<BigMemoryPool&&>(my_pool1);

在这段代码中,my_pool1是一个BigMemoryPool类型的对象,也是一个左值,所以用它去构造my_pool2的时候调用的是复制构造函数。为了让编译器调用移动构造函数构造my_pool3,这里使用了static_cast<BigMemoryPool &&>(my_ pool1)将my_pool1强制转换为右值(也是将亡值,为了叙述思路的连贯性后面不再强调)。由于调用了移动构造函数,my_pool1失去了自己的内存数据,后面的代码也不能对my_pool1进行操作了。

现在问题又来了,这样单纯地将一个左值数据转换到另外一个左值似乎并没有什么意义。在这个例子中的确如此,这样的转换不仅没有意义,而且如果有程序员在移动构造之后的代码中再次使用my_pool1还会引发未定义的行为。正确的使用场景是在一个右值被转换为左值后需要再次转换为右值, 典型的例子是一个右值作为实参传递到函数中。我们在讨论左值和右值的时候曾经提到过,无论一个函数的实参是左值还是右值,其形参都是一个左值,即使这个形参看上去是一个右值引用,例如:

void move_pool(BigMemoryPool&& pool)
{
    cout << "call move_pool" << endl;
    BigMemoryPool my_pool(pool);
}

int main(int argc, char** argv)
{
    move_pool(make_pool());
    return 0;
}

编译运行以上代码输出结果如下:

copy big memory pool.
move big memory pool.
call move_pool copy big memory pool.

在上面的代码中,move_pool函数的实参是make_pool函数返回的临时对象,也是一个右值,move_pool的形参是一个右值引用,但是在使用形参pool构造my_pool的时候还是会调用复制构造函数而非移动构造函数。为了让my_pool调用移动构造函数进行构造,需要将形参pool 强制转换为右值:

void move_pool(BigMemoryPool&& pool)
{
    cout << "call move_pool" << endl;
    BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}

请注意,在这个场景下强制转换为右值就没有任何问题了,因为 move_pool函数的实参是make_pool返回的临时对象,当函数调用结束后临时对象就会被销毁,所以转移其内存数据不会存在任何问题。

在C++11的标准库中还提供了一个函数模板move帮助我们将左值转换为右值,这个函数内部也是用static_cast做类型转换。只不过由于它是使用模板实现的函数,因此会根据传参类型自动推导返回类型,省去了指定转换类型的代码。另一方面从移动语义上来说,使用move函数的描述更加准确。所以建议读者使用move将左值转换为右值而非自己使用static_cast转换,例如:

void move_pool(BigMemoryPool&& pool)
{
    cout << "call move_pool" << endl;
    BigMemoryPool my_pool(move(pool));
}

​​​​​​​ 万能引用和引用折叠

1.2节提到过常量左值引用既可以引用左值又可以引用右值,是一个几乎万能的引用,但可惜的是由于其常量性,导致它的使用范围受到一些限制。其实在C++11中确实存在着一个被称为“万能”的引用,它看似是一个右值引用,但其实有着很大区别,请看下面的代码:

void foo(int&& i)         // i为右值引用 
{

}

template<class T>
void bar(T&& t)           // t为万能引用 
{

}    

int get_val() 
{
    return 5;
} 

int&& x = get_val();      // x为右值引用 
auto &&y = get_val();     // y为万能引用

在上面的代码中,函数foo的形参i和变量x是右值引用,而函数模板的形参t和变量y则是万能引用。我们知道右值引用只能绑定一个右值,但是万能引用既可以绑定左值也可以绑定右值,甚至const和 volatile的值都可以绑定,例如:

int i = 42; 
const int j = 11;
bar(i); 
bar(j);
bar(get_val());

auto&& x = i; 
auto&& y = j; 
auto&& z = get_val();

看到这里读者应该已经发现了其中的奥秘。所谓的万能引用是因为发生了类型推导,在T&&和auto&&的初始化过程中都会发生类型的推导,如果已经有一个确定的类型,比如int &&,则是右值引用。在这个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出左值引用;反之如果源对象是一个右值,则会推导出右值引用,不过无论如何都会是一个引用类型。

万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出 终类型,如表1-1所示。

表格 1‑1

类模板型

T实际类型

最终类型

T&

R

R&

T&

R&

R&

T&

R&&

R&

T&&

R

R&&

T&&

R&

R&

T&&

R&&

R&&

上面的表格显示了引用折叠的推导规则,可以看出在整个推导过程中,只要有左值引用参与进来, 后推导的结果就是一个左值引用。只有实际类型是一个非引用类型或者右值引用类型时, 后推导出来的才是一个右值引用。那么这个规则是如何在万能引用中体现的呢?让我们以函数模板bar为例看一下具体的推导过程。

在bar(i);中i是一个左值,所以T的推导类型结果是int&,根据引用折叠规则int& &&的 终推导类型为int&,于是bar函数的形参是一个左值引用。而在bar(get_val());中get_val返回的是一个右值,所以T的推导类型为非引用类型int,于是 终的推导类型是 int&&,bar函数的形参成为一个右值引用。

值得一提的是,万能引用的形式必须是T&&或者auto&&,也就是说它们必须在初始化的时候被直接推导出来,如果在推导中出现中间过程,则不是一个万能引用,例如:

#include <vector> 
template<class T> 
void foo(vector<T>&& t) 
{

} 
int main(int argc, char** argv)
{
    vector<int> v{ 1,2,3 };   
    foo(v);                                // 编译错误 
    return 0;
}

在上面的代码中,foo(v)无法编译通过,因为foo的形参t并不是一个万能引用,而是一个右值引用。因为foo的形参类型是vector<T>&&而不是T&&,所以编译器无法将其看作一个万能引用处理。

​​​​​​​ 完美转发

1.8节介绍了万能引用的语法和推导规则,但没有提到它的用途。

现在是时候讨论这个问题了,万能引用 典型的用途被称为完美转发。在介绍完美转发之前,我们先看一个常规的转发函数模板:

#include <iostream> 
#include <string> 

template<class T> 
void show_type(T t)
{
    cout << typeid(t).name() << endl;
}

template<class T>
void normal_forwarding(T t)
{
    show_type(t);
}

int main(int argc, char** argv)
{
    string s = "hello world";   
    normal_forwarding(s);
    return 0;
}

在上面的代码中,函数normal_forwarding是一个常规的转发函数模板,它可以完成字符串的转发任务。但是它的效率却令人堪忧。因为normal_forwarding按值转发,也就是说string 在转发过程中会额外发生一次临时对象的复制。其中一个解决办法是将void normal_forwarding(T t)替换为void normal_forwarding(T &t),这样就能避免临时对象的复制。不过这样会带来另外一个问题,如果传递过来的是一个右值,则该代码无法通过编译,例如:

string get_string()
{
    return "hi world";
}
normal_forwarding(get_string());    // 编译失败

当然,我们还可以将void normal_forwarding(T &t)替换为void normal_forwarding (const T &t)来解决这个问题,因为常量左值引用是可以引用右值的。但是我们也知道,虽然常量左值引用在这个场景下可以“完美”地转发字符串,但是如果在后续的函数中需要修改该字符串,则会编译错误。所以这些方法都不能称得上是完美转发。

万能引用的出现改变了这个尴尬的局面。上文提到过,对于万能引用的形参来说,如果实参是给左值,则形参被推导为左值引用;反之如果实参是一个右值,则形参被推导为右值引用,所以下面的代码无论传递的是左值还是右值都可以被转发,而且不会发生多余的临时复制:

#include <iostream> 
#include <string> 

template<class T> 
void show_type(T t)
{
    cout << typeid(t).name() << endl;
}

template<class T>
void perfect_forwarding(T&& t)
{
    show_type(static_cast<T&&>(t));
}
string get_string()
{
    return "hi world";
}
int main(int argc, char** argv)
{
    string s = "hello world";   
    perfect_forwarding(s);
    perfect_forwarding(get_string());
    return 0;
}

如果已经理解了引用折叠规则,那么上面的代码就很容易理解了。唯一可能需要注意的是show_type(static_cast<T&&> (t));中的类型转换,之所以这里需要用到类型转换,是因为作为形参的t是左值。为了让转发将左右值的属性也带到目标函数中,这里需要进行类型转换。当实参是一个左值时,T被推导为string&,于是static_cast<T&&>被推导为 static_cast< string&>,传递到show_type函数时继续保持着左值引用的属性;当实参是一个右值时,T被推导为string,于是static_cast <T&&>被推导为 static_cast<string&&>,所以传递到show_type函数时保持了右值引用的属性。

和移动语义的情况一样,显式使用static_cast类型转换进行转发不是一个便捷的方法。在C++11的标准库中提供了一个forward函数模板,在函数内部也是使用static_cast进行类型转换,只不过使用forward转发语义会表达得更加清晰, forward函数模板的使用方法也很简单:

template<class T>
void perfect_forwarding(T&& t)
{
    show_type(forward<T>(t));
}

请注意move和forward的区别,其中move一定会将实参转换为一个右值引用,并且使用move不需要指定模板实参,模板实参是由函数调用推导出来的。而forward会根据左值和右值的实际情况进行转发,在使用的时候需要指定模板实参。

​​​​​​​ 针对局部变量和右值引用的隐式移动操作

在对旧程序代码升级新编译环境之后,我们可能会发现程序运行的效率提高了,这里的原因一定少不了新标准的编译器在某些情况下将隐式复制修改为隐式移动。虽然这些是编译器“偷偷”完成的,但是我们不能因为运行效率提高就忽略其中的缘由,所以接下来我们要弄清楚这些隐式移动是怎么发生的:

#include <iostream> 

struct X {
    X() = default;
    X(const X&) = default;  
    X(X&&) {
        cout << "move ctor";
    }
};

X f(X x) {
    return x;
}

int main(int argc, char** argv)
{
    X r = f(X{});
    return 0;
}

这段代码很容易理解,函数f直接返回调用者传进来的实参x,在main函数中使用r接收f函数的返回值。关键问题是,这个赋值操作究竟是如何进行的。从代码上看,将r赋值为x应该是一个复制,对于旧时的标准这是没错的。但是对于支持移动语义的新标准,这个地方会隐式地采用移动构造函数来完成数据的交换。编译运行以上代码终会显示move ctor字符串。

除此之外,对于局部变量也有相似的规则,只不过大多数时候编译器会采用更加高效的返回值优化代替移动操作,这里我们稍微修改一点f函数:

X f()
{
    X x;  
    return x;
}
int main(int argc, char** argv) 
{
    X r = f();
    return 0;
}

请注意,编译以上代码的时候需要使用-fno-elide-constructors选项用于关闭返回值优化。然后运行编译好的程序,会发现X r = f();同样调用的是移动构造函数。

在C++20标准中,隐式移动操作针对右值引用和throw的情况进行了扩展,例如:

#include <iostream> 
#include <string> 

struct X {
    X() = default;
    X(const X&) = default;  
    X(X&&) 
    {
        cout << "move";
    }
};

X f(X&& x) 
{
    return x;
}

int main(int argc, char** argv) 
{
    X r = f(X{});
    return 0;
}

以上代码使用C++20之前的标准编译是不会调用任何移动构造函数的。原因前面也解释过,因为函数f的形参x是一个左值,对于左值要调用复制构造函数。要实现移动语义,需要将return x;修改为return move(x);。显然这里是有优化空间的,C++20标准规定在这种情况下可以隐式采用移动语义完成赋值。具体规则如下。

可隐式移动的对象必须是一个非易失或一个右值引用的非易失自动存储对象,在以下情况下可以使用移动代替复制。

1.return或者co_return语句中的返回对象是函数或者 lambda表达式中的对象或形参。

2.throw语句中抛出的对象是函数或try代码块中的对象。

实际上throw调用移动构造的情况和return差不多,我们只需要将上面的代码稍作修改即可:

void f()
{
    X x;
    throw x;
}

int main(int argc, char** argv)
{
    try {
        f();
    }
    catch (…) {

    }
    return 0;
}

可以看到函数f不再有返回值,它通过throw抛出x,main函数用try-catch捕获f抛出的x。这个捕获调用的就是移动构造函数。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

神奇的小强

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值