目录
- 前言
- 一、左值引用和右值引用
- 二、右值引用和移动语义
- 2.1 移动构造
- 2.2 移动赋值
- 2.3 STL容器插入接口
- 2.4 左值右值相互转换
- 2.5 完美转发
- 三、类的新功能
- 3.1 新默认成员函数
- 3.2 新关键字
前言
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
一、左值引用和右值引用
引用简单来说就是给对象取别名,我们刚开始接触C++的时候就学过,这里又区分出左值引用和右值引用,它们有什么不同?要想探讨这个问题,首先应该了解清楚具体什么是左值什么是右值。
- 左值: 一个表示数据的表达式,一般情况可以赋值(如果被const修饰就不能修改),左值可以出现在“=”的左边或右边,最关键的特性是左值可以取地址
- 右值: 一个表示数据的表达式,一般不能修改,通常是字面常量、临时对象、匿名对象等,右值在“=”的右边,不能在左边,右边不能取地址
int main()
{
//以下的a、p、b、*p、s[0]都是左值
int a = 1;
int* p = &a;
const int b = a;
*p = 10;
string s("abcdef");
s[0];
//以下都是右值
10;
x + y;
fmin(x, y);//函数返回值
string("1234");
return 0;
}
引用都是给对象取别名,左值引用就是给左值取别名,右值引用就是给右值取别名。
那左值引用能不能给右值取别名,右值引用能不能给左值取别名呢?
如果左值引用不能给右值取别名,那C++11出来之前右值是不是都不能取别名?猜测一下也知道大概率不是的。
左值引用一般是不能给右值取别名的,但是可以用const
修饰就行了。因为前面也说了右值一般都是字面常量、临时对象、匿名对象等,而这些值都具有常性,如果不用const
修饰就存在权限放大的问题。所以早期右值引用没出来之前右值也可以通过左值引用给取别名。
const int& r1 = 10;
const int& r2 = x + y;
const int& r3 = fmin(x, y);
const string& r4 = string("abcdef");
例如下面的场景:
int main()
{
vector<string> v;
string s("1111");
v.push_back(s);
v.push_back(string("2222"));
v.push_back("3333");
return 0;
}
前面我们模拟实现List的push_back
:void push(const T& x)
,加上const
修饰另一个目的也是为了既能接收左值又能接收右值,这样我们既可以插入一个有名对象,又能插入匿名对象了。
同样的右值引用也一般不能给左值取别名,但是可以通过move(左值)
的方式来给左值取别名。move()
可以看作像是强制类型转换,所以也不会改变操作对象本身的属性。
int main()
{
int a = 1;
int* p = &a;
const int b = a;
*p = 10;
string s("abcdef");
s[0];
int&& r1 = move(a);
int*&& r2 = move(p);
const int&& r3 = move(b);
string&& r4 = move(s);
string&& r5 = (string&&)s;
return 0;
}
右值不能取地址,但是给右值取别名后,右值会被存储到特定位置,且可以取到该位置的地址,可以修改,如果不想被修改可以用const
修饰。
二、右值引用和移动语义
引用的意义是减少拷贝。 在右值引用出现之前,左值引用还不太全面,有些传返回值的场景只能传值返回,不能传引用返回。比如传局部对象:
yjz::string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
yjz::string str;
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false)
{
str += '-';
}
std::reverse(str.begin(), str.end());
return str;
}
这里的str是一个局部对象,出了作用域就销毁,传引用会造成野引用,所以只能传值,返回值传值会先拷贝构造一个临时对象,再用临时对象拷贝构造目标对象。
2.1 移动构造
右值可分为纯右值和消亡值,纯右值比如字面常量,消亡值比如临时对象。临时对象用完就要消亡,再对它拷贝构造显得有点多余,既然它的结局已经注定了还不如把它的东西直接拿过来,这里就引出了移动构造,所以移动构造直接将构造的对象和被构造的对象数据交换(掠夺)一下就行。
移动构造本质是将参数右值的资源窃取过来,占位已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。
//拷贝构造
string(const string& str)
{
_str = new char[str._capacity + 1];//多开一个存'\0'
strcpy(_str, str._str);
_size = str._size;
_capacity = str._capacity;
}
//移动构造
string(string&& str)
{
swap(str);
}
虽然即使没有移动构造,只有上面的拷贝构造也能因为有const
修饰而接收左值和右值,但是有了移动构造编译器会走最匹配的。
1、string类只有拷贝构造,没有移动构造
2、string类有拷贝构造,也有移动构造
虽然str是一个左值,但是它出了作用域就消亡,和临时对象的结局是一样的,所以可以把str作为一个右值来走移动构造,这里是隐式的将strmove
为右值。返回局部的大对象,调用移动构造的代价非常低,很实用。
也不是说所有的局部对象传值返回都要走移动构造,只有需要深拷贝的对象移动构造才有意义,像日期类这种对象拷贝构造和移动构造没有区别。
上面我们提到了像VS2022这种比较激进的编译器优化比较夸张,它一步到位优化为直接构造,这里str就像ret1的左值引用一样。
那既然编译器都优化的这么好了,那移动构造还有意义吗?并且它是直接构造,而走移动构造的话是构造+移动构造。既然右值引用现在被广泛使用了,就说明移动构造还是有重要意义的。
- 移动构造代价很小
- 不是所有的编译器都像VS2022这样做极致的优化
- 有其他场景下优化不了
2.2 移动赋值
除了移动构造,还有移动赋值,本质还是一样的。下面我们来看下有移动赋值和没有移动赋值有什么区别。
//赋值重载
string& operator=(const string& str)
{
//防止自己给自己赋值
if (this != &str)
{
delete[] _str;
_str = new char[str._capacity + 1];
strcpy(_str, str._str);
_size = str._size;
_capacity = str._capacity;
}
return *this;
}
//移动赋值
string& operator=(string&& str)
{
swap(str);
return *this;
}
有调用赋值重载的情况时编译器不能像之前一样优化为直接构造,因为这里调用to_string前ret1是已经存在的对象,编译器就没办法优化了。
这里还是把左值str隐式作为右值调用了移动赋值,因为虽然str是左值,但它是局部对象,终归是为ret1服务的,出了作用域就消亡,和临时对象的意义差不多。
所以不管是移动拷贝还是移动赋值,都是有意义的,编辑器的极致优化也处理不了所有情况,相比之下这里编译器的极致优化反倒显得意义不大,因为即使多了移动拷贝和移动赋值这一步骤,它们的消耗也是非常小的。
不管是移动构造还是移动赋值,处理的都是传值返回的问题。
2.3 STL容器插入接口
右值引用解决的不只是传值返回的问题,还有一些容器插入接口的问题。
int main()
{
std::list<yjz::string> lt;
yjz::string s1("111111");
lt.push_back(s1);
lt.push_back(yjz::string("222222"));
lt.push_back("333333");
lt.push_back(move(s1));
return 0;
}
有了右值引用,我们就可以很方便的插入一些匿名对象,这样写不仅简单还会少一次拷贝构造。所以以后我们可以插入匿名对象,少了一次拷贝构造,消耗更低一些。
这里插入匿名对象时还有一个奇怪的现象:
其中红色箭头是实际执行路径。首先插入了一个string类型的一个匿名对象,push_back
调到了右值引用的函数没问题,但下一步调用insert
函数时为什么调到了左值引用的函数呢?
探讨这个问题前我们先来看这个:
yjz::string&& r1 = yjz::string("11111111");
这是一个右值引用没错,但右值是yjz::string("11111111")
,而r1却是一个左值,所以右值引用本身是一个左值。
虽然右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址。 例如:不能取字面量10的地址,但是r1引用后,可以对r1取地址,也可以修改r1。如果不想r1被修改,可以用const int&& r1
去引用。
其实右值引用本身是左值也不奇怪,如果右值引用本身是右值,右值一般不能修改,那还怎么通过移动语义来掠夺资源呢。
再回到上面的问题,虽然匿名对象push_back
时调到了右值引用的接口,但是接口中的x
却是一个左值,所以接下来调用insert
时就调到了左值引用的接口。
x
本身一个左值,其引用的对象是一个右值,在调用insert
时我们期望调用右值引用的接口,是参数匹配的问题,所以可以考虑用move
进行类似强转的操作。
void push_back(T&& x)
{
insert(end(), move(x));
}
这样就完了吗?还没完,在insert
函数内部也存在着相同的问题。
这样就完了吗?还没完,这里move(x)
传过去是一个右值,所以Node
的构造函数也需要有一个右值引用为接口的版本。
这样就完了吗?还没完,这里的x
也是一个左值,所以下面初始化_data
时也需要move
强转一下。
这样就完了吗?是的,这次真的完了。
上面的层层转换过程少一步都不行,这类似一个属性退化的问题。
2.4 左值右值相互转换
x
是一个左值,如果我们需要它是一个右值也只是一句代码的事,同样右值如果我们需要它是一个左值也可以强转得到。
通过上面的一些实例可以看出,左值和右值可以相互转换,其实说到底左值和右值在底层没什么区别,其能不能取地址也只是语法层面上的约束,当然现阶段的我们还不适合过多关注底层,因为底层和语法层在某些地方是相悖的,这不利于我们小萌新学习,我们学习主要还是以语法层为主的。
2.5 完美转发
上面看到C++11后STL容器插入接口基本都对左值和右值做了对应的函数,那以后类似这样的场景我们都要写两个甚至更多的版本吗?为了方便C++11又引入了万能引用:
template<class T>
void func(T&& x)
{
//...
}
在函数模版中,这里的T&& x
不再是前面我们见到的右值引用,而是万能引用。它不是具体的左值引用或右值引用,而是根据传过去的参数自动推导引用类型。传左值就是左值引用,传右值就是右值引用。
在各种场景下它帮助我们实例化出下面四种函数:
void func(int& x); //左值
void func(const int& x); //const 左值
void func(int&& x); //右值
void func(const int&& x); //const 右值
为什么那些容器接口没有使用万能引用呢?
- 那些函数是在类模版中的,类模版实例化后函数中的参数就是一个确定的类型,除非再套一层模版
- 历史遗留的原因,因为前面已经有左值引用的版本了
模板的万能引用只是提供了既能接收左值又能接收右值的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值。
void func(int& x) { cout << "左值引用" << endl; };
void func(const int& x) { cout << "const 左值引用" << endl; };
void func(int&& x) { cout << "右值引用" << endl; };
void func(const int&& x) { cout << "const 右值引用" << endl; };
//函数模版
template<class T>
void PerfectForward(T&& t)
{
func(t);
}
int main()
{
PerfectForward(10); //右值
int a = 1;
PerfectForward(a); //左值
PerfectForward(move(a)); //右值
const int b = 2;
PerfectForward(b); //const 左值
PerfectForward(move(b)); //const 右值
return 0;
}
我们希望能够在传递过程中保持它的左值或右值的属性,就需要完美转发。
std::forward<T>(t)
完美转发在传参的过程中保持了t的原生类型属性。
std::forward<T>(t)
和move
的区别是:我们提前知道它是一个退化的右值,需要继续保持它的属性就用move
;如果我们提前不知道它是左值还是右值,就用std::forward<T>(t)
,不管对于左值还是右值,完美转发都会保持它原本的属性。
三、类的新功能
3.1 新默认成员函数
前面我们学了类的6个默认成员函数:
- 构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值重载
- 取地址重载
- const 取地址重载
默认成员函数是我们不写编译器会默认生成的函数,其中最后两个不常用。
C++11后又增加了两个默认的成员函数:移动构造和移动赋值重载。 它们两个的特点是:
- 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值
- 如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝(值拷贝),自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造
- 移动赋值重载和移动构造基本类似
但是只要破坏了其中的一个条件就不会生成默认的移动构造,比如实现了析构函数:
可能有同学觉得移动构造和移动赋值重载这两个默认成员函数的自动生成条件有点苛刻,其实不然。
如果某个类需要显示写析构,就说明有资源释放,那就需要显示的写拷贝构造和复制重载,那就需要显示的写移动构造和移动赋值,它们是一体化的。对于日期类这样的不需要深拷贝的类,这些默认成员函数使用编译器默认生成的就可以了。所以对于这些默认成员函数,要么都自己写,要么都使用编译器默认生成的。
而默认生成的移动构造和移动赋值主要是作用于上面Person
这样的类,它本身的成员并不需要深拷贝,但是其有自定义类型的成员,一般这个自定义类型成员都有自己的移动构造和移动赋值,那就会调用这个自定义类型自己的移动构造和移动赋值。
从这里也可以得出,如果一个类不需要写析构、拷贝构造等,就不要去多此一举了,因为这样可能会在其他地方出问题。
3.2 新关键字
- default:强制生成默认成员函数关键字
如果因为某些原因我们真的需要默认成员函数,则可以用default
强制生成。
- delete:禁止生成默认成员函数关键字
如果正常情况下某些成员函数会默认生成,但是我们不想让它生成,可以使用delete
声明该函数为删除函数。
- final:禁止父类被继承,禁止虚函数被重写
- override:检查虚函数是否重写
这两个关键字在《多态》中已经有详细介绍,这里就不再赘述。
本篇文章的分享就到这里了,如果您觉得在本文有所收获,还请留下您的三连支持哦~