🐱作者:一只大喵咪1201
🐱专栏:《C++学习》
🔥格言:你只管努力,剩下的交给时间!
C++11——新特性 | 右值引用 | 完美转发
- 👀列表初始化
- 🦴 std::initializer_list
- 👀新语法
- 🦴声明
- 🦴范围for循环
- 🦴STL中的一些变化
- 👀右值引用
- 🦴右值引用类型的左值属性
- 🦴右值引用的场景和意义
- 移动构造
- 移动赋值
- 👀完美转发
- 🦴万能引用
- 👀新的类功能
- 👀新的关键字
- 👀总结
C++的发展截至到目前为止,虽然版本有很多,但是C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言。相比较而言,C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个重点去学习。
👀列表初始化
- 列表:{ }就被叫做列表。
我们之前使用列表初始化都是这样的,如上图代码所示,可以使用列表来初始化数组,初始化结构体变量,初始化元素类型为结构体变量的数组等等。
- C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号(=),也可不添加。
可以不加等会进行初始化,如上图代码所示,但是强烈不建议使用。
不写等号列表初始化的正确用法:
正确的用法应该是在new一个对象,一个数组并且对它们进行初始化的时候使用,如上图所示。
简单写一个日期类,在构造函数中打印一句话表面构造函数被调用过。
我们一直使用的都是C++98中方式初始化,如上图代码中的第一种方式,C++11中提供的列表初始化如上图后两种方式,这些都是在调用构造函数来初始化。
这其实也很鸡肋,没有什么价值,继续使用C++98中的方式就挺好的,而且容易理解,C++11中的方式反而不太好理解了。
🦴 std::initializer_list
用初始化列表来初始化STL容器,如上图所示初始化vector,list,map等,当然等号可以去掉(强烈不建议)。
- vector和list以及map等STL中的容器也可以像普通数组一样使用初始化列表来初始化了。
这是因为列表初始化本身就是一个类模板:
如上图所示,这是C++11才有的一个类型,该类型叫做列表初始化,而且还有自己的成员函数,包括构造函数,计算列表大小的接口,获取列表起始和结束位置的接口(迭代器位置)。
创建一个列表,使用typeid().name()将类型打印出来,如上图所示。
列表中不仅可以放内置类型,还可以放自定义类型,如上图所示。
- 列表中的自定义类型会调用它的构造函数,构造出的对象组成列表。
列表也相当于一个容器。
那么为什么可以用列表来初始化vector,list等容器呢?
C++11为这些容器提供了新的构造函数,如上图所示。
- 该构造函数是使用列表来初始化对象的,它的形参就是initializer_list。
vector(std::initializer_list<T> il)
{
//列表也是存在迭代器的
for(auto& e : il)
{
push_bakc(e);
}
}
其代码实现如上。list,map等其他容器也是这个道理,都提供了一个用列表初始化的构造函数。
赋值运算符重载函数也有一个列表的重载版本,如上图所示。
可以用列表直接给vector赋值,list等其他容器也一样可以。
对于列表初始化:
- 省略等号的用法只建议在new对象的时候使用,其他时候要加上等号。
- 使用列表不仅能够初始化数结构,还可以初始化STL中的容器。
👀新语法
C++11提供了一些新语法,这一小结中本喵来介绍一下比较小的语法,很多我们都接触过甚至是使用过。
🦴声明
c++11提供了多种简化声明的方式,尤其是在使用模板时。
auto
- auto能自动类型推断,要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。
这个关键字我们已经使用过很多了,这里就不再详细解释了,如:
可以自动推演出类型,使用起来非常方便。
decltype:
关键字decltype将变量的类型声明为表达式指定的类型。
使用typeid().name()只能打印出类型的名称,并不能用这个名称继续创建变量,而decltype可以:
使用decltype可以自动推演类型,并且可以用推演出的结果继续创建变量,如上图所示,对于一些不同类型直接的运算结果,decltype有奇效。
nullptr
由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
- 在C语言中,NULL是(void*)0,仍然是一个指针。
在C++中存在条件编译:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void*)0)
#endif
#endif
说实在的,C++中对NULL定义为0本喵觉得没有道理。
🦴范围for循环
范围for我们也一直都在使用,这是C++11提供的语法糖,使用起来非常方便,它的底层就是迭代器,只是编译器给自动替换了,本喵曾经讲解过,这里就不再详细解释了。
🦴STL中的一些变化
新容器:
红色框中的是C++11增加的新容器,个人觉得只有unordered_map和unordered_set有用,其他很鸡肋。
容器array对标的是静态数组,array也是一个静态的,也就是在栈区上的,大小是通过一个非类型模板参数确定的。
- 唯一的优势:可以强制检测越界。
具体的本喵在非类型模板参数一文中详细讲过。
容器forward_list是一个单链表,也很鸡肋,因为绝大部分场景双链表都可以满足要求,而且更加方便,唯一使用到单链表的地方就是哈希桶中。
它的接口后面有after,表示在指定元素的后面进行操作,比如插入到指定元素的后面。
- 楞要说单链表的优势,就是它比双链表少一个节点指针变量。
至于unordered_map和unordered_set,这两个容器的底层是哈希桶,虽然不能实现排序,但是可以降重。而且在查找时具有其他容器无法比拟的效率。这两个容器是非常实用的,而且也是我们经常使用的。
容器中的新方法:
- 使用列表构造
这一点在前面本喵就讲解过了,几乎每个容器都增加了新的接口,使用std::initializer_list类型来构造。
- 移动构造和移动赋值
在下面本喵讲解了右值引用就可以明白了。
- emplace_xxx插入接口或者右值引用版本的插入接口。
同样在后面才能学习到。
👀右值引用
什么是右值?什么是左值?
- 左值:一个表示数据的表达式,如变量名或者指针解引用。
- 特点:可以对左值取地址 + 可以对左值赋值。
上图代码中所示的变量都属于左值,要牢记左值可以取地址这一个特性。
- 定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。
- 左值可以出现在赋值符合的左边,也可以出现在赋值符合的右边。
- 右值:也是一个表示数据的表达式。如:字面常量,表达式返回值,函数返回值,类型转换时的临时变量等等。
- 特点:右值不可以取地址,不可以赋值。
上面这些都是右值,要牢记右值特性–不能取地址不能赋值。
- 右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边。
什么是右值引用?
左值引用是给左值取别名,右值引用显而易见就是给右值取别名。
- 右值引用使用两个&符号。
上图代码中的rr1,rr2,rr3就是三个右值的别名,也就是右值引用。
🦴右值引用类型的左值属性
- 右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址。
- 对于内置类型的右值,如字面常量,一旦右值引用以后,就会被存储到特定的位置,并且可以取到该地址,而且还可以修改。
字面常量10原本是不可以被修改的,但是右值引用以后,在特定的位置开辟了变量来存放10,所以就可以被修改了。
表达式或者函数的返回值,会有一个临时变量来存放返回值,我们知道这样的临时变量具有常性,也是右值。对于这种右值引用,编译器会修改它的属性,将常性修改,并且存储在特定位置。
const类型的右值,即便开辟了变量存放该右值也是不可以被修改的,因为被const修饰了。
内置类型的右值被称为纯右值。
- 对于自定义类型的右值,如容器的临时变量,它确确实实会被销毁,而不会被存放。
自定义类型的右值才能体现出右值存在的意义,后面本喵会详细讲解。
自定义类型的右值被称为将亡值。
- 右值引用是右值的别名,它所指向的右值是不可以被修改的。
- 但是右值引用本身也是一种类型,并且它的属性是左值,可以取地址,可以赋值。
左值引用总结:
- 左值引用只能引用左值,不能引用右值
- const左值引用既可以引用左值,也可以引用右值
因为右值不可修改,只有加上const的左值引用去引用右值时才不会导致权限的放大,我们之前都是用const左值引用来引用右值的。
右值引用总结:
- 右值引用只能引用右值,不能引用左值
- 右值引用可以引用move后的左值
左值经过move以后就变成了右值,如:
int a = 10;
int&& rra = move(a);
🦴右值引用的场景和意义
先自己实现一个string,只有拷贝构造函数,赋值运算符重载函数,析构函数,以及一个普通的构造函数。无论是拷贝构造还是赋值运算符重载,都会进行深拷贝,采用现代写法来实现。
左值引用的场景:
- 使用普通传值调用,存在一次深拷贝。
- 使用传拷贝调用时,不存在深拷贝,func函数直接使用main函数中的s1对象。
- 传值返回时,存在一次深拷贝。
- 传左值引用返回时,不存在深拷贝。
要知道深拷贝的代价是比较大的,深拷贝次数减少可以很大程度上提高代码的效率。
但是左值引用存在短板:
前面我们在调用to_string函数的时候,形参就是左值引用,然后再返回,main函数传过去的string对象一直存在。
此时需要拿到函数中的string对象,而且string对象是一个临时变量,此时mian函数中拿到to_string中的string对象要进行两次深拷贝。
- 第一次深拷贝,to_string函数返回时,会将string对象放在一个临时变量中,此时发生的深拷贝。
函数返回时,如果是内置类型等几个字节的变量,会将函数中的临时变量放在寄存器中返回,如果是自定义类型所占空间比较大,就会放在临时变量中压栈到上一级栈帧中。
- 第二次深拷贝,main函数中,ret接收函数返回了的string对象时会再发生一次深拷贝。
但是编译器会进行优化,将两次深拷贝优化成一次。
如上图所示,可以看到发生了一次深拷贝,即使减少了一次,但是仍然存在代价。
- 我们现在想让它一次拷贝都没有。
我们现在的深拷贝感受不到代价比较大,试想如果深拷贝的是一个vector<vectot<…>>,此时就代价相当大了。
- 函数调用使用传左值引用返回。
但是报错了,因为to_string函数中的string对象出了作用域会消失,此时传引用返回就会发生类似野指针的问题。
所以现在要解决的就是让局部的临时对象出了作用域后不消失。
移动构造
此时用右值引用就可以解决这个问题。
右值引用的价值之一:补齐临时对象不能传引用返回这个短板。
在string类中增加一个移动构造函数,如上图所示。
- 移动构造的形参是右值引用。
从to_string中返回的string对象是一个临时变量,具有常性,也就是我们所说的右值。
- 用右值来构造string对象时,会自定匹配移动构造函数。
- 直接使用swap拿走将亡值。
返回的临时变量是一个自定义类型的右值,也就是我们前面所说的将亡值。将亡值意味着马上就要结束生命了,所以在移动构造中,直接拿走将亡值,此时就不用发生深拷贝。
- 此时编译器识别到了右值ret,然后匹配了移动构造函数。
移动构造减少了深拷贝的次数,能够更大程度上的提高效率,减少代价。
- to_string返回的是一个右值,用这个将亡值构建新对象时,如果没有移动构造就会匹配构造函数,因为cons 左值引用可以引用右值。
- 如果有移动构造就匹配移动构造。
移动赋值
将赋值和创建string对象分开写,此时编译器就不会进行优化,在赋值的时候就会调用赋值运算符重载,而不是构造函数。
可以看到,调用了一次移动构造,一次深拷贝:
- to_string返回的是一个临时变量,是一个右值,所以这里匹配移动构造函数。
- 在赋值运算符重载中,现代写法中会调用一次拷贝构造的深拷贝,所以这两会出现深拷贝。这里显示的两次深拷贝其实只有一次。
此时用一个右值进行赋值仍然发生了深拷贝,可以采用同样的思路,被赋值对象将右值直接拿过来使用。
- 将to_string返回的右值对象识别到以后,匹配移动赋值运算符重载函数。
- 在函数中使用swap直接拿走右值去使用。
此时只调用了一次移动构造和一次移动赋值,都是直接使用右值对象,相比于深拷贝提高了很大的效率。
总结:右值引用和左值引用减少拷贝的原理不太一样。
- 左值引用是别名,直接在原本的对象上起作用。
- 右值引用是间接起作用,通过右值引用识别到右值,然后在移动构造和移动赋值中进行资源转移。
使用移动构造和移动赋值时,被转移资源的对象必须是个将亡值,负责会被销毁:
将左值对象s1通过move变成右值对象,用来构建s2,匹配的是移动构造函数,通过调试窗口可以看到,当s2被构造好时,s1就被销毁了,因为s1的资源被转移了。
可以看到,C++11的STL标准库中也提供了移动构造和移动赋值函数。
右值引用的价值之二:插入右值时可以减少深度拷贝。
简单实现一个链表,仅支持尾插。
向我们写的list中插入我们实现的string匿名对象,如上图所示。在插入过程中,多次调用了string的深拷贝。
- 每插入一个string对象时,就要new一个list中的节点,节点的构造函数中对string对象进行了深拷贝。
由于string对象都是匿名对象,都属于右值,所以深拷贝是完全没有必要的,最理想的状况是用移动拷贝。
- 在list的尾插中增加右值引用类型的接口。
- 在节点的构造函数中增加右值引用的构造函数。
当向list中插入string的匿名对象时,会匹配list的右值引用类型的尾插接口,在尾插接口中new一个新节点时,会匹配list_node的移动构造函数,又会使用string的移动构造函数来初始化节点。
- 尾插接口以及节点的构造函数中,需要使用move将右值引用的左值属性改成右值,负责会匹配普通构造函数,而不是移动构造函数。
因为右值引用类型本身是一个左值,所以需要使用move改变其属性,使其始终保持右值属性。
此时所有的插入都是使用的移动构造,相比于深拷贝,效率提升了很多。
C++11为STL库中添加了右值引用版本的尾插。
这里右值引用类的接口不能使用const修饰右值引用:
仅将尾插接口的形参改成const修饰的右值引用以后,在插入匿名对象时,调用的是string的深拷贝函数,没有匹配到移动构造。
仅将节点构移动构造函数的右值引用用const修饰,同样会导致无法匹配string的移动构造函数。
- 右值引用会在特定位置开辟空间来存储右值,所以右值引用本身的属性是左值。
- 是左值就可以修改,尤其是在移动构造函数中,会将右值引用里的右值转移过来。
- 如果使用const修饰了右值引用,此时右值引用就不可以修改了,同样就不可以进行转移了。
- 所以编译器就去匹配深拷贝去了。
虽然右值不可以被修改,但是右值引用以后具有了左值属性,才能被转移,一旦被const修饰以后就无法转移了。所以我们在使用右值引用的时候,不要使用const来修饰。
👀完美转发
🦴万能引用
写多个重载函数,根据实参类型调用不同函数。
- 形参类型分别是左值引用,const左值引用,右值引用,const右值引用
上图代码中的perfectForward函数模板被叫做万能引用模板,无论调用该函数时传的是什么类型,它都能推演出来。
- 左值:调用模板函数时,实参是左值,推演出来的t就是左值引用。实参是const左值,推演出来的t就是const左值引用。
- 右值:调用模板函数时,实参是右值,推演出来的t就是右值引用。实参是const右值,推演出来的t就是const右值引用。
由于右值引用本身也是左值,所以需要通过move将其转换成右值才能看到推演效果。
实参 | T | t |
---|---|---|
int a=10 | int | int&(左值引用) |
const int b=20 | const int | const int&(const左值引用) |
30 | int | int&&(右值引用) |
move(int c=40) | const int | const int&&(const 右值引用) |
在函数模板推演的过程中会发生引用折叠:模板参数T&&中的两个&符号折叠成一个。
当传入的实参是左值时,就会发生引用折叠,是右值时就不会发生引用折叠。
- 无论传的实参是什么,都不用改变模板参数T&&,编译器都能够自己推演。
这就是万能引用,只需要一个模板就可以搞定,不需要分类去写。
上面万能模板中,虽然推演出来了各自实参类型,但是由于右值引用本身是左值属性,所以需要使用move改变属性后才能调用对应的重载函数。
有没有办法不用move改变左值属性,让模板函数中的t保持它推演出来的类型。答案是有的,完美转发就能够保持形参的属性不变。
- 完美转发:完美转发在传参的过程中保留对象原生类型属性。
- 实参传递过来后,推演出的形参是什么类型就保持什么类型继续使用。
完美转发同样是C++11提供的,它也是一个模板。
此时再使用万能引用的时候,在函数模板中调用重载函数时只需要使用完美转发就可以保持推演出来的属性不变,右值引用仍然是右值,const右值引用也仍然是右值。
前面在向list中插入string匿名对象的时候,同样面临这个问题,当时我们都是使用的move解决的,此时就可以使用完美转发(forward)了。
可以看到,使用完美转发同样可以实现目的。
👀新的类功能
在原来的C++类中,有6大默认成员函数:
构造函数,拷贝构造函数,赋值运算符重载,析构函数,取地址重载,const取地址重载。
最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会生成一个默认的,而且完全符号我们使用的需求。
在C++11中新增了两个:移动构造和移动赋值运算符重载。
这两个成员函数在前面本喵已经介绍过它是什么了,这里站在默认成员函数的角度继续谈谈。
满足下列条件,编译器会自定生成移动构造函数:
- 没有自己显示定义移动构造函数
- 且没有实现析构函数,拷贝构造函数,拷贝赋值重载中的任何一个。
此时编译器会自定生成一个默认的移动构造函数。
- 默认生成的移动构造函数,对于内置类型会逐字节进行拷贝。
- 对于自定义类型,如果实现了移动构造就调用移动构造,没有实现就调用拷贝构造。
创建一个类,屏蔽掉拷贝构造,拷贝赋值,以及析构函数,成员变量有一个是我们自己实现的string,里面有移动构造。
此时Person就自动生成了移动构造函数,并且调用了string中的移动构造函数来构造string对象。
- 将Person中的拷贝构造,拷贝赋值,析构函数任意放出一个来。
- 使用右值构建string对象时,都会调用string的拷贝构造函数。
满足下列条件,编译器会自动生成移动赋值重载函数
- 自己没有显示定义移动赋值重载函数。
- 且且没有实现析构函数,拷贝构造函数,拷贝赋值重载中的任何一个。
此时编译器会自动生成一个默认移动赋值函数。
- 对于内置类型会按字节拷贝。
- 对于自定义类型,如果实现了移动赋值就调用移动赋值,如果没有实现就调用拷贝赋值。
和上面的移动构造完全类型。
同样将Person中的拷贝构造,拷贝赋值,析构函数屏蔽,给s4赋值右值对象。此时编译器自动生成移动赋值,调用string的移动赋值函数。
同样将上面的三个成员函数任意放出一个,编译器都不会自动生成默认移动赋值,而是会调用string的拷贝赋值函数。
- 编译器默认生成的移动赋值和移动构造非常类型。
- 如果符合条件就生成,内置内心按字节处理,自定义类型调用自定义类型的移动赋值或者移动构造,如果没有的化就调用它们的拷贝赋值或者拷贝构造。
- 如果不符合条件,就直接调用自定义类型的拷贝复制或者拷贝构造。
👀新的关键字
default:
这个default并不是switch中的default,而是C++11的新用法。
- 假设类中的某个默认成员函数没有自动生成,但是我们需要它,就可以用default,强制让编译器自动生成默认函数。
将Person中的拷贝构造,拷贝复制,析构函数都显示定义,此时就破坏了自动生成移动构造的条件。
- 使用default强制生成默认的移动构造函数,如上图红色框中所示。
从结果中可以看到,仍然调用了string中的移动构造函数,而不是调用的拷贝构造。
- 说明Person中仍然生成了默认的移动构造函数。
delete
- 要求不生成默认成员函数。
在Person类中不显示定义拷贝构造函数,拷贝复制函数,析构函数,此时符合自动生成默认移动构造的条件。
- 声明移动构造函数,但是没有定义。
此时在编译的时候就会报错,这是C++98中的方式,利用链接时找不到函数的定义报错。
- C++11中,使用delete同样可以实现不让自动生成默认成员函数。
同样在编译时报错了。
编译器会自动生成移动构造函数,但是此时使用了delete,编译器就会报错,告诉我们这里生成了移动构造。
这是为了在编译阶段就报错,而不是运行时再报错,实话讲,这个很鸡肋。
final与override
这两个关键字在继承和多态部分本喵详细讲解过。
final
- 在继承中,被final修饰的类叫做最终类,是无法继承的。
- 在多态中,被final修饰的虚函数是无法进行重写的。
override
- 在多态中,用来检查虚函数是否完成了重写。
👀总结
C++11中的很多东西虽然让C++越来越不像C++,比如列表初始化等内容,但是还是有一些非常有用的东西的,比如今天讲到的右值引用,可以大大提高效率。