配套视频课程已更新完毕,大家可通过以下两种方式观看视频讲解:

关注公众号:爱编程的大丙,或者进入大丙课堂学习。


1. 右值引用

1.1 右值

C++11 增加了一个新的类型,称为右值引用( R-value reference),标记为 &&。在介绍右值引用类型之前先要了解什么是左值和右值:

  • lvalue 是loactor value的缩写,rvalue 是 read value的缩写

  • 左值是指存储在内存中、有明确存储地址(可取地址)的数据;

  • 右值是指可以提供数据值的数据(不可取地址);

通过描述可以看出,区分左值与右值的便捷方法是:可以对表达式取地址(&)就是左值,否则为右值 。所有有名字的变量或对象都是左值,而右值是匿名的。

1
2
3
int a = 520;
int b = 1314;
a = b;

一般情况下,位于=前的表达式为左值,位于=后边的表达式为右值。也就是说例子中的a, b为左值,520,1314为右值。a=b是一种特殊情况,在这个表达式中a, b都是左值,因为变量b是可以被取地址的,不能视为右值。

C++11 中右值可以分为两种:一个是将亡值( xvalue, expiring value),另一个则是纯右值( prvalue, PureRvalue):

  • 纯右值非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和 lambda 表达式等
  • 将亡值与右值引用相关的表达式,比如,T&&类型函数的返回值、 std::move 的返回值等。
1
int value = 520;

在上面的语句中,value是左值,520是字面量也就是右值。其中value可以被引用,但是520就不行了,因为字面量都是右值。

1.2 右值引用

右值引用就是对一个右值进行引用的类型。因为右值是匿名的,所以我们只能通过引用的方式找到它。无论声明左值引用还是右值引用都必须立即进行初始化,因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名。通过右值引用的声明,该右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。

关于右值引用的使用,参考代码如下:

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
#include <iostream>
using namespace std;

int&& value = 520;
class Test
{
public:
Test()
{
cout << "construct: my name is jerry" << endl;
}
Test(const Test& a)
{
cout << "copy construct: my name is tom" << endl;
}
};

Test getObj()
{
return Test();
}

int main()
{
int a1;
int &&a2 = a1; // error
Test& t = getObj(); // error
Test && t = getObj();
const Test& t = getObj();
return 0;
}
  • 在上面的例子中int&& value = 520;里面520是纯右值,value是对字面量520这个右值的引用。

  • int &&a2 = a1;a1虽然写在了=右边,但是它仍然是一个左值,使用左值初始化一个右值引用类型是不合法的。

  • Test& t = getObj()这句代码中语法是错误的,右值不能给普通的左值引用赋值。

  • Test && t = getObj();getObj()返回的临时对象被称之为将亡值t是这个将亡值的右值引用。

  • const Test& t = getObj()这句代码的语法是正确的,常量左值引用是一个万能引用类型,它可以接受左值、右值、常量左值和常量右值。

2. 性能优化

在C++中在进行对象赋值操作的时候,很多情况下会发生对象之间的深拷贝,如果堆内存很大,这个拷贝的代价也就非常大,在某些情况下,如果想要避免对象的深拷贝,就可以使用右值引用进行性能的优化。

再来修改一下上面的实例代码:

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
#include <iostream>
using namespace std;

class Test
{
public:
Test() : m_num(new int(100))
{
cout << "construct: my name is jerry" << endl;
}

Test(const Test& a) : m_num(new int(*a.m_num))
{
cout << "copy construct: my name is tom" << endl;
}

~Test()
{
delete m_num;
}

int* m_num;
};

Test getObj()
{
Test t;
return t;
}

int main()
{
Test t = getObj();
cout << "t.m_num: " << *t.m_num << endl;
return 0;
};

测试代码执行的结果为(当时使用的vs版本为2019,vs2022已无法看到相同的输出,代码被优化了):

1
2
3
construct: my name is jerry
copy construct: my name is tom
t.m_num: 100

通过输出的结果可以看到调用Test t = getObj();的时候调用拷贝构造函数对返回的临时对象进行了深拷贝得到了对象t,在getObj()函数中创建的对象虽然进行了内存的申请操作,但是没有使用就释放掉了。如果能够使用临时对象已经申请的资源,既能节省资源,还能节省资源申请和释放的时间,如果要执行这样的操作就需要使用右值引用了,右值引用具有移动语义,移动语义可以将资源(堆、系统对象等)通过浅拷贝从一个对象转移到另一个对象这样就能减少不必要的临时对象的创建、拷贝以及销毁,可以大幅提高C++应用程序的性能。

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
#include <iostream>
using namespace std;

class Test
{
public:
Test() : m_num(new int(100))
{
cout << "construct: my name is jerry" << endl;
}

Test(const Test& a) : m_num(new int(*a.m_num))
{
cout << "copy construct: my name is tom" << endl;
}

// 添加移动构造函数
Test(Test&& a) : m_num(a.m_num)
{
a.m_num = nullptr;
cout << "move construct: my name is sunny" << endl;
}

~Test()
{
delete m_num;
cout << "destruct Test class ..." << endl;
}

int* m_num;
};

Test getObj()
{
Test t;
return t;
}

int main()
{
Test t = getObj();
cout << "t.m_num: " << *t.m_num << endl;
return 0;
};

测试代码执行的结果如下(当时使用的vs版本为2019,vs2022已无法看到相同的输出,代码被优化了):

1
2
3
4
5
construct: my name is jerry
move construct: my name is sunny
destruct Test class ...
t.m_num: 100
destruct Test class ...

通过修改,在上面的代码给Test类添加了移动构造函数(参数为右值引用类型),这样在进行Test t = getObj();操作的时候并没有调用拷贝构造函数进行深拷贝,而是调用了移动构造函数,在这个函数中只是进行了浅拷贝,没有对临时对象进行深拷贝,提高了性能。

在测试程序中getObj()的返回值就是一个将亡值,也就是说是一个右值,在进行赋值操作的时候如果=右边是一个右值,那么移动构造函数就会被调用。移动构造中使用了右值引用,会将临时对象中的堆内存地址的所有权转移给对象t,这块内存被成功续命,因此在t对象中还可以继续使用这块内存。

对于需要动态申请大量资源的类,应该设计移动构造函数,以提高程序效率。需要注意的是,我们一般在提供移动构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造函数。

3 && 的特性

在C++中,并不是所有情况下 && 都代表是一个右值引用,具体的场景体现在模板和自动类型推导中,如果是模板参数需要指定为T&&,如果是自动类型推导需要指定为auto &&,在这两种场景下 &&被称作未定的引用类型。另外还有一点需要额外注意const T&&表示一个右值引用,不是未定引用类型。

先来看第一个例子,在函数模板中使用&&:

1
2
3
4
5
6
7
8
template<typename T>
void f(T&& param);
void f1(const T&& param);
f(10);
int x = 10;
f(x);
f1(x); // error, x是左值
f1(10); // ok, 10是右值

在上面的例子中函数模板进行了自动类型推导,需要通过传入的实参来确定参数param的实际类型。

  • 第4行中,对于f(10)来说传入的实参10是右值,因此T&&表示右值引用
  • 第6行中,对于f(x)来说传入的实参是x是左值,因此T&&表示左值引用
  • 第7行中,f1(x)的参数是const T&&不是未定引用类型,不需要推导,本身就表示一个右值引用

再来看第二个例子:

1
2
3
4
5
6
7
8
9
int main()
{
int x = 520, y = 1314;
auto&& v1 = x;
auto&& v2 = 250;
decltype(x)&& v3 = y; // error
cout << "v1: " << v1 << ", v2: " << v2 << endl;
return 0;
};
  • 第4行中 auto&&表示一个整形的左值引用
  • 第5行中 auto&&表示一个整形的右值引用
  • 第6行中decltype(x)&&等价于int&&是一个右值引用不是未定引用类型,y是一个左值,不能使用左值初始化一个右值引用类型。

由于上述代码中存在T&&或者auto&&这种未定引用类型,当它作为参数时,有可能被一个右值引用初始化,也有可能被一个左值引用初始化,在进行类型推导时右值引用类型(&&)会发生变化,这种变化被称为引用折叠。在C++11中引用折叠的规则如下:

  • 通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型

  • 通过非右值(右值引用、左值、左值引用、常量右值引用、常量左值引用)推导 T&& 或者 auto&& 得到的是一个左值引用类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int&& a1 = 5;
    auto&& bb = a1;
    auto&& bb1 = 5;

    int a2 = 5;
    int &a3 = a2;
    auto&& cc = a3;
    auto&& cc1 = a2;

    const int& s1 = 100;
    const int&& s2 = 100;
    auto&& dd = s1;
    auto&& ee = s2;

    const auto&& x = 5;
    • 第2行:a1为右值引用,推导出的bb左值引用类型
    • 第3行:5为右值,推导出的bb1右值引用类型
    • 第7行:a3为左值引用,推导出的cc左值引用类型
    • 第8行:a2为左值,推导出的cc1左值引用类型
    • 第12行:s1为常量左值引用,推导出的dd常量左值引用类型
    • 第13行:s2为常量右值引用,推导出的ee常量左值引用类型
    • 第15行:x为右值引用,不需要推导,只能通过右值初始化

再看最后一个例子,代码如下:

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
#include <iostream>
using namespace std;

void printValue(int &i)
{
cout << "l-value: " << i << endl;
}

void printValue(int &&i)
{
cout << "r-value: " << i << endl;
}

void forward(int &&k)
{
printValue(k);
}

int main()
{
int i = 520;
printValue(i);
printValue(1314);
forward(250);

return 0;
};

测试代码输出的结果如下:

1
2
3
l-value: 520
r-value: 1314
l-value: 250

根据测试代码可以得知,编译器会根据传入的参数的类型(左值还是右值)调用对应的重置函数(printValue),函数forward()接收的是一个右值,但是在这个函数中调用函数printValue()时,参数k变成了一个命名对象,编译器会将其当做左值来处理。

最后总结一下关于&&的使用:

  1. 左值和右值是独立于他们的类型的,右值引用类型可能是左值也可能是右值。
  2. 编译器会将已命名的右值引用视为左值,将未命名的右值引用视为右值。
  3. auto&&或者函数参数类型自动推导的T&&是一个未定的引用类型,它可能是左值引用也可能是右值引用类型,这取决于初始化的值类型(上面有例子)。
  4. 通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型,其余都是左值引用类型。