Chapter 57: Container Selection Principles
容器选型的核心任务,是把业务访问模式翻译成 STL 容器能稳定承担的对象状态、内存布局、迭代器有效性、顺序语义和复杂度承诺。读完本章后,读者应能从一段业务代码中定位主操作,判断元素是否需要稳定地址,区分顺序需求和查找需求,追踪扩容、rehash、节点分配和视图生命周期带来的工程后果。
标准库把容器组织成 sequence container、associative container、unordered associative container、container adaptor 和 view 等类别。这个分类本身只能提供入口,真正的选择要落到工作负载:读多还是写多,按下标访问还是按键访问,末尾增长还是中间频繁修改,是否需要排序遍历,是否把 iterator、reference 或 view 交给外部对象长期持有。容器分类可对照 cppreference 的 containers overview,本文把这些接口现象整理成工程判断顺序。
本章贯穿一个遥测事件处理程序。它从网络收到一行文本,解析出事件,按接收顺序保留批量数据,按 id 查找最新事件,按时间戳做范围查询,把待处理事件排入队列,并为超时任务取最早 deadline。这个程序覆盖了序列容器、有序和无序关联容器、容器适配器、字符串和非拥有视图的主要边界。
一个初始版本可以写成下面的形状。代码只展示数据关系,省略解析细节和错误处理。
#include <cstdint>
#include <deque>
#include <map>
#include <queue>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
struct Event {
std::uint64_t id{};
std::uint64_t timestamp{};
std::string payload;
};
struct Deadline {
std::uint64_t timestamp{};
std::uint64_t id{};
};
struct EarlierDeadline {
bool operator()(const Deadline& lhs, const Deadline& rhs) const {
return lhs.timestamp > rhs.timestamp;
}
};
struct EventStore {
std::vector<Event> events;
std::unordered_map<std::uint64_t, std::size_t> latest_by_id;
std::map<std::uint64_t, std::size_t> by_timestamp;
std::deque<std::size_t> pending;
std::priority_queue<Deadline, std::vector<Deadline>, EarlierDeadline> deadlines;
};
这段代码已经暗含了几条选择:events 负责拥有对象并提供批量顺序访问;latest_by_id 保存索引而非保存指针,减少 std::vector 扩容后地址变化带来的风险;by_timestamp 保留排序关系;pending 表达先进先出的处理顺序;deadlines 只暴露最早 deadline。后续小节会不断回到这个例子,把每个容器的使用条件和失败边界拆开。
57.1 sequence container selection
Sequence container 的选型从元素排列方式开始。std::vector、std::deque、std::list 和 std::array 都能表达“一组同类型元素”,但它们为不同的访问模式付出成本:连续存储换来缓存友好和下标访问,分段存储换来两端增长,节点存储换来局部插入删除时的 iterator 稳定性,固定大小对象换来无动态容量变化的布局确定性。
在贯穿例子里,EventStore::events 使用 std::vector<Event>。原因来自主路径:事件被追加,之后常被整批扫描、序列化、压缩或传给算法。std::vector 把元素放在连续存储中,顺序扫描时 CPU 能利用相邻地址上的缓存行。这个选择的代价也清楚:扩容会把元素搬到新存储,原来指向元素的 pointer、reference 和 iterator 会失效。因此 latest_by_id 保存下标,后续读取时用 events[index] 重新定位。
下面的追加函数展示了 vector 选型需要同步维护哪些状态。关键点在于所有外部索引都以 std::size_t 表示,函数结束后才把索引写入其它结构。
void append_event(EventStore& store, Event event) {
const std::size_t index = store.events.size();
const std::uint64_t id = event.id;
const std::uint64_t timestamp = event.timestamp;
store.events.push_back(std::move(event));
store.latest_by_id[id] = index;
store.by_timestamp.emplace(timestamp, index);
store.pending.push_back(index);
store.deadlines.push(Deadline{timestamp + 5000, id});
}
这个函数的对象关系是:events 拥有 Event;其它容器保存索引或键;payload 的字符串由 Event 自己拥有。这样的关系让 events 扩容时只需要更新它自己的内部存储,其它容器里的整数索引仍然表示第几个事件。索引策略也有前提:元素只追加,且已经写入的元素位置保持稳定。如果后续要在 events 中间 erase,索引就会和元素位置错位,需要改用稳定 id、句柄表或节点式存储。
std::deque 适合两端增长且仍需要按下标访问的队列形数据。它的常见实现形状是控制数组加多个固定大小 block,元素整体上按逻辑顺序排列,物理上分布在多个 block 中。相比 vector,它在前端 push_front 和后端 push_back 上更均衡;相比 list,它仍支持随机访问。代价是连续扫描的局部性弱于 vector,且中间插入删除的 iterator 失效规则比节点容器更难维护。
在贯穿例子里,pending 只表达待处理事件的先后顺序。这里直接使用 std::deque<std::size_t>,因为它在队首 pop_front 和队尾 push_back 上匹配队列流动方式。若这个队列被封装成 std::queue<std::size_t>,调用方就只看到 push、pop、front,接口进一步收窄;本章 57.3 会处理这个 adaptor 边界。
std::list 适合节点地址或 iterator 稳定性压过局部性的场景。典型判断是:外部对象长期持有 iterator,且修改主要发生在已知位置附近的插入删除。list 的每个元素是独立节点,局部链接变化通常不搬移其它元素,所以未被删除的元素 iterator 可以保持有效。代价来自每个节点的额外指针、独立分配和缓存不连续。顺序扫描大量元素时,这些代价会进入真实运行时间。
因此,把 events 改成 std::list<Event> 需要一个明确证据:系统确实要把大量 iterator 或 reference 暴露给外部,并且中间删除频繁到足以抵消扫描和分配成本。若只是担心 vector 扩容失效,常见工程修正是保存索引、提前 reserve、分离对象池或使用稳定句柄,而非直接把主存储换成链表。
std::array<T, N> 表达固定数量、对象内存储和编译期大小。它没有动态扩容路径,N 是类型的一部分。适用场景包括固定维度坐标、协议头中固定字段、有限状态的计数桶,以及函数内部的小型固定缓冲。它和 vector 的选择边界在于容量是否属于对象类型的契约:如果容量来自配置、输入文件或运行时负载,array 会把运行时事实硬编码进类型;如果容量是协议和算法的固有规则,array 让对象布局更清晰。
序列容器的选择可以按同一组维度检查。
| 容器 | 存储形状 | 主访问模式 | 修改成本 | 稳定性边界 | 典型选择证据 |
|---|---|---|---|---|---|
std::vector<T> | 连续动态数组 | 顺序扫描、随机访问、尾部追加 | 扩容搬移元素,中间插入删除移动后续元素 | 扩容使 iterator、reference、pointer 失效 | 热路径批量扫描,元素位置可用索引表示 |
std::deque<T> | 分段连续 block | 两端增长、随机访问 | 两端操作较稳定,中间修改成本高 | 修改端点和中间的失效规则需要逐项检查 | 队列形流动,同时需要下标访问 |
std::list<T> | 双向节点链 | 已知位置附近插入删除 | 局部改链接,分配次数多 | 未删除节点的 iterator 通常稳定 | 外部长期持有 iterator,局部修改压过扫描成本 |
std::array<T, N> | 对象内固定数组 | 固定容量访问 | 无容量增长 | 对象移动仍会改变元素地址 | 容量是协议或算法的固定组成 |
这一节的结论是:sequence container 先看元素是否需要连续扫描,再看增长发生在末尾、两端还是中间,随后检查外部是否持有位置对象。vector 是批量拥有和扫描的默认强候选;deque 服务两端流动;list 服务稳定节点和局部改链;array 服务固定容量对象语义。
57.2 ordered vs unordered associative containers
关联容器的选型先区分“按键查找”与“按键排序”。std::map 和 std::set 这类 ordered associative container 维护按比较器定义的有序关系,常见实现是红黑树一类平衡二叉搜索树;std::unordered_map 和 std::unordered_set 维护哈希桶结构,依赖 hash 和 equality 把键定位到 bucket。两类容器都能查找键,但它们交付的语义不同。
在贯穿例子里,latest_by_id 使用 std::unordered_map<std::uint64_t, std::size_t>。业务问题是“给定 id 找最新事件的位置”,调用路径是单点查找,代码没有按 id 排序遍历、查找邻近 id、做区间查询的需求。哈希表在平均情况下能把查找压到接近常数成本;代价来自 bucket 数组、hash 计算、冲突链或等价结构,以及 rehash 对 iterator 的影响。
by_timestamp 使用 std::map<std::uint64_t, std::size_t>。业务问题换成“按 timestamp 找一段事件”。这里需要 lower_bound 定位第一个不小于起点的时间戳,再顺序走到结束边界。哈希表可以通过键精确查找某个 timestamp,却不能自然表达有序区间扫描。有序树的对数查找加有序遍历,正好对应时间窗口查询。
下面的代码展示两个索引分别承担的查询。
const Event* find_latest_by_id(const EventStore& store, std::uint64_t id) {
const auto it = store.latest_by_id.find(id);
if (it == store.latest_by_id.end()) {
return nullptr;
}
return &store.events[it->second];
}
std::vector<const Event*> events_in_time_range(
const EventStore& store,
std::uint64_t begin_ts,
std::uint64_t end_ts
) {
std::vector<const Event*> result;
for (auto it = store.by_timestamp.lower_bound(begin_ts);
it != store.by_timestamp.end() && it->first < end_ts;
++it) {
result.push_back(&store.events[it->second]);
}
return result;
}
这段代码把“查一个键”和“查一段顺序”分开。find_latest_by_id 的返回值来自 unordered_map 中保存的下标;events_in_time_range 的遍历顺序来自 map 的比较器。二者都没有把 Event* 长期存入索引结构,因此 events 扩容后仍然可以重新计算地址。
Ordered 容器的优势来自稳定顺序和节点语义。map、set、multimap、multiset 支持 lower_bound、upper_bound、equal_range,可以按照 comparator 做范围查询、排序输出、邻近查找和有序合并。节点式实现通常让插入和删除只影响被改动节点及树形链接,未删除元素的 reference 和 iterator 更容易保持稳定。它的成本是每个节点独立分配、指针跳转、比较次数随树高增长,缓存局部性弱于连续数组和很多哈希表实现。
Unordered 容器的优势来自精确键查找和插入。unordered_map、unordered_set、unordered_multimap、unordered_multiset 的平均查找成本依赖 hash 分布、bucket 数量和 load factor。它的关键边界是:迭代顺序不表达业务顺序;插入可能触发 rehash;rehash 会让 iterator 失效;哈希函数和 key_equal 必须一致地定义等价关系。对外暴露 unordered 容器的 iterator 时,调用方需要知道哪些操作会改变 bucket 结构。
哈希风险需要用输入模型判断。内部 id、递增整数、短生命周期进程内键通常适合默认 hash 起步;来自外部请求的字符串键、可被攻击者构造的大量冲突键、强实时接口和多租户服务,需要把 worst-case 查找成本和冲突防护纳入设计。此时可以选择有序容器,也可以使用更合适的哈希策略、限流、预分配和输入归一化。容器选择在这里连接到安全和延迟稳定性,而非单纯平均复杂度。
选择 map 还是 unordered_map 可以用下面的顺序:先问输出是否需要排序或范围查询;再问 key 是否有自然稳定的严格弱序;随后问输入是否有哈希退化风险;再检查 iterator、reference、node handle 是否跨调用保存;最后测量典型负载下比较成本、hash 成本、分配成本和缓存行为。若“按顺序访问”是接口承诺,ordered 容器优先;若“按键精确定位”是主路径,unordered 容器优先进入候选。
57.3 container adaptor selection
Container adaptor 的选型核心是接口约束。std::stack、std::queue 和 std::priority_queue 不是全新存储结构,它们把底层 sequence container 包起来,只暴露满足特定语义的一组操作。接口被收窄后,调用方更难绕开规则,也更难执行 adaptor 没有提供的查询、遍历和删除。
std::stack 表达后进先出。调用方只能操作顶端:push 放入新元素,top 读取栈顶,pop 移除栈顶。它适合括号匹配、深度优先搜索、撤销记录和手写状态机。若业务需要遍历中间元素、按条件删除旧元素或查看底部元素,stack 的接口就和需求错位,此时应回到 vector 或 deque 等底层容器。
std::queue 表达先进先出。贯穿例子中的 pending 可以进一步封装为 std::queue<std::size_t>,让处理线程只看到队尾入队和队首出队。这样做能把“待处理事件按接收顺序流动”写进类型表面。若业务还要取消任意位置的任务、按优先级重排或批量扫描待处理项,纯 queue 会把这些操作变成额外结构问题。
std::priority_queue 表达“每次取当前最高优先级元素”。它通常基于 heap 算法,底层默认使用 std::vector。它可以快速得到 top,也可以高效插入新优先级项;它不提供有序遍历,也不提供按 id 删除或降低优先级的直接接口。在贯穿例子中,deadlines 只需要取最早 timeout,因此 priority_queue 合适。
下面的超时处理展示了 priority_queue 的常见工程边界:过期 deadline 可能对应已经更新过的事件,需要在弹出时复查当前状态。
std::vector<std::uint64_t> collect_expired(EventStore& store, std::uint64_t now) {
std::vector<std::uint64_t> expired_ids;
while (!store.deadlines.empty() && store.deadlines.top().timestamp <= now) {
const Deadline deadline = store.deadlines.top();
store.deadlines.pop();
const auto id_it = store.latest_by_id.find(deadline.id);
if (id_it == store.latest_by_id.end()) {
continue;
}
const Event& latest = store.events[id_it->second];
if (latest.timestamp + 5000 == deadline.timestamp) {
expired_ids.push_back(deadline.id);
}
}
return expired_ids;
}
这段代码使用了 lazy validation。priority_queue 中可能残留旧 deadline,弹出时通过 latest_by_id 找到当前事件,再检查当前事件生成的 deadline 是否等于堆顶值。这样做把任意删除和更新转成弹出时验证,保持了堆接口的简单性。代价是堆中会临时保留无效条目,空间和弹出次数会增加。若无效条目比例过高,工程上应考虑 indexed heap、std::set 或专用调度结构。
std::set 经常被拿来和 priority_queue 比较。二者都能拿到最小或最大元素,但 set 维护全序,支持查找、删除任意键、范围遍历和稳定 iterator;priority_queue 维护堆序,只承诺顶端优先级。若需求是“持续插入任务,每次取最早 deadline”,priority_queue 结构更贴合;若需求是“按 deadline 排序,同时能取消任意任务、更新任务位置、遍历一段 deadline”,set 或 map 更贴合。
Adaptor 选择可以用一句工程规则收束:当受限接口完整覆盖业务动作时,用 adaptor 把语义写进类型;当业务需要越过顶端或端点访问内部元素时,直接选择能表达这些操作的底层容器或关联容器。接口越窄,越能保护不变量;接口越窄,越需要确认未来操作不会频繁绕行。
57.4 string、string_view、array 与 span
std::string、std::string_view、std::array 和 std::span 的共同问题是“数据由谁拥有,视图能活多久,是否要求连续内存”。它们经常出现在容器边界:解析文本时希望减少复制,函数参数希望接收一段连续数据,固定大小字段希望留在对象内部。选错这组类型,后果通常是悬垂 view、隐式分配、错误修改权限或容量假设被破坏。
std::string 是拥有型文本对象。它管理字符存储和长度,能修改内容,也能把数据传给需要 C 字符串接口的代码。具体实现可能有 small string optimization,短字符串在对象内部存放,长字符串分配堆内存;这是常见实现策略,不能作为跨实现的标准语义承诺。工程上应把 string 当作“拥有文本并负责生命周期”的类型,而把是否分配作为测量和优化项。
std::string_view 是 C++17 引入的非拥有只读字符视图。它通常保存字符指针和长度,不延长被观察字符串的生命周期。适合函数参数、解析器 token 和临时读取窗口。它的主要风险来自生命周期:view 指向的 std::string 销毁、修改导致缓冲重分配,或者 view 指向临时对象时,后续读取会进入悬垂状态。string_view 的语义可对照 cppreference 的 basic_string_view 页面。
贯穿例子里,解析一行网络输入时可以使用 std::string_view 指向 line 的片段,但最终写入 Event 时必须让 payload 拥有自己的字符串。下面是简化代码,parse_id、parse_timestamp 和 parse_payload_view 代表已经完成边界检查的解析函数。
Event parse_line(std::string_view line) {
const std::uint64_t id = parse_id(line);
const std::uint64_t timestamp = parse_timestamp(line);
const std::string_view payload_view = parse_payload_view(line);
return Event{
id,
timestamp,
std::string(payload_view)
};
}
这段代码把生命周期边界写得很清楚:line 是输入窗口,payload_view 只是解析过程中的观察结果,返回的 Event 拥有 payload。若把 std::string_view payload; 放进 Event,那么 Event 的生命周期就被外部输入缓冲约束。网络缓冲复用、局部 std::string 销毁或接收线程覆盖内容后,Event 会保存悬垂视图。
std::array<T, N> 和 std::span<T> 的关系类似于 string 和 string_view 的一部分,但对象范围扩展到任意连续元素。array 拥有固定数量元素,大小进入类型;span 是 C++20 的非拥有连续区间视图,保存起点和长度,适合函数参数和算法边界。span<T> 允许通过视图修改元素,span<const T> 只读。它的语义可对照 cppreference 的 span 页面。
下面的函数展示 span 作为参数如何统一接收 vector、array 和原始连续缓冲。示例按 C++20 书写。
std::uint64_t sum_timestamps(std::span<const Event> events) {
std::uint64_t sum = 0;
for (const Event& event : events) {
sum += event.timestamp;
}
return sum;
}
void example_span_input(const std::vector<Event>& batch) {
const std::uint64_t sum = sum_timestamps(batch);
(void)sum;
}
这里 sum_timestamps 不拥有事件,也不保存 span。它只在调用期间读取连续区间。这个参数形状比 const std::vector<Event>& 更宽,可以接收 vector、array、C 数组和其它连续存储;它也比 const Event* 加长度更明确,因为长度和起点被包装在同一个对象中。选择 span 的前提是输入确实连续,且函数不会把 view 存到调用之后。
string_view 和 span 的选择顺序可以统一成三问:调用方是否继续拥有底层数据;被调函数是否只在当前调用期间使用;底层存储在 view 使用期间是否保持地址和内容稳定。三个条件同时满足时,非拥有 view 能降低复制和接口耦合。任一条件缺失时,应改成拥有型 string、vector、array 或其它明确生命周期的对象。
57.5 工程选型案例
现在把贯穿例子收束成一次完整选型。需求是:服务每秒接收大量事件;多数事件只追加和批量扫描;接口按 id 查询最新事件;报表按时间窗口读取;处理线程按接收顺序消费;超时扫描只关心最早 deadline;解析阶段希望少复制,但存储层必须独立于输入缓冲。这个需求可以写成一张选择清单。
| 业务动作 | 主容器候选 | 选择依据 | 需要检查的边界 |
|---|---|---|---|
| 保存所有事件并批量扫描 | std::vector<Event> | 尾部追加、连续扫描、下标索引 | 扩容失效、erase 后索引错位、提前 reserve 的收益 |
| 按 id 找最新事件 | std::unordered_map<std::uint64_t, std::size_t> | 精确键查找为主,无排序输出 | rehash、哈希退化、保存索引的有效范围 |
| 按时间窗口遍历 | std::map<std::uint64_t, std::size_t> | lower_bound 和有序遍历表达区间 | 时间戳重复时改用 multimap 或复合键 |
| 待处理队列 | std::deque<std::size_t> 或 std::queue<std::size_t> | 队尾写入、队首读取 | 是否需要取消中间任务或批量扫描 |
| 超时任务 | std::priority_queue<Deadline> | 只取最早 deadline | 更新和删除需要 lazy validation 或其它结构 |
| 解析 token | std::string_view | 只在解析期间观察输入 | 返回对象必须拥有需要长期保存的文本 |
| 批量只读参数 | std::span<const Event> | 接收任意连续事件区间 | 函数不保存 view,输入期间地址稳定 |
这个表的价值在于每一行都把“容器名”绑定到“主操作”和“边界”。例如 latest_by_id 选择 unordered 容器的原因来自精确查找;若下一版产品要求按 id 顺序导出报表,这个原因会变化,std::map 或额外排序步骤会进入候选。再如 deadlines 使用 priority_queue 的原因来自只访问最早 deadline;若产品要求取消任意超时项,lazy validation 的无效条目比例就要被测量。
选型过程应先写出主操作频率,再写出位置对象是否跨调用保存。主操作频率包括追加、随机访问、范围查询、精确查找、端点弹出、中间删除和批量扫描。位置对象包括 iterator、reference、pointer、index、string_view、span 和业务 id。容器一旦把这些对象暴露出去,后续扩容、rehash、erase、对象移动和输入缓冲复用都会影响正确性。
第二步检查顺序语义。排序输出、范围查询、邻近查找和稳定遍历顺序会推动选择有序容器;精确键定位和插入吞吐会推动选择 unordered 容器;仅访问端点或 top 会推动选择 adaptor;按原始接收顺序保留数据会推动选择 sequence container。这里需要把“碰巧当前实现迭代出来是某个顺序”和“接口承诺这个顺序”分开,业务代码只能依赖后者。
第三步检查所有权和生命周期。拥有型容器和对象负责释放资源,非拥有 view 只保存观察窗口。string_view、span、iterator 和 reference 都要求底层对象活着,并且相关地址没有因扩容、rehash、erase 或字符串重分配而变化。跨线程、跨异步任务、跨缓存层保存这些对象时,要把生命周期写进类型和接口;常见做法是保存 id、index、拷贝后的字符串或拥有型对象。
第四步检查复杂度之外的成本。vector 的复杂度优势来自连续内存,但扩容和中间移动会集中释放成本;list 的局部修改看起来稳定,但节点分配和指针跳转会拉高扫描成本;unordered_map 的平均查找很低,但 hash、bucket、冲突和 rehash 会进入延迟尾部;map 的对数查找稳定,但每次比较和节点跳转都有成本。上一章的性能模型在这里转化为容器选择证据。
第五步用测量验证热路径。容器选择属于可复盘的工程判断,测量对象应贴近真实负载:元素数量、键分布、字符串长度、插入和查询比例、删除比例、是否跨线程、是否保留 view 或 iterator。微基准可以定位单个操作成本,端到端指标能看到分配器、缓存、锁和业务逻辑共同形成的延迟。测量结果应回写到选择理由中,方便下一次需求变化时复用。
可以把最终判断顺序压缩成下面的流程图。图只表达选择入口,具体容器仍要回到本章各节的边界检查。
流程图的关键路径是先分清拥有和观察,再分清主访问方式。许多选型错误都来自跳过前两步,直接在容器名称之间比较。名称本身不提供答案,业务动作、生命周期、顺序语义和性能证据共同决定答案。
本章最终建立的理解是:STL 容器选型是接口语义、对象生命周期和成本模型的联合判断。稳定做法是先固定主操作和所有权,再比较内存布局、iterator 与 reference 有效性、顺序需求、哈希风险和测量证据。这样得到的选择能随着需求变化被复盘,而不会停留在“某个容器通常更快”的经验口号。
最小自检任务
阅读下面的需求,给出容器选择,并说明每个选择的边界。
一个日志聚合器每秒接收大量 LogRecord。它需要保留最近 10 分钟记录用于批量扫描;按 trace_id 找到同一 trace 的最新记录;按 timestamp 导出某个时间窗口;处理线程按接收顺序消费记录;每条记录有一段 message,解析阶段可以引用输入缓冲,但存储后需要在后台线程继续使用。请为主存储、trace 索引、时间索引、待处理队列、解析 message 和后台只读批量参数选择合适的 STL 类型。
答案要点
主存储优先选择 std::vector<LogRecord> 或固定窗口上的分块结构;在只追加和批量扫描为主时,vector 的连续存储有利于扫描,边界是扩容和删除会影响 pointer、reference、iterator 以及直接保存的位置。如果窗口滚动需要频繁从前端淘汰,std::deque<LogRecord> 或环形缓冲会进入候选,判断依据是前端删除频率和扫描成本。
trace 索引优先选择 std::unordered_map<TraceId, std::size_t>,保存主存储中的下标或稳定句柄。它匹配精确键查找,边界是 rehash、哈希退化和下标在主存储删除后的有效范围。若输出要求按 trace_id 排序或做范围查询,std::map 才进入主候选。
时间索引优先选择 std::map<Timestamp, std::size_t>、std::multimap<Timestamp, std::size_t> 或以 (timestamp, sequence) 为键的 std::map。它匹配 lower_bound 加顺序遍历的窗口导出。时间戳重复时,需要 multi 容器或复合键保证多条记录都能进入索引。
待处理队列可以使用 std::queue<std::size_t>,底层通常用 std::deque,因为业务只需要队尾进入和队首消费。若还要取消任意位置任务或扫描队列内部,应选择能暴露对应操作的容器,并重新定义队列不变量。
解析 message 时可以使用 std::string_view 指向输入缓冲中的片段,但写入 LogRecord 后应使用 std::string 拥有消息内容。后台线程继续使用记录,说明存储层的生命周期长于输入缓冲,非拥有 view 会悬垂。
后台只读批量参数可以使用 std::span<const LogRecord>,前提是传入区间连续,函数只在调用期间读取并且不保存 view。若批量数据来自 deque 或链表,span 的连续性前提不成立,应改用 iterator range、ranges view 或先组织连续批次。
本章知识点总结
- 主操作先行:容器选择应从追加、扫描、查找、范围查询、端点弹出和中间删除这些业务动作开始。
- 连续存储优势:
std::vector适合尾部追加、批量扫描和随机访问,扩容时需要重新评估位置对象有效性。 - 两端流动模型:
std::deque适合队首和队尾都发生变化的场景,分段存储会改变扫描局部性和失效规则。 - 节点稳定边界:
std::list适合外部长期持有节点位置且局部插入删除频繁的场景,节点分配和指针跳转会增加运行成本。 - 固定容量语义:
std::array适合容量属于类型契约的对象,运行时容量应交给动态容器处理。 - 有序索引职责:
std::map和std::set适合排序输出、范围查询和邻近查找,成本来自节点分配、比较和树形跳转。 - 哈希索引职责:
std::unordered_map和std::unordered_set适合精确键查找,边界是 hash 质量、load factor、冲突和 rehash。 - 适配器语义:
std::stack、std::queue和std::priority_queue用受限接口表达 LIFO、FIFO 和 top-priority 语义。 - 堆的更新边界:
std::priority_queue适合只取最高优先级,任意删除和更新通常需要 lazy validation 或其它索引结构。 - 文本所有权:
std::string负责拥有文本,std::string_view只观察已有字符区间,长期存储时应回到拥有型对象。 - 连续视图参数:
std::span适合调用期间观察连续元素,函数保存 view 时必须重新设计生命周期。 - 索引替代地址:主存储可能扩容时,索引结构保存下标、id 或句柄通常比保存元素地址更容易维护。
- 顺序语义检查:业务依赖排序、范围和稳定遍历时,应选择明确承诺这些语义的容器。
- 测量闭环:最终选型应结合元素规模、访问比例、键分布、分配次数和缓存行为进行验证。