C++ | STL III
在这一节, 我们将尝试探讨一个沟通了算法与容器的重要概念–迭代器
前言
参考资料
正文
从指针到迭代器
迭代器iterator这个名词初见可能比较陌生, 因此在前一篇有关STL的文章 C++ | STL I 中, 我使用了指针来类比这个概念. 今天让我们继续从指针重新开始, 来理解迭代器.
数组是最简单的容器辣!
让我们考虑实现一个数组容器吧, 也不需要支持特别复杂的算法, 有最简单的增改删查就可以了, 因此我们直接考虑用C艹自带的数组来实现我们的数组, 仿照STL的习惯, 我们把成员函数作如下命名
template <class ItemType, int Size> |
如果没有学习迭代器, 上面的写法已经是一个很优秀的回答了 (因为是Vanadium写的), 我们主要关注查找函数的实现, 相信聪明的你很快就能做出实现
template <class ItemType, int Size> |
这个写法已经很完美了, 基本可以满足需求demand. 但是, 这是针对我们只有my_array
这么一个需求的时候. 当我们需求扩大, 比如STL这样一个十分庞大的需求, 返回一个ItemType*
是远不足以满足需求的. 比如我们需要一个my_map
, 我们知道map
在STL中是用红黑树red black tree实现的, 我们就要针对红黑树实现另外一个find
函数–因为我们使用了指针的++
运算和*
解引用运算, 而这样的简单运算是不足以遍历红黑树的, 但是我们重新写一个这样的函数, 而不是选择重载, 就大大的违背了泛型编程的逻辑了.
最好的解决办法是保留这个弱爆了的遍历函数不动, 我们修改ItemType*
为另外一个变量, 它能唯一的指向特定容器的某一个元素, 也能通过重载++
运算和*
解引用运算的方式实现对任一算法的适配, 定义这个新的变量类型, 用来代替指针的过程就被称为指针的抽象, 与此同时, 迭代器产生了.
假如我们已经拥有了迭代器类型iterator
, 我们就可以改写那个find
函数了.
template <class ItemType, int Size> |
在我们的写法中, 迭代器的定义更像是一个模板类中的类, 事实上也是如此, 以my_array
为例, 我们会在类中定义一个类来实现迭代器(略去了影响观感的多余成员函数)
template <class ItemType, int Size> |
经过这样子的改写, 我们的my_array
终于能支持迭代器了, 虽然对数组使用迭代器显得有些多余, 但我们的目标可是星辰大海! 况且, 我们目前已经跨出了第一步–从指针到迭代器
除此以外, 我们的find
函数(算法)已经脱离了容器my_array
, 这也从另一个侧面说明了STL的迭代器的作用, 沟通算法与容器, 避免算法错误的对不应被操作的容器操作, 以及方便算法的实现和重载
最后一个迭代器
不论是数组还是抽象的遍历函数, 我们判断终点的方法都是i != this->end()
, 这是由于我们想要程序在越过容器边界之后再进行退出. 之所这么做是有很多考量的, 其中之一就是方便程序的编写.
迭代器的分类
好的, 编写一个STL的小活动到此结束(当然我们也没有能力编写一个完整的STL, 毕竟一个<array>
都有910行代码, 而<algorithm>
包含了9948行代码!!!), 接下来我们继续往应用层面讨论迭代器的分类.
我们知道, 对于不同的容器, STL实现时, 采用了不同的数据结构, 同样的, 迭代器的实现也会使用不同的方法. 一个最简单的例子就是, 你无法对一个set
容器使用sort
函数, 但是vector
却可以.
我们知道, STL的sort
函数主要使用的是快速排序算法 (不知道就去看文档!), 实现快速排序算法的必要条件就是能够以O(1)的代价随机访问元素, 并且能以O(1)的代价计算任意两个元素之间的距离; 用迭代器的语气说, 就是你要对迭代器与整型之间的+=
, -=
, +
, -
运算符和迭代器之间的-
运算符有定义.
然而, 由于set
的底层是红黑树, 我们并没有办法通过迭代器随机访问, 也没有办法计算距离, 因此sort
无法对set
使用; 对于vector
, 我们知道它的实现其实是数组, 其迭代器的实现是指针, 而且拥有一块连续的内存, 因而可以满足要求.
让我们拿回上一篇有关STL的文章 C++ | STL II 的图
图源: Github | PPT | Back to Basics: Classic STL - Bob Steagall - CppCon 2021
先不看表格的前面两行与输入输出有关的特化迭代器, 我们关注后面四个常见的迭代器, 我们从上往下看, 因为下面的迭代器是上面迭代器的加强版本.
前向迭代器forward iterator
前向迭代器支持多趟的对容器遍历, 并且在读写后依然指向同一个元素.
支持的操作: * -> ++ == != =
应用于: forward_list(单向链表), unordered_*(hash维护的单链表)
双向迭代器bidirectional iterator
在前者的基础上, 可以双向移动
额外支持的操作: --
应用于: list(双向链表), set, map, multi*(红黑树)
随机访问迭代器random access iterator
在前者的基础上, 可以以O(1)的代价访问任一元素
额外支持的操作: [] += -= + - < > <= >=
应用于: deque(多个定长数组)
连续迭代器contiguous iterator
(C艹17+)在前者的基础上, 逻辑相邻的元素, 物理也相邻
额外支持的操作: 无
应用于: array, vector(数组)
一个容器的迭代器的种类决定了他能应用的算法. 因此, 我们不必机械的记忆适用于某一个或某一类的算法有哪些, 我们只需要思考算法的实现过程, 判断算法需求的迭代器的种类, 即可以更加普遍的方式获知算法的适用的容器. 当然, 比这种方法更加便捷的应当是查询文档–当且仅当无法确认时.
比较器专栏
在STL | III的最后, 我们来探讨一下比较器数的原理和用法. 在前一篇有关STL的文章 C++ | STL I 中, 我在最后提了一嘴降序序列如何设置cmp
函数, 然而当时我们并没有系统的理解迭代器, 因此编写的cmp
函数并不是一个良好的函数.
匿名函数
在C艹11+中, 提供了匿名函数(也被称为lambda表达式)以解决我们在使用STL的过程中, 定义了过多仅仅使用了一两次的函数类, 导致编译变慢或代码冗长臃肿的情况. 由于匿名函数可以在使用域内部定义使用, 我们也将其称作匿名的内联函数.
匿名函数的语法格式如下:
[capture](parameters) -> return TypeName{} |
在上面的定义中,
[capture]
被称为捕获列表, 用于捕获外部变量(parameters)
被称为参数列表, 相当于普通函数的参数列表, 采用形参传值的方法-> return TypeName
规定了匿名函数的返回值类型, 一般情况下可缺省, 交由编译器推导{}
为函数体, 不要求只能包含一条语句, 也可以是语句块(但是语句过多, 就不适合使用匿名函数了)
我们直接通过例子来理解匿名函数, 至于更细节的东西, 我们可以放到之后有机会再讲.
|
同样的, 我们也可以在当时的代码中, 使用匿名函数.
//原始代码 |
可以看到, 假如我们使用生命周期更短的匿名函数, 就可以减少对cmp
函数的重载(因为我们不一定只需要一个特殊的cmp函数), 而是可以完全交由匿名函数完成.
比较器的本质
让我们回到比较器, 回到使用比较器的典型函数sort
, 参阅官方标准文档, 我们发现, sort
只有两个函数签名
一个是缺省了比较器的版本(这个时候会调用重载后的<
运算符完成比较); 另一个就是带有比较器的版本, 但是奇怪的是, 我们可以使用很多种方式为sort
传递比较器, 比如下列方法都是可以接受的
// sort algorithm example |
然而, 这么多种方法对应的仅有一个重载, 这说明, 这么多实现方法的比较器的类型, 应该是一样的.
事实上, 这个相同的类型, 就是函数对象function object, 要注意的是, 函数对象中的对象, 与我们OOP中熟知的对象并不相同, 与此同时, 也要清楚函数并不是对象–包括结构体, 以及他们的模板和实例化, 他们统统都是一段代码而已.
具体来说, 函数对象指的, 就是函数指针, 可以转化为函数指针或是重载了()
运算符的东西. 因此我们回头看上面的代码, 就可以理解他们分别调用了什么函数对象了
- 行20: 函数指针
- 行23: 重载后的
()
运算符 - 行26: 可以转化为函数指针