【C++11】右值引用 && 移动语义 && 完美转发
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址或者可以对它赋值,左值可以出现在 =的左边,右值不能出现在 =表达式左边。定义时 const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。简单的说,能取地址的就是左值!(虽然 C++11将 const修饰的变量认为虽然不能修改值,但是它还是能修改地址的,所以将常量视为左值int
文章目录

Ⅰ. 左值引用和右值引用
传统的 C++
语法中就有引用的语法,而 C++11
中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。 无论左值引用还是右值引用,都是给对象取别名。
一、什么是左值❓❓什么是左值引用❓❓
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址或者可以对它赋值,左值可以出现在 =
的左边,右值不能出现在 =
表达式左边。定义时 const
修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。
简单的说,能取地址的就是左值!(虽然 C++11
将 const
修饰的变量认为虽然不能修改值,但是它还是能修改地址的,所以 将常量视为左值)
int main()
{
// 以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
// 以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
二、什么是右值❓❓什么是右值引用❓❓
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。
简单点说,不能取地址的就是右值,且右值引用使用的符号是 &&
。
int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10;
x + y; // 因为表达式返回的值是临时的,所以是没办法取地址的
fmin(x, y); // 函数调用也是没办法取地址的
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
10 = 1;
x + y = 1;
fmin(x, y) = 1;
return 0;
}
需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址。
举个例子,不能取字面量 10
的地址,但是通过 rr1
右值引用后,可以对 rr1
取地址,也可以修改 rr1
。如果不想 rr1
被修改,可以用 const int&& rr1
去引用,是不是感觉很神奇,这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。
int main()
{
double x = 1.1, y = 2.2;
int&& rr1 = 10;
const double&& rr2 = x + y;
rr1 = 20;
rr2 = 5.5; // const属性,修改会报错
return 0;
}
三、左右值总结
因此关于左值与右值的区分不是很好区分,一般认为:
-
普通类型的变量,因为有名字,可以取地址,都认为是左值。
-
const
修饰的常量,不可修改,是只读类型的,理论应该按照右值对待,但因为其可以取地址,C++11
认为其是左值。 -
如果表达式的运行结果是一个临时变量或者临时对象,认为是右值。
-
如果表达式运行结果或单个变量是一个引用则认为是左值。
总结:
-
不能简单地通过能否放在
=
左侧右侧或者取地址来判断左值或者右值,要根据表达式结果或变量的性质判断,比如 常量也是左值。 -
能得到引用的表达式一定能够作为引用,否则就用常引用。
-
能取地址的是左值,不能取地址的是右值
此外,C++11
对右值进行了严格的区分:
- 纯右值。比如:
a+b
、100
等等。 - 将亡值。比如:表达式的中间结果、函数按照值的方式进行返回。(后面讲移动构造的时候会讲到)
Ⅱ. 左值引用与右值引用比较
左值引用总结:
-
非
const
左值引用只能引用左值,一般不能引用右值。 -
const
左值引用既可引用左值,也可引用右值。
int main()
{
// 非const左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
右值引用总结:
-
右值引用只能右值,一般不能引用左值。
-
使用
std::move()
可以将左值转化为右值进行引用。(这个下面会讲)
int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
int a = 10;
int&& r2 = a; // ❌
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
Ⅲ. 右值引用的使用场景和意义
问题:既然 C++98
中的 const
类型引用左值和右值都可以引用,那为什么 C++11
还要复杂的提出右值引用呢?下面我们来看看左值引用的短板,以及右值引用是如何补齐这个短板的!
class string
{
public:
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
//cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
string tmp(s._str);
swap(tmp);
}
// 赋值重载
string& operator=(const string& s)
{
string tmp(s);
swap(tmp);
return *this;
}
string operator+(char ch)
{
string tmp(*this);
push_back(ch);
return tmp;
}
~string() { if (_str) delete[] _str;}
private:
char* _str;
size_t _size;
size_t _capacity;
};
int main()
{
string s1("hello");
string s2("world");
string s3(s1+s2);
return 0;
}
上述代码看起来没有什么问题,但是有一个不太尽人意的地方:
这是左值引用无法做到的一个短板,如果这里是重载 operator+=()
的话,那么返回的是 *this
,就可以使用左值引用进行返回。但这里重载的是 operator+()
,其返回的是一个临时对象,所以只能传值返回,传值返回会导致至少一次拷贝构造(如果是一些旧的编译器可能是两次拷贝构造),这大大的降低了程序的效率!
所以就有了下面的右值引用!但是我们先了解一下移动语义:
Ⅳ. 移动语义/移动构造
C++11
中提出了移动语义概念,即:将一个对象中资源移动到另一个对象中的方式,而不是拷贝,这可以有效缓解效率问题!其实就是类似我们之前实现 operator=()
中,我们使用 swap
函数进行交换指针等操作的思想,只不过还要结合右值引用罢了!
那么下面我们结合右值引用来解决这个问题:
要注意的是,我们不是在 operator+()
上面进行 swap
操作,也不是直接将其返回值改为 string&&
,因为即使改成了这样子的话,当这个函数的作用域结束的时候,这个返回的右值引用其实也是和左值引用一样,是不存在的,那么这个时候就错误了,所以我们要换一种思路:移动构造函数!
// string的移动构造
string(string&& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
this->swap(s);
}
// string的拷贝构造
string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(s._str);
this->swap(tmp);
}
还记得上面我们介绍右值的时候,将右值分为两种:纯右值 和 将亡值 (忘记的翻上去看)
这里我们举的例子是关于 将亡值 的:
不仅仅有移动构造,还有 移动赋值:
// 移动赋值(在类内实现)
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动语义" << endl;
this->swap(s);
return *this;
}
int main()
{
liren::string ret1;
ret1 = liren::to_string(1234);
return 0;
}
// 运行结果:
// string(string&& s) -- 移动语义
// string& operator=(string&& s) -- 移动语义
这里运行后,我们看到调用了一次移动构造和一次移动赋值,这是因为如果是用一个已经存在的对象接收,编译器就没办法优化了(如果是在定义的时候就初始化,则原本需要两次的拷贝构造,因为编译器优化之后就只拷贝构造一次,这个下面讲编译器优化的时候会讲)。
liren::to_string
函数中会先构造生成一个临时对象,这里假设这个临时对象为 str
,但是我们可以看到,编译器很聪明的在这里把 str
识别成了右值,调用移动构造。然后再把这个临时对象作为 liren::to_string
函数调用的返回值赋值给 ret1
,这里调用的是移动赋值。
🔴 注意事项:
-
移动构造函数的参数千万不能设置成
const
类型的右值引用,因为会导致资源无法转移而导致移动语义失效。 -
在
C++11
中,编译器会为类 默认生成一个移动构造(前提是没有自己写移动构造、析构函数、拷贝构造、赋值重载),该默认移动构造为 浅拷贝,因此当类中涉及到资源管理时,用户必须显式定义自己的移动构造。 -
STL
中的容器都是增加了移动构造和移动赋值的。
Ⅴ. 右值引用引用左值及其一些更深入的使用场景分析
按照前面讲到的语法,右值引用只能引用右值,但右值引用一定不能引用左值吗❓❓❓
因为有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过 std::move()
函数将左值转化为右值。它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义,如下图所示:
💥注意事项:
std::move()
只是改变了属性,将左值强制转化为右值引用。
如果只是单纯的调用 std::move(s)
而没有进行赋值的话,则并没有将 s
的资源转移到别的地方,相当于什么都没干,而 str = std::move(s)
就不一样了,这个时候 s
的资源已经转移到 str
上面去了!
所以一般情况下,在使用 std::move()
的时候要考虑清楚要 std::move()
的变量是否不要的了,否则会出错。
// move的函数定义如下所示:
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
int main()
{
liren::string s1("hello world");
// 这里s1是左值,调用的是拷贝构造!
liren::string s2(s1);
// 这里我们把s1进行move处理以后, 会被当成右值,调用的是移动构造!
// 但是这里要注意,一般是不要这样用的,因为我们会发现s1的资源被转移给了s3,而s1就被置空了!
liren::string s3(std::move(s1));
return 0;
}
说到VS编译器的优化,我们了解一下它的优化效果
-
对于 只有拷贝构造来说,如果我们是在变量定义的时候初始化(如这里的
ret
),那么没有编译器优化的话,会先拷贝构造一次产生一个临时对象,再拿这个临时对象去拷贝构造给ret
,总共调用 两次拷贝构造;而如果有编译器优化的话,编译器会直接就让str
拷贝构造给ret
即可,所以优化后一共只调用 一次拷贝构造! -
对于 既有拷贝构造又有移动构造来说,没优化前的编译器首先要先拿
str
拷贝构造一个临时对象,再拿这个临时对象移动构造给ret
(因为这个临时对象被认为是将亡值也就是右值),所以就是一次拷贝构造+
一次移动构造;而优化后编译器是不生成临时对象的,而是直接拿str
的资源移动到ret
,这就只调用了 一次移动构造!
🔴 注意:对于非定义阶段的初始化或者赋值,编译器也是有进行优化处理的!
👹 但是有些情况是比较特殊的,是编译器没办法优化的,比如说 只是调用了某个返回临时对象的函数而不赋值给任何变量 等时候,情况就比较特殊。下面我们先来看看这种情况,也就是调用某个返回临时对象的函数但是不赋值给任何变量的情况:
这里调用的是 to_string
函数,这个函数返回的是一个临时对象,那么返回的时候我们不赋值给任何变量,编译器做不了优化,就不能说直接拿 str
赋值给谁了,并且这里是 cout << ... << endl
,所以返回来的这个对象是要被使用的,使用肯定不能是 str
,只能是一个临时对象,因为 str
出了 to_string
的函数体就销毁了,所以优化不了!
Ⅵ. STL容器插入接口也增加了右值引用版本
这里以 std::list
为例:
要注意的是,上图中的 list
是库里面的,其生成节点是在内存池里面,配合 定位new 进行操作的。
其中因为 s
是左值,所以不管是不是有移动构造,s
都只会去调用到 lt.push_back(const value_type& val)
,也就是 深拷贝。
而对于有移动构造后,lt.push_back(“222222”)
来说,因为该字符串会被编译器调用构造函数变成临时对象,所以相当于是右值,又因为有移动构造,所以调用的就是移动构造!
最后的也很明显,to_string()
返回的是一个临时对象,其中就用到一次移动构造,接着再传给 lt.push_back()
,因为是临时对象,所以又是一次移动构造!
Ⅶ. 完美转发
完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。
所谓完美:函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;如果相应实参是右值,它就应该被转发为右值。这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理(比如参数为左值时实施拷贝语义;参数为右值时实施移动语义)。
这里我们先介绍一个东西,就是 模板中的
&&
万能引用:template<typename T> void PerfectForward(T&& t) { Fun(t); }
这种格式的模板,其中模板参数接收用的是
&&
,这个在这里不只是右值引用的意思,当你t
传的是左值的时候,那么你的t
最后还是左值,当你传的是右值的时候,那么你的t
就是右值!这就达到了万能模板的效果!
先来看一下下面的代码:
void Fun(int &x) { cout << "左值引用" << endl; }
void Fun(const int &x) { cout << "const 左值引用" << endl; }
void Fun(int &&x) { cout << "右值引用" << endl; }
void Fun(const int &&x) { cout << "const 右值引用" << endl; }
// 下面模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
// 模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
// 而引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,
// 但我们希望能够在传递过程中保持它的左值或者右值的属性, 所以就需要用我们学习完美转发机制
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
//运行结果:
左值引用
左值引用
左值引用
const 左值引用
const 左值引用
嘶~结果是不是很奇怪,原本我们以为会调用右值引用的反而都去调用左值引用了,为什么❓❓❓
记得我们上面讲的一个知识点吗,就是当一个右值比如这里的函数 PerfectForward(T&& t)
中的 t
,原本其接收的是右值,但是当该右值被 t
存储了之后,t
专门开辟了一个空间,也就相当于其属性就改变了,隐式的转化为了左值,所以最后 Fun(t)
的时候,t
已经是一个左值了,那就达不到我们的预期结果了。
很明显就可以发现我们达不到万能的预期,因为上面的右值其实已经转化为左值了,那么我们该怎么办?
std::forward 的引入
为了解决这个问题,C++11
引入了 std::forward
完美转发 在传参的过程中保留对象原生类型属性!
void Fun(int &x) { cout << "左值引用" << endl; }
void Fun(const int &x) { cout << "const 左值引用" << endl; }
void Fun(int &&x) { cout << "右值引用" << endl; }
void Fun(const int &&x) { cout << "const 右值引用" << endl; }
// std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
template<typename T>
void PerfectForward(T&& t)
{
Fun(std::forward<T>(t));
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
// 运行结果
右值引用
左值引用
右值引用
const 左值引用
const 右值引用
完美的解决了上面的问题!
其中完美转发实际中的使用场景如下所示:
template<class T>
struct ListNode
{
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
T _data;
};
template<class T>
class List
{
typedef ListNode<T> Node;
public:
List()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
void PushBack(T&& x)
{
//Insert(_head, x);
Insert(_head, std::forward<T>(x)); // 关键位置,使用std::forward
}
void PushFront(T&& x)
{
//Insert(_head->_next, x);
Insert(_head->_next, std::forward<T>(x)); // 关键位置,使用std::forward
}
void Insert(Node* pos, T&& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = std::forward<T>(x); // 关键位置,使用std::forward,保证如果是自定义类型的话调用移动赋值
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
void Insert(Node* pos, const T& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = x; // 关键位置,接收的是左值,不需要完美转发
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
private:
Node* _head;
};
int main()
{
List<liren::string> lt;
lt.PushBack("1111");
lt.PushFront("2222");
return 0;
}
// 运行结果
string& operator=(string&& s) -- 移动语义
string& operator=(string&& s) -- 移动语义
在各种容器中的插入等操作,移动语义提高了效率!
更多推荐
所有评论(0)