Chapter 2: Object Lifetime
对象生命周期把 C++ 程序里的三件事连在一起:一段存储何时可以承载某个类型的对象,构造函数何时把这段存储变成有效对象,析构函数何时封闭对象持有的资源责任。读 STL 源码时,容器扩容、插入、擦除、异常回滚和 allocator 路径都围绕这条链条展开。
本章使用一个贯穿材料:std::vector<Resource> 管理一批拥有动态资源的元素。这个材料足够短,又能暴露 STL 容器实现中的核心问题:vector 先取得原始存储,再在存储上逐个构造元素;元素迁移或复制成功后,旧元素按顺序销毁;整个过程中,内存分配状态、对象生命周期和资源所有权必须分开判断。
C++ 对象生命周期的可执行判断顺序是:先看是否已经取得满足大小与对齐要求的存储,再看初始化是否完成,再看当前操作是在构造新对象、赋值已有对象、绑定临时对象,还是销毁有效对象,最后看容器是否已经提交新状态。C++ 标准语义可以用 cppreference 的 object lifetime 条目 复核,本文只抽取读 STL 源码时需要持续使用的判断框架。
下面这段代码贯穿本章。它的重点在于观察一个拥有资源的元素在局部对象、临时对象和容器元素中的状态变化,API 调用只提供触发场景。
#include <utility>
#include <vector>
class Resource {
public:
explicit Resource(int value)
: ptr_(new int(value)) {}
Resource(const Resource& other)
: ptr_(new int(*other.ptr_)) {}
Resource(Resource&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)) {}
Resource& operator=(const Resource& other) {
if (this == &other) {
return *this;
}
int* next = new int(*other.ptr_);
delete ptr_;
ptr_ = next;
return *this;
}
Resource& operator=(Resource&& other) noexcept {
if (this == &other) {
return *this;
}
delete ptr_;
ptr_ = std::exchange(other.ptr_, nullptr);
return *this;
}
~Resource() {
delete ptr_;
}
private:
int* ptr_{};
};
void sample() {
std::vector<Resource> values;
values.reserve(2);
values.emplace_back(1);
values.emplace_back(2);
values.push_back(Resource(3));
}
Resource 的每个对象都负责一个 int。这个责任从构造函数完成后开始,到析构函数执行时结束。std::vector<Resource> 负责的是元素对象的存储、构造位置、销毁时机和扩容提交;Resource 负责的是自身内部资源。两层责任分开后,STL 源码中的 allocate、construct、destroy、deallocate 才能读清楚。
2.1 生命周期起点:存储、构造与有效对象
对象生命周期的起点由两个条件共同决定:程序已经取得一段满足目标类型大小与对齐要求的存储,并且目标对象的初始化已经完成。前者让地址可以承载对象,后者让这段存储进入该类型的有效状态。对 class 类型来说,构造函数体执行完毕后,对象才完成初始化。
这条规则解释了为什么 STL 容器经常把“分配内存”和“构造元素”写成两个步骤。vector 扩容时会先向 allocator 请求一段可容纳多个元素的原始存储。这段存储的类型意图是 Resource,但其中每个槽位只有在构造完成后才进入 Resource 对象生命周期。容器随后在指定地址上调用构造逻辑,让某个槽位从原始存储变成有效元素。
可以用最小 placement new 示例观察这个边界。示例中的数组只提供字节存储,std::construct_at 才在这段存储上创建 Resource 对象。
#include <memory>
#include <new>
void raw_storage_case() {
alignas(Resource) unsigned char buffer[sizeof(Resource)];
auto* ptr = std::construct_at(
reinterpret_cast<Resource*>(buffer),
42
);
std::destroy_at(ptr);
}
alignas(Resource) 保证 buffer 的起始地址满足 Resource 的对齐要求。sizeof(Resource) 保证存储大小足够。std::construct_at 把构造实参 42 交给 Resource 的构造函数,构造完成后 ptr 指向一个有效 Resource 对象。std::destroy_at 启动析构过程,对象生命周期结束,底层字节数组本身仍然存在。
STL 容器的实现形状与这个例子一致,只是它把单个槽位扩展成一段连续或节点式存储。以 vector 为例,常见实现会保存三个指针:已构造元素起点、已构造元素终点、已分配存储终点。end() 指向的是已构造范围的尾后位置,capacity() 的尾部区域只是可用存储。读源码时应把“指针能指到这里”和“这里已经有对象”分开判断。
这个状态可以用生命周期图表示。图中“原始存储”具有地址和大小,但还没有目标类型对象;“有效对象”具有类型、值和析构责任。
图中的 RawStorage → LiveObject 是构造边界,LiveObject → Dying 是销毁边界。allocator 管理第一段和最后一段,元素类型的构造函数与析构函数管理中间两段。源码里只要看到容器先 allocate 后 construct,就应按这张图判断当前地址是否已经进入对象生命周期。
2.2 构造函数家族:默认、拷贝与移动
构造函数的共同任务是在一段合格存储上建立对象的初始状态。默认构造、拷贝构造和移动构造的差异在于初始状态从哪里来,以及新对象和旧对象之间形成什么资源关系。
默认构造从类型自身的规则建立初始状态。Resource 没有默认构造函数,因此 std::vector<Resource> values(3); 这类写法无法成立,因为容器需要创建三个没有外部实参的 Resource 元素。这个接口现象背后的规则很直接:容器只能调用元素类型实际可用的构造路径。
拷贝构造从另一个同类型对象读取状态,并为新对象建立独立资源关系。Resource(const Resource& other) 中的 new int(*other.ptr_) 表示新对象得到一份新资源,两个对象的析构函数随后各自释放自己的 int。这就是拥有资源类型通常需要自定义拷贝构造的原因:默认逐成员拷贝会复制裸指针值,两个对象随后会指向同一块动态内存,析构路径会重复释放。
移动构造从另一个对象接管资源句柄,并把源对象放入可析构状态。Resource(Resource&& other) noexcept 使用 std::exchange 取走 other.ptr_,再把源对象的指针置空。移动后的源对象仍然是有效对象,它可以析构,也可以被重新赋值;它的业务值通常不再承诺保持原值。这个边界将在 Chapter 3 展开,本章只需要抓住一点:移动构造创建的是新对象,源对象的生命周期仍然继续。
下面的代码展示三类构造路径分别在什么位置发生。每一行都在创建一个新对象,目标对象在该语句之前尚未进入生命周期。
void construction_cases() {
Resource first(1); // 直接构造 first
Resource copied(first); // 拷贝构造 copied
Resource moved(std::move(first)); // 移动构造 moved
}
first 的构造需要一个整数实参。copied 的构造读取 first 的资源值,并创建新的资源。moved 的构造接管 first 的资源句柄,first 仍然会在作用域结束时析构,析构函数看到空指针时释放动作没有效果。
std::vector<Resource>::emplace_back(1) 与 push_back(Resource(3)) 的生命周期路径不同。emplace_back(1) 通常把实参直接交给目标槽位,在容器存储上构造元素。push_back(Resource(3)) 先创建一个临时 Resource,再用移动构造或拷贝构造在容器槽位上创建元素,临时对象在完整表达式结束时析构。两者最终都让容器多出一个有效元素,但中间是否出现临时对象会影响构造次数、异常路径和资源转移。
源码阅读时,构造函数家族的判断顺序是:先看这行代码是否在创建新对象;再看构造实参来自普通值、同类型左值还是同类型右值;最后看元素类型是否把资源关系处理完整。容器源码中的 construct(pos, args...) 本身只负责把实参传到元素构造函数,资源语义由元素类型决定。
2.3 赋值函数家族:拷贝赋值与移动赋值
赋值函数处理的是已有对象的新状态建立。它和构造函数的关键差异在于:赋值目标的生命周期已经开始,目标对象可能已经持有资源。一次合格赋值必须先处理旧资源,再建立新状态,并保持对象最终仍然可析构。
拷贝赋值的典型风险是半途失败。Resource& operator=(const Resource& other) 先分配 next,再释放旧 ptr_,最后提交新指针。这个顺序让分配失败时原对象仍保持旧状态。若代码先 delete ptr_ 再 new int(...),分配失败会让对象失去旧资源,异常传播后对象状态被破坏。
移动赋值的典型路径是释放目标已有资源,再接管源对象资源,并把源对象置为可析构状态。Resource& operator=(Resource&& other) noexcept 中的 delete ptr_ 对应目标旧资源关闭,std::exchange(other.ptr_, nullptr) 对应资源句柄转移。noexcept 在 STL 容器里会影响扩容时选择移动还是拷贝;这个选择属于异常安全路径,本章先建立对象状态基础。
自赋值检查服务于一个具体边界:左右两边可能引用同一个对象。拷贝赋值中,x = x; 读取源对象和修改目标对象发生在同一实体上。移动赋值中,x = std::move(x); 也可能出现。示例代码使用 this == &other 直接返回,保证对象资源不被自身释放后又被自身接管。
下面的代码把构造和赋值放在同一个作用域中。读这类代码时,可以只问一个问题:等号左侧对象的生命周期是否已经开始。
void assignment_cases() {
Resource a(1);
Resource b(2);
Resource c(a); // 创建新对象 c
b = a; // 修改已有对象 b
c = Resource(3); // 临时对象先构造,随后移动赋值给已有对象 c
}
Resource c(a) 调用拷贝构造,因为 c 在这一行之前还没有生命周期。b = a 调用拷贝赋值,因为 b 已经是有效对象。c = Resource(3) 先创建临时对象,再调用移动赋值修改 c,临时对象在完整表达式结束时析构。
STL 容器实现中,赋值操作常出现在两个层级。元素赋值发生在已经构造的元素位置,例如 values[i] = Resource(7);。容器赋值发生在容器对象本身,例如 values = other;,这会牵涉容量复用、元素拷贝构造、元素销毁和异常安全承诺。读源码时应先定位赋值目标是哪一层对象,再判断旧资源由谁关闭。
赋值函数家族的工程判断顺序是:确认目标对象已处于有效生命周期;确认目标旧资源在提交新状态前后如何处理;确认源对象在操作后仍满足可析构要求;确认异常发生时目标对象能保持可说明的状态。这个顺序比记忆“拷贝赋值和移动赋值语法”更接近 STL 源码中的真实问题。
2.4 临时对象、生命周期延长与引用边界
临时对象通常由表达式创建,并在包含它的完整表达式结束时销毁。完整表达式可以理解为当前语句的求值边界。push_back(Resource(3)); 中的 Resource(3) 是临时对象,它会参与参数传递和元素构造,随后在这一条语句结束时析构。
引用绑定会改变部分临时对象的结束时机。一个临时对象直接绑定到局部 const 左值引用或右值引用时,它的生命周期会延长到该引用本身的生命周期结束。这个规则让局部引用可以安全观察临时值,但它不会让所有间接引用都自动变安全。
下面的代码给出两个常见边界。第一个引用安全,第二个引用悬垂。
const Resource& local_reference() {
const Resource& ref = Resource(10);
return ref;
}
void temporary_cases() {
const Resource& safe = Resource(20);
const Resource& dangling = local_reference();
(void)safe;
(void)dangling;
}
safe 直接绑定到 Resource(20),临时对象生命周期延长到 safe 所在作用域结束。local_reference 内部的 ref 也延长了 Resource(10) 的生命周期,但只延长到函数返回前。函数返回后,调用方得到的引用指向已经结束生命周期的对象,继续读取会进入悬垂引用风险。
临时对象规则对 STL 视图类和引用返回值影响很大。std::string_view、iterator、引用参数和 const T& 返回值都可能把对象生命周期问题暴露给调用方。容器本身只保证其元素在特定操作前后是否有效;外部保存的引用、指针和 iterator 需要按容器失效规则重新判断。
std::vector<Resource> 扩容时也会创建中间对象状态。容器可能先在新存储上构造新元素,再销毁旧存储上的旧元素。此时旧 iterator 和引用指向旧存储,扩容提交后它们失去可用对象。这个失效现象来自对象生命周期迁移到新存储:提交后旧地址上的对象已经销毁并释放,旧引用语法仍然存在,但底层对象已经退出生命周期。
引用边界的判断顺序是:先看引用绑定到的对象在哪里创建;再看是否是直接绑定到临时对象;再看引用是否跨越函数返回、容器修改或完整表达式边界;最后看底层对象的生命周期是否仍然覆盖引用使用点。这个顺序适用于普通引用、iterator、string_view 和容器元素引用。
2.5 析构、销毁顺序与 RAII
析构函数在对象生命周期结束时启动,它负责关闭对象在生命周期内取得的资源。RAII(Resource Acquisition Is Initialization)把资源责任绑定到对象生命周期:构造完成表示资源进入对象管理,析构启动表示资源退出对象管理。这样异常路径、提前返回和普通作用域退出都能走同一条清理路径。
Resource::~Resource() 中只有一行 delete ptr_;,但它表达了完整责任:每个有效 Resource 对象在析构时释放自己持有的动态内存。移动后的对象把 ptr_ 置空,所以析构仍然安全。拷贝后的对象拥有独立 ptr_,所以析构不会与源对象冲突。
局部对象按构造完成的逆序析构。成员对象通常在包含对象析构函数体执行完成后,按照成员声明顺序的逆序析构。数组元素和容器元素也有确定的销毁路径,容器会在自身析构、清空、擦除或扩容失败回滚时调用元素析构。读 STL 源码时,这个顺序决定了资源释放、异常回滚和指针失效的可见顺序。
下面的示例展示作用域退出时的逆序销毁。second 后构造,所以先析构;first 后析构。
void destruction_order() {
Resource first(1);
Resource second(2);
}
析构顺序直接影响依赖关系。假设 second 内部保存了指向 first 资源的非拥有指针,逆序销毁可以让 second 在析构期间仍看到 first 的对象仍处于生命周期内。成员声明顺序同理,依赖关系强的成员应在声明顺序上让被依赖对象后析构。
异常路径是 RAII 的主要收益场景。下面的函数中,Resource local(1) 构造成功后,后续代码抛出异常时 local 仍会析构。资源释放挂在对象生命周期上,异常传播不需要每个分支手写清理代码。
void exception_path(bool fail) {
Resource local(1);
if (fail) {
throw 1;
}
}
容器实现会把 RAII 思想扩展到批量元素。常见 vector 扩容路径中,新存储和已构造的新元素会由 guard 类或局部清理对象管理。迁移到第 N 个元素时发生异常,guard 会销毁已经构造的前 N 个新元素,并释放新存储;旧 vector 状态仍然可用。这种 guard 属于常见实现用来落实异常安全的形状,标准接口本身不要求固定使用这个名字。
析构与 RAII 的判断顺序是:先找资源进入对象的构造点;再找资源退出对象的析构点;再看中间的拷贝、移动、赋值是否保持单一释放责任;最后看异常路径是否也能触发同一套析构逻辑。这个顺序能直接用于审查拥有文件句柄、互斥锁、内存块和容器节点的类型。
2.6 容器元素生命周期与 STL 管理路径
STL 容器管理元素生命周期时,通常把原始存储和有效元素范围分开维护。allocator 提供存储,allocator_traits 统一调用构造和销毁,容器维护哪些位置已经有有效元素。cppreference 对 std::allocator_traits::construct 的描述也体现了这个边界:它在已分配的未初始化存储上构造对象;destroy 则调用目标对象的析构路径。
以 std::vector<Resource> 为例,reserve(2) 只保证容量,不创建两个 Resource 元素。随后两次 emplace_back 才分别在前两个槽位构造有效对象。push_back(Resource(3)) 触发容量不足时,容器会进入扩容路径:分配更大的原始存储,在新存储上构造已有元素和新元素,成功后销毁旧存储上的元素,释放旧存储,最后更新内部指针。
这个路径可以拆成六个状态,源码阅读时按顺序检查即可。
图中的关键提交点在最后。提交前,旧 vector 状态仍然承担对外可见语义;提交后,新存储成为容器状态,旧 iterator、旧引用和旧指针按失效规则处理。异常安全设计的核心就是在提交前把失败清理干净,在提交后让旧状态完全退出生命周期。
emplace_back、erase 和 clear 对生命周期的影响不同。emplace_back 在尾部新位置构造对象,元素数量增加。erase 销毁被移除位置上的元素,并可能移动或赋值后续元素来填补空洞。clear 销毁所有已构造元素,但通常保留已分配存储,因此 capacity() 可能不变。这个现象再次说明:存储容量和元素生命周期是两个维度。
容器析构时会沿已构造元素范围调用元素析构,再释放容器拥有的存储。对于节点式容器,销毁路径通常按节点逐个销毁元素并释放节点。对于连续容器,销毁路径通常遍历 [begin, end) 这个已构造范围,再释放整段缓冲区。两类容器的内存布局不同,但都遵守同一个对象生命周期边界。
教学简化版的 vector 尾插可以写成下面这样。代码省略增长策略和异常回滚,只保留生命周期动作的顺序。
template <class T, class Alloc>
class MiniVector {
public:
void push_back(const T& value) {
if (finish_ == end_of_storage_) {
grow();
}
std::allocator_traits<Alloc>::construct(alloc_, finish_, value);
++finish_;
}
private:
void grow();
Alloc alloc_;
T* start_{};
T* finish_{};
T* end_of_storage_{};
};
start_ 到 finish_ 是有效元素范围,finish_ 到 end_of_storage_ 是已分配但未构造范围。construct 发生在 finish_ 指向的原始存储上,成功后 finish_ 才前移。这个顺序让容器状态和对象生命周期保持一致:指针范围只覆盖已经成功构造的元素。
完整实现还要处理构造失败。若 construct 抛出异常,finish_ 尚未前移,容器不会把未完成构造的位置暴露为有效元素。若 grow() 期间移动已有元素失败,容器需要销毁新存储中已经构造的部分,并释放新存储。旧元素在提交前仍保持生命周期,因此外部可见状态能维持原样或维持标准承诺的状态。
读 STL 容器生命周期源码时,可以固定以下判断顺序:第一,定位容器当前拥有哪段存储;第二,定位哪一段已经构造成有效元素;第三,定位当前操作是在构造新元素、赋值已有元素、销毁元素还是释放存储;第四,定位提交点;第五,检查失败路径是否销毁了已构造的新元素并释放临时存储;第六,按容器失效规则更新对 iterator、引用和指针的判断。
最小自检任务
阅读下面的代码,判断每个编号位置发生的是构造、赋值、析构、生命周期延长还是悬垂引用风险,并说明 std::vector<Resource> 扩容时旧元素和新元素的生命周期边界。
#include <vector>
Resource make_resource() {
Resource temp(9); // A
return temp; // B
}
const Resource& bad_reference() {
const Resource& ref = Resource(8); // C
return ref; // D
}
void check_lifetime() {
Resource a(1); // E
Resource b(a); // F
b = Resource(2); // G
const Resource& local = Resource(3); // H
const Resource& dangling = bad_reference(); // I
std::vector<Resource> values;
values.reserve(1); // J
values.emplace_back(4); // K
values.push_back(make_resource()); // L
(void)local;
(void)dangling;
}
答案要点
A 创建局部对象 temp,构造完成后 temp 进入有效生命周期。B 返回对象时会按当前标准和实现策略发生返回值构造、拷贝省略或移动构造,核心判断是返回结果对应一个新的有效对象,temp 的局部生命周期不会跨越函数作用域。C 中临时 Resource(8) 直接绑定到局部引用 ref,生命周期延长到 ref 所在函数返回前。D 返回这个引用后,调用方得到的是悬垂引用风险。
E 直接构造 a。F 以 a 为源创建新对象 b,调用拷贝构造。G 先创建临时 Resource(2),随后对已有对象 b 调用移动赋值,临时对象在完整表达式结束时析构。H 直接绑定局部引用,临时对象生命周期延长到 check_lifetime 结束。I 接收来自 bad_reference 的引用,底层对象在函数返回前已经结束生命周期,因此使用 dangling 具有悬垂风险。
J 只分配或调整容器存储容量,不创建 Resource 元素。K 在容器已分配存储上直接构造一个元素。L 先取得 make_resource() 的返回结果,再把它作为右值插入 vector。由于之前只预留了一个容量,第二次插入可能触发扩容:容器分配新原始存储,在新存储上构造已有元素和新元素,提交后销毁旧存储上的旧元素并释放旧存储。旧 iterator、引用和指针在这类扩容后按失效处理。
完成这道题时,稳定的检查顺序是:先看对象是否已经存在;再区分构造和赋值;再标出临时对象的完整表达式边界;再检查引用是否跨越函数返回或容器扩容;最后把 vector 的容量存储和已构造元素范围分开判断。
本章知识点总结
- 生命周期起点:对象生命周期从合格存储取得并完成初始化后开始。
- 原始存储:已分配存储只有地址、大小和对齐要求,构造完成后才承载目标类型对象。
- 有效对象:有效对象具有类型、状态和析构责任,读写它必须发生在生命周期覆盖范围内。
- 默认构造:默认构造用类型自身规则建立初始状态,容器批量创建元素时依赖这条路径。
- 拷贝构造:拷贝构造创建新对象,并按类型规则建立源对象和新对象之间的资源关系。
- 移动构造:移动构造创建新对象,并让源对象继续保持可析构状态。
- 拷贝赋值:拷贝赋值修改已有对象,需要先规划目标旧资源和新状态提交顺序。
- 移动赋值:移动赋值释放目标旧资源并接管源对象资源,源对象仍然保留有效生命周期。
- 临时对象:临时对象通常在完整表达式结束时析构,直接绑定到局部引用时可能延长生命周期。
- 悬垂引用:引用本身的生命周期可以长于被引用对象,判断引用安全必须回到对象生命周期。
- RAII:RAII 用构造和析构绑定资源进入与退出,让普通路径和异常路径共享清理逻辑。
- 销毁顺序:局部对象、成员对象和容器元素都有确定销毁顺序,依赖关系要按这个顺序审查。
- 容器存储:STL 容器把已分配存储和已构造元素范围分开管理。
- allocator 路径:allocator 负责存储来源,
allocator_traits负责统一进入元素构造和销毁路径。 - 扩容提交:vector 扩容通常先构造新存储中的元素,再销毁旧元素并提交新指针状态。
- 判断顺序:读 STL 生命周期路径时,按存储、构造、赋值、销毁、提交点和失效规则依次检查。