pusidun
← All posts

侯捷 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;

零需要单独处理,否则零乘二仍然无法放入第一个元素。八个位置变成十六个位置,则为后续插入留下余量。

讲者已提醒标准没有规定两倍,但随后把多个实现都概括成两倍增长。实际使用时只应把公式归于这份历史源码。程序不能依赖每次增长恰好乘二,也不应把单次增长的搬迁成本误认为每次尾插都一样昂贵。

为什么尾插之后还要复制“后半段”

申请新空间以后,课件把处理拆成三个步骤:

  1. 将旧区间中插入点之前的元素复制构造到新空间。
  2. 在新空间构造本次插入的元素。
  3. 将旧区间中插入点及其后面的元素复制构造到新空间。

对于“八个元素后面追加第九个”的起始案例,第三步面对的是空区间。于是又有疑问:尾插已经完成,为什么还写一段后半部复制?

课堂随即改变条件:如果要在第三个位置前面插入呢?这时原来的元素分成前后两段,新元素夹在中间,两段都必须保存。尾插只是这个通用插入过程的特殊情况。

这与前一个重复检查的问题连在一起:辅助函数同时支持多种入口,单看一个调用场景容易把必要逻辑误判为冗余。它也说明为什么不能只背“扩容就是复制全部元素”:真正的源码还要把新元素放到正确位置,并在未初始化空间里建立对象。

画面中使用的是 uninitialized_copyconstruct。前者处理的是尚未存在目标对象的空间,不能随意替换成向既有对象赋值的 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_traitsvalue_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_begininsert_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_backinsert_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_instancebegin/end 零长度与没有显式特殊成员各是什么意思?
P17 · 07:00–10:23 __array_traitstypedef int T[100] 如何沿类型别名找回数组成员?

这两讲把连续存储和固定边界说明白后,下一步可以追问:如果希望在两端增长,又不想每次都搬走全部元素,迭代器需要承担什么额外工作?这正是下一篇 deque 与容器适配器的问题。