从 Vector 理解对象生命周期与异常安全

阅读 stl1weekend 的 Vector.hpp 时,我记录了五个问题:迭代器区间为何左闭右开,分配和构造如何分开,扩容与插入为何采用不同的移动方式,拷贝时为何使用 as_const,以及分配器负责什么。本文分析的是读取时 main 分支上的教学实现,不把它等同于标准库 std::vector。

first 和 last 的左闭右开区间

区间构造函数接收 first 和 last,使用 last - first 计算元素数,再依次读取 *first:

// 摘自源码,简化了名称和分配器参数
std::size_t n = last - first;
for (std::size_t i = 0; i != n; ++i) {
    std::construct_at(&data[i], *first);
    ++first;
}

因此它处理的是 [first, last):包含首元素,last 指向末尾之后,不被解引用。first == last 表示空区间;相邻的 [first, middle) 和 [middle, last) 可拼成 [first, last)。这里用减法求长度,所以这份实现要求随机访问迭代器,且传入的迭代器必须构成有效区间。区间 assign、insert 也采用这一约定。

内存分配与对象生命周期

Vector 要区分存储空间和存活的元素:capacity 描述可容纳多少元素,size 描述当前已有多少元素。空间已经分配,并不意味着每个位置上的元素都已构造。

已构造元素                         尚未构造元素的空间
[ 0 ][ 1 ][ 2 ] | [   ][   ][   ][   ][   ]
                  size = 3                  capacity = 8

普通的 new T(args) 通常在一个表达式里完成两件事:取得存储空间,然后在其中初始化 T;delete p 则负责销毁对象并释放相应空间。容器需要更细的控制:先取得可容纳多个元素的空间,再根据 size 逐个构造和销毁元素,因此要把这两组操作拆开。

allocator:先取得空间

std::allocator<T> 是标准库的默认分配器。allocate(n) 的参数是元素个数,不是字节数;它取得大小和对齐适合存放 n 个 T 的存储,返回 T*。这一步不调用每个 T 的构造函数;虽然 C++20 对底层数组对象的生命周期有专门规则,这里需要关注的是各元素尚未因此被初始化,不能直接读取为已构造的值。

std::allocator<std::string> alloc;
std::string* p = alloc.allocate(3); // 为 3 个 string 预留空间
// 此时尚未构造那 3 个 string,不能读取 p[0]。

p 是指向该存储中位置的指针,有指针不代表那里已经有可正常使用的 std::string。分配可能因资源不足抛出异常。用完空间时,要调用对应的 alloc.deallocate(p, 3);它归还存储,不负责替代逐个销毁已构造的元素。deallocate 必须使用相容的分配器及符合要求的指针和数量;本例使用原分配器、原指针和原数量。

源码以 _Alloc = std::allocator<_Tp> 作为默认模板参数,保存 _M_alloc,在构造函数和 reserve 中调用 _M_alloc.allocate(n)。这样 capacity 可以大于 size,无需为尚未使用的空间构造一批 T。

为什么还要有分配器这个抽象,而不直接把分配方式写死在每个容器里?工作草案的分配器要求将 allocator 描述为封装分配模型的对象,包括指针类型、大小类型及分配和释放操作。容器可以按统一接口管理存储,使用者则可以选择适用的分配模型,例如让多个容器从特定内存池取得空间。std::allocator 是默认选择,分配器这个接口本身并不等同于内存池。

std::allocator_traits 给不同分配器提供统一入口:依照草案,traits::construct 在分配器提供可用的 construct 时调用它,否则调用 construct_at;traits::destroy 同理回退到 destroy_at。这让容器能够使用分配器提供的定制,同时让简单分配器不必自行实现所有成员。本文的教学 Vector 直接调用 construct_at,因此不会经过这些分配器定制。

placement new:在指定位置构造

placement new 的写法是 ::new (地址) T(构造参数)。标准的这个形式使用调用者提供的地址,而不是另行申请一块存储;new 表达式仍负责在该位置初始化 T。调用者必须提供大小足够、对齐正确、适合放置该对象的存储。

它解决的是“我已经有一块空间,如何在其中指定位置创建对象”的问题:例如 Vector 已预留多个位置,新增元素时只构造下一个位置。工作草案的非分配形式规定,相应的 operator new(size_t, void*) 只返回传入的地址;随后的对象初始化属于 new 表达式。这里讨论的是标准非分配形式,其他自定义 placement new 可以有不同的分配行为。

#include <memory>
#include <new>
#include <string>
 
std::allocator<std::string> alloc;
std::string* p = alloc.allocate(1);
std::string* s = ::new (static_cast<void*>(p)) std::string("hello");
// *s 此时是一个已经构造好的 string,值为 "hello"。
s->~basic_string();       // 显式销毁对象
alloc.deallocate(p, 1);  // 再归还原存储

这里 ::new 指定全局形式,static_cast<void*>(p) 使参数匹配标准的非分配 placement new;返回值 s 指向新构造的对象。显式调用析构函数不会释放存储。不要对这块由 allocator::allocate 取得的空间直接使用 delete s;它应由分配器归还。上例为了说明顺序省略了异常处理:如果字符串构造抛异常,必须仍然调用 deallocate,但不能对未成功构造的字符串调用析构函数。

construct_at 和 destroy_at:构造与销毁工具

C++20 的 std::construct_at(p, args...) 在 p 指定的位置构造对象并返回指向它的指针;对于这里的非数组对象,它的通常效果类似 ::new (static_cast<void*>(p)) T(args...)。std::destroy_at(p) 则销毁 p 所指的对象,对非数组类类型可理解为调用析构函数;它从 C++17 起可用。正确名称是 destroy_at。

construct_at 不只是给 placement new 换了个名字。P0784R7《More constexpr containers》希望让 vector 这样的变长容器可用于常量求值,例如在编译期解析 JSON 时不必只依赖固定大小的结构。C++20 的方案允许符合条件的临时动态分配,并提供 construct_at 这样的工具,供常量求值中的容器在已分配空间构造元素;分配的空间必须在该次常量求值结束前释放。提案中允许分配结果跨求值保留的旧方案最终被移除,不能把它当作 C++20 的规则。

后续提案 P2747R2解释了当时的限制:普通 placement new 在 C++20 的常量求值中不能直接完成同样的工作,construct_at 因此受到特殊支持。但它用参数转发构造,并不能表达 placement new 的所有初始化形式,例如 new (p) T{a, b} 的列表初始化。该提案讨论进一步支持常量求值中的 placement new;它是后续演进,不影响本文作为 C++20 示例使用 construct_at。

destroy_at 与构造形成配套:泛型代码只需给出指针,就能按元素类型销毁对象,之后仍可复用或归还存储。工作草案的 destroy 算法也是对区间逐个调用 destroy_at;P0784R7 又为相关销毁操作增加了常量求值支持。

下面是可独立编译的 C++20 示例,展示从空存储到构造、销毁、归还的完整路径,也处理了构造失败:

#include <iostream>
#include <memory>
#include <string>
 
int main() {
    std::allocator<std::string> alloc;
    std::string* p = alloc.allocate(2);
    std::string* first;
    try {
        first = std::construct_at(p, "hello");
    } catch (...) {
        alloc.deallocate(p, 2); // 第一个构造失败,没有存活的元素
        throw;
    }
 
    try {
        std::construct_at(p + 1, 3, '!'); // 构造字符串 "!!!"
    } catch (...) {
        std::destroy_at(first); // 第一个已经构造成功
        alloc.deallocate(p, 2);
        throw;
    }
 
    std::cout << p[0] << ' ' << p[1] << '\n';
    std::destroy_at(p + 1);
    std::destroy_at(first);
    alloc.deallocate(p, 2);
}

输出为 hello !!!。第一个参数是构造位置,后续参数转发给对应构造函数。两个位置分别构造完成后才能作为字符串使用;销毁两个字符串后,这块空间仍由 alloc 持有,直到 deallocate。

源码的 clear() 正是逐个 destroy_at,再把 _M_size 设为零,而不释放容量;析构函数还会归还空间。实际异常安全需要持续记录哪些位置构造成功,上面的例子将这个责任显式展开。

扩容与 move_if_noexcept

reserve 分配新空间后,用以下方式构造旧元素的副本或移动结果,再销毁旧元素:

// 摘自 reserve,简化了名称
for (std::size_t i = 0; i != size; ++i)
    std::construct_at(&data[i], std::move_if_noexcept(old_data[i]));
for (std::size_t i = 0; i != size; ++i)
    std::destroy_at(&old_data[i]);

std::move_if_noexcept(x) 自身只返回引用:当 T 不可拷贝或可从 T&& 不抛异常地构造时,返回 T&&;否则返回 const T&。随后实际调用哪个构造函数由重载决议决定。常见情况下,这意味着优先采用不抛异常的移动;若移动可能抛异常且可以拷贝,则拷贝,避免已移动的旧元素在中途失败后难以恢复。只可移动且移动可能抛异常时,仍须尝试移动,旧值可能已被改变。

std::move_if_noexcept 自己的 noexcept 没有问题:返回引用不执行元素构造。后面的 construct_at 才可能调用会抛异常的构造函数。函数声明的条件异常说明与这个返回引用的工具是不同的概念,可结合 条件 noexcept 回看。

但使用 move_if_noexcept 本身不等于已经实现强异常安全。源码的 reserve 在搬迁之前就修改 _M_data 和 _M_cap,循环中若元素构造抛异常,它没有清理已构造的新元素、释放新空间或恢复旧指针;旧空间也会失去正常管理。因此即使它选中了拷贝,也不能据此断言该实现具备强异常安全。一个具有回滚能力的设计需要先在局部管理新空间和成功构造的元素,全部成功后再提交新状态。

shrink_to_fit 的 noexcept 疑问

源码的 shrink_to_fit() 声明为无条件 noexcept,函数体却可能执行 _M_alloc.allocate(_M_size) 和元素构造。分配失败或元素构造抛异常时,异常不能从 noexcept 函数向上传播,程序会调用 std::terminate。所以这里的问题不在 move_if_noexcept 自身,而在整个 shrink_to_fit 的异常承诺与函数体可能抛异常的操作之间。

此外,该函数先更改容量并替换数据指针,再逐个构造新元素、销毁旧元素;它同样没有实现失败时的事务式回滚。移除无条件 noexcept 只能让异常传播,并不能单独修好资源管理和对象状态。

insert 与 emplace 的元素移动

这份实现的 insert 和 emplace 先调用 reserve,然后从尾部向插入点逆序搬移元素:

// 摘自 emplace,简化了名称
for (std::size_t i = size; i != position; --i) {
    std::construct_at(&data[i], std::move(data[i - 1]));
    std::destroy_at(&data[i - 1]);
}
++size;
std::construct_at(&data[position], std::forward<Args>(args)...);

逆序处理使当前位置腾空后,可以在下一轮构造移动过来的元素。std::move 本身只是把表达式转为右值形式;是否真正调用移动构造函数仍由重载决议决定。emplace 最后使用 std::forward 将调用者参数转发给新元素的构造函数;右值 insert 则对传入值使用 std::move。

为什么这里可能选择 move

先把 reserve 和原地插入的空间关系画出来。假设插入前有 [A, B, C, 空],要在 B 前插入:

reserve:旧空间 [A, B, C]       新空间 [空, 空, 空, ...]
插入:   同一空间 [A, B, C, 空] -> [A, 空, B, C] -> [A, 新值, B, C]

扩容时,新旧空间分离。如果选择拷贝并在全部拷贝完成之前保留旧元素,一旦某次拷贝失败,就有机会清理新空间并保留旧值。这解释了 move_if_noexcept 适合用在一个设计完整、支持回滚的扩容过程;上面已经分析,这份源码的 reserve 尚未完成回滚。resize 增大时调用 reserve,所以搬迁发生在 reserve 中;resize 随后还会构造新增元素。

对作者这份原地插入算法来说,仅把 move 换成 move_if_noexcept,不能修复异常安全问题,也不能让整个插入操作获得失败后保持原样的保证。 原因是目标位置与旧元素区间重叠:源码先把 C 构造到空位,再销毁原来的 C;接着才能在原来 C 的位置构造 B 并销毁原来的 B。即使选择拷贝,源码也会在下一步之前销毁刚拷贝过的源对象,中途失败时没有一份完整的旧状态可直接恢复,也没有处理已经构造和销毁的位置。

这不等于替换在每一种情况下都毫无影响:如果某次移动构造可能抛异常且可以拷贝,move_if_noexcept 可以选择拷贝,避免该次移动构造本身修改源对象。但此前步骤仍可能已经改变了容器,拷贝本身或最后新元素的构造也可能抛异常;容器整体依然缺少恢复和清理。

拷贝元素时为什么用 as_const

拷贝构造函数和拷贝赋值中,源码都使用类似的表达式:

std::construct_at(&data[i], std::as_const(that._M_data[i]));

虽然 that 的类型是 const Vector&,成员 _M_data 的类型是 T*。容器自身为 const 不会递归地把指针指向的 T 变为 const T;因此 that._M_data[i] 仍是 T&。std::as_const 将这个左值转换为 const T&,不复制对象,让后续构造从只读源进行重载决议。

如果元素类型同时有 T(T&) 和 T(const T&),不加 as_const 时非 const 引用重载可能更匹配;加上后传入的是 const T&,从而使用适合 const 源的构造路径。这明确了拷贝时不应通过非 const 引用修改源元素的意图。这里说的是元素构造的参数选择;源码的拷贝赋值还在已有元素位置直接 construct_at,并可能在缩小时遗漏销毁原有尾部元素,其对象生命周期管理需要单独处理,as_const 本身不能解决它。

std::allocator 的职责

模板声明为 template<class T, class Alloc = std::allocator<T>>:默认分配器是 std::allocator<T>,也可传入其他分配器类型。源码保存 _M_alloc,通过它的 allocate(n) 和 deallocate(p, n) 管理空间;元素的构造与销毁则直接调用 construct_at、destroy_at。

std::allocator<T> alloc;
T* p = alloc.allocate(1);       // 取得可容纳一个 T 的存储
// 构造可能抛异常;实际代码需要在失败时释放 p。
std::construct_at(p, args...);  // 构造元素
std::destroy_at(p);             // 销毁元素
alloc.deallocate(p, 1);         // 归还存储

这份教学实现对自定义分配器的支持是简化的:它直接使用 T* 和分配器成员函数,而没有通过 allocator_traits 处理完整的指针、构造和传播规则。例如接受额外分配器参数的移动构造直接接管旧指针,移动赋值也直接接管旧指针而不更换分配器;对不能互相释放对方存储的有状态分配器,这种做法会产生问题。不能从支持模板参数这一点推断它完整满足标准容器的分配器要求。

max_size 为什么除以元素大小

源码将最大元素数量写为:

static constexpr std::size_t max_size() noexcept {
    return std::numeric_limits<std::size_t>::max() / sizeof(_Tp);
}

按字节计算分配大小时,size_t 最多能表示 size_t 的最大值个字节;再除以每个元素占用的 sizeof(T) 字节,就得到这个计算下最多能容纳的元素个数。实际分配器能够分配的空间可能更小。

参考文献

工作草案持续更新;提案描述当时的问题和建议,并不都代表当前标准的最终规定。