这篇是侯捷关于C++标准模板库的课程《C++标准库: 体系结构与内核分析》的笔记, 上一篇在此, 课程内容大家自己找吧. 这个课程质量很高, 除了介绍STL的基础操作外, 更进一步介绍了STL的工作原理并展示了部分源代码. 尽管这门课所介绍的都是较老版本的STL内容, 但是毕竟底层思想多年来也没有太大改变, 对今天仍有很大意义.
这一节是Part3和Part4部分, Part3介绍了标准库的算法相关的内容, Part4以介绍STL周边设施的思路回顾了一圈标准库操作. 全文6.1k字, 难度不高内容也不长. 本文同步存于我的Github仓库, (https://github.com/ZFhuang/Study-Notes/tree/main/Content/C%2B%2B%E7%9B%B8%E5%85%B3/C%2B%2B%E6%A0%87%E5%87%86%E5%BA%93-%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84%E4%B8%8E%E5%86%85%E6%A0%B8%E5%88%86%E6%9E%90%20%E7%AC%94%E8%AE%B0)
STL的算法是经由迭代器操作数据的, 算法对目标数据的一切信息都从迭代器取得, 因此迭代器需要回答算法所需的各种问题. 首先就是迭代器的数据访问类型, 暗示了迭代器底层容器的组织结构. 迭代器分为以下五种, 它们并非并列而是有继承关系的一组类, 左侧的输入迭代器从底往上限制条件逐渐放宽:
当我们自己写的迭代器属于某个类型时, 我们就让自己的迭代器继承其中某一个类. 之所以这么写是为了能利用模板参数自动重载所需的函数, 从而在编译期解决判断的问题:
// 先让模板函数自己推导迭代器类型
template <typename I>
void do_something(I iter){
// 由于迭代器继承了某个迭代器类型, 因此萃取器可以询问迭代器类型
typename iterator_traits<I>::iterator_category cagy;
// 然后这个迭代器类型可以用来调用重载函数
_do_sth(cagy);
}
...
// 通过给函数加入"特化"了迭代器类型的匿名参数, 程序就可以利用重载进行跳转
// 且由于迭代器类型是继承关系的类, 失配的时候还能借助上转型进行泛化的匹配
void _do_sth(input_iterator_tag){}
void _do_sth(random_access_iterator_tag){}
...
之所以要对迭代器进行分类, 是因为标准库算法会用上述的重载技术来按照不同的迭代器种类进行效率优化. 尽管迭代器表现出来的行为模式都大差不差, 但不同分类的迭代器实际计算的时候效率会有很大区别. 下图的advance()
函数的代码很好表现了STL对于迭代器种类的效率适配:
对于advance()
, 需要对迭代器进行n步的移动. 右边的萃取器先取出迭代器的种类, 然后用函数重载的方法对不同迭代器类型使用不同的移动策略, 其中随机访问类型可以直接用效率最高的地址移动方法, 而双向链表型则需要用循环的方式移动迭代器, 剩余的迭代器类型我们认为是单向链表型的, 无法反向移动, 这里的代码没有对负移动值进行检查有点奇怪.
这种函数重载的思路在STL的算法中非常常见, 各种算法都会进行复杂的迭代器判断, 这类分支很多发生在编译时所以不会对执行效率有太大影响. 下面是copy()
函数的重载分支逻辑:
value_type
, 当发现目标迭代器元素是最简单的C风格字符串风格时, 直接调用底层的内存复制来进行字符串拷贝.random_access_iterator_tag
, 因此这里还可以进行优化直接计算所需的循环次数, 减少迭代器头尾求相等的比较开销.iterator_category
, random_access_iterator_tag
则和原生指针进行一样的循环, 否则只能使用效率最低的迭代器首尾比较循环拷贝赋值的方法了.使用STL算法首先知道C++标准库的算法大多都以指示目标容器范围的两个迭代器开始, C标准库的算法则比较混乱. 其中C++算法有些会接受一个额外的函数操作作为参数, 这个函数操作是用来改变算法关键行为的, 例如原版的accumulate()
中是将容器内容累加到初值上, 第二个版本变为将容器内容使用传入的操作与当前累计值进行叠加计算.
在STL算法中传入的操作除了原生的函数外, 我们可以传入所谓的仿函数(Functor; 函数对象), 也就是一个行为上类似函数的类, 这种类都重载了自己调用运算符operator()
. 仿函数比起函数有多个好处, 一方面仿函数由于本质是一个对象, 因此可以有自己额外的状态, 数据成员, 可以进行更加复杂的操作. 另一方面仿函数可以进行继承从而可以进行模板函数重载等操作.
这些算法都在头文件<algorithm>
内
名称 | 默认行为 | 附加行为 |
---|---|---|
accumulate | 将容器内容累加到初值init上 | 累加操作变为函数累赋值 |
for_each | 无 | 对容器中每个元素执行函数 |
replace | 额外传入old_value和new_value, 将容器等于old_value的值替换为new_value | 在replace_if()中 |
count | 传入value, 返回容器中等于value的数量 | 在count_if()中 |
find | 传入value, 返回容器中第一个等于value的迭代器 | 在find_if()中 |
sort | 要求迭代器随机访问, 将用小于号将容器元素从小到大排序 | 两个元素类似比较操作进入函数, 返回bool, true在前 |
binary_search | 用小于号找到第一个等于value的元素 | 改为返回bool的函数进行比较 |
..._if | 部分算法有 | 将等号运算符转为函数调用 |
..._copy | 部分算法有 | 不修改原容器, 而是复制到新的容器中并返回, 需要传入指向新保存结果的容器的迭代器 |
适配器是为了给STL的各个组件额外的改造, 实际上就是一种包装, 适配器的思想是来自适配器设计模式的, 通过包含而非继承目标部件, 模仿目标部件的行为对部件进行包装.
平时常用的适配器是容器适配器(stack和quene就属于对deque之类的容器的适配器), 迭代器适配器(通过操作符重载改变迭代器的行为例如重载加减改变迭代器方向的reverse_iterator和重载赋值改变迭代器拷贝操作的inserter), 还有重载取值操作符的X适配器(用ostream_iterator和istream_iterator将输入输出流与容器操作连接起来).
但STL最强大的适配器是函数适配器, 名为绑定. 核心是旧版本的bind2nd()
和C++11的bind()
适配器, 目的是让我们能利用适配器提前指定仿函数的一些参数的数值, 简化自动化的函数调用效果. 理解起来比较简单的bind2nd()
是为了实现这个功能首先设计了下面这个辅助的模板函数作为接口:
// 使用模板函数作为入口是因为只有模板函数能进行模板实参推导, 核心依然是函数里的模板类
template <class Operation, class T>
inline binder2nd<Operation> bind2nd(const Operation& op, const T& x){
// 询问仿函数其参数的类型, 这需要仿函数继承可适配接口
// 这里额外标注typename是为了告知编译器此时不用检查Operation是否有这个成员
typedef typename Operation::second_argument_type arg2_type;
// 利用推导出来的类型进行模板类构造, binder2nd才是真正发生适配的地方
// 这里用arg2_type(x)来将实际传入的参数转为适合仿函数的类型, 也算一种编译时类型检查
return binder2nd<Operation>(op, arg2_type(x));
}
// 实际进行适配的模板类, 目的是让自己表现得像是绑定了第二实参后的仿函数自己
template <class Operation>
// 这里继承仿函数可适配接口unary_function是为了让适配后的自己又成为一个仿函数
// 从而可以给其它适配器进行嵌套适配, 这里选择了单个参数的unary_function
class binder2nd: public unary_function<
typename Operation::first_argument_type,
typename Operation::result_type>
{
protected:
// 适配器接收到的仿函数的本体
Operation op;
// 适配器接收到的要输入仿函数的额外参数
typename Operation::second_argument_type value;
public:
// 在构造的时候进行仿函数和参数的初始化
binder2nd(const Operation& x,
const typename Operation::second_argument_type& y):
op(x), value(y){}
// 重载调用运算符让自己也变为一个仿函数, 返回值的类型与原先相同
typename Operation::result_type
// 此时只需要接受一个实参了, 实参类型与之前相同
operator()(const typename Operation::first_argument_type& x) const{
// 将准备好的参数和当前接收到的参数放入准备好的仿函数中包装调用并返回
return op(x, value);
}
};
在上面这一段代码中, 可以看到适配器一直在询问仿函数的属性, 因此如果自己写的仿函数想被适配器使用, 需要继承仿函数可适配类. 所谓的可适配类是如下图的两个只有typedef而没有成员的结构体. 我们自己的仿函数在构造的时候就需要从模板类给出继承的接口的问题答案, 这样适配器才能正确处理. 显然unary_function是指行为只有一个参数的仿函数, binary_function是有两个参数的函数. 在这里有个小称呼, 没有继承可适配接口的仿函数我们称其"没有融入STL", 这是因为只有继承了可适配接口才能完整地与STL协同使用.
C++11以后, 标准库推出了更好用的bind适配器. 其实现原理更复杂, 但是将原先多个适配器(例如bind1st, bind2nd)整合到一起了, 一个适配器可以实现下面四种功能, 且可以选择要绑定哪些参数, 参数顺序, 参数数量, 返回类型...非常自由.
下面是新版绑定的典型使用效果, 核心改进是引入了占位符(std::placeholder), 现在我们只需要在bind里将需要动态改变的参数用占位符占用, 然后可以固定的参数直接写上即可. 由于bind的机制比较复杂, 实际的类型可能写出来会很长, 所以通常与C++11引入的自动类型代号auto一同使用.
上图中绑定成员函数和成员数据的部分可能比较乱, 实际上就是让目标类对象本身成为了一个可变的参数(借助取地址), bind将对象的某个成员提取出来变成了接口而已.
利用C++11推出的可变模板和模板特化的设计, 下面是这门课给出的一个比较泛用的hash函数的实现, 这个函数是递归模板函数的一种典型情况, 值得学习.
// 首先是模板函数的入口, 这一系列函数都是模板重载的范例
// 这里用到了可变模板参数, 通过三个点(...)的标识, 可以传入任意数量的参数
template <typename... Types>
// 最泛化的版本, 接收所有参数作为入口
inline size_t hash_val(const Types&... args){
// 通过传引用来修改返回值seed
size_t seed = 0;
// 由于是模板函数, 所以根据实参推导转到下面的函数
hash_val(seed, args...);
return seed;
}
// 接受seed和可变数量的参数, 这是递归的主要部分
template <typename T, typename... Types>
// 通常传到这里的时候seed之后的参数数量都是不定的, 由于可变模板参数的设计
// 这里编译器会自动进行切分, 将可变参数的第一个区分出来, 然后剩余的继续传递
// 这种写法在C++11之后的STL中出现得很多, 一定要注意
inline size_t hash_val(size_t& seed, const T& val, const Types&... args){
// 此时的val就是当前列表里的第一个参数, 用来刷新现在的hashcode
hash_combine(seed, val);
// 更新后的seed继续传递
// 这里不再传递val了, 因此参数列表就减少了一个项, 继续递归下去直到只剩一个参数
hash_val(seed, args...);
}
// 至此为止是模板递归的最后一层, 只剩下一个参数时进入
template <typename T>
inline size_t hash_val(size_t& seed, const T& val){
// 仅仅是刷新最后的seed然后开始返回
hash_combine(seed, val);
}
// 这里是计算hashcode的函数, 将其理解为黑盒就行
#include <functional>
template <typename T>
// 只接受两个参数, 分别是当前hashcode和新出现的需要附加到hashcode中的参数
inline void hash_combine(size_t& seed, const T& val){
// 主要是调用了std的hash仿函数来对基本类型进行散列
// 其它部分就是一些打乱操作, 没什么特别的原理, 下面的魔数是黄金分割数
seed^=std::hash<T>()(val) + 0x9e3779b9 + ((seed<<6) + (seed>>2));
}
有了这个hash函数, 对于我们自己的类型, 只要传递所需的一些可被std::hash<T>()
处理成员进去就可以得到一个合适的hashcode, 打乱得比较彻底因此不容易碰撞. 而为了让自己的类型可以被std::hash<T>()
处理, 可以在std内追加一个适合自己类型的偏特化模板函数:
// 追加在std内方便处理
namespace std{
// 给自己的类型偏特化一份
template<>
struct hash<MyClass>{
// 关键是重载调用运算符, 因为hash属于仿函数
size_t operator()(const MyClass& inp) const noexcept{
// 自己想办法返回自定义的hashcode即可
return inp._do_something();
}
}
}
tuple也是C++11的新特性, 可以暂称为"数据组合", 可以以模板参数的形式接受任意类型任意数量的元素, 组合为一个tuple对象. 下面是使用的范例:
// 空构造
tuple<string, int, float> t0;
// 带元素构造
tuple<string, int, float> t("something", 42, 42.0);
// 用辅助模板函数推断构造
t = make_tuple("something", 42, 42.0);
// 读取tuple的元素, 注意不要越界
string s = get<0>(t);
int i = get<1>(t);
float f = get<2>(t);
// 对某一项赋值, 注意类型问题
get<1>(t) = get<2>(t);
// tuple间的比较, 整体赋值之类的自然也是可以的
bool b = t0 < t;
t0 = t;
// 用tie()函数将tuple赋值给离散的元素
tie(s, i, f) = t;
// 询问tuple的成员数量
typedef tuple<string, int, float> t_type;
size_t num = tuple_size<t_type>::value;
// 询问tuple某个成员的类型
typedef tuple_element<0, t_type>::type v_type;
之所以介绍tuple, 是因为tuple是很好的利用了可变模板参数列表来实现的模板递归继承类. 下面是简化的tuple实现:
// 最泛化版本的tuple
template<typename... Values> class tuple;
// 最特化版本的tuple, 作为递归继承的最后一层存在
template<> class tuple<> {};
// 递归继承的主要部分, 充分利用了可变模板参数列表会被自动切分的特性
template<typename Head, typename... Tail>
// 参数更多的tuple递归继承自参数少的tuple
class tuple<Head, Tail...>: private tuple<Tail...>{
// 改个名字
typedef tuple<Tail...> inherited;
public:
tuple(){}
// 构造的时候初始化一个属于当前层次的元素head, 其它部分都用来初始化父类
tuple(Head vhead, Tail... vtail): m_head(vhead), inherited(vtail...){}
// 返回当前层次的元素
typename Head::type head(){
return m_head;
}
// 借助上转型变为自己的父类, 由于继承关系在内存中呈线性排列, 因此放心转型
inherited& tail(){
// 返回父类的指针从而可以继续递归
return *this;
}
protected:
// 每一层继承有一个这样的元素, 是这一层真正保存的元素
Head m_head;
}
经过这样设计的结构后, 我们可以不断调用tail来取出排在前列的tuple元素, 用head取出自己想要的那一层的元素, 这整个继承树如下图所示.
在C++11之前, 如果想要询问一个类的性质, 需要自己对萃取器进行特化来方便算法询问. 当时算法能询问的问题很少, 且需要类的编写者对自己的类都去特化一份如下的空结构, 非常繁琐.
这里默认最泛化的类所有成员都是重要的(trivial项都是false), POD是指无函数的结构体. 由于这种写法非常繁琐且能力有限, C++11给出了更强的类型萃取器, 分为以下如此多的种类, 可以自动返回几乎任何我们常会想到的类的特性. 这些萃取器一部分使用特化模板函数实现, 还有一部分是靠编译器内部实现, 不用深究原理, 像普通的萃取器一样使用即可. 例如is_void<myClass>::value
会回答当前询问的类是否为空, 值是0或1.
cout是我们非常常用的STL对象, 其本质是一个与屏幕输出流默认绑定在一起的对象, 是_IO_ostream_withassign
类型. cout以类似下面的形式进行了大量的流运算符的重载, 从而实现了对各种类型的输出处理. 下图是对bitset的一种重载.
moveable也是C++11的新特性, 利用右值引用语义给类新增了搬移构造相关函数. 在STL的容器普遍实现了这种新的构造函数, 能大大提高拷贝构造的性能. 搬移构造和搬移赋值函数的特征是参数带有右值引用符&&
而非普通的引用符&
, 然后需要调用搬移函数的时候要使用std::move()函数如下:
string s1(s2); // 普通拷贝构造
string s1(std::move(s2)); // 调用搬移构造
下图string类的代码很好地标识出了传统拷贝构造拷贝赋值和搬移构造搬移赋值的区别:
从上图中可以看到右边的传统拷贝函数会真正调用内存分配, 字符串拷贝等操作, 称为深拷贝. 传统拷贝的好处是逻辑上符合拷贝的常见语义, 拷贝后的对象与拷贝前的对象都是独立的.
相比之下能看到左边的搬移函数仅仅是swap了对象的指针, 并没有发生内存的分配, 且为了保证指针的唯一性swap后原对象的指针将为空. 因此搬移函数实际上是浅拷贝, 能告诉完成内存指向的转移, 但是会破坏原先的对象. 这也就是搬移函数使用右值引用作为参数的原因, 因为搬移语义下, 被拷贝的原对象应该是临时的, 可被马上抛弃的对象, 也就是右值对象.