深入应用C++11:代码优化与工程级应用这本书的笔记,磨刀不误砍柴工。
auto/decltype
auto
使用
auto不仅可以自动推断变量类型,还能结合decltype来表示函数的返回值,这些新特性可以让我们写出更简洁、更现代的代码。
使用auto声明的变量必须马上初始化,以让编译器推断出它的实际类型,并在编译时将auto占位符替换为真正的类型。
- 当不声明为指针或引用时,auto的推导结果和初始化表达式抛弃引用和cv限定符后类型一致
- 当声明为指针或引用时,auto的推导结果将保持初始化表达式的cv属性
- auto的推导和函数模板参数的自动推导有相似之处
1 | int x = 1; |
auto一般会忽略掉顶层const,同时底层const则会保留下来,顶层const就是本身是常量,底层const就是指向的是常量。
比如若p的类型为const int,那么对于const auto q = p,q的类型是什么呢,q本身本身不是或者&,不保留p的const,但是p的const是个底层const,需要保留,则q类型最后为const int* const。
1 | // type of p is const int* |
限制
- auto是不能用于函数参数的
void func(auto a = 1) {}
- auto不能用于非静态成员变量
-
struct Foo {auto var_1 = 0;};
- auto无法定义数组
-
auto rr[10] = arr;
- 无法推导出模版
-
Bar<int> bar; Bar<auto> bb = bar;
何时用auto?
1)提高迭代器的声明效率
比如resultMap.begin();其实可以明确表示一个迭代器,可以直接用auto,而不是用map<double,double>::iterator it之类的。
直接使用auto it = resultMap.begin()即可。
1 | // 穿插一个euqal_range的用法 |
2)简化函数定义
1 | class Foo { |
当然不加选择地随意使用auto,会带来代码可读性和维护性的严重下降。
decltype
使用
auto所修饰的变量必须被初始化,编译器需要通过初始化来确定auto所代表的类型,即必须要定义变量。
若仅希望得到类型,而不需要(或不能)定义变量的时候应该怎么办呢?
C++11新增了decltype关键字,用来在编译时推导出一个表达式的类型: decltype(exp) exp表示一个表达式(expression)
1 | int x = 0; |
decltype通过表达式得到的类型,可以保留住表达式的引用及const限定符。
对于decltype和引用(&)结合的推导结果,与C++11中新增的引用折叠规则(Reference Collapsing)有关之后再讨论。
推导规则
- exp是标识符、类访问表达式,decltype(exp)和exp的类型一致
- exp是函数调用,decltype(exp)和返回值的类型一致
- 其他情况,若exp是一个左值,则decltype(exp)是exp类型的左值引用,否则和exp类型一致
左值 (lvalue, locator value) 表示了一个占据内存中某个可识别的位置(也就是一个地址)的对象。右值 (rvalue) 则使用排除法来定义。一个表达式不是 左值 就是 右值 。 那么,右值是一个 不 表示内存中某个可识别位置的对象的表达式。
对于纯右值而言,只有类类型可以携带cv限定符,此外则一般忽略掉cv限定。
1 | const int func_cint(void); // 纯右值 |
decltype的实际应用
1 | template <class ContainerT> |
Container::iterator不能包括所有迭代器类型,当ContainerT是一个const类型时,要使用const_iterator,所以这段代码会报错。
解决这个问题首先想到的方法肯定是特化template,但是会增加很多冗余的代码,这时候就可以直接这么写: decltype(ContainerT().begin()) it_;。
返回类型后置语法——auto和decltype的结合使用
在泛型编程中,可能需要通过参数的运算来得到返回值的类型。
1 | template <typename R, typename T, typename U> |
模板的细节改进
重定义一个模板
使用typedef重定义类型是很方便的,但它也有一些限制,比如,无法重定义一个模板。
因此,在C++98/03中往往不得不这样写:
1 | template <typename Val> |
一个虽然简单但却略显烦琐的str_map外敷类是必要的。这明显让我们在复用某些泛型代码时非常难受。
现在,在C++11中终于出现了可以重定义一个模板的语法。请看下面的示例:
1 | template <typename Val> |
在重定义普通类型上,using
,typedef
两种使用方法的效果是等价的,唯一不同的是定义语法。
typedef的定义方法和变量的声明类似:像声明一个变量一样,声明一个重定义类型,之后在声明之前加上typedef即可。这种写法凸显了C/C++中的语法一致性, Apple Books. “但有时却会增加代码的阅读难度。比如重定义一个函数指针时:
typedef void (*func_t)(int, int);
与之相比,using后面总是立即跟随新标识符(Identifier),之后使用类似赋值的语法,把现有的类型(type-id)赋给新类型:
using func_t = void (*)(int, int);
从上面的对比中可以发现,C++11的using别名语法比typedef更加清晰。
函数模板的默认模板参数
参数填充顺序是从右往左的。
1 | template <typename R = int, typename U> |
函数模板func的返回值类型是long,而不是int,因为模版参数的填充顺序从右往左,所以指定的模版参数类型long会作为func的参数类型而不是func的返回类型,最终func的返回类型为long。这个细节虽然简单,但在多个默认模板参数和模板参数自动推导穿插使用时很容易被忽略掉,造成使用时的一些意外。
初始化列表
C++11中的stl容器拥有和未显示指定长度的数组一样的初始化能力,代码如下:
1 | int arr[] { 1, 2, 3 }; |
但是自定义的class却不具备这种能力
实际上,stl中的容器是通过使用std::initializer_list这个轻量级的类模版来完成上述功能支持的
同理,我们只需要对自定义的class添加一个std::initializer_list构造函数,也可以拥有任意长度初始化的能力
1 | class FooVector{ |
std::initializer_list不仅可以用来对自定义类型做初始化,还可以用来传递同类型的数据集合,代码如下:
1 | void func(std::initializer_list<int> l) |
initializer_list的一些细节
- 对于std::initializer_list而言,它可以接收任意长度的初始化列表,但要求元素必须是同种类型T(或可转换为T)。
- 他有三个成员接口:size(),begin(),end()
- 它只能被整体初始化或赋值
- initializer_list的访问只能通过begin()和end()进行循环遍历,遍历时取得的迭代器是只读的,但是可以整体修改
1 | initializer_list<int> list; |
initializer_list是非常高效的。它的内部并不负责保存初始化列表中元素的拷贝,仅仅存储了列表中元素的引用而已
所以,它本身做传递或赋值的时候是十分高效的的,但在处理函数返回值的时候需要小心,代码如下:
1 | initializer_list<int> func(void) { |
使用真正的容器,或具有转移/拷贝语义的物件来替代std::initializer_list返回需要的结果。我们应当总是把std::initializer_list看做保存对象的引用,并在它持有对象的生存期结束之前完成传递。
for/range
for : 循环
本身这个知识点原本是不打算介绍的,但是发现了一些有趣的点,所以还是记录一下
1 | vector<int>arr = { 1, 2, 3, 4, 5 }; |
同普通的for循环一样,在迭代时修改容器很可能会引起迭代器失效,导致一些意料之外的结果
原因是对于上面的基于范围的for循环而言,等价的代码如下
1 | vector<int> arr2 = { 1, 2, 3, 4, 5 }; |
从这里可以很清晰地看到,和我们平时写的容器遍历不同,基于范围的for循环倾向于在循环开始之前确定好迭代的范围,而不是在每次迭代之前都去调用一次arr.end()
另外一个要点是,大部分关联性容器,比如map,set在遍历时,是不允许修改元素的,因为对应的容器本身的内部元素是只读的,auto获得的类型会带上const
让基于范围的for循环支持自定义类型
对自定义类类型来说,分别实现begin()、end()方法即可
例子1:实现一个支持步长的range方法
这个例子其实更重要的倒不是上述的知识点,而是给了如何实现一个通用方法的思路
- 设计方法的作用,以及使用这个方法的类型需要满足哪些接口
- 设计对应的类,并实现这个类的对应接口
- 这个类的底层元素结构该如何实现,通俗的说不通的类型实现相同的接口,但每个底层接口的实现可以不一样,因为方法只需要有对应接口的支持,但不管接口的底层如何实现
- 设计的顺序从上至下,但是编写的顺序,可以正向或者相反
- 这个例子充分的表明了,顶层设计的合理远比盲目编写代码要强得多
1 | // 自定义class,支持range方法 |
关于这个设计思路,我打算自己实现一个能够可以让forward_list和list都是用的insert方法,思路也是从上至下,先设计方法,在设计list,最后设计iterator,可能最终实现的不会特别好,但可以练习这个思路,代码地址:https://github.com/woaixiaoyuyu/Trip-of-learning-C-Plus/tree/main/myList
std::function和bind绑定器
C++中的可调用对象虽然具有比较统一的操作形式(除了类成员指针之外,都是后面加括号进行调用),但定义方法五花八门。这样在我们试图使用统一的方式保存,或传递一个可调用对象时,会十分烦琐。
C++11通过提供std::function和std::bind统一了可调用对象的各种操作
可调用对象
可调用对象有如下几种定义:
- 是一个函数指针。
- 是一个具有operator()成员函数的类对象(仿函数)。
- 是一个可被转换为函数指针的类对象。
- 是一个类成员函数指针 or 类成员指针。
std::function
std::function是可调用对象的包装器。它是一个类模板,可以容纳除了类成员(函数)指针之外的所有可调用对象。通过指定它的模板参数,它可以用统一的方式处理函数、函数对象、函数指针,并允许保存和延迟执行它们。
针对以上提出的概念,我们一点一点来实践。
std::function的基本用法
1 | void func3(void) |
当我们给std::function填入合适的函数签名(即一个函数类型,只需要包括返回值和参数表)之后,它就变成了一个可以容纳所有这一类调用方式的“函数包装器。
function作为回调函数的事例
这个例子的构思非常精巧,相比于function的用法,设计思路更值得学习,这里替代了函数指针的作用。
1 | class A { |
function比普通函数指针更灵活和便利,而且用这种写法替换函数指针,可以让别人一眼就看出这里需要的是函数之争,可读性也会大大增强。
std::bind
基础使用
std::bind用来将可调用对象与其参数一起进行绑定。绑定后的结果可以使用std::function进行保存,并延迟调用到任何我们需要的时候。
通俗来讲,它主要有两大作用:
- 将可调用对象与其参数一起绑定成一个仿函数。
- 将多元(参数个数为n,n>1)可调用对象转成一元或者(n-1)元可调用对象,即只绑定部分参数。
这里可能看得不是很明白,来一个例子。
1 | void call_when_even(int x, const function<void(int)>& f) { |
上面只是绑定普通函数,再来看一个绑定类成员函数指针 and 类成员指针的例子。
1 | class A2 { |
可以看到涉及到类成员函数指针 or 类成员指针,需要先把已经实例化的成员和对应的类成员绑定,当然如果是静态成员函数,没必要实例化成员,因为编译阶段就有地址了。
使用组合bind函数
bind还有一个强大之处就是可以组合多个函数,结构上有点像函数式编程,但其实并不是。
1 | auto f = bind(logical_and<bool>(), |
lambda表达式
lambda表达式的优点
lambda表达式有如下优点:
- 声明式编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或者函数对象。以更直接的方式去写程序,好的可读性和可维护性。
- 简洁:不需要额外再写一个函数或者函数对象,避免了代码膨胀和功能分散,让开发者更加集中精力在手边的问题,同时也获取了更高的生产率。
- 在需要的时间和地点实现功能闭包,使程序更灵活。
lambda表达式的概念和基本用法
lambda表达式的语法形式可简单归纳如下:
[ capture ] ( params ) opt -> ret { body; };
capture是捕获列表;params是参数表;opt是函数选项;ret是返回值类型;body是函数体。
因此,一个完整的lambda表达式看起来像这样:
1 | auto f = [](int a) -> int { return a + 1; }; |
接下俩介绍一下capture的类别
- []不捕获任何变量。
- [&]捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
- [=]捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获)。
- [=,&foo]按值捕获外部作用域中所有变量,并按引用捕获foo变量。
- [bar]按值捕获bar变量,同时不捕获其他变量。
- [this]捕获当前类中的this指针,让lambda表达式拥有和当前类成员函数同样的访问权限。如果已经使用了&或者=,就默认添加此选项。捕获this的目的是可以在lamda中使用当前类的成员函数和成员变量。
1 | class A3 { |
延迟调用
1 | int a = 0; |
lambda表达式的类型
lambda表达式的类型在C++11中被称为“闭包类型(Closure Type)”。它是一个特殊的,匿名的非nunion的类类型。
因此,我们可以认为它是一个带有operator()的类,即仿函数。因此,我们可以使用std::function和std::bind来存储和操作lambda表达式:
1 | function<int(int)> f4 = [](int a){ return a; }; |
另外,对于没有捕获任何变量的lambda表达式,还可以被转换成一个普通的函数指针。而被捕获的lambda表达式则不能转换为函数指针:
1 | using func_t = int(*)(int); |
lambda表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终均会变为闭包类型的成员变量。而一个使用了成员变量的类的operator(),如果能直接被转换为普通的函数指针,那么lambda表达式本身的this指针就丢失掉了。而没有捕获任何外部变量的lambda表达式则不存在这个问题。
这里也可以很自然地解释为何按值捕获无法修改捕获的外部变量。因为按照C++标准,lambda表达式的operator()默认是const的。一个const成员函数是无法修改成员变量的值的。而mutable的作用,就在于取消operator()的const。
tuple元组
tuple元组是一个固定大小的不同类型值的集合,是泛化的std::pair
tuple的基本使用
1 | tuple<const char*, int> tp = make_tuple("hello",3); |
用tuple<const char*,int>tp就可以不用创建这个结构体了,而作用是一样的,更简洁直观了。
用std::tie,它会创建一个元组的左值引用。
1 | int a = 1; |
tuple虽然可以用来代替简单的结构体,但不要滥用,如果用tuple来替代3个以上字段的结构体时就不太合适了,因为使用tuple可能会导致代码的易读性降低。
1 | tuple<int, string, float> t1(10, "Test", 3.14); |
右值引用
C++11增加了一个新的类型,称为右值引用(R-value reference),标记为T&&。在介绍右值引用类型之前先要了解什么是左值和右值。左值是指表达式结束后依然存在的持久对象,右值是指表达式结束时就不再存在的临时对象。一个区分左值与右值的便捷方法是:看能不能对表达式取地址,如果能,则为左值,否则为右值 。所有的具名变量或对象都是左值,而右值不具名。
来看一个例子
1 | int g_constructCount=0; |
通过右值引用,比之前少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了,我们可以利用这个特点做一些性能优化,即避免临时对象的拷贝构造和析构。
再来一个例子
如果&&被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是一个右值
1 | int w1, w2; // w1,w2都是左值 |
编译器会将已命名的右值引用视为左值,而将未命名的右值引用视为右值
1 | void PrintValue(int& i) { |
&&的总结如下:
- 左值和右值是独立于它们的类型的,右值引用类型可能是左值也可能是右值。
- auto&&或函数参数类型自动推导的T&&是一个未定的引用类型,被称为universal references,它可能是左值引用也可能是右值引用类型,取决于初始化的值类型。
- 所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用折叠都为左值引用。当T&&为模板参数时,输入左值,它会变成左值引用,而输入右值时则变为具名的右值引用。
- 编译器会将已命名的右值引用视为左值,而将未命名的右值引用视为右值。
用来支持移动语义
右值引用的一个重要目的是用来支持移动语义的。
移动语义可以将资源(堆、系统对象等)通过浅拷贝方式从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,可以大幅度提高C++应用程序的性能,消除临时对象的维护(创建和销毁)对性能的影响。
1 | class A2 { |
上面的代码中没有了拷贝构造,取而代之的是移动构造(Move Construct)。从移动构造函数的实现中可以看到,它的参数是一个右值引用类型的参数A&&,这里没有深拷贝,只有浅拷贝,避免了深拷贝,提高了性能。
需要注意的一个细节是,我们提供移动构造函数的同时也会提供一个拷贝构造函数,以防止移动不成功的时候还能拷贝构造,使我们的代码更安全。
move语义
移动语义是用过右值引用来匹配临时值,c++11提供了std::move方法来将左值转换为右值。
move是将对象的状态或者所从权从一个对象转移到另一个对象,只是转移,没有拷贝。
在图2-1中,对象SourceObject中有一个Source资源对象,如果是深拷贝,要将SourceObject拷贝到DestObject对象中,需要将Source拷贝到DestObject中;如果是move语义,要将SourceObject移动到DestObject中,只需要将Source资源的控制权从SourceObject转移到DestObject中,无须拷贝。
move实际上并不能移动任何东西,它唯一的功能是将一个左值强制转换为一个右值引用 [1] ,使我们可以通过右值引用使用该值,以用于移动语义。
1 |
|
这里也要注意对move语义的误解,move只是转移了资源的控制权,本质上是将左值强制转换为右值引用,以用于move语义,避免含有资源的对象发生无谓的拷贝。move对于拥有形如对内存、文件句柄等资源的成员的对象有效。如果是一些基本类型,比如int和char[10]数组等,如果使用move,仍然会发生拷贝(因为没有对应的移动构造函数),所以说move对于含资源的对象来说更有意义。
forward和完美转发
之前提到过,一个右值引用参数作为函数的形参,在函数内部再转发该参数的时候它已经变成一个左值了,并不是它原来的类型了。
所谓完美转发(Perfect Forwarding),是指在函数模板中,完全依照模板的参数的类型(即保持参数的左值、右值特征),将参数传递给函数模板中调用的另外一个函数。
C++11中提供了这样的一个函数std::forward,它是为转发而生的,不管参数是T&&这种未定的引用还是明确的左值引用或者右值引用,它会按照参数本来的类型转发。
举个例子
1 | template<typename T> |
函数包装器
右值引用、完美转发再结合可变模板参数,我们可以写一个万能的函数包装器,带返回值的、不带返回值的、带参数的和不带参数的函数都可以委托这个万能的函数包装器执行。下面看看这个万能的函数包装器。
1 | template<class Function, class... Args> |
emplace_back减少内存拷贝和移动
emplace_back能就地通过参数构造对象,不需要拷贝或者移动内存,相比push_back能更好地避免内存的拷贝与移动,使容器插入元素的性能得到进一步提升。在大多数情况下应该优先使用emplace_back来代替push_back。
emplace_back的用法比较简单,直接通过构造函数的参数就可以构造对象,因此,也要求对象有对应的构造函数,如果没有对应的构造函数,编译器会报错。
emplace_back和push_back的比较
1 | struct Complicated { |
emplace/emplace_back的性能比之前的insert和push_back的性能要提高很多,我们应该尽量用emplace/emplace_back来代替原来的插入元素的接口以提高性能。
需要注意的是,我们还不能完全用emplace_back来取代push_back等老接口,因为在某些场景下并不能直接使用emplace来进行就地构造,比如,当结构体中没有提供相应的构造函数时就不能用emplace了,这时就只能用push_back。
unordered container无序容器
C++11增加了无序容器unordered_map/unordered_multimap和unordered_set/unordered_multiset,由于这些容器中的元素是不排序的,因此,比有序容器map/multimap和set/multiset效率更高。map和set内部是红黑树,在插入元素时会自动排序,而无序容器内部是散列表(Hash Table),通过哈希(Hash),而不是排序来快速操作元素,使得效率更高。由于无序容器内部是散列表,因此无序容器的key需要提供hash_value函数,其他用法和map/set的用法是一样的。
不过对于自定义的key,需要提供Hash函数和比较函数。
基本用法
1 | struct Key { |
type_traits – 类型萃取
基本的type_traits
简单的type_traits
先看看在C++11之前,在一个类中定义编译期常量的一些方法,可以直接定义一个static成员变量来实现。
1 | template<typename Type> |
当然也可以直接如下面这般定义,同样可以根据GetLeftSize::value来获得常量1。
1 | template<typename Type> |
为什么可以用第二种方法来实现,来看看将编译期常量包装为一个类型的type_trait——integral_constant,代码如下
1 | template <class T, T v> |
相当于直接public继承了integral_constant这个struct,而integral_constant的value又是个public成员变量,子类可以直接调用
类型判断type_traits
有些type_traits从std::integral_constant派生,用来检查模版类型会否为某种类型,通过这些trait可以获取编译器检查的bool值结果。
因此可以通过std::is_xxx::value是否为true来判断模板类型是否为目标类型,用法如下
1 | std::cout << "is_const:" << std::endl; |
这里判断一个类型是否为const,判断的是顶层是否为const,所以这里const int*判断为false,因为虽然指向的内容是const,但是指针本身不为const。同理const int& 也返回false,int& const返回的就是true。
判断两个类型之间的关系traits
上一小节的traits是检查某一个模板的类型,有时需要检查两个模板类型之间的关系,比如两个类型是否相同或是否为继承关系等。
1 | class A {}; |
std::is_convertible<B,A*>::value,由于A是B的基类指针,是可以隐式转换的,所以判断的结果为true。
类型的转换traits
常用的类型的转换traits包括对const的修改——const的移除和添加,引用的修改——引用的移除和添加,数组的修改和指针的修改.
1 | // 添加和移除 const、reference |
根据模版参数类型创建对象时,需要注意移除引用,代码如下:
1 | template<typename T> |
在上述例子中,模板参数T可能是引用类型,而创建对象时,需要原始的类型,不能用引用类型,所以需要将可能的引用移除。
有时需要添加引用类型,比如从智能指针中获取对象的引用时,代码如下:
1 | template <class T> |
创建智能指针时需要获取T的原始类型,我们通过std::remove_reference来移除T可能的引用,后面,需要获取智能向的对象时又要对原始类型U添加左值引用。
再看一个带cv符类型的例子,假如类型是const x&,要新建一个对象,不得不先移除&,再移除const,代码如下:
1 | template<typename T> |
这样虽然能解决问题,但是代码比较长,可读性也不好。这时就可以用decay来简化代码,简化后的代码如下:
1 | template<typename T> |
对于普通类型来说,std::decay是移除引用和cv符,大大简化了我们的书写。除了普通类型之外,std::decay还可以用于数组和函数,具体的转换规则如下:
- 先移除T类型的引用,得到类型U,U定义为
remove_reference<T>::type
。 - 如果
is_array<U>::value
为true,修改类型type为remove_extent<U>::type*
。 - 否则,如果
is_function<U>::value
为true,修改类型type将为add_pointer<U>::type
。 - 否则,修改类型type为
remove_cv<U>::type
。
1 | typedef std::decay<int>::type A; // int |
由于std::decay对于函数来说是添加指针,利用这一点,我们可以将函数变成函数指针类型,从而将函数指针变量保存起来,以便在后面延迟执行,比如下面的例子。
1 | template<typename F> |
根据条件选择的traits
std::conditional在编译期根据一个判断式选择两个类型中的一个,和条件表达式的语义类似,类似于一个三元表达式。
1 | template< bool B, class T, class F > |
1 | typedef std::conditional<true,int,float>::type A; // int |
获取可调用对象返回类型的traits
有时要获取函数的返回类型是一件比较困难的事情
当然我们首先想到可以使用decltype结合auto太清晰的表明,代码如下:
1 | template <typename F, typename Arg> |
上面的写法比之前的写法更简洁,在一般情况下也是没问题的,但是在某个类型没有模板参数时,就不能通过decltype来获取类型了
1 | class A2 { |
decltype(A2()(0)) i = 4; // error
上面的代码将会编译报错,因为A没有默认构造函数。对于这种没有默认构造函数的类型,我们如果希望能推导其成员函数的返回类型,则需要借助std::declval。
decltype(declval<A2>()(declval<int>())) i = 4;
declval获取的临时值引用不能用于求值,因此,我们需要用decltype来推断出最终的返回类型。
但显得不够简洁,C++11提供了另外一个trait——std::result_of,用来在编译期获取一个可调用对象(关于可调用对象,读者可以参考1.5.1节)的返回类型。
std::result_of<A2(int)>::type i2 = 4;
上面的std::result_of<A2(int)>::type
实际上等价于decltype(std::declval<A2>()(std::declval<int>()))
1 | template< class F, class... ArgTypes > |
一个例子
如果要对某个函数使用std::result_of
,要先将函数转换为可调用对象
1 | int fn(int) {return int();} // function |
再来一个例子
1 | template<typename Fn> |
根据条件禁用或启用某种或某些类型traits
编译器在匹配重载函数时会匹配所有的重载函数,找到一个最精确匹配的函数,在匹配过程中可能会有一些失败的尝试,当匹配失败时会再尝试匹配其他的重载函数。
std::enable_if
利用SFINAE实现根据条件选择重载函数,std::enable_if
的原型如下:
1 | emplate< bool B, class T = void > |
若 B
为 true ,则 std::enable_if
拥有等同于 T
的公开成员 typedef type
;否则,无该成员 typedef 。
可以通过判断式和非判断式来将入参分为两大类,从而满足所有的入参类型,代码如下:
1 | template <class T> |
对于arithmetic类型的入参则返回0,对于非arithmetic的类型则返回1,通过arithmetic将所有的入参类型分成了两大类进行处理
std::enable_if
的第二个模板参数是默认模板参数void类型,因此,在函数没有返回值时,后面的模板参数可以省略。
可变参数模版
可变参数模版函数
现在C++11中的新特性可变参数模板允许模板定义中包含0到任意个模板参数。可变参数模板和普通模板的语义是一样的,只是写法上稍有区别,声明可变参数模板时需要在typename或class后面带上省略号“…”。
省略号的作用有两个:
- 声明一个参数包,这个参数包中可以包含0到任意个模板参数。
- 在模板定义的右边,可以将参数包展开成一个一个独立的参数。
一个可变参数模版函数定义如下:
1 | template <class... T> |
如果需要使用参数包中的函数,有两种展开参数包的方法:
- 一种方法是通过递归的模板函数来将参数包展开
- 另外一种是通过逗号表达式和初始化列表方式展开参数包。
递归函数方式展开参数包
1 | void print() { |
可以通过type_traits来展开并打印参数包,思路同样是递归。
1 | // 这里enable_if<bool, T>的T省略了,所以使用默认值void,即要是判断成功,获得void的type |
逗号表达式和初始化列表方式展开参数包
递归函数展开参数包是一种标准做法,也比较好理解,但也有一个缺点,就是必须有一个重载的递归终止函数,即必须有一个同名的终止函数来终止递归,这样会感觉稍有不便。
之前的print可以改成如下代码:
1 | template <class T> |
这种就地展开参数包的方式实现的关键是逗号表达式。逗号表达式会按顺序执行逗号前面的表达式,比如:
d = (a = b, c);
这个表达式会按如下顺序执行:b会先赋值给a,接着括号中的逗号表达式返回c的值,因此d将等于c。
expand函数中的逗号表达式:(printarg(args),0),也是按照这个执行顺序,先执行printarg(args),再得到逗号表达式的结果0。同时还用到了C++11的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组,{(printarg(args),0)…}将会展开成((printarg(arg1),0),(printarg(arg2),0),(printarg(arg3),0),etc…),最终会创建一个元素值都为0的数组int arr[sizeof…(Args)]。
我们还可以对上面的expand方法做一点改进,通过std::initializer_list来代替原来的int arr[]数组。改进之后的代码如下:
1 | template <class ...Args> |
事实上,还可以进一步做改进,上面代码中的printarg函数也可以改为lambda表达式。改进之后的代码如下:
1 | template<typename... Args> |
可变参数模版类
可变参数模板类的参数包展开的方式和可变参数模板函数的展开方式不同,可变参数模板类的参数包需要通过模板特化或继承方式去展开,展开方式比可变参数模板函数要复杂。
模版递归和特化方式展开参数包
可变参数模板类的展开一般需要定义2~3个类,包括类声明和特化的模板类。如下方式定义了一个基本的可变参数模板类。
1 | // 这个前向声明限制了sum的模板参数至少有一个,当然此处的前向声明可以参略 |
可以看到一个基本的可变参数模板应用类由三部分组成:
- 前向声明
- 类的定义
- 特化的递归终止类
继承方式展开参数包
通过模板递归和模板特化的方式展开。还有另外一种方式:通过继承和特化的方式展开。
1 | // 整型序列的定义 |
再来一个例子收尾
1 | template<int...> |
其实最后的目的就是给print_helper函数传递两个参数,第一个参数是tuple个元素对应的序列,第二个元素就是实际存放数值的tuple。
可变参数模版和type_taits的综合应用
optional的实现
尝试实现一个optional,optional之前接触过scala,所以大概什么作用还是知道的,和这里的作用类似,不了解的可以去额外搜一下。
这里额外介绍一个概念:placement_new
Placement new
Placement new is a variation new operator in C++. Normal new operator does two things : (1) Allocates memory (2) Constructs an object in allocated memory.
Placement new allows us to separate above two things. In placement new, we can pass a preallocated memory and construct an object in the passed memory.
1 | new (address) (type) initializer |
主要就是两步,第一步是开辟空间,第二步是执行对应的构造函数。
只不过我们提前准备好了对象存放的空间,好处是it allows to construct an object on memory that is already allocated, it is required for optimizations as it is faster not to re-allocate all the time.
思路
那就可以设想到一个场景,因为optional
假设确实需要提供T的值,那么就需要一个存储空间,这里我们叫它缓冲区。一般性考虑到通用性,可能会想到用char[]来作为缓冲区的类型,但是这里就涉及到了一个内存对齐的问题,char是一字节对齐的,可是T大概率不是,不能对齐可能会引发效率问题或者错误,这里如果用gdb进行一下debug一下可能会了解更多,包括c语言也需要你自己处理对齐。
这里就需要使用内存对齐缓冲区 std::aligned_storage,原型如下:
1 | template< std::size_t Len, std::size_t Align = /*default-alignment*/ > |
代码
1 | template<typename T> |
这一个小单元其实最大的收获,对自己而言其实是再一次了解了placement new,之后才是了解了std::aligned_storage<sizeof(T), alignof(T)>这个操作。
惰性求值类lazy的实现
思路
先用一个例子介绍什么是lazy实现。
一个典型的应用场景是这样的:当初始化某个对象时,该对象引用了一个大对象,这个对象的创建需要较长的时间,同时也需要在托管堆上分配较多的空间,这样可能会在初始化时变得很慢,尤其是UI应用时会导致用户体验很差。其实很多时候并不需要马上就获取大数据,只是在需要时获取,这种场景就很适合延迟加载。
目前C++中还没有类似的Lazy
之前提到过lambda表达式可以实现延迟调用,在定义完的一瞬间可以捕获当时的外部变量,然后再之后合适的时机再调用。
代码
1 | // Lazy内部的std::function<T()>用来保存传入的函数,以便在后面延迟执行 |
dll帮助类
本身对dll不是很熟悉,但是win上经常有一些dll文件,你可以像程序注入一些dll来辅助完成任务,暂时当作外部存放的工具类。
在C++中调用dll中的函数有点烦琐,调用过程如下:在加载dll后还要定义一个对应的函数指针类型,接着调用GetProcAddress获取函数地址,再转成函数指针,最后调用该函数,代码如下:
1 | void TestDll() { |
每用一个函数就需要先定义一个函数指针,然后再根据名称获取函数地址,最后调用。如果一个dll中有上百个函数,这种烦琐的定义会让人不胜其烦。
我们希望调用dll中的函数就像调用普通的函数一样,即传入一个函数名称和函数的参数就可以实现函数的调用了,就类似于:
Ret CallDllFunc(const string& funName, T arg)
如果我们希望实现一个通用的function,有两个问题需要解决:
- 函数的返回值可能是不同类型
- 函数的入参数目的个数是人任意的
这两点其实结合之前学过的模版和可变参数已经可以很好的解决的,一点点来推进。
先封装一下GetProcAddress:
1 | template <typename T> |
函数返回值和入参不统一的问题,通过result_of和可变参数模板来解决,最终的调用函数如下:
1 | template <typename T, typename... Args> |
实现的关键是如何将一个FARPROC变成一个函数指针复制给std::function
,然后再调用可变参数执行。函数的返回值通过std::result_of
来泛化,使得不同返回值的dll函数都可以用相同的方式来调用。
lambda链式调用
直接看代码实现,不多啰嗦,代码如下:
1 | template<typename T> |
非常棒的工程思维,值得学习!
any类的实现
是一个特殊的只能容纳一个元素的容器,它可以擦除类型,可以赋给它任何类型的值,不过在使用的时候需要根据实际类型将any对象转换为实际的对象,和scala中的any类似。
思路
接下来重点介绍any的关键技术,要细品他的工程思维。
any能容纳所有类型的数据,因此,当赋值给any时,需要将值的类型擦除,即以一种通用的方式保存所有类型的数据。这里可以通过继承去擦除类型,基类是不含模板参数的,派生类中才有模板参数,这个模板参数类型正是赋值的类型。在赋值时,将创建的派生类对象赋值给基类指针,基类的派生类携带了数据类型,基类只是原始数据的一个占位符,通过多态的隐式转换擦除了原始数据类型,因此,任何数据类型都可以赋值给它,从而实现能存放所有类型数据的目标。当取数据时需要向下转换成派生类型来获取原始数据,当转换失败时打印详情,并抛出异常。由于向any赋值时需要创建一个派生类对象,所以还需要管理该对象的生命周期,这里用unique_ptr智能指针去管理对象的生命周期。
以上都是书中的原文,几个关键点很值得学习:擦出类型可以考虑集成;当有连带关系自动创建的新对象是,可以考虑unique_ptr。
代码
1 | struct Any { |
又是需要细品的一段代码,这里 enable_if 的作用是干预重载决议。因为你这个模板构造函数只有一个形参,且类型是模板形参,主要是为了防止和你的复制构造和移动构造函数产生冲突。
function_traits
在C++编程中,有时候需要获取函数的实际类型,返回类型,参数个数和参数具体类型。
C++提供了function_traits来获取这些信息,可以获取普通函数、函数指针、std::function、函数对象和成员函数的函数类型、返回类型、参数个数和参数的具体类型。
boost库里有提供function_traits这个功能,但是原始的library里没有。
代码
1 | //转换为std::function和函数指针. |
解析
这段代码给我的震撼是巨大的,让我有了等看完深入应用c++11后,直接去看template编程的冲动,这是值得铭记的一天哈哈哈。
这段代码有几个知识点可以帮作为新手的我进一步巩固:
- template的特化,T*,const T等等
- 学到了一招tuple_element
- 再次接触了remove_reference等等,这些的实际作用进一步理解了
至于define那段其实我也没有细看,一是我本身并不喜欢define,其次是我能清晰的感觉的他暂时对我没用,只有在打开define的大门。
Variant
这玩意实现起来对于新手而言是有些复杂的,是3.3到现在我自认为从设计的角度而言最复杂的,以学习书本上的原始代码为主了解一下。
基本作用
variant类似于union,它能代表定义的多种类型,允许赋不同类型的值给它。它的具体类型是在初始化赋值时确定的。boost库中有variant,这个variant的基本用法如下:
1 | typedef variant<int,char, double> vt; |
和union是比较像的。
思路
我们自己思考一下如何实现,之后直接看代码,初学者多看标准代码不容易走弯路。
首先和option一样,肯定要实现类型擦除,同时要用到placement new,就业需要aligend_storage,原始代码如下,分析一下。
代码
这段就暂时不具体说了,我给书上的代码加了很多注释,对于初学者的我来说还是比较复杂的,但是收获非常大。
1 | /** 获取最大的整数 */ |
ScopeGuard
ScopeGuard的作用是确保资源面对非正常返回(比如,函数在中途提前返回了,或者中途抛异常了,导致不能执行后面的释放资源的代码)时总能被成功释放。它利用C++的RAII机制,在构造函数中获取资源,在析构函数中释放资源。当非正常返回时,ScopeGuard就会析构,这时会自动释放资源。如果没有发生异常,则正常结束,只有在异常发生或者没有正常退出时释放资源。
这里的RAII的全称是Resource Aqucisition is Initialization,资源取得时机就是初始化时机,这里可以进步看一下effective c++的条款14。
注释已加好,直接来看一下书上的代码:
1 | template <typename F> |
tuple_helper
这里主要写一些关于tuple的工具类。
打印tuple的每个元素
1 | template<class Tuple, size_t N> |
根据索引序列打印元素
1 | template<typename T> |
遍历,对tuple中每个元素都应用相同的动作
其实就是个递归,和之前的差不多
1 | template<typename Func, typename Last> |
合并tuple
总体上也不难理解
1 | namespace details |
本文链接: http://woaixiaoyuyu.github.io/2022/03/17/%E6%B7%B1%E5%85%A5%E5%BA%94%E7%94%A8C++11%EF%BC%9A%E4%BB%A3%E7%A0%81%E4%BC%98%E5%8C%96%E4%B8%8E%E5%B7%A5%E7%A8%8B%E7%BA%A7%E5%BA%94%E7%94%A8--note/
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!