侯捷 STL:vector 怎样增长,array 怎样保留固定边界
知道 vector 是动态数组之后,下一个问题是:数组满了,容器究竟要对旧元素做什么? P16 的价值在于沿着一次插入读源码,并解释两段乍看多余的逻辑。P17 再拿固定大小的 array 对照,让我们分清容器的存储、对象的生命周期和迭代器接口。
本文对应 P16《vector深度探索》和 P17《array、forward_list深度探索》。先修是指针运算、构造与析构、函数模板,以及 iterator_traits 如何统一迭代器的相关类型。文章依据两讲完整音轨的本地转录与关键代码画面核对,由 AI 辅助整理;独立代码和现代标准说明属于整理补充。系列位置见STL 课程目录,全课位置见侯捷 C++ 课程总导读。
来源档案
讲者:侯捷。课程标题页题为**《C++标准库:体系结构与内核分析》**,画面带 Boolan 博览标识。本次采用 B 站账号 DetachmentSy 上传的《侯捷 - STL标准库和泛型编程》,标识 BV1kBh5zrEYh,当前目录共 46 个分 P;本文对应上述 P16、P17,编号和时间以这个上传版本为准。
当前上传时间属于 2025 年,原课录制年份未核实,不能据此给课程定年。课堂比较标为 G2.9、G4.9 和 TR1 的历史实现;这些标签也不等于录制年份。来源与目录核对日期:2026-09-12。上传者与原讲者分别署名。
三根指针把“已有元素”和“可用空间”分开
课堂先画一块能容纳八个元素、实际只放六个元素的空间。在展示的旧实现里,start 指向起点,finish 指向已有元素之后,end_of_storage 指向整块分配空间之后。
于是,这里有两个不同的区间:
[start, finish)是已有元素;size()对应这一段的元素数。[finish, end_of_storage)是备用存储;它能容纳元素,但当前不属于有效元素区间。
begin() 返回前者的起点,end() 返回前者的尾后位置。即使备用空间很多,也不能解引用 end(),或仅凭 capacity() 足够就用下标写入尚未创建的元素。容量不是已经构造好的对象数量。
这也是 reserve(100) 和 resize(100) 的根本区别。前者安排容纳空间,后者把元素数量调整到 100;对类类型,后者还涉及创建相应对象。标准规定 reserve 不改变 size(),需要重新分配时会使元素引用、指针和迭代器失效。C++ 工作草案:vector 容量操作
课堂依据三根指针,在当时的 32 位环境算出容器对象本身为 12 字节。这个数字没有包括动态分配的元素空间,也不是可移植保证:库实现、分配器状态、调试模式和平台都可能影响对象布局。读这段的目标,是学会沿数据成员追踪存储职责,而不是记住一个 sizeof 答案。
第九个元素:从尾插追到一个通用辅助函数
当八个位置全部用完,再 push_back 第九个元素时,课堂实现走到 insert_aux(end(), x)。原缓冲区没有足够空间,辅助函数申请更大的缓冲区,再把内容搬过去。
在此之前,源码先提出一个小谜题:push_back 已经判断过是否存在备用空间,为什么 insert_aux 开头又检查同一件事?
只顺着尾插路径看,这像是重复工作。但辅助函数也供其他插入入口使用。另一个入口可能在仍有备用空间时调用它,所以辅助函数不能假定“每次调用都意味着容量耗尽”。这里的阅读方法很有迁移价值:看到一个分支似乎永远走不到,先检查函数是否还有别的调用者。
接着进入没有备用空间的分支。课件给出的增长公式是:
// 历史实现的结构片段,不是现代 std::vector 的指定增长规则。
const size_type old_size = size();
const size_type len = old_size != 0 ? 2 * old_size : 1;
零需要单独处理,否则零乘二仍然无法放入第一个元素。八个位置变成十六个位置,则为后续插入留下余量。
讲者已提醒标准没有规定两倍,但随后把多个实现都概括成两倍增长。实际使用时只应把公式归于这份历史源码。程序不能依赖每次增长恰好乘二,也不应把单次增长的搬迁成本误认为每次尾插都一样昂贵。
为什么尾插之后还要复制“后半段”
申请新空间以后,课件把处理拆成三个步骤:
- 将旧区间中插入点之前的元素复制构造到新空间。
- 在新空间构造本次插入的元素。
- 将旧区间中插入点及其后面的元素复制构造到新空间。
对于“八个元素后面追加第九个”的起始案例,第三步面对的是空区间。于是又有疑问:尾插已经完成,为什么还写一段后半部复制?
课堂随即改变条件:如果要在第三个位置前面插入呢?这时原来的元素分成前后两段,新元素夹在中间,两段都必须保存。尾插只是这个通用插入过程的特殊情况。
这与前一个重复检查的问题连在一起:辅助函数同时支持多种入口,单看一个调用场景容易把必要逻辑误判为冗余。它也说明为什么不能只背“扩容就是复制全部元素”:真正的源码还要把新元素放到正确位置,并在未初始化空间里建立对象。
画面中使用的是 uninitialized_copy 和 construct。前者处理的是尚未存在目标对象的空间,不能随意替换成向既有对象赋值的 copy。而有备用空间的中间插入路径,还会涉及对既有元素的移动或赋值;存储是否已分配,与对象是否已经存在,是两层判断。
成功才替换旧空间,失败要清理半成品
P16 的代码画面不仅有复制,还展示了 try/catch:新空间构造途中失败时,销毁已经构造的部分、释放新空间,然后继续抛出异常。成功后,才销毁旧位置的元素、释放旧缓冲区,最后更新三根指针。
这解释了为什么“申请不到更大空间,容器的生命就结束了”不能照字面写成一般规则。异常不等于容器对象被销毁;操作失败后是否保持原值,要看具体操作及元素类型的异常条件。
现代 C++ 还引入移动构造。搬迁某个元素可以调用移动构造,也可能为满足异常保证采用复制;不能把旧课的复制路径直接当成今日所有元素类型的执行过程。对于不能复制且移动构造可能抛出的类型,部分强保证有例外,需查具体接口要求。C++ 工作草案:reserve 的异常条件
这个主题和设计模式中的协作与生命周期约定相接:容器管理自己的元素对象,不会自动修复元素内部不清楚的所有权。例如,vector<T*> 销毁的是指针值,是否销毁指针指向的对象仍由你的设计决定。
存储连续,不要求迭代器的类型就是裸指针
旧版源码把 vector<T>::iterator 定义成 T*。这很直观:连续存储让指针加减自然对应元素位置变化。算法通过 iterator_traits<T*> 的偏特化获取元素类型、距离类型及随机访问类别。
G4.9 画面则沿多层类型别名追到 __normal_iterator。它是一个类,内部 _M_current 最终保存相应指针;算法此时通过 iterator_traits 主模板读取这个类定义的相关类型。
两种实现说明,算法依赖的是迭代器契约,不是某个容器恰好用了哪个类型名。讲者批评新版源码难读,并在结尾保留了实现可能存在扩展目的的余地。整理时可以接受“追踪类型链更费力”这个观察,却不能据此断言包装与内部层次没有价值。
课件还有一处值得改正:指向 const T 的指针,其 iterator_traits 的 value_type 应描述去掉顶层 cv 限定的元素类型,而不是照旧课件写成 const T;不可修改性由解引用得到的引用类型表达。当前标准对指针偏特化明确使用 remove_cv_t<T>。C++ 工作草案:iterator_traits
这些结论讨论普通 vector<T>;vector<bool> 的专门化不适合用“一格就是一个普通 T 对象”的图直接解释。
array 固定大小,但仍有正常的对象生命周期
P17 将 array<int, 10> 与动态容器对照:元素类型和数量都写进类型,数量不会在运行时增长。课件从 TR1 的直接数组成员,追到 G4.9 的 __array_traits 类型别名,最后仍回到固定大小的数组存储。
其中的数组声明对照非常具体:
// 语法对照片段。
int a[100]; // 合法
// int[100] b; // 非法声明语法
typedef int T[100];
T c; // 合法:T 已经是数组类型
新版源码里“某个类型别名后面跟一个成员名”,看起来不像直接声明数组,但展开类型后可以得到同一种对象。读模板源码时,先追类型,再判断变量究竟存了什么,比只盯着一行长名称有效。
这里还需要修正三种容易从口述带走的印象。
第一,原生数组并没有被排除在 STL 算法之外。它的指针本来就能充当迭代器;std::sort(std::begin(raw), std::end(raw)) 可以排序原生数组。std::array 提供了统一容器接口、保存固定数量的类类型和值语义等便利,不能把这些便利解释成使用算法的必要门票。
第二,TR1 代码中的 N ? N : 1 是内部存储选择,不是把 array<T, 0> 的逻辑大小变成 1。std::array<T, N> 的 size() == N 是不变量;零长度特例的 begin() 与 end() 相等,不能访问任何元素。array 概述、零长度 array
第三,“没有 ctor、没有 dtor”应理解为课件没有显式定义这些特殊成员。它不意味着类类型元素无需构造或析构。一个包含三个资源对象的 std::array 仍然会管理这三个对象的生命周期;固定大小也不等于“只能放在栈上”,其存储期取决于包含它的对象和创建方式。
P17 末尾的 forward_list 只展示一张单向链表图,讲者明确略去展开,建议与前面的双向 list 比较。因此这一讲能支持的结论是:节点只沿一个方向连接,迭代器也不能像双向链表一样后退。before_begin、insert_after 等接口值得另做练习,但不是这段课实际完成的深度讲解。
一个同时检验四种边界的实验
下面是整理者编写的完整 C++20 程序,已用 Clang 编译运行,通过全部断言。它不测试增长倍率或固定布局,而是验证原生数组算法、零长度数组、元素生命周期和备用容量内的尾插。
#include <algorithm>
#include <array>
#include <cassert>
#include <iterator>
#include <vector>
struct Element {
inline static int alive = 0;
Element() { ++alive; }
~Element() { --alive; }
};
int main() {
int raw[] = {3, 1, 2};
std::sort(std::begin(raw), std::end(raw));
assert(raw[0] == 1 && raw[2] == 3);
std::array<int, 0> empty{};
assert(empty.size() == 0 && empty.begin() == empty.end());
{
std::array<Element, 3> values{};
assert(Element::alive == 3);
}
assert(Element::alive == 0);
std::vector<int> values{1, 2, 3};
values.reserve(8);
assert(values.size() == 3 && values.capacity() >= 8);
const int* first = &values[0];
values.push_back(4);
assert(first == &values[0]);
values.insert(values.begin() + 1, 9);
assert((values == std::vector<int>{1, 9, 2, 3, 4}));
}
实验里的 Element 只用于构造与析构计数,不参与复制移动测试。若扩展成搬迁实验,就要同时定义对应操作并维护计数,否则结果会被测试类型自身的缺陷误导。
可以进一步验证这些问题:
- 将
reserve(8)改为resize(8),先预测size()和后续序列,再修改断言核对。不要用越界访问来“试出”容量和大小的区别。 - 用纸面数组演算一次在第三个位置插入,分别标出旧前段、新元素、旧后段;再和
insert得到的结果逐项比较。 - 给测试类型增加复制、移动与析构日志,比较容量足够和触发重新分配的尾插。记录编译器及标准库版本;观察到的次数不能自动升级成跨实现保证。
重点回看
| 讲次与时间 | 画面或讨论线索 | 回看的问题 |
|---|---|---|
| P16 · 00:49–03:41 | 三指针、容量 8、第 9 个元素 | 有效元素和备用存储如何分开? |
| P16 · 08:44–10:59 | push_back → insert_aux |
为什么辅助函数还检查一次空间? |
| P16 · 12:30–14:59 | 两次 uninitialized_copy 夹着 construct |
尾插案例怎样推广到中间插入? |
| P16 · 25:19–29:59 | G4.9、__normal_iterator、_M_current |
包装迭代器怎样通过 traits 向算法提供类型? |
| P17 · 01:27–05:40 | TR1、_M_instance、begin/end |
零长度与没有显式特殊成员各是什么意思? |
| P17 · 07:00–10:23 | __array_traits、typedef int T[100] |
如何沿类型别名找回数组成员? |
这两讲把连续存储和固定边界说明白后,下一步可以追问:如果希望在两端增长,又不想每次都搬走全部元素,迭代器需要承担什么额外工作?这正是下一篇 deque 与容器适配器的问题。