1. 统一的列表初始化
我们之前提到过,C++11中出现了一种新的思想就是一切皆可用 { } 初始化,无论是内置类型还是自定义类型,同时还可以将赋值符号 = 省略。
由此产生了一个新的类型 initializer_list 初始化列表,这个东西用来初始化各类容器格外的好用。这个初始化列表的原理是有两个指针管理了一个开在栈上的数组,初始化列表中的内容会被存在数组中备用。
2. 声明
C++11提供多种简化声明的方式尤其是在使用模板的时候
2.1 auto
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部变量默认就是自动存储类型,所以auto就没什么价值了。C++11中废弃了auto的原用法,将其用于实现自动类型判断。这样的要求就是必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。
auto关键字的应用场景一般在类型名太长我们不想写的时候用。
2.2 decltype
decltype (declaration type) 声明出某变量的类型名,这个关键字的用法就是推导出某变量的类型名供使用。我们讲过一个另一个关键字typeid这个只是取出变量的一些信息,比如typeid.name(),取出变量名,但是不能使用。
可以看到decltype中还可以放表达式,推导出表达式结果的类型名。
2.3 nullptr
这个空指针关键字我们太熟悉了,在C++11之前,空指针都是用宏NULL表示的,但是空指针直接被这样简单粗暴的宏定义成0会出现很多问题,比如无法隐式类型转换成其他类型指针了。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
3. 范围for循环
这个功能也是C++11新增的,不过我们也是早就在容器部分熟悉它了,它的底层就是迭代器遍历。
4. 右值引用和移动用语
再讲解右值引用之前我们要先清楚两个概念,什么是左值,什么是右值。
左值是一个表示数据的表达式(如变量名或解引用指针),我们可以对左值取地址,一般情况下也可以赋值。左值可以出现在赋值符号的左边,但是右值不能出现在赋值符号的左边。
右值也是一个数据的表达式(如字面常量、表达式返回值,函数传值返回值),右值不能取地址,右值只能出现在赋值符号的右边。
可以看到右值一般都是一些临时变量,用临时空间存储,比如说fmin()函数是传值返回,这中间会生成一个临时对象用于返回,string()生成的匿名对象也是一种临时对象。但是,如果我们将[ ] 方括号操作符视为一种函数的话,它返回的是值的引用,不是临时对象,所以s[0]是左值。
4.1 右值引用
右值引用的语法就是比左值引用多一个&符号
rr1引用右值常量10,此时rr1以左值身份取别名为b,此时无论是增加b还是增加rr1,结果都是突破了常量10的const限制,将这个位置的值从10增加到了20。这就是右值引用。
看起来很神奇是吧,右值引用时竟然可以突破常量的const限制。这其中的原理其实很简单,常量的const限制其实只是在语法层上的一个保护,防止用户随意篡改常量。但是在底层,无论是左值引用还是右值引用,都是用一个指针对被引用元素所在空间的一个提取,而内存空间是不存在保护这一说的,因此右值引用常量之后我们可以直接对常量进行在内存上的修改,以达到修改所谓"常量"的一个效果。
4.1.1 左值右值的交叉引用
右值和左值之间还可以通过一些手段进行交叉引用操作,就是说用左值引用来引用右值,或用右值引用来引用左值。
用左值引用来引用右值
我们知道左值引用是不能直接引用右值的,这是因为权限放大的原因,但是加上const就可以使左值引用右值了
用右值引用来引用左值
右值引用也无法直接引用左值,但可以将左值放进move()函数中转成右值进行引用。
因为左值s没有语法层面的const限制,同时引用的底层是指针的操作,所以我们还可以直接强转左值类型为右值进行右值引用。
当然,左值引用也可以强转,但是因为右值在语法层面上的const限制,强转引用的方法还是无法避免使用const限定权限。
4.2 右值引用使用场景和意义
如果一块空间开在了一个函数中,那出了这个函数,这块空间的生命周期就结束了,该空间也会销毁。如果我们就想使用函数中新开出来的这块空间的内容,就必须进行传值返回。那如果这块新空间大的很,势必就会在构造临时变量或者说深拷贝的时候造成大量的消耗。
右值引用有两个概念:一个是纯右值,也就是内置类型的右值;一个是将亡值,就是上面提到的声明周期即将结束的临时变量
4.2.1 移动构造
基于上面的那个问题,在C++11中基于右值引用开发出了移动构造的方案来解决此问题,使得深拷贝构造被替代成了移动构造,同时在移动构造中完全避免了新开空间的消耗。
我们使用一个自己写的string类,然后给出一个to_string()函数用于将正整数转换成string类的内容。
我们之前在将string类时讲过,传统的传值返回会先拷贝构造str出一个临时对象,然后再将该临时对象拷贝构造给s1,但是这个过程会被编译器优化成直接将str拷贝构造成s1对象,省去中间生成临时对象的一次拷贝构造的消耗。
但是C++11引入右值引用后我们可以写一个新的构造了,就是移动构造
s就是一个将亡值我们将他用右值引用的方式取过来,然后让这个将亡值和当前的变量交换,把这个将亡值的所有内容直接交换到当前变量身上,再把当前变量要抛弃的内容交给将亡值去析构。
那么当我们在string类中写入移动构造后传值传参时就不会使用拷贝构造了,编译器会自动识别将亡值,用移动构造的方式进行传值传参。
此时,使用移动构造将节省拷贝构造中开空间和释放空间的消耗。不幸的是我使用的编译器是vs2022这个版本优化过猛了传值传参时无论时拷贝构造还是移动构造都被优化掉了,如果是2019版本的可以尝试打印出拷贝构造或移动构造的过程观察。
可以看到vs2022中直接让s1替代了str,这个优化的原理就是让str在构造的时候就构造在s1所在内存位置,也就是说str的栈帧不在to_string()函数中,而是main()函数的s1所在内存位置上,str就像是s1的别名。
右值引用虽然好,但是不能随便使用,小心出问题。我们可以把move()函数当成一个死亡标记,被标记了的变量一定要尽快"死亡",同时我们在编写程序时也尽量只转移将亡值的资源,不要随便给一般变量打上死亡标记。
总结一下,移动构造只有在深拷贝的类中才有意义,它解决的是函数在传值返回时的新开空间的问题,极大提升了需要深拷贝的类在函数中传值返回的效率。
4.3 STL容器中的右值引用
C++11更新后STL库中不仅支持了移动构造和移动赋值等默认成员函数的右值引用的使用。还在容器的一些接口中加入了右值引用的重载方案。
4.3.1 push_back
这使得push_back接口不必进行构造+深拷贝的操作,转而进行构造+移动构造的操作,节省了深拷贝时开空间释放空间的消耗。
这个功能我们也可以手动实现:拿出我们的陈年老代码list.h,找到对于push_back的实现,做一个右值引用版本的函数重载
这里要注意,我们转的参数是一个右值引用,但是传过来给到变量x的时候,x是一个左值,所以将x继续向下传递的时候要注意用move函数将其转成右值
在持续向下传递的过程中,我们不断做函数重载,同时注意传递的时候是右值,但传递之后被接受了之后就变成左值,想要继续向下传递右值就要move,最后传递到new节点的时候,做一个构造的函数重载,注意这里重载的构造函数不能给缺省值,否则就造成了默认函数冲突。
最后一定要记住,交给 _data 的data一定一定要传move版本,这样才能在构造_data的时候使用移动构造而不是拷贝构造,这也是最容易忽略的一步。
最后用我们改好的list执行一下刚才的测试代码:
绿框中多出来的这行代码是在构造 list(带头循环双向链表) 时的头节点产生的,可以忽略它们,看剩下的几行,我们自己的list也圆满完成任务了。
4.3.2 完美转发
完美转发是在函数模板的万能引用(引用折叠),的基础上防止右值引用传参时退化的一个操作。
万能引用,有时也被称为引用折叠,它是C++11新增的函数模板机制。
其语法就是模板参数后面加上 && 两个引用符号,但是这不是接收右值引用的意思而是万能引用,传参是右值就引用右值,传参是左值就引用左值。
注意万能引用的要求是T必须是个未实例化的模板参数,像前面的那个push_back就无法写成万能引用,因为在形成容器的时候T已经被实例化好了。
说回万能引用,即使我们使用万能引用接收了右值或是左值,在传参给t之后都一律会退化成左值。那如果我们想要继续向下传参就会被固定住,不move就只能向下传左值,move了就只能向下传右值。那如果我们想要保持最初的左右值特性向下传参,同时不写重载函数,此时就要用到完美转发。
完美转发,是一个仿函数模板,我们将万能引用的模板参数给到完美转发的模板,函数参数给到完美转发的参数,完美转发仿函数 forward() 就会自动识别原生参数的左右值特性。如果是左值就不对 t 操作并返回;如果是右值就将 t move()成右值返回
最后我们小小的总结一下,右值引的出现是为了解决那些将亡值在回传参数时拷贝构造的消耗问题,右值引用的出现使我们可以使用移动语义,最大程度的利用将亡值转移资源并减小消耗。
5. 新的类功能
在之前的类中,我们知道有6个默认成员函数
1. 构造函数
2. 析构函数
3. 拷贝构造函数
4. 拷贝赋值重载
5. 取地址重载
6. const 取地址重载
但是在C++11后新加入了2个默认成员函数:移动构造 和 移动赋值运算符重载。从此以后默认成员函数变成了8个。
这两个新增的移动成员函数也有默认生成的规则。当没有显示写它们,同时没有显示实现析构函数、拷贝构造、拷贝赋值重载中的任一个,满足这两个条件才会生成默认的移动成员函数。
默认移动成员函数的行为是:对于内置类型进行逐字节拷贝,对于自定义类型,如果该类型有移动构造就调用移动构造,如果没有移动构造就调用拷贝构造。
如果你显示提供了移动构造,编译器则不会自动生成拷贝构造和拷贝赋值。有没有发现在自定义类型中,析构函数、拷贝构造、拷贝赋值重载、移动构造、移动复制重载,这5个成员函数是一个被绑定在一起的状态,实现一个就要实现别的。这也是合理的,因为如果显示实现了其中的任意一个,在一般情况下说明该类中有new空间的操作,而new空间的话,就一定需要显示实现这5个成员函数。
类中还有四个关键字:强制生成默认函数的关键字 default ;进制生成默认函数的关键字 delete ;继承和多态中的 final(最终态标记) 和 override(需要重写标志) 关键字
6. 可变模板参数
C++11的新特性可变模板参数,就是可以创建模板参数个数可在0~N之间随情况变化的函数模板和类模板。就如同C中printf函数可传任意多个参数一样。
可变模板参数部分的语法比较逆天,我们需要死记硬背一下。
Args是一个模板参数包,args是函数形参参数包。... 就是在修饰这个参数是个包。
我们可以使用sizeof查看参数包中有几个参数,但是要注意的是 ... 要放在 sizeof 和 () 的中间,这是语法规定。
参数包不支持 [ ] 的方式取到包中的n各个参数,因此我们想要拿到包中的参数就要出奇招,比如使用模板函数递归一次次的取到参数包中的第一个参数的方式。
或者我们借助数组的特性完成这个解包的操作
数组在编译阶段需要进行初始化,初始化就要一个一个解包参数包,借助解包参数的这个动力,我们将参数包套上一层函数,这样就能在解包的时候同时打印出参数包内容了。至于逗号表达式后面的0,是为了保证数组初始化的语法正确,因为数组是int形的嘛。
7. emplace_back
这个emplace系列的接口我们之前都没有说,不过学了右值引用完美转发和可变模板参数之后我们就可以讲emplace系列的接口了。
这个节口的功能是插入,我们传的参数只要是可以构造成容器的指定类就行。但是这个参数列表不是让一下子插入好几个元素。
emplace_back插入左值或右值和普通的push_back是一样的,但是不一样的是,它的参数可以选择支持构造该类型的参数列表,比如 10,'x' 该参数列表可以用于构造一个string对象, 1,"你好" ,可以用于构造一个pair对象。
emplace_back的参数列表是支持构造指定类型使用的,一次只能插入一个对象。而不是像错误示范中的那样妄图一下插入好几个指定类型的对象,最后那个错误示范没报错提示是因为语法上走的通,毕竟传的是参数列表,但是编译时不通过的。
下面我们尝试自己写一个emplace_back来加深对于这个接口的理解
其实也没什么神奇的,就是把可变参数包一层一层的向下传,跟这个跟push_back的写法是完全一样的。要注意在向下传包的时候记得完美转发,传到new节点的时候,我们要重载一个参数为可变参数包的节点构造函数。
最后 _data 会拿着可变参数包去对应各种构造方案,如果可变参数包中只有一个左值,就去对应拷贝构造,只有一个右值就去对应移动构造,如果包中的变量类型是 int,char 就去对应这种构造。
emplace_back的效率综合来讲是要比push_back高的。对于需要深拷贝的类来讲,如果不考虑编译器的优化会少一个构造的消耗;对于浅拷贝的类来说,也是同理,可以直接在参数包中去对应构造函数,而不用在传参的时候先构造好临时对象,拿进去new的时候在逐字节拷贝。
8. lambda表达式
如果我们想给一个自定义类型排序,就要写它对应的排序规则的仿函数,比如一个商品类,可以按价格排序,按销量排序,按热度排序等等,但是这样做会写很多的仿函数有点麻烦。lambda表达式在一定程度上解决了这一问题,lambda表达式可以理解成一个匿名仿函数的写法。
lambda表达式语法
[ ] 中写捕捉列表,可以捕捉前面的变量供lambda函数使用
( ) 中写参数列表,lambda函数所需要的参数,这个传参的功能在一些情况下可以被捕捉列表代替。
mutable 关键字在默认情况下,lambda函数通过捕捉列表捕捉的变量都具有常性,这个关键字可以取消其常性,不过这个关键字一般都没用,一般可以不写。
->return_type 它们是一个整体,整体代表返回值的类型,但是如果函数体中有确定的返回值类型,这个也可以不写。只有返回值类型可以不写,同时参数列表中没有参数时,( ) 也可以省略。不过还是推荐把这些都显示写出来,保证代码的可读性。
下面我们尝试写一些简单的lambda表达式
这里我们展示了lambda表达式的单行写法,多行写法,以及省略写法,但还没有展示捕捉列表的用法。
这个lambda表达式所返回的是一个用 uuid通用唯一识别码 表达的类型,这个识别码是用一种算法生成的,可以基本保证每次生成的内容都不一样,总之就是我们自己没办法写出来的,只能用auto让编译器去操作。
下面我们说一下捕捉列表的选项:
最基本的就是传父作用域中选定的变量或变量的引用,比较有意思的是 = 和 & 选项,它们可以拿到父作用域中的所有变量,或所有变量的引用。
捕捉列表中拿到的变量是父作用域中的一份拷贝构造,也就是说,即使在lambda表达式中更改这个变量,也不会影响到父作用域中的该变量的值,况且如果不写mutable关键字,这个变量都是被const修饰的,是无法被改变的。
相反,如果用引用取变量就会影响到父作用域中的该变量了,不过在使用引用捕获的时候要和取地址区分开,因为他俩的写法正好重了。
如果使用 = 或 & 捕获变量的话,编译器会根据函数体中的内容按需捕获,函数体中没出现的变量是不会去捕获的,在我们理解的层面来说,就理解成所有变量都捕获就好。
我解释一下第三种和第四种lambda表达式,混合捕获是不允许出现重复捕获的情况的,就比如已经用 = 捕获所有变量的拷贝了,然后又 a 捕获了一下a变量的拷贝,此时就是语法错误。通过第四种表达式我们可以看出x是被拷贝了的,因为改变它并没影响到父域中的x的值。
我简单解释一下lambda表达式的底层:
简单来说,lambda表达式的行为就是又构造了一个类,然后用这个类写了一个仿函数。其中捕获列表中的内容就是构造类时用的参数,{}中的函数体就是仿函数的函数体,()中的参数列表就是仿函数的参数列表。这个类的名称是uuid生成的,也就是说,虽然我们看起来这是一个匿名的仿函数,但其实人家底层是有名的,只不过我们看不到而已。
9. 包装器
9.1 function包装器
function包装器也叫做适配器,它是一个类模板,可以把函数指针、仿函数或lambda表达式包装起来。
首先如果想使用function包装器要引用头文件functional
包装的语法比较怪,第一个模板参数是函数的返回值类型,括号中的是函数的参数。
我们用f1包装了函数指针,f2包装了仿函数,f3包装了lambda表达式,包装的时候一定要将类型对应上。
我们可以看一下function的类模板原型
这个Ret用来接收被调用函数的返回值类型,Args接收函数的参数包。
function包装器还可以包装类的成员函数
当包装静态成员函数的时候与包装普通函数是没有区别的,但是要注意即使是静态成员函数也存在类域的限制。
当包装非静态成员函函数的时候,要加取地址符号才能拿到函数的指针,当然静态成员函数也推荐加上取地址符号,这样统一一点。同时普通成员函数是有隐含的this指针参数的,也就是说我们包装的时候要想起来this指针的问题,不过这里既可以用类的指针,也可以用类的对象来充当这个this位置的参数类型。因为this指针本身是不能显示传递的,因此这个位置的参数只起到一个充当this指针,调用非静态成员函数的作用。因此只要能调用到这个成员函数,无是否是指针都可。
9.2 bind包装器
bind包装器也是一个函数模板,它接受一个可调用对象(callable object),生成一个新的可调用对象来包装原对象的参数列表。
可以说function包装器是对函数的名字进行包装,而bind包装器是对函数的参数列表进行一个包装操作。这里我所说的函数是广义上的, 包括一般的函数、仿函数、lambda表达式、类中的成员函数。
官网资料:bind - C++ Reference
Fn模板参数是接受可调用对象用的,后面的Args参数包是用来接收 placeholders 用的。
placeholders是一堆 _1 _2 _3 ...... 这种标识符,它们用来标定对应第几个函数参数用的。
下面我们来体会一下bind和placeholders是怎么用的。
bind包装好了之后的东西我们还是只能用auto接收,这个包装好的东西和function包装好生成的东西是一个性质,都可以被认为是一个仿函数对象。因为placeholders中的表示符都在命名空间中,为了不把标识符写的太长,我们在使用前先展开一下。这个表示符的序号就对应着bind包装好的可调用对象参数的顺序。
_1表示包装好的可调用对象的第一个参数,_2表示包装好的可调用对象的第二个参数。当这个_1_2去调用Sub函数的时候就是按照它们的书写顺序走的,谁在前面谁是第一个参数。
除了这样调整函数的参数传参顺序,bind包装还可以调整参数的个数。
像上面这种操作就可以支持我们把某个参数固定成一个数值,然后在传参的时候就需要传一个参数就好了,这个就相当于调整了参数的个数。当然这么用还是太奇怪了,我们可以把bind和刚才的function结合起来,包装一个成员函数。
我们可以将成员函数的函数指针那个参数绑死,这样使用的时候就可以只传两个函数就能调用了。