文章目录
- 前言
- 1. 左、右值的概念
- 1.1 左值
- 1.2 右值
- 1.3 右值引用
- 2. 右值引用的价值和使用场景
- 2.1 左值引用的价值和缺陷
- 2.2 右值引用的价值和使用场景
- 2.3 小结
- 3. 完美转发
- 4. 类的移动构造和移动赋值
前言
在C++11之前,面对C++11之前出现的临时对象的传参构造,都只有老老实实进行深拷贝一份。但是C++11引入一个新的概念和语法特性(右值和右值引用),进而解决临时对象的深拷贝问题等等。
本文章小编主要从以下几个方面来带读者认识右值:
- 左、右值的概念
- 右值引用的价值和应用场景
- 完美转发
- 类的移动构造和移动赋值
注:小编的代码环境VS2022。
1. 左、右值的概念
1.1 左值
-
左值:
左值是指具有明确存储位置(内存地址)的表达式,通常可以出现在赋值语句的左侧。左值的特点是持久性,即其生命周期超出表达式求值的范围。
两个特征:
- 可以取地址。
- 可以被赋值。(常变量不可被修改)
例1:
int main()
{int p = 10; //普通整型变量int* a = new int(10); //普通整型指针const int c = 10; //普通常变量"1111111"; //字符串变量return 0;
}
上面代码中的
p,a,c,"1111111"
都是左值。它们的共同特点都是:可以被取地址。
1.2 右值
-
右值:
右值是指临时对象或没有持久存储位置的表达式,通常只能出现在赋值语句的右侧。右值的特点是短暂性,其生命周期通常仅限于当前表达式。
两个特征:
- 不能取地址。
- 不能出现在表达式左边。(特例《C++ Primer》中有提到)
例2:
int main()
{int x = 0, y = 1;
// --------------------10;'c';x + y; //表达式计算结果min(x, y); //函数返回结果return 0;
}
类似于上面的
10,'c',x + y, min(x, y)
这样的常量或者表达式求值都是属于右值。它们的特点都是:不能被取地址,即没有具体的存储位置!
1.3 右值引用
认识了右值以后,我们来认识右值引用
左值小编已经出过一片文章了:左值引用。这里就不再过多赘诉了。
- 右值引用:就是对右值取别名。
- 语法形式::
&&
。例如:int&& p = 10;
这个p
就是10
的别名。
例3:
int main()
{int a = 10;int& ra = a; //左值引用int&& p1 = 10; //右值引用double x = 1.1, y = 3.3;double&& p2 = x + y; //右值引用return 0;
}
引用都是一样的,都是为左值或者右值取别名(这里有坑,我们后面在完美转发小节的时候会填)
-
现在我们思考一个问题?
左值引用可以引用右值吗?右值引用可以引用左值吗?
例4:
#include<iostream>
using namespace std;
int main()
{const int& a = 10; //const左值引用可以引用右值int x = 0;int&& b = std::move(x); //C++标准库中的move函数可以将一个左值返回一个右值return 0;
}
move的语法词典
move
这个函数可以将左值转化为右值返回。std::move
本质上是一个静态类型转换(static_cast
),不涉及任何运行时计算或内存操作。其典型实现如下:
template <typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept {return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
模板中的
T&&
代表万能引用,即既可以又来引用左值、也可以用来引用右值。
- 结论:可以左值引用引用右值,右值引用引用左值。
2. 右值引用的价值和使用场景
进入右值引用的价值之前,我们先来回顾一下左值引用的价值。进而找出左值引用的缺陷,然后再引入右值引用的价值。
- 我们先来看一个前置知识。方便后续的理解
例5:
#include<iostream>
using namespace std;
void func(const int& x)
{cout << "void func(const int& x)" << endl;
}void func(int&& x)
{cout << "void func(int&& x)" << endl;
}int main()
{int x = 10;func(10);func(x);return 0;
}
上面的代码正确吗?即问题是:左值和右值引用能够构成重载吗?
显然:这里的const T&
和T&&
构成函数重载而且没有调用歧义。在调用的时候,编译器会调用类型更匹配的函数。
- 前置知识:拥有右值属性的值会调用右值属性的参数的函数。
2.1 左值引用的价值和缺陷
补充:减少拷贝多用于自定义类型中,内置类型拷贝消耗不大。主要考虑自定义类型的拷贝。
-
价值:
- 做函数参数,减少拷贝。
- 做返回值,减少拷贝。
-
缺陷:
-
当我们一个函数需要返回一个临时对象的时候,这个时候我们不能返回一个临时对象的左值引用!!!
结果是未知的。
-
2.2 右值引用的价值和使用场景
我们再看左值引用的使用缺陷:无法返回一个具有临时性的对象。
右值不就是用来引用一个临时性的对象吗?
大致我们能够明白了:右值引用的语法是用来解决左值引用没有解决的历史性问题的!!!
接下来,我们详细探讨一个右值引用的价值(自定义类型string
为例)
下面是手写的一个string.h文件,便于我们观察拷贝的细节。
namespace Er //防止命名冲突
{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);}// s1.swap(s2)void swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}// 拷贝构造string(const string& s):_str(nullptr){cout << "string(const string& s) -- 深拷贝" << endl;string tmp(s._str);swap(tmp);}// 赋值重载string& operator=(const string& s){cout << "string& operator=(string s) -- 深拷贝" << endl;string tmp(s);swap(tmp);return *this;}~string(){delete[] _str;_str = nullptr;}const char* c_str() const{return _str;}private:char* _str;size_t _size;size_t _capacity; // 不包含最后做标识的\0};
}
- 我们先聚焦于这两个成员函数:
来看下面的场景,例6:
#include"String.h"
#include<iostream>
using namespace std;
Er::string func()
{Er::string str("xxxxx");return str;
}int main()
{Er::string ret;ret = func();return 0;
}
上面代码中如果没有编译器的优化,会进行两次深拷贝(抛开我们实现的swap之外)这样的开始是十分恐怖的!原因就在于我们函数返回的参数是一个右值。我们不得不进行深拷贝。
对于上面的右值我们有新概念:
- 对于内置类型的右值:纯右值。
- 对于自定义类型的右值:将亡值。
如果我们更想减少拷贝,反正将亡值都快要消失了,那么我们是不是可以将将亡值的成员直接给我交换过来?我的数据和其交换,这样效率是否会大大提高?是的,这就是移动语义的构造和赋值的主要思想!!!
此时,String.h中添加两个函数:
// 移动构造
string(string&& s) noexcept:_str(nullptr), _size(0), _capacity(0)
{cout << "string(string&& s) -- 移动语义" << endl;swap(s);
}
// 移动赋值
string& operator=(string&& s) noexcept
{cout << "string& operator=(string&& s) -- 移动语义" << endl;swap(s);return *this;
}
noexcept是表示该函数不会抛异常。swap是自己实现的成员函数Er::string::swap。
由于s是一个将亡值,所以我们直接用this
指针指向的内容进行交换,不需要过多的拷贝!!
极大地提高了效率
再来运行上面的例6:
对面图片中提出的这个问题,如果我们需要右值返回,那么我们返回的值是否需要是一个右值呢?是的,但是
str
并不是一个右值,相反它是一个左值!!但是为什么调用了移动构造了呢?
先前也有例子,这里再谈一遍:因为函数返回不是返回的变量,而是返回的是一个临时变量!!
函数返回值是一个临时变量,是一个右值并不是左值!
所以总结一下:
- 使用场景一:右值引用可以用于做函数参数,可用于将亡值对于对象的构造或者赋值。
- 价值:在接受函数返回值、临时变量的时候减少深拷贝。
例7:
//头文件已包含
int main()
{Er::string s; //正常构造Er::string tmp("aaaaa"); //语句一s = "xxxxxx"; //语句二s = std::move(tmp); //右值的移动赋值return 0;
}
来看运行结果:
二者都是移动语义
下面进行补充:
- 补充一,关于到底是调用什么拷贝或者赋值函数:
例8:
//头文件已包含
int main()
{Er::string str1("aaaaa"); //语句一//Er::string str2(move("aaaaa")); //语句二 不要对常量进行moveEr::string tmp = "xxxxx";move(tmp);Er::string str3(tmp); //语句三str1 = "xxxxx"; //语句四return 0;
}
- 前置:字符串常量是一个左值类型。类型为:
const char* const
。
关于运行结果,小编给出提示,读者下来自己理解:
- 函数重载,参数传入类型更匹配的地方。解决语句一,语句三。
- 隐式类型转化导致的参数发生变化。解决语句四。
- 补充二,关于
move
:
例9:
-
我们应该认识到:move的返回值是一个右值引用,并不是将传入的参数改为右值……
-
验证:
-
使用场景二:作为函数参数,用于一些常用的接口增中。
例如STL中C++11各个容器都添加了新的接口:
……
2.3 小结
右值引用的价值和使用场景:
-
价值:解决了左值引用没有解决的临时对象返回的问题,大大地减少了深拷贝的消耗。
-
使用场景:
- 移动构造和移动赋值
- 一些接口的设计
3. 完美转发
在前面1.3右值引用时提到了T&&
代表完美引用。同时也可以解决上面在1.3留的一个坑。
来看下面一个场景:
例10:
//头文件已经正确包含
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;
}
- 来看运行结果:
结果全是左值?这是为什么呢???
关于这个问题,我们先回到1.3的例3:
int main()
{int&& p1 = 10; //语句一return 0;
}
对于语句一来说:p1是左值还是右值呢?
右值引用居然是一个左值???
- 不管是左值引用还是右值引用,形参的属性都是左值属性!!!
如果形参属性不是左值属性,那么我们之前代码所写的swap(s)
这样的代码还能成功吗?这也是编译器做出的优化,将右值引用的属性改为左值属性,这样更有利于我们的设计。
那么对于这样的万能引用,我们应该如何来保持其原有属性呢?
这就要讲到我们所用的完美转发了:
- 函数
std::forward
。forward词典
- 作用:在传参过程中保持对象原生属性。
回到上面的例11:
//其余代码保持不变
template<typename T>
void PerfectForward(T&& t)
{Fun(std::forward<T>(t)); //即可
}
结果完美正确
所以现在再来看STL设计的容器接口,一定是运用到了完美转发!!!
4. 类的移动构造和移动赋值
-
针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:
-
如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。
默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。
-
如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。
默认生成的移动赋值重载函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)
-
如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值
-
简要概括:
(涉及深拷贝:实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个)、
- 不涉及深拷贝的类不需要写,编译器自动提供
- 涉及深拷贝的类需要写,编译器不提供。
- 一旦自己写了,编译器就不再提供。
完。
- 小编希望这篇文章能够帮助你!