0%

C11:左值右值

这篇文章记述了关于C++11左值右值的知识点。

阿里:左值和右值的区别

左值,可以通过地址定位到的值(可以使用 & 获取到内存位置);右值,只读的值,临时结果

左值与右值的根本区别在于能否获取内存地址,而能否赋值不是区分的依据。通常临时量均为右值。

临时变量(右值)生命周期
a) 临时对象应该在完整表达式结束时销毁
b) 常量左值引用会延长临时变量的生命

1
2
3
4
5
关于右值,在 c++11 以前有一个十分值得关注的语言的特性:右值能被 const 类型的引用所指向,所以如下代码是合法的。
const cs& ref = get_cs();
而且准确地说,右值只能被 const 类型的 reference 所指向,非 const 的引用则是非法的:
// error
cs& ref = get_cs();

std::move() 函数的实现原理

在C++11中,标准库在中提供了一个有用的函数std::move,std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);

std::move函数可以以非常简单的方式将左值引用转换为右值引用。

  1. C++ 标准库使用比如vector::push_back 等这类函数时,会对参数的对象进行复制,连数据也会复制.这就会造成对象内存的额外创建, 本来原意是想把参数push_back进去就行了,通过std::move,可以避免不必要的拷贝操作。
  2. std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能.。
  3. 对指针类型的标准库对象并不需要这么做.

用法:

原lvalue值被moved from之后值被转移,所以为空字符串.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//摘自https://zh.cppreference.com/w/cpp/utility/move
#include <iostream>
#include <utility>
#include <vector>
#include <string>
int main()
{
std::string str = "Hello";
std::vector<std::string> v;
//调用常规的拷贝构造函数,新建字符数组,拷贝数据
v.push_back(str);
std::cout << "After copy, str is \"" << str << "\"\n";
//调用移动构造函数,掏空str,掏空后,最好不要使用str
v.push_back(std::move(str));
std::cout << "After move, str is \"" << str << "\"\n";
std::cout << "The contents of the vector are \"" << v[0]
<< "\", \"" << v[1] << "\"\n";
}

输出:

1
2
3
After copy, str is "Hello"
After move, str is ""
The contents of the vector are "Hello", "Hello"

std::move 的函数原型定义

1
2
3
4
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type&&>(t);

原型定义中的原理实现:

首先,函数参数T&&是一个指向模板类型参数的右值引用,通过引用折叠,此参数可以与任何类型的实参匹配(可以传递左值或右值,这是std::move主要使用的两种场景)。关于引用折叠如下:奇变偶不变

公式一)X& &、X&& &、X& &&都折叠成X&,用于处理左值

补:

需要注意的有2点:1、输入参数的类型是Args&&… , &&的作用是引用折叠,其规则是:

1
2
3
4
&& && -> &&
& && -> &
& & -> &
&& & -> &

(1)所有右值引用折叠到右值引用上仍然是一个右值引用。如X&& &&折叠为X&&。

(2)所有的其他引用类型之间的折叠都将变成左值引用。如X& &, X& &&, X&& &折叠为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
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
#include <iostream>

using namespace std;

class Widget{};

template<typename T>
void func(T&& param){}

//Widget工厂函数
Widget widgetFactory()
{
return Widget();
}

//类型别名
template<typename T>
class Foo
{
public:
typedef T&& RvalueRefToT;
};

int main()
{
int x = 0;
int& rx = x;
//auto& & r = x; //error,声明“引用的引用”是非法的!

//1. 引用折叠发生的语境1——模板实例化
Widget w1;
func(w1); //w1为左值,T被推导为Widget&。代入得void func(Widget& && param);
//引用折叠后得void func(Widget& param)

func(widgetFactory()); //传入右值(栈上临时对象),T被推导为Widget,代入得void func(Widget&& param)
//注意这里没有发生引用的折叠。

//2. 引用折叠发生的语境2——auto类型推导
auto&& w2 = w1; //w1为左值auto被推导为Widget&,代入得Widget& && w2,折叠后为Widget& w2
auto&& w3 = widgetFactory(); //函数返回Widget,为右值,auto被推导为Widget,代入得Widget w3

//3. 引用折叠发生的语境3——tyedef和using
Foo<int&> f1; //T被推导为int&,代入得typedef int& && RvalueRefToT;折叠后为typedef int& RvalueRefToT

//4. 引用折叠发生的语境3——decltype
decltype(x)&& var1 = 10; //由于x为int类型,代入得int&& rx。
decltype(rx) && var2 = x; //由于rx为int&类型,代入得int& && var2,折叠后得int& var2

return 0;
}

引用折叠示例代码

二、完美转发

(一)std::forward原型

1
2
3
4
5
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param); //可能会发生引用折叠!
}

(二)分析std::forward实现条件转发的原理(以转发Widget类对象为例

image-20201114120612220

  1. 当传递给func函数的实参类型左值Widget时,T被推导为Widget&类别。然后forward会实例化为std::forward<**Widget&**>,并返回Widget&(左值引用,根据定义是个左值!

  2. 而当传递给func函数的实参类型右值Widget时,T被推导为Widget。然后forward被实例化为std::forward<**Widget**>,并返回Widget&&(注意,匿名的右值引用是个右值!)

  3. 可见,std::forward会根据传递给func函数实参(注意,不是形参)的左/右值类型进行转发当传给func函数左值实参时,forward返回左值引用,并将该左值转发给process。而当传入func的实参为右值时,forward返回右值引用,并将该右值转发给process函数。

1
2
3
4
5
6
7
8
9
10
string s("hello");
std::move(s) => std::move(string& &&) => 折叠后 std::move(string& )
此时:T的类型为string&
typename remove_reference<T>::type为string 
整个std::move被实例化如下
string&& move(string& t) //t为左值,移动后不能在使用t
{
    //通过static_cast将string&强制转换为string&&
    return static_cast<string&&>(t); 
}

公式二)X&& &&折叠成X&&,用于处理右值

1
2
3
4
5
6
7
8
std::move(string("hello")) => std::move(string&&)
//此时:T的类型为string 
//     remove_reference<T>::type为string 
//整个std::move被实例如下
string&& move(string&& t) //t为右值
{
    return static_cast<string&&>(t);  //返回一个右值引用
}

简单来说,右值经过T&&传递类型保持不变还是右值,而左值经过T&&变为普通的左值引用.

C11 右值

1、值得一提的是,左值的英文简写为“lvalue”,右值的英文简写为“rvalue”。lvalue rvalue不是leftvalue rightvalue的意思,而是locator value 和 read value的意思。locator value代表该值为存储在内存中、有明确存储地址(可寻址)的数据。而 rvalue指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据、临时对象)。

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;

class demo{
public:
demo():num(new int(0)){
cout<<"construct!"<<endl;
}
//拷贝构造函数
demo(const demo &d):num(new int(*d.num)){
cout<<"copy construct!"<<endl;
}
~demo(){
cout<<"class destruct!"<<endl;
}
private:
int *num;
};

demo get_demo(){
return demo();
}

int main(){
demo a = get_demo();
return 0;
}

可以看到,程序中定义了一个可返回 demo 对象的 get_demo() 函数,用于在 main() 主函数中初始化 a 对象,其整个初始化的流程包含以下几个阶段:

  1. 执行 get_demo() 函数内部的 demo() 语句,即调用 demo 类的默认构造函数生成一个匿名对象;
  2. 执行 return demo() 语句,会调用拷贝构造函数复制一份之前生成的匿名对象,并将其作为 get_demo() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁);
  3. 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的对象会被析构);
  4. 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。

注意,目前多数编译器都会对程序中发生的拷贝操作进行优化,因此如果我们使用 VS 2017、codeblocks 等这些编译器运行此程序时,看到的往往是优化后的输出结果:

construct!
class destruct!

而同样的程序,如果在 Linux 上使用g++ demo.cpp -fno-elide-constructors命令运行(其中 demo.cpp 是程序文件的名称),就可以看到完整的输出结果:

construct! <– 执行 demo()
copy construct! <– 执行 return demo()
class destruct! <– 销毁 demo() 产生的匿名对象
copy construct! <– 执行 a = get_demo()
class destruct! <– 销毁 get_demo() 返回的临时对象
class destruct! <– 销毁 a

如上所示,利用拷贝构造函数实现对 a 对象的初始化,底层实际上进行了 2 次拷贝(而且是深拷贝)操作。当然,对于仅申请少量堆空间的临时对象来说,深拷贝的执行效率依旧可以接受,但如果临时对象中的指针成员申请了大量的堆空间,那么 2 次深拷贝操作势必会影响 a 对象初始化的执行效率。

事实上,此问题一直存留在以 C++ 98/03 标准编写的 C++ 程序中。由于临时变量的产生、销毁以及发生的拷贝操作本身就是很隐晦的(编译器对这些过程做了专门的优化),且并不会影响程序的正确性,因此很少进入程序员的视野。

那么当类中包含指针类型的成员变量,使用其它对象来初始化同类对象时,怎样才能避免深拷贝导致的效率问题呢?C++11 标准引入了解决方案,该标准中引入了右值引用的语法,借助它可以实现移动语义。

C++移动构造函数(移动语义的具体实现)

所谓移动语义,指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。

以前面程序中的 demo 类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用 get_demo() 函数返回的临时对象初始化 a 时,我们只需要将临时对象的 num 指针直接浅拷贝给 a.num,然后修改该临时对象中 num 指针的指向(通常另其指向 NULL),这样就完成了 a.num 的初始化。

事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

例如,下面程序对 demo 类进行了修改:

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
#include <iostream>
using namespace std;
class demo{
public:
demo():num(new int(0)){
cout<<"construct!"<<endl;
}

demo(const demo &d):num(new int(*d.num)){
cout<<"copy construct!"<<endl;
}
//添加移动构造函数
demo(demo &&d):num(d.num){
d.num = NULL;
cout<<"move construct!"<<endl;
}
~demo(){
cout<<"class destruct!"<<endl;
}
private:
int *num;
};
demo get_demo(){
return demo();
}
int main(){
demo a = get_demo();
return 0;
}

可以看到,在之前 demo 类的基础上,我们又手动为其添加了一个构造函数。和其它构造函数不同,此构造函数使用右值引用形式的参数,又称为移动构造函数。并且在此构造函数中,num 指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了 d.num,有效避免了“同一块对空间被释放多次”情况的发生。

在 Linux 系统中使用g++ demo.cpp -o demo.exe -std=c++0x -fno-elide-constructors命令执行此程序,输出结果为:

1
2
3
4
5
6
construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!

通过执行结果我们不难得知,当为 demo 类添加移动构造函数之后,使用临时对象初始化 a 对象过程中产生的 2 次拷贝操作,都转由移动构造函数完成。

我们知道,非 const 右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值、lambda 表达式等)既无名称也无法获取其存储地址,所以属于右值。当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。

在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。

读者可能会问,如果使用左值初始化同类对象,但也想调用移动构造函数完成,有没有办法可以实现呢?

默认情况下,左值初始化同类对象只能通过拷贝构造函数完成,如果想调用移动构造函数,则必须使用右值进行初始化。C++11 标准中为了满足用户使用左值初始化同类对象时也通过移动构造函数完成的需求,新引入了 std::move() 函数,它可以将左值强制转换成对应的右值,由此便可以使用移动构造函数。

Welcome to my other publishing channels