一条日志进入处理系统后,可能先放进接收缓冲区,再进入待处理队列;解析失败时要删除,处理成功后要留下撤销记录。看起来都是“保存一批数据”,操作方式却不同:有的只在末尾追加,有的从两端进出,有的要求中间元素的位置长期稳定,还有的长度从程序启动前就不会改变。
C++ 的顺序容器正是为这些差异准备的。选择容器时,先描述最频繁的访问和修改,再决定存储结构。本篇把 array、vector、deque、list、forward_list 放到同一套接口中比较,并重点处理结构修改后的迭代器安全问题。
后面的示例都以 C++17 为基准。复杂度是判断方向,不是替代测量的承诺;元素大小、缓存命中、分配次数和实际数据量都会改变最终表现。
容器选择不是类型名称的联想题。业务上叫“任务列表”的数据,常常只需要顺序扫描和末尾追加,vector 仍可能是最合适的类型。真正有用的问题是:数据是否定长、是否需要下标、修改发生在什么位置、是否长期保存元素位置。
没有明显限制时,先用 vector。它的连续存储让处理器更容易成批读取相邻元素,尾部追加的摊还成本也低。只有需求明确指向两端操作、稳定节点或固定长度时,再换到其他容器。

假设要在 list 的第 50,000 个位置插入元素。如果手里已经有指向该位置的迭代器,插入本身是常数时间;如果只有数字 50,000,程序仍要从头走过大量节点。vector 虽然要移动后续元素,但连续内存上的批量移动可能很快。
因此,“链表中间插入是常数时间”只描述已有位置之后的结构操作,不包括定位,也不包括节点分配和缓存成本。性能敏感时,应使用真实元素类型和真实数据规模测量。
vector<Event>。queue<Task> 表达流程,而不是开放整个底层容器。stack<Action>。array<int, 24>。list<Line>。string。同一个程序同时使用多种容器很正常。类型应该表达局部数据的操作约束,不必追求全项目统一成一种容器。
顺序容器的实现差异很大,但它们尽量使用相同的名字表达相同操作。掌握这套共同语言后,阅读一个新容器时,先找 value_type、begin()、end()、empty() 和 size(),就能迅速建立基本认识。
泛型代码不应猜测 size() 一定返回 unsigned long,也不应假设 iterator 就是裸指针。实现可以使用类类型迭代器,只要它提供该类别承诺的操作。
以 std::vector<int> 为例,常见构造形式如下:
std::vector<int> empty;
std::vector<int> five_zeros(5);
std::vector<int> four_sevens(4, 7);
std::vector<int> values{4, 7, 9};
std::vector<int> copy(values圆括号数量构造和花括号元素列表不能混淆:
std::vector<int> a(3); // 三个元素:0, 0, 0
std::vector<int> b{3}; // 一个元素:3范围构造的两个迭代器必须描述同一个有效序列,并且终点能从起点按该迭代器的前进规则到达。把两个不同容器的迭代器拼成一对,不会形成合法范围。
多数容器提供这些共同操作:
if (events.empty()) {
// 没有元素
}
auto count = events.size();
auto first = events.begin();
auto last = events.end();
events.clear();forward_list 是重要例外:它不提供 size()。如果每次询问长度都要遍历整条单向链,就很容易把一个看似便宜的调用写进循环。需要频繁知道数量时,可以单独维护计数,或改选别的容器。
复制容器会复制元素值,副本和被复制对象随后可以独立修改。移动容器通常转移内部资源;被移动对象仍然可以析构、赋值或重新使用,但不要依赖它保留哪些元素。
swap 交换两个同类型容器的内容,通常比逐元素复制更直接:
std::vector<int> ready{1, 2};
std::vector<int> pending{8, 9, 10};
ready.swap(pending);调用后,ready 保存 8, 9, 10,pending 保存 1, 2。
array 参与大部分统一遍历和访问接口,但长度属于类型的一部分;forward_list 为单向结构删掉了一些无法高效提供的接口。统一接口减少记忆负担,不代表每个容器拥有完全相同的成员。
一对迭代器可以描述完整容器,也可以描述其中一段。C++ 使用半开范围 [first, last):first 指向范围内第一个元素,last 指向最后一个元素之后的位置。
半开范围让三个边界情况使用同一套规则:
first == last。first。++first,最终恰好到达 last。典型遍历骨架是:
while (first != last) {
use(*first);
++first;
}end() 不是最后一个元素。无论容器是否为空,都不能解引用 end();也不能先对空容器的 end() 做减一,再期待得到有效元素。
下面的函数只要求迭代器能解引用、前进和比较,因此既能打印 vector 的子范围,也能打印整个 list。
#include <iostream>
#include <list>
#include <vector>
template <class Iterator>
void print_range(Iterator first, Iterator last) {
if (first == last) {
std::cout << "<empty>\n";
return;
}
std::cout << *first;
++
输出:
35 18 42
1 2 1cbegin() 和 cend() 明确返回只读迭代器。函数仍然可以复制和移动迭代器本身,但不能通过它修改元素。
所以 list.begin() + 3 不能通过编译。若代码只需要“向前移动 n 步”,可以使用 std::advance(it, n);对链表它逐节点前进,对随机访问迭代器则可以直接跳转。
不同容器的迭代器不能拿来比较。即使两个容器元素相同,a.begin() == b.begin() 也没有“位置相同”的语义。迭代器的位置只在它所属的序列中有意义。

begin()、end() 与 [first, last) 提供共同遍历语言;额外操作必须服从迭代器类别。范围只描述位置,不延长容器寿命。容器已经销毁,或结构修改使迭代器失效后,先前保存的 [first, last) 也随之失效。
除固定长度的 array 外,顺序容器通常能改变元素数量。接口名称相似,但支持的端点与位置取决于存储结构。
insert(pos, value) 把新元素放在 pos 之前,并返回新元素的迭代器。插入多个元素时,返回值指向第一个新元素。使用返回值很重要,因为修改可能让旧迭代器失效。
如果手里已有一个完整对象,push_back(object) 很自然。如果手里是构造参数,emplace_back(args...) 可以直接在目标位置调用元素构造函数。
#include <iostream>
#include <string>
#include <utility>
#include <vector>
struct Event {
Event(std::string event_id, int event_priority)
: id(std::move(event_id)), priority(event_priority) {}
std::string id;
int priority;
};
int main() {
输出:
inserted=E-200
E-100:2
E-200:3
E-300:1这里保存的是 emplace 返回的新迭代器,没有继续使用调用前的位置。reserve(3) 只减少这段示例的重新分配次数;中间插入仍会移动插入点之后的元素。
emplace 不是无条件的性能开关。若传入的本来就是 Event 对象,容器仍要复制或移动它;在 vector 中间原地构造,也无法消除后续元素为腾位置而发生的移动。
forward_list 的节点只知道下一个节点。删除当前节点时,需要修改前一个节点保存的链接,因此它提供“前驱位置”接口:
std::forward_list<int> jobs{20, 30};
auto before_first = jobs.before_begin();
jobs.insert_after(before_first, 10); // 10, 20, 30before_begin() 是首元素之前的特殊位置,不能解引用。它让“在开头之前插入”也能使用 insert_after 这套统一规则。
不要默认把同一容器中的一段范围直接插回自身。迭代器可能在操作过程中失效,某些重载还明确要求输入范围不来自正在修改的容器。需要复制片段时,先放到临时容器;链表节点整体转移则应查看链表专用接口。
访问接口常常返回引用,删除接口只改变容器。把这两件事分开理解,可以避免空容器访问和“以为 pop 会返回值”两类错误。
list 不提供下标,因为第 n 个节点只能从端点逐步走到。若确实要移动若干步,迭代器和 std::advance 会把这项成本写得更明显。
来自输入或配置的下标应先检查,或直接使用 at():
if (index < events.size()) {
events[index].mark_ready();
}front()、back() 和适配器的 top() 都不替你处理空序列。先调用 empty(),再访问。
pop_back()、pop_front()、stack::pop() 和 queue::pop() 的返回类型都是 void。需要取值时分两步:
if (!tasks.empty()) {
Task task = std::move(tasks.front());
tasks.pop();
run(task);
}先读取再删除,让“取得元素”和“修改容器”各自拥有清楚的异常与生命周期边界。
删除循环最容易写错。正确形式是在删除分支接住 erase 返回值,只在保留分支递增:
#include <iostream>
#include <string>
#include <vector>
struct Event {
std::string id;
bool valid;
};
int main() {
std::vector<Event> events{
{"E-10", true},
{"E-11", false},
{"E-12", false},
{
输出:
E-10
E-13若先调用 events.erase(it),再执行 ++it,第二步使用的已经是失效迭代器。连续两个无效元素还可能导致跳过检查或直接触发未定义行为。
范围删除同样返回删除区间之后的位置:
auto next = events.erase(first_bad, last_bad);clear() 相当于删除全部元素。对 vector 而言,它把 size() 变为 0,但通常不会把 capacity() 也变为 0。

insert、emplace、erase 等操作返回的新位置。迭代器、元素引用和元素指针都依赖容器内部位置。结构修改后,底层元素可能搬家,也可能在原位置左右移动。此时旧句柄不只是“值可能不准”,而是不能再解引用、比较或递增。
deque 的端点删除还有更细的边界规则。工程代码若要跨结构修改长期保存它的迭代器,往往比重新获取位置更难审查。一个稳妥约定是:deque 结构变化后重取迭代器,只在确实需要时依赖引用稳定性。
下面的旧位置在插入后不能再用:
auto old = labels.begin() + 1;
labels.insert(labels.begin(), "receive");
// old 可能已经失效;不要解引用,也不要和 end() 比较。程序偶尔打印出“看起来正确”的值,并不能证明操作安全。未定义行为可能在另一次构建、另一组数据或完全无关的修改后才显现。
若逻辑位置可以用下标表达,在修改前保存下标,修改后重新取得迭代器:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> labels{"read", "parse", "store"};
const auto old_position = labels.cbegin() + 1;
auto index = static_cast<std::vector<std::
输出:
parse这段代码从不使用插入前的迭代器。若修改操作本身返回新位置,优先接住返回值,通常比手工调整下标更简单。
已知最多追加 10,000 个元素时,先 reserve(10000) 可以减少扩容,并在这段只做尾部追加的流程中降低全部句柄失效的机会。但是:
reserve 自己一旦触发重新分配,会使全部元素句柄失效。end() 在 push_back 后也失效。insert 或 erase 仍会让受影响位置及其后的句柄失效。如果需求真正要求中间增删时其他元素的位置长期稳定,节点容器比“到处提前 reserve”更能直接表达约束。

reserve、resize 和结构修改改变的边界不同;一旦句柄失效,就通过索引或修改操作的返回值重新定位。失效迭代器不能用来做诊断比较。发现某次修改可能失效后,立即丢弃旧迭代器、引用和指针,再从容器或修改操作的返回值取得新位置。
vector 和 string 把“已有元素”和“为未来增长准备的存储”分开管理。混淆两者会写出最常见的容量错误:只调用 reserve,随后就用下标写入并不存在的元素。
对一个 vector,可以把内部状态想成两条边界:
已构造元素: [0, size())
已分配空间: [0, capacity())只有第一段是可以访问的元素。[size(), capacity()) 只是备用存储,其中还没有可通过容器接口访问的对象。
capacity() 和 reserve() 主要属于 vector 与 string 的连续动态存储模型。deque 自己管理分段存储,不公开同样的容量数值。
不同实现采用的增长倍率可以不同,因此不要把某次运行打印出的具体 capacity() 当成固定答案。下面只检查“容量至少足够”和“元素数量如何变化”。
#include <iostream>
#include <vector>
int main() {
std::vector<int> values;
values.reserve(5);
std::cout << std::boolalpha;
std::cout << "after reserve: size=" << values.size()
<< ", can_hold_5=" << (values.capacity() >= 5U) << '
输出:
after reserve: size=0, can_hold_5=true
after grow: size=3, values=7 7 7
after shrink: size=1, first=7values.reserve(5) 后仍为空,所以此时写 values[0] = 7 是越界访问。resize(3, 7) 才创建三个值为 7 的元素。
实现通常按几何方式扩大容量,使连续 push_back 的平均成本保持较低,但标准没有承诺“每次翻倍”。代码只能依赖以下事实:
capacity() >= size()。reserve(n) 成功后容量至少为 n。resize 改的是元素数量。shrink_to_fit() 只是请求实现尝试收缩容量,不保证一定发生。它适合经过测量后的内存调整,不适合作为正确性条件。
string 既遵循字符序列接口,又增加了文本操作。容器适配器则走向另一个方向:它们故意隐藏大部分容器能力,只保留某种流程需要的操作。
常用文本修改可以直接按位置表达:
find 返回的是下标类型,不是迭代器。检查失败要与 std::string::npos 比较:
const auto pos = message.find("error");
if (pos != std::string::npos) {
message.replace(pos, 5, "warning");
}字符串也有 size()、capacity() 和 reserve()。结构修改可能让先前取得的字符指针、引用和迭代器失效;修改后应重新获取。c_str() 和 data() 得到的指针尤其不能跨任意字符串修改长期保存。
适配器不提供普通迭代器,也不允许在中间随意插入。这个限制能阻止调用方破坏队列或栈的顺序语义。pop() 仍然只删除;应先用 front() 或 top() 读取。
下面的程序用 string 清理任务文本,用 queue 保证接收顺序,用 stack 保存最近完成的任务。
#include <iostream>
#include <queue>
#include <stack>
#include <string>
#include <utility>
std::string normalize(std::string task) {
const auto temporary = task.find("[tmp] ");
if (temporary != std::string::npos) {
task.erase(temporary, 6);
输出:
run: parse logs
run: urgent restart worker
undo: urgent restart workerqueue 让先接收的任务先运行,stack 让最后完成的任务先撤销。若改成“紧急任务优先”,可以为任务定义比较规则并使用 priority_queue,而不是每次手工扫描普通序列。
[begin, end) 描述遍历边界,不解引用尾后位置。erase 返回值。size 与 capacity,不把备用存储当元素。这套检查能覆盖顺序容器中最常见的设计错误。处理逻辑需要排序、查找、计数或变换时,再把有效范围交给通用算法,而不用重写容器内部操作。