STL:把数据结构做成模板
确定性是唯一的庇护。今天聊 STL。
为什么需要 STL
回顾前面写过的内容:顺序表、单链表、双向链表、栈、队列、二叉树、堆、各种排序。每写一个,都要走同一套流程------定义结构、初始化、扩容、插入、删除、销毁。换成 double,或者换成自定义结构体,整套代码再抄一遍,只改类型名。
C 语言没有模板,只能靠 void* 加强制转换,类型安全全部丢失。C++ 给出了解法:模板(template) 让代码脱离具体类型,类与运算符重载 让自定义类型用起来像内置类型。两者结合,标准库就能提供一套"写一次、任意类型复用"的数据结构与算法。
这就是 STL(Standard Template Library,标准模板库)。
一句话:STL 是数据结构的标准化、泛型化实现。
💡 背景补充:手写过的顺序表对应
vector,链表对应list,栈对应stack,队列对应queue,堆对应priority_queue。STL 不是新知识,是已学知识的工业版本。
六大组件
STL 不是"一堆现成函数",而是一套分工明确的体系:
| 组件 | 职责 | 代表 |
|---|---|---|
| 容器(Containers) | 存储数据 | vector、list、deque、map、set |
| 算法(Algorithms) | 处理数据 | sort、find、reverse、copy |
| 迭代器(Iterators) | 连接容器与算法的访问接口 | begin()、end() |
| 仿函数(Functors) | 让对象像函数一样调用,常作算法的策略参数 | less<int>、greater<int> |
| 适配器(Adapters) | 改造已有组件的接口 | stack、queue、priority_queue、reverse_iterator |
| 空间配置器(Allocators) | 为容器管理内存 | allocator<T> |
关系可以压成一句:容器持有数据,迭代器提供访问方式,算法只通过迭代器工作。
text
容器 ──提供──> 迭代器 ──喂给──> 算法
↑ ↑
配置器 仿函数(策略)
适配器:包装容器 / 迭代器 / 仿函数
这条设计最关键的地方是:sort 不知道自己在排 vector 还是原生数组,它只认迭代器。所以 sort(v.begin(), v.end()) 和 sort(a, a + n) 是同一个算法。这就是泛型编程(Generic Programming)的核心------算法与数据结构解耦。
试想没有解耦的世界:给数组写一份快排,给链表写一份,给 deque 再写一份。同一套逻辑复制 N 遍,改一处 bug 要改 N 个地方。迭代器把"访问方式"抽出来,算法只写一次。
容器分类
按数据组织方式分:
| 类别 | 容器 | 底层结构 | 特点 |
|---|---|---|---|
| 序列式 | vector |
动态数组 | 随机访问 O(1),中间插入删除 O(n) |
| 序列式 | list |
双向链表 | 已知位置插删 O(1),不支持随机访问 |
| 序列式 | deque |
分段连续空间 | 头尾插删 O(1),随机访问 O(1) |
| 序列式 | string |
动态字符数组 | 专门管理字符序列 |
| 关联式 | map / set / multimap / multiset |
红黑树 | 有序,查找 O(log n) |
| 无序关联式(C++11) | unordered_map / unordered_set |
哈希表 | 平均 O(1),无序 |
| 适配器 | stack / queue / priority_queue |
包装其他容器 | 只暴露受限接口 |
📖 参考:《数据结构知识库》第一节、第四节------顺序表与链表的复杂度对比、红黑树与堆的适用场景。
从这里能看出先学数据结构的意义:选容器本质是选复杂度 。知道 vector 中间插入要搬移元素,就不会在频繁中间插入的场景用它;知道 list 无法随机访问,就不会拿它做二分查找。
注意 stack / queue 的位置。它们不是独立的数据结构,而是容器适配器 ------底层可以是 deque 或 list,只是对外只暴露"栈顶进出"或"队头队尾"的接口。这和手写栈时"接口由需求决定"是同一个思路。
迭代器:泛化的指针
迭代器(Iterator)是指针的抽象。它至少支持 *it、++it、it != end 三种操作,用起来和一个指针没有区别。
标准把迭代器按能力分成五类:
| 类别 | 能力 | 典型容器 |
|---|---|---|
| 输入迭代器 | 只读、单向 | istream_iterator |
| 输出迭代器 | 只写、单向 | ostream_iterator |
| 前向迭代器 | 读写、单向 | forward_list |
| 双向迭代器 | 读写、可后退 | list、map、set |
| 随机访问迭代器 | 读写、可跳跃(it + n、it[n]) |
vector、deque、string |
这个分类不是学术摆设,它直接决定算法能不能用。find 只要求输入迭代器;sort 需要随机访问迭代器,所以 list 用不了 std::sort,只能调用成员函数 list::sort。容器不支持某个算法,通常不是"忘了实现",而是迭代器能力不够。
C++11 之后,遍历通常写成范围 for,但底层仍然是 begin() / end() 这一对迭代器:
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main()
{
vector<int> v = { 5, 2, 8, 1, 9 };
sort(v.begin(), v.end()); // 算法只依赖迭代器
for (auto e : v) // 范围 for 的底层仍是迭代器
cout << e << " ";
cout << endl;
auto it = find(v.begin(), v.end(), 8);
if (it != v.end())
cout << "found: " << *it << endl;
return 0;
}
sort 和 find 都来自 <algorithm>,与 vector 所在的 <vector> 没有任何包含关系。容器和算法在头文件层面就是分开的,接口靠迭代器对齐。
版本的来龙去脉
STL 并非某一家公司的产品,它有多个实现版本,接口一致、内部不同:
| 版本 | 出处 | 特点 |
|---|---|---|
| 原始版本 | Alexander Stepanov、Meng Lee(HP 实验室) | STL 的起点,奠定了组件划分 |
| P.J. 版本 | P.J. Plauger(微软) | 被 Visual C++ 采用,闭源,风格保守 |
| RW 版本 | Rouge Wave | 被 C++ Builder 采用,闭源 |
| SGI 版本 | Silicon Graphics | 开源,可读性最好,GCC 采用 |
读源码首选 SGI 版:结构清晰、注释完整,allocator、traits 这些概念都在这里成型。现代三大标准库实现(GCC 的 libstdc++、Clang 的 libc++、MSVC STL)都受它影响。
📖 参考:《Effective C++》条款 54------标准程序库由 STL、iostreams、国际化、数值处理、异常层次构成,STL 只是其中最核心的一块。
学习路径
三个阶段,顺序不能反:
- 会用 :记住常用容器的接口和复杂度,能在
vector/list/map之间做出选择。 - 会读:知道迭代器失效规则、扩容策略、底层数据结构。
- 会写 :模拟实现
string、vector、list,把"它会自动管理内存"变成"我知道它怎么管理内存"。
跳过前两步直接啃源码,只会看到一堆模板和 traits。先用起来,再拆开看。工具先替你干活,然后才轮到理解它为什么能干这个活。
常见误区
- 把 STL 当成函数集合 。它是组件协同的体系,
sort能用是因为有迭代器,不是因为sort认识每个容器。 - 以为所有容器都能
sort。list的迭代器是双向的,std::sort编译不过,必须用成员函数list::sort。 - 以为所有容器都能
[]。只有vector、deque、string等提供随机访问。 - 以为
list插入一定快 。定位插入位置仍然是 O(n),而且节点分散、缓存不友好,小数据量下往往不如vector。 - 忽略迭代器失效 。
vector扩容后旧迭代器全部失效,继续使用就是未定义行为。
本节要点
- STL 六大组件:容器、算法、迭代器、仿函数、适配器、空间配置器。
- 算法不依赖容器,只依赖迭代器------这是泛型编程的核心。
- 迭代器分五类,能力决定算法能否使用。
- 选容器本质是选复杂度,数据结构知识直接决定选型。
- 学习顺序:会用 → 懂失效与复杂度 → 模拟实现。
📖 参考:《Effective C++》第 54 条