博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
好文推荐:
【C++进阶】STL容器与迭代器 - 01 STL 容器先解决元素放在哪里
【C++进阶】STL容器与迭代器 - 02 vector 为什么是一段会长大的连续数组
【C++进阶】STL容器与迭代器 - 03 string、array 和 deque 各自守住什么边界
【C++进阶】STL容器与迭代器 - 04 list 和 forward_list 用节点换稳定位置
【C++进阶】STL容器与迭代器 - 05 map 和 set 为什么按键保持有序
【C++进阶】STL容器与迭代器 - 06 unordered_map 和 unordered_set 用哈希桶换平均效率
【C++进阶】STL容器与迭代器 - 07 迭代器是容器和算法之间的通用游标
【C++进阶】STL容器与迭代器 - 08 插入删除之后,哪些迭代器会失效
【C++进阶】STL容器与迭代器 - 09 容器选择看访问模式,不看习惯
前九章逐个拆解了容器的内部结构、迭代器的工作原理和失效规则。但真实代码里,没有哪个程序只用一个容器,数据从输入到处理到输出,在不同阶段有不同的访问模式,需要不同的容器来承接。这一章用一个库存报表的小例子,把这个系列的要点,容器选择、迭代器范围、失效规则、输出顺序,串成一条完整的处理链。
需求:一个库存报表程序
需求不复杂但足够覆盖多个容器的协作:
- 输入是一批商品记录,每条记录包含 SKU(商品编号)、商品名称、库存数量、是否有效。
- 过滤掉无效记录。
- 按 SKU 汇总,同一个 SKU 的多条记录要把库存数量加起来。
- 能快速查询某个 SKU 的汇总库存。
- 按 SKU 字典序输出一份完整的库存报表。
这个需求三言两语说完了,但它同时包含了顺序保留、按键聚合、按键查找和有序输出四种不同的数据使用模式。单一容器无法同时优化这四种模式,但多个容器各司其职,配合迭代器范围串联,可以让每一段处理都用到最适合的数据结构。

数据结构设计:每个容器只做一件事
需求分拆之后,每个阶段的容器选择是清晰的:
原始记录:vector<Record>。输入数据需要保留原始顺序,不排除后续需要按输入顺序追溯或去重。vector 的连续内存让初始的顺序读入和过滤遍历最高效。
SKU 到汇总的索引:unordered_map<string, InventorySummary>。需要频繁按键更新(每个原始记录增加对应 SKU 的库存数量),典型的写多读少场景,更新速度优先,不需要有序输出。因为 SKU 是 string,标准库自带高质量的 std::hash<string>,碰撞率通常很低。如果 SKU 数量能预估,reserve 提前准备桶空间可以消除聚合过程中的 rehash。
有序报表:map<string, InventorySummary>。报表需要在所有数据聚合完成后按 SKU 字典序输出。这里有两种做法:一是聚合过程直接使用 map,插入过程中自动维持顺序,但这会让每次聚合更新的实现变得啰嗦(需要先在 map 中 find 再更新值,或者利用 operator[] 的插入语义,不过 map 的插入是对数级别,聚合大量记录时比 unordered_map 的常数插入慢);更好的做法是先聚合到 unordered_map 再导出到 map,导出本身就是一次遍历和插入,O(N log N),与边聚合边排序的总复杂度同阶,但聚合阶段的更新效率高得多。
这种结构选择的关键在于:不要求一个容器做所有事。vector 负责保留输入,unordered_map 负责高效聚合,map 负责有序输出,三个容器分别解决"按顺序存放""按键快速更新""按键有序遍历"三个独立的需求。如果你试图用一个 map 同时做这三件事,代码也许能更紧凑,但聚合更新的每一步都要付出对数级别的查找和插入成本,在大量输入记录面前,这个选择让聚合变成瓶颈。
这里还藏着一个所有权边界。records 拥有原始输入记录,后续过滤会真正改变这批记录的数量;index 拥有汇总结果,它不保存指向 records 元素的引用,因为 records.erase 之后元素位置可能变化;report 拥有一份从 index 导出的有序副本,用于稳定输出。三个容器之间传递的是值和范围,不是互相保存裸指针。这样设计看起来多复制了一点数据,却把生命周期关系变得很干净:任意一个阶段完成后,下一个阶段不依赖前一个容器内部地址是否还稳定。
如果库存记录很大,可以进一步调整所有权模型。例如 records 里只保存原始行和轻量字段,index 保存 SKU 到聚合数据的映射,商品详情放在单独的对象池里,由 shared_ptr 或稳定 ID 引用。但这属于规模扩大后的优化。本章示例故意使用值类型,是为了让容器协作的主线更清楚:先让数据流正确、生命周期清楚,再根据真实成本决定要不要引入间接层。

从原始记录到聚合索引
原始数据进入程序后,第一步是过滤和聚合。这里迭代器范围派上了用场,我们可以用 remove-erase 惯用法过滤无效记录(保持 vector 的原始顺序),然后用 unordered_map 聚合有效记录。
cpp
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <map>
#include <algorithm>
struct Record {
std::string sku;
std::string name;
int quantity;
bool valid;
};
struct Summary {
std::string name; // 商品名(取最后遇到的,实际中可能需要统一)
int total = 0;
int occurrences = 0;
};
int main() {
// 模拟原始输入
std::vector<Record> records{
{"SKU-3", "键盘", 10, true},
{"SKU-1", "鼠标", 5, true},
{"SKU-2", "显示器", 2, true},
{"SKU-3", "键盘", 3, true}, // 同 SKU 追加
{"SKU-1", "鼠标", 0, false}, // 无效记录
{"SKU-4", "耳机", 8, true},
{"SKU-2", "显示器", 4, true}, // 同 SKU 追加
};
// 第一步:过滤无效记录,remove-erase 惯用法
auto new_end = std::remove_if(records.begin(), records.end(),
[](const Record& r) { return !r.valid; });
records.erase(new_end, records.end());
std::cout << "=== 过滤后有效记录: " << records.size() << " 条 ===\n";
for (const auto& r : records)
std::cout << " " << r.sku << " " << r.name << " x" << r.quantity << '\n';
// 第二步:按 SKU 聚合,unordered_map 高效更新
std::unordered_map<std::string, Summary> index;
for (const auto& r : records) {
auto& s = index[r.sku]; // operator[]: 不存在则插入默认 Summary
s.name = r.name;
s.total += r.quantity;
s.occurrences++;
}
// 第三步:快速查询某个 SKU
std::string query = "SKU-3";
auto it = index.find(query);
if (it != index.end())
std::cout << "\n查询 " << query << ": "
<< it->second.name << " 总计 " << it->second.total << '\n';
// 第四步:有序报表,从 unordered_map 导出到 map
std::map<std::string, Summary> report(index.begin(), index.end());
std::cout << "\n=== 库存报表(按 SKU 有序)===\n";
for (const auto& [sku, s] : report)
std::cout << sku << " | " << s.name
<< " | 总计:" << s.total
<< " | 记录数:" << s.occurrences << '\n';
}
这段程序展示了四个关键决策的落地:
过滤用 remove-erase:remove_if 把无效记录移到末尾,erase 一次性删除,O(n) 一次遍历完成过滤,没有反复搬动。对于 vector 上的条件删除,这是标准做法。
聚合用 unordered_map 的 operator[]:index[r.sku] 在键不存在时自动插入默认构造的 Summary(total=0, occurrences=0),然后代码安全地在此基础上累加。这比手动写 if (index.find(sku) != index.end()) ... else index.insert(...) 干净得多。但要注意,这个用法的正确性依赖 Summary 有合理的默认构造函数。对于没有默认构造的值类型,需要用 insert 或 emplace 的分支逻辑。
查询用 find 不用 operator[]:因为查询不应该修改容器。这是第五章强调过的边界,operator[] 在键不存在时会插入,导致意外修改。查询阶段用 find 是只读保证。
报表用 map 构造函数直接从 unordered_map 范围导入:一行就把所有聚合结果按照 SKU 字典序排好。不需要逐元素插入的循环,构造函数接受迭代器范围,这就是迭代器作为"通用游标"的威力:不管你底层是哈希桶还是树,begin()/end() 暴露的范围对另一个容器来说就是可导入的元素序列。
这个示例里还应该补一个生产代码会做的动作:在聚合前调用 index.reserve(records.size()) 或按预估 SKU 数量预留桶空间。示例为了突出主线省略了这一行,但真实批处理里它很重要。过滤后的记录数给了你一个上界,唯一 SKU 数量不会超过有效记录数,提前预留可以避免聚合过程中多次 rehash。这样不仅能减少分配成本,也能降低迭代器在聚合过程中失效的意外风险。
错误处理也要和容器设计匹配。示例中的 SKU、名称、数量都是干净输入,实际系统里会遇到空 SKU、负库存、同一 SKU 对应不同商品名、重复导入等情况。空 SKU 应该在进入 index 前过滤或打入错误列表,负库存要看业务规则决定是否允许,同一 SKU 多个名称不能简单地用最后一次覆盖。容器只能提供存储结构,业务一致性需要在聚合逻辑里显式处理。把这些检查放在聚合前后,比等报表输出时才发现异常要安全得多。

迭代器范围在数据流中的角色
注意第四步中 report 的构造,std::map<std::string, Summary> report(index.begin(), index.end()),这行代码完美体现了"迭代器范围让数据结构之间顺畅传递数据"。unordered_map 和 map 的内部结构完全不同,但它们的元素类型都是 pair<const string, Summary>,它们的迭代器都支持前向遍历。map 的构造函数只需要确认"我能从这些迭代器读出一系列元素",不需要知道这些迭代器背后是什么容器。
这种范围传递在数据处理管道中经常出现:从 vector 的过滤结果(begin 到 new_end)传给聚合循环,从聚合结果(index.begin() 到 index.end())传给有序容器构造。每段管道都输入迭代器对、输出迭代器对,管道之间的连接不依赖具体容器类型。如果你后来决定聚合阶段改成 map(比如输入本来就排好序、想利用有序容器的合并优势),你只需要改 index 的类型声明,范围传递的那一行 report(index.begin(), index.end()) 完全不用动。

删除和过滤的失效处置
这个小程序只有一处修改容器后可能触及失效规则的地方:remove_if + erase 之后,new_end 以及它到旧 end() 之间的所有迭代器都失效了。但因为我们在 erase 之前已经用 new_end 截断了范围,erase 之后 records 的大小已经缩短,后续遍历用的是 records.begin() 到 records.end(),全部是新获取的迭代器,没有悬空问题。
如果过滤阶段用的是错误的遍历删除:
cpp
for (auto it = records.begin(); it != records.end(); ++it) { // ❌ 错误写法
if (!it->valid)
records.erase(it); // it 失效,++it 未定义行为
}
这段代码在 vector 上的行为是未定义的,erase 后 it 失效,后续的 ++it 是对失效迭代器的操作。正确的写法,要么用 erase 返回值推进迭代器,要么(如本例)用 remove-erase 惯用法把删除和递推分离。第八章的失效规则在这里兑现为直接的代码质量差异。
这里还要注意一个容易被忽略的交接点:不要在 records 过滤前保存指向某条记录的指针,然后过滤后继续使用。remove_if 会移动元素,erase 会缩短容器,原来指向某个元素的地址和业务记录之间的对应关系可能已经被破坏。正确方式是保存稳定的业务标识,比如 SKU 或行号,过滤完成后重新查找;或者把需要长期稳定的对象放入不会搬动对象本体的结构中。本例选择 vector 是因为记录只在输入阶段短期存在,不需要外部长期持有位置。
unordered_map 阶段的失效规则也要纳入脑子里。聚合循环中我们没有保存 index 的迭代器,所以 rehash 不会伤害后续逻辑;查询 query 发生在聚合完成之后,也不会被后续插入打断。如果你把查询迭代器保存下来,再继续向 index 插入新 SKU,某次 rehash 后这个迭代器就可能失效。解决办法有两个:在批量插入前 reserve,或者不要跨修改操作保存无序容器迭代器。后一条更通用,也更符合 STL 的使用习惯。

容器协作的设计原则
从这个小程序抽象出几条容器协作的通用原则,它们在本系列的每一章都有对应的技术依据:
一个容器解决一个问题。vector 存原始序列,unordered_map 提供快速查找和聚合,map 提供有序遍历。不指望一个容器同时满足所有需求,那会造成每个操作都不在最优结构上执行。
结构跟随访问模式。index 选择 unordered_map 而不是 map,因为聚合阶段的访问模式是"频繁按键更新、不关心顺序"。report 选择 map 因为"需要按顺序使用一次"。如果聚合阶段也同时对顺序敏感(比如需要边聚合边按 SKU 输出进度),也许直接用 map 更合适,但这里不是,所以分开最优。
迭代器范围是容器之间的桥梁。数据从一个容器流向另一个容器时,不要手动拆包打包,用构造函数、insert、copy 算法加迭代器范围,一行表达"把这批元素搬过去"。
失效规则不只在循环中重要,在容器交接中同样重要。当一个容器迭代器构造另一个容器时(map report(index.begin(), index.end())),源容器不能在这个过程中被修改,否则传递的是失效的迭代器范围。这个小程序中构造完 report 后没有再修改 index,所以安全。但在更复杂的并发或异步场景中,这种"迭代器传递期间容器是否被修改"就需要显式同步。
输出层不要反向污染处理层。报表需要按 SKU 有序输出,所以我们把 unordered_map 导出到 map;这不代表聚合阶段也应该换成 map。很多代码会因为最终输出有序,就让中间处理也一直维护有序结构,结果每一次聚合都付出排序成本。更好的做法是把处理层和输出层分开:处理层用适合更新的结构,输出层在边界处转成适合展示的结构。数据流越长,这种分层越重要。
测试要覆盖顺序和无序的边界。对于 unordered_map 阶段,测试不能假设遍历顺序;对于 map 报表阶段,测试必须确认输出顺序稳定。一个常见的测试错误是直接比较 unordered_map 遍历得到的字符串,导致测试在不同平台上随机失败。更稳的测试方式是对无序结果按 key 归一化后比较,对有序报表则直接比较完整输出。容器语义应该反映到测试写法里,测试才能真正保护设计。

回到起点:最初的那张容器地图
回头看第一章的那张容器分类地图,顺序容器、有序关联容器、无序关联容器,经过了九章的深入,同样的分类站在现在看含义不同了。顺序容器之间不只是在"能不能随机访问"上有差异,它们的差异根植于内存布局:连续、分段固定、节点链。有序关联容器不只是"按键排序",它们的树结构同时决定了复杂度、迭代器双向性和失效规则。无序关联容器不只是"用哈希查找",它们的哈希桶和负载因子管理体系决定了在什么条件下才能维持 O(1) 的承诺。
从地图到结构、从结构到选择、从选择到协作,这个认知路径的价值不在于记住了多少细节,而在于建立了一种习惯:面对任何数据集合和操作需求,先不从记忆中搜索"这个场景该用 X 容器",而是退回去看这个需求到底要求什么样的访问模式和稳定性,然后自然推出合适的结构。
库存程序只是一个小模型,但它对应的是很多真实系统的数据通路。日志系统会先用顺序容器收集原始事件,再用哈希表按用户或会话聚合,最后按时间或 ID 输出;编译器会先把 token 放进连续缓冲区,再用符号表做按名查找,最后按作用域或地址顺序生成报告;游戏服务器会用数组保存热路径实体,用哈希表按 ID 查找,用有序结构维护排行榜。容器不会孤立出现,孤立选择容器也很少能得到好设计。
当程序继续变复杂,下一步通常不是换一个"更高级"容器,而是把数据流边界画得更清楚。输入阶段关注吞吐,聚合阶段关注查找和更新,输出阶段关注顺序和格式,调试阶段关注可观察性。每个阶段的容器选择都应该服务于阶段目标。只要阶段目标明确,多个容器同时存在就不会显得杂乱;一个容器承担太多阶段目标,代码后来就会变难维护。

容器之后:算法登场
容器把数据按照合适的格局摆放好,迭代器把摆放后的结构呈交给算法,到目前为止本系列一直在讲"把数据放好"的前半部分。接下来该轮到"把算法表达好"的后半部分。
std::sort、std::find、std::copy、std::transform、std::accumulate、std::partition,这些算法在本系列中只是偶尔出现,但它们背后有一整套与容器同样精细的设计:算法的复杂度承诺、迭代器能力要求、自定义谓词和比较函数、函数对象和 lambda 如何嵌入算法调用、算法彼此之间的组合。理解了容器和迭代器,你已经拿到了理解算法的最核心前置知识,因为所有 STL 算法的接口都建立在迭代器范围之上,算法的能力边界就是迭代器分类带来的限制。
如果你跟完了整个系列,现在面对一段数据处理需求时,你大脑里应该有一个自动分层的流程:先定容器(数据怎么放),再定迭代器边界(从哪里开始,到哪里结束),最后选算法(怎么处理这个范围内的元素)。三个层次独立但无缝衔接,这就是 STL 设计中最漂亮的部分,也是你在日常 C++ 中最常使用的思维模式。
这套流程也能帮你阅读别人的代码。看到 std::vector,先看它是否被当作连续数组使用;看到 std::map,先看顺序或范围查询是否真的有价值;看到 std::unordered_map,先看是否依赖了遍历顺序;看到一个循环里边遍历边删除,马上检查迭代器推进方式。容器和迭代器一旦成为你读代码的第一层地图,很多 bug 会在运行前就露出形状。
接下来的算法系列会接住这个地图。算法不拥有数据,它们只接受范围;算法不关心容器名字,它们关心迭代器能力;算法的效率由容器结构和迭代器能力共同决定。std::sort 在 vector 上自然,在 list 上不可用;std::find 能跑遍所有范围,但在哈希表已经提供 find 时未必是好选择;std::copy 看似简单,却是容器之间传递数据最常见的桥梁。理解了容器和迭代器,算法就不再是一堆函数名,而是一套处理范围的语言。
把这套小程序继续推进到工程版本时,第一步通常是拆函数。load_records 负责输入,返回 vector<Record>;filter_records 负责清洗,可以原地修改也可以返回新容器;build_index 负责聚合,返回 unordered_map<string, Summary>;make_report 负责生成有序视图,返回 map<string, Summary> 或排序后的 vector。每个函数的返回类型都表达了下一阶段的访问模式,读代码的人不需要猜容器为什么存在。
第二步是补测试。过滤测试确认无效记录被删除且有效记录顺序保留;聚合测试确认同一 SKU 的数量累加、记录数正确、空输入能得到空索引;查询测试确认不存在的 SKU 不会通过 operator[] 被意外插入;报表测试确认输出按 SKU 有序。测试点和容器语义一一对应,能防止未来优化时破坏关键约束。比如有人把 map 报表换成直接遍历 unordered_map,顺序测试会立即失败。
第三步是测量。小数据下,map 直接聚合可能比 unordered_map 再导出更简单,性能差距也不明显;大数据下,哈希聚合的优势才会显现。真实项目里应当保留一组接近线上规模的样本,测聚合耗时、内存峰值、报表生成耗时和最慢请求路径。容器选择不是写完就封存的决定,数据规模和业务频率变了,测量结果也会变。
最后一步是控制接口泄露。对外提供库存查询服务时,外部不应该知道内部是否用 unordered_map;对外提供报表时,外部只关心结果有序,不应该依赖内部保存的是 map。容器是实现工具,业务接口才是长期承诺。把这个边界守住,后续无论是把聚合索引换成 flat_hash_map,还是把报表改成排序 vector,影响都能限制在模块内部。
这个小程序还可以作为你以后审查 STL 代码的模板。看到一段数据处理逻辑,先把数据流画出来:输入在哪里,过滤在哪里,聚合在哪里,查询在哪里,输出在哪里。每一段旁边写出当前容器和主要操作,再检查容器结构是否服务于这个操作。大多数容器误用的根源不是 API 生疏,而是数据流没有分层,导致一个容器被迫承担多个阶段的目标。
学完容器和迭代器之后,真正要带走的是一种工程反射:数据怎么放,范围怎么传,修改后位置是否还有效,输出顺序是否有承诺。这个反射形成后,STL 就不再是"标准库里一堆模板类",而是 C++ 里组织数据和表达处理流程的一套基础语言。
从这里继续往前,接下来就是算法。容器负责摆放,迭代器负责连接,算法负责处理。三者合在一起,才是 STL 的完整工作方式。

码字不易,欢迎大家点赞,关注,评论,谢谢!