传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
什么是左值?什么是左值引用?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取左值的地址,可以对左值赋值,左值可以出现赋值符号的左边,也可以出现在赋值符号的右边。定义const修饰的左值,不能赋值,但是可以取地址。能取地址的就是左值。
左值引用就是给左值的引用,给左值取别名。
左值引用只能引用左值,不能引用右值。
但是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;
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
什么是右值?什么是右值引用?
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(传值返回) 等等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址,不能赋值。
右值引用就是对右值的引用,给右值取别名。
右值引用只能引用右值,不能引用左值。
但是右值引用可以引用move以后的左值。
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;
10 = 1;
x + y = 1;
fmin(x, y) = 1;
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
//int a = 10;
//int&& r2 = a; // error:无法从“int”转换为“int &&”;无法将左值绑定到右值引用
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
左值引用既可以引用左值和又可以引用右值(const左值引用),那为什么C++11还要提出右值引用呢?
左值引用做函数参数可以减少拷贝次数,提高效率。但有时我们需要区分函数参数到底是左值引用还是右值引用。这是const左值引用无法做到的。
C++11提出右值引用之后,我们就可以重载一个参数是右值引用的函数,与左值引用进行区分处理。
const左值引用可以引用左值和也可以引用右值,但如果有专门的右值引用函数,编译器会优先选择后者。
区分出是右值引用后要干什么呢?
以之前模拟实现的string类为例:【STL】模拟实现string类-CSDN博客
在拷贝构造的过程中:
如果拷贝对象是左值,则必须进行深拷贝。
但如果拷贝对象是右值,可以进行移动构造,提高效率。因为右值对象(又叫将亡值)会在完成构造后自动销毁,所以我们可以将右值对象的资源直接拿来占用,免去了开空间和拷贝数据的工作。
class string{
private:
char *_str = nullptr; //注意!一定要将指针初始化为nullptr,防止野指针错误。
size_t _size = 0;
size_t _capacity = 0;
public:
//拷贝构造
Mystring(const Mystring &str){ //左值引用
_size = str._size;
_capacity = str._capacity;
_str = new char[_capacity+1];
memcpy(_str, str._str, str._size+1);
}
//移动构造
Mystring(Mystring &&str){ //右值引用
swap(str);
}
void swap(Mystring &str){
::swap(_str, str._str);
::swap(_size, str._size);
::swap(_capacity, str._capacity);
}
};
int main(){
string str1 = "abc"; //构造(隐式类型转换)
string str2 = str1; //左值构造——拷贝构造
string str3 = str1 + str2; //右值构造——移动构造
return0;
}
提示:
注意!一定要将指针初始化为nullptr,防止野指针错误。
在拷贝构造中,左值引用加const,只是为了保证对象在拷贝过程中不被修改。
在移动构造中,右值引用不能加const,因为要在构造时移动右值对象的内部资源。
移动赋值也是同样的道理:
如果拷贝对象是左值,则必须进行深拷贝。
但如果拷贝对象是右值,可以进行移动赋值。因为右值对象(又叫将亡值)会在完成赋值操作后自动销毁,所以我们可以将右值对象的资源直接拿来占用,同时将赋值对象的原数据交换给右值对象让其帮助销毁。
class string{
//拷贝赋值
Mystring& operator=(const Mystring &str){ //左值引用
if(this != &str)
{
char *tmp = new char[str._capacity+1];
memcpy(tmp, str._str, str._size+1);
delete[] _str;
_str = tmp;
_size = str._size;
_capacity = str._capacity;
}
return *this;
}
//移动赋值
Mystring& operator=(Mystring &&str){ //右值引用
swap(str);
return *this;
}
};
int main(){
string str1 = "abc";
string str2 = "def";
str1 = str2; //左值赋值——拷贝赋值
str1 = "ghi"; //右值赋值——移动赋值
}
如果想让左值进行移动构造或者移动赋值怎么办?用move()!
move是一个函数模版,返回指定对象的右值引用,用于将左值临时转换为右值。
int main(){
string str1 = "abc";
string str2 = str1; //左值构造——拷贝构造
string str3 = move(str1); //move将str1临时转为右值——移动构造。
//完成移动构造之后,str1中的资源就被转移走了,此时str1为空。
return 0;
}
C++11以后,STL中的所有容器都增加了移动插入接口。
原来C++98中的插入接口其实都是拷贝插入,即不管要插入的元素是左值还是右值都统统需要重新开空间并进行数据拷贝。
而C++11中的移动插入接口则不同,如果插入的元素是右值,则直接移动其资源,无需进行拷贝,提高效率。
以list为例:
int main(){
list<string> ls;
string str = "hello world!";
ls.push_back(str); //插入左值——拷贝插入
ls.push_back(move(str)); //move将左值临时转为右值——移动插入
ls.push_back("china"); //插入右值——移动插入
}
提示:list移动插入的模拟实现在【完美转发的使用场景】部分介绍。
首先在讲解这个问题的解决方法之前,我们需要先回顾一下编译器是如何优化连续的构造和拷贝构造的:
【Object-Oriented C++】类和对象(下) {初始化列表,explicit关键字,匿名对象,static成员,友元,内部类,优化连续的构造和拷贝构造}_芥末虾的博客-CSDN博客
因此当函数传值返回时,构造接收返回值和赋值接收返回值的优化结果是不同的,因该一分为二的看待。
如果函数的返回值是一个局部对象,出了函数作用域就会被销毁,就不能使用引用返回,只能传值返回。
例如:在bit::string to_string(int value)
函数中可以看到,这里只能使用传值返回。传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造),效率较低。
但C++11引入了右值引用之后,传值返回的深拷贝问题得到了彻底的解决。
在bit::string类中增加移动构造函数,再去调用bit::to_string(1234)
:
注意:
编译器在优化传值返回时,对析构函数的调用顺序做了特殊调整。
不能显示的返回局部对象的右值引用。如果是显示返回,会先析构,再返回。在函数外访问时,空间已经被销毁。
再在bit::string类中增加移动赋值函数,再去调用bit::to_string(1234)
,不过这次是将bit::to_string(1234)
返回的右值对象赋值给ret1对象,这时调用的是移动赋值。
注意:编译器不会对连续的拷贝构造和赋值重载进行优化,不能将两个过程合二为一。
总的来说,不管是构造接收还是赋值接收,不管会不会进行合并优化。由于移动构造和移动赋值的实现,使得复杂函数的传值返回不再需要进行深拷贝,大大提高了传值返回的效率。因此,STL中几乎所有的容器都增加了移动构造和移动赋值。
左值引用和右值引用都是通过减少拷贝来提高效率的。
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值:
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;
}
运行结果:
为什么全都调用的是左值引用版本的Fun函数呢?
给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,并对值进行修改。
例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。
如果不想rr1被修改,可以用const int&& rr1 去引用,const右值引用可以取地址,不可修改。
可以这么理解:右值取右值引用后变为了左值,这么设计是因为要使用右值引用移动右值对象的资源,而移动资源就意味着要修改右值(矛盾),所以要将右值转为左值。
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;
}
那么如何在内外层函数传递参数的过程中保持参数的原生类型属性呢?这时就需要用到新语法:完美转发
//同样还是上面的代码,加入完美转发
template<typename T>
void PerfectForward(T&& t) //模板中的&&——万能引用
{
// std::forward(t)在传参的过程中保持了t的原生类型属性。
Fun(std::forward<T>(t));
}
再次运行:
注意:在多层嵌套调用时,要想在内外层函数传递参数的过程中保持参数的原生类型属性,需要在所有的传参位置进行完美转发。
以之前模拟实现的list和string为例:
下面我们实现Mylist的移动插入:
template <class T>
struct list_node{
T _data;
list_node *_next;
list_node *_prev;
//节点的构造
list_node(const T &val = T()) //左值引用
:_data(val), //调用存储类型的拷贝构造
_next(nullptr),
_prev(nullptr)
{}
//重载了右值引用版本
list_node(T &&val = T()) //右值应用
:_data(forward<T>(val)), //完美转发3-->调用存储类型的移动构造
_next(nullptr),
_prev(nullptr)
{}
};
template <class T>
class List{
//拷贝插入
iterator insert(iterator pos, const T &val){
Node *cur = pos._pnode;
Node *prev = cur->_prev;
Node *newnode = new Node(val);
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
//移动插入
iterator insert(iterator pos, T &&val){
Node *cur = pos._pnode;
Node *prev = cur->_prev;
//需要在所有的传参位置进行完美转发
Node *newnode = new Node(forward<T>(val)); //完美转发2
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
//拷贝插入
void push_back(const T &val){
insert(end(), val); //val是左值引用,调用拷贝插入insert
}
//移动插入
void push_back(T &&val){
//insert(end(), val); //右值引用将val转换为左值,所以也调用拷贝插入insert
insert(end(), forward<T>(val)); //完美转发1
}
};
测试代码:
#include
#include "list.hpp"
#include "string.hpp"
using namespace std;
int main(){
Mylist<Mystring> ls; //在创建头结点时会进行一次移动构造(用匿名对象初始化头结点)
cout << "----------------------------------" << endl;
Mystring str1 = "abcd";
cout << "----------------------------------" << endl;
ls.push_back(str1); //插入左值——拷贝构造
cout << "----------------------------------" << endl;
ls.push_back(Mystring("qwer")); //插入右值——移动构造
cout << "----------------------------------" << endl;
ls.push_back("1234"); //插入右值——移动构造
cout << "----------------------------------" << endl;
}
完美转发前:
由于右值引用会将右值的属性转换为左值,所以也去调用了拷贝插入insert 。因此我们需要将移动插入过程中所有涉及的函数都实现一份右值引用版本,并在所有的传参位置进行完美转发,以保持参数的右值属性。
完美转发后:
原来C++类中,有6个默认成员函数:
最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会自动生成一个默认的。
C++11 新增了两个默认成员函数:移动构造函数和移动赋值运算符重载。
针对默认移动构造和默认移动赋值有一些需要注意的点:
如果你没有实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。在进行右值构造时调用默认生成的移动构造函数,对于内置类型成员会执行逐字节拷贝;对于自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,否则就调用拷贝构造。
如果你没有实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。在进行右值赋值时调用默认生成的移动赋值函数,对于内置类型成员会执行逐字节拷贝;对于自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,否则就调用拷贝赋值。
如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。
为什么生成默认移动构造/赋值的前提条件是:没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个?
如果你实现了其中之一,编译器就会认为该类是直接涉及资源申请的类。 因为只有直接涉及资源申请的类才有实现析构 、拷贝构造、拷贝赋值的必要。而面对这种类,编译器不知道该如果进行资源的移动处理。所以就不会生成默认移动构造/赋值。
// 以下代码在vs2013中不能体现,在vs2019下才能演示体现上面的特性。
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
//生成默认移动构造/赋值的前提条件是:没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个
/*Person(const Person& p)
:_name(p._name)
,_age(p._age)
{}*/
/*Person& operator=(const Person& p)
{
if(this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}*/
/*~Person()
{}*/
private:
string _name;
int _age;
};
int main()
{
Person s1;
Person s2 = s1; //拷贝构造
Person s3 = std::move(s1); //移动构造
Person s4;
s4 = std::move(s2); //移动赋值
return 0;
}
运行结果:
只要实现了析构函数 、拷贝构造、拷贝赋值重载中的任意一个,就不会生成默认移动构造/赋值:
总结:
C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成默认移动构造/赋值了,那么我们可以使用default关键字显示指定移动构造/赋值生成。
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
Person(const Person& p) //实现了拷贝构造
:_name(p._name)
,_age(p._age)
{}
Person(Person&& p) = default; //强制生成默认移动构造
Person& operator=(Person&& p) = default; //强制生成默认移动赋值
private:
bit::string _name;
int _age;
};
int main()
{
Person s1;
Person s2 = s1; //拷贝构造
Person s3 = std::move(s1); //移动构造
Person s4;
s4 = std::move(s2); //移动赋值
return 0;
}
运行结果:
如果能想要禁止某些默认函数的生成,在C++98中是将该函数设置成private,并且写明函数声明即可,这样只要其他人想要调用就会报错。
在C++11中更简单,只需在该函数声明后加上=delete
即可。该语法指示编译器不生成对应函数的默认版本,称=delete
修饰的函数为删除函数。
比如istream和ostream类就禁止生成默认的拷贝构造,即不允许拷贝构造对象:
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
Person(const Person& p) = delete; //禁止生成默认拷贝构造:C++11
Person(Person&& p) = default; //强制生成默认移动构造
private:
bit::string _name;
int _age;
//Person(const Person& p) //禁止生成默认拷贝构造:C++98
};
int main()
{
Person s1;
Person s2 = s1; //不允许进行拷贝构造,报错。
//由于声明了拷贝构造(虽然是禁止声明),所以没有生成移动构造。
//要想进行移动构造需要使用default进行强制生成。
Person s3 = std::move(s1);
return 0;
}
C++11允许在类成员变量声明时指定缺省值,默认生成的构造(包括拷贝构造、移动构造)函数会在初始化列表使用这些缺省值进行初始化,这个我们在类和对象章节就讲了,这里就不再细讲了。
详细内容请回顾:【Object-Oriented C++】类和对象(下) {初始化列表,explicit关键字,匿名对象,static成员,友元,内部类,优化连续的构造和拷贝构造}_芥末虾的博客-CSDN博客
这两个关键字我们在继承和多态章节已经进行了详细讲解这里就不再细讲。
详细内容请回顾: