Chapter 1: C++ Object Model
C++ 标准库容器表面上在保存元素,底层实际在管理一组具有类型、地址、生命周期和对齐要求的对象。读 STL 源码时,std::vector<T> 的扩容、std::list<T> 的节点分配、allocator 的 construct / destroy 路径、iterator 指向对象的位置语义,都需要回到 C++ 对象模型。对象模型回答一个基础问题:某段存储在什么时候成为一个 T 对象,什么时候失去 T 的身份,以及标准库实现需要为这段过程承担哪些责任。
本章用一段贯穿材料串起对象模型。它代表 STL 容器最常见的底层动作:先取得足够大、足够对齐的原始存储,再在存储中构造对象,随后通过对象地址访问状态,最终销毁对象并释放或复用存储。
#include <cstddef>
#include <memory>
#include <new>
#include <type_traits>
struct Item {
int key;
double score;
Item(int k, double s) : key(k), score(s) {}
void add(double delta) {
score += delta;
}
};
alignas(Item) std::byte raw[sizeof(Item)];
Item* make_item() {
return std::construct_at(reinterpret_cast<Item*>(raw), 7, 3.5);
}
void drop_item(Item* p) {
std::destroy_at(p);
}
这段代码的重点在 raw 和 Item 的关系。raw 起初只是一段字节存储;std::construct_at 之后,那里才有一个有效的 Item 对象;std::destroy_at 之后,Item 的生命周期结束,存储仍然存在。读完本章后,应能按固定顺序判断一个 STL 实现片段:先看存储来源,再看对象生命周期,再看对象布局和对齐,再看成员函数如何操作状态,最后看多态和资源管理对容器实现的约束。
对象模型的标准语义可以回溯到 cppreference 的 Object 页面、Object lifetime 页面 和 Storage duration 页面。本章引用这些材料的目的,是把标准术语压缩成读 STL 源码时可执行的判断路径。
1.1 对象身份、存储期与有效对象
C++ 对象由一段存储、一个类型、一个生命周期、一组值状态和可观察地址共同确定。对象身份指“此刻这段存储中活着的那个对象是谁”。同一段地址在不同时间可以承载不同对象,所以地址提供定位,生命周期提供身份边界,类型提供解释规则。
贯穿材料中的 raw 是一个 std::byte 数组对象。它本身有对象身份、大小和生命周期。raw 内部的字节提供存储,但起初没有 Item 对象。std::construct_at(reinterpret_cast<Item*>(raw), 7, 3.5) 执行后,构造函数在这段存储上建立 Item 的生命周期。此时 p->key 和 p->score 才有 Item 类型下的读取意义。
Item* p = make_item();
p->add(1.0); // 访问活着的 Item 对象
drop_item(p); // Item 生命周期结束
这个例子中有两个层级。第一层是 raw 数组对象,它的生命周期由定义位置决定。第二层是放在 raw 内部的 Item 对象,它的生命周期由显式构造和销毁决定。STL 容器内部经常使用同类分层:容器对象自己活着,容器持有的原始存储也可能已经分配完成,但某些槽位中的元素对象尚未构造。
存储期(storage duration)描述存储至少能存在多久。常见类别有自动存储期、静态存储期、线程存储期和动态存储期。生命周期(lifetime)描述对象从创建完成到销毁开始或存储被复用之间的运行时区间。存储期给对象提供物理承载区间,生命周期给类型化访问提供合法区间。
下面的状态图只描述贯穿材料中的一格存储。它刻意把“取得存储”和“创建对象”分开,因为 STL 容器正是在这条边界上工作。
图中的 AliveItem 才允许按 Item 解释存储。RawStorage 阶段允许保存地址、计算容量和参与分配策略,但读取 key 或调用 add 没有对象语义。Released 阶段连存储承载关系也结束了,任何旧指针都失去定位价值。
这个边界解释了容器为什么需要区分 size 和 capacity。以 vector 的常见实现形状为例,capacity 对应已经取得的元素槽位数量,size 对应已经构造完成的元素对象数量。capacity 之外没有容器可用存储,size 到 capacity 之间有容器管理的原始存储,begin() 到 end() 之间有有效元素对象。
对象身份的工程后果是:指针和 iterator 指向的目标需要同时满足地址仍然属于容器、目标对象生命周期仍然有效、目标类型解释仍然正确。vector 扩容后旧存储释放,旧 iterator 指向的地址失去容器承载关系;erase 销毁元素后,指向被销毁元素的 iterator 失去对象生命周期;placement new 在同一地址上创建新对象后,旧对象身份已经结束,新对象身份重新建立。
1.2 对齐、padding 与 sizeof
对象布局由大小、对齐和成员排列共同决定。sizeof(T) 给出一个 T 完整对象占用的字节数,alignof(T) 给出 T 对象起始地址需要满足的对齐要求。padding 是实现插入的填充字节,用来让成员或数组中下一个元素满足对齐要求。
看一个小布局例子。具体大小受 ABI、目标平台和编译器实现影响,但推导方式稳定。
#include <cstddef>
struct LayoutA {
char tag;
int value;
};
struct LayoutB {
int value;
char tag;
};
static_assert(sizeof(LayoutA) >= sizeof(char) + sizeof(int));
static_assert(sizeof(LayoutB) >= sizeof(char) + sizeof(int));
LayoutA 中 tag 后面通常需要填充字节,使 value 的地址满足 int 的对齐要求。LayoutB 中 value 先出现,tag 后面可能出现尾部 padding,使数组里的下一个 LayoutB 元素也满足整体对齐要求。sizeof 覆盖成员值字节和 padding 字节,所以它表达“相邻对象之间的步长”,并非成员大小的简单求和。
这条规则直接影响连续容器。std::vector<LayoutA> 的第 i 个元素地址通常按 begin + i * sizeof(LayoutA) 的步长定位。容器无需理解每个成员在哪里,它只需要保证每个元素对象的起始地址满足 alignof(LayoutA),并按 sizeof(LayoutA) 安排相邻对象。成员布局交给类型和 ABI 决定。
贯穿材料里写了 alignas(Item) std::byte raw[sizeof(Item)]。sizeof(Item) 只解决空间大小,alignas(Item) 解决起始地址约束。去掉 alignas(Item) 后,raw 可能只满足 std::byte 的对齐要求;把 Item 构造在这种地址上会破坏 Item 的对齐前提。STL allocator 的 allocate 必须返回适合 T 的存储,容器才能在返回地址上构造元素。
空类大小也服务对象身份。一个空类完整对象通常仍然有非零大小,使两个相邻完整对象拥有可区分地址。作为基类子对象时,常见实现可做 empty base optimization,把空基类子对象并入派生对象布局;C++20 的 [[no_unique_address]] 也允许某些空成员共享布局空间。源码阅读时要分清完整对象、成员子对象和基类子对象,三者在地址唯一性和布局优化上的规则并不相同。
padding 字节不承担业务值。对可平凡复制类型,按字节复制对象表示通常能复制值;对带资源的类型,字节复制会绕过构造、拷贝和析构责任。STL 容器在搬迁元素时必须根据类型语义选择构造、移动或拷贝路径,不能把所有 T 都当成字节块处理。
对齐、padding 和 sizeof 给 STL 实现带来三条检查线。第一,分配器返回地址必须满足元素对齐。第二,连续容器的元素步长必须使用 sizeof(T)。第三,元素搬迁需要尊重 T 的构造和析构语义。空间布局提供性能基础,类型生命周期提供正确性基础。
1.3 栈对象、堆对象与所有权入口
工程讨论中常说“栈对象”和“堆对象”,更准确的判断对象是自动存储期对象和动态存储期对象。自动存储期通常来自块作用域局部变量,离开作用域时按构造的逆序销毁。动态存储期通常来自 new 表达式或 allocator 分配路径,需要某个所有者在合适时间销毁对象并释放存储。
下面的代码把自动对象、动态对象和原始动态存储分开。三者都可能出现在 STL 容器实现或容器元素类型中。
#include <cstdlib>
#include <new>
void local_object() {
Item local{1, 2.0}; // 自动存储期对象
local.add(0.5);
} // local 在作用域退出时销毁
Item* dynamic_object() {
return new Item{2, 4.0}; // 分配存储并构造 Item
}
void raw_dynamic_storage() {
void* mem = std::malloc(sizeof(Item));
Item* p = std::construct_at(static_cast<Item*>(mem), 3, 6.0);
std::destroy_at(p);
std::free(mem);
}
new Item{2, 4.0} 是组合动作:先调用分配函数取得足够存储,再在存储上构造 Item,表达式结果是指向活对象的指针。delete 也是组合动作:先结束对象生命周期,再调用释放函数交还存储。std::malloc 只提供原始存储,std::free 只释放原始存储;对象生命周期需要由 std::construct_at 和 std::destroy_at 这类路径显式管理。
所有权入口是资源责任开始的位置。局部变量的所有权入口是声明语句,退出作用域时自动封闭责任。new 表达式的所有权入口是返回指针的那一刻,后续必须有明确释放路径。STL 容器的所有权入口是容器成功取得存储并成功构造元素的位置;容器析构、clear、erase、扩容失败回滚都需要沿着这个入口反向关闭责任。
std::vector<Item> 拥有的是一组 Item 元素对象。若元素类型本身持有资源,例如文件句柄、堆内存或互斥量,容器负责调用元素析构函数,元素析构函数再释放它自己的资源。容器所有权和元素内部所有权构成两层责任链:容器管理元素生命周期,元素类型管理自己的成员资源。
#include <vector>
void container_owns_elements() {
std::vector<Item> items;
items.emplace_back(10, 1.0);
items.emplace_back(20, 2.0);
} // vector 析构时销毁两个 Item,并释放内部存储
这段代码中 items 是自动存储期对象,items 内部存储通常来自动态分配。局部变量离开作用域时,vector 析构函数运行;析构函数销毁已构造元素,然后释放内部动态存储。读 STL 源码时要把外层容器对象的存储期、内部缓冲区的存储期、元素对象的生命周期拆开看。
容器中的指针元素需要额外判断。std::vector<int*> 拥有一组指针对象,指针所指向的 int 对象归谁管理取决于程序设计。std::vector<std::unique_ptr<int>> 拥有一组 unique_ptr 元素,unique_ptr 的析构再释放对应的 int。同样是 vector,元素类型不同,资源责任链不同。
这形成 STL 源码阅读中的第一条判断顺序:先看谁拥有存储,再看谁启动元素生命周期,再看谁负责析构,再看释放动作是否和分配动作配对。只要这四个问题闭合,容器在正常路径和异常路径上才可能保持资源状态稳定。
1.4 this 指针、成员函数与对象状态
非静态成员函数通过隐藏的 this 指针操作某个具体对象。成员函数的机器代码属于函数实体,通常不存入每个对象;对象内部保存非静态数据成员以及实现为支持多态而放入的必要元数据。调用 p->add(1.0) 时,调用目标是 Item::add 的函数体,p 作为 this 进入函数,函数通过 this->score 修改那一个对象的状态。
下面把成员函数写成接近源码阅读时的展开视角。真实语言层面仍然使用成员函数语法,这里只是展示 this 如何连接函数和对象。
struct Counter {
int value;
void increment() {
++value;
}
int get() const {
return value;
}
};
void use_counter() {
Counter c{0};
c.increment();
int n = c.get();
(void)n;
}
c.increment() 修改 c.value,因为 this 指向 c。get() const 中的 this 具有“指向 const 对象”的访问约束,所以函数体能读取 value,但常规写法下不能修改 value。const 成员函数约束的是通过该成员函数观察到的对象状态修改权限,它不改变对象的存储布局。
成员函数和对象存储的分离解释了一个常见源码现象:容器搬迁元素时关心的是元素对象的构造、移动、拷贝和析构,普通成员函数本身不会随元素移动而复制一份。对象被移动到新地址后,后续成员函数调用接收新的 this,于是访问新地址上的成员状态。
静态数据成员和静态成员函数需要单独判断。静态数据成员属于类作用域下的独立对象,通常不进入每个实例的大小。静态成员函数没有 this,所以它不能直接访问某个实例的非静态成员。读类布局时,只把非静态数据成员、基类子对象、可能的多态元数据和对齐填充纳入单个对象的存储判断。
this 的有效性依赖对象生命周期。贯穿材料中 drop_item(p) 之后,p 仍然保存一个地址值,但那里已经没有活着的 Item 对象。再次调用 p->add(1.0) 会把一个失效生命周期的地址当作 Item* 使用。STL iterator 的很多风险也来自这点:iterator 保存的位置状态看起来还在,目标对象身份已经结束或已经迁移。
成员函数还承担不变量维护。一个类型可以要求每次修改都维持“成员之间的关系成立”,例如 size <= capacity、链表节点前后指针互相指回、哈希表元素位于匹配桶中。STL 容器实现调用元素构造、赋值、比较、哈希和析构时,需要尊重这些函数对对象状态的承诺;绕过成员函数直接按字节改对象,会破坏类型自己的不变量。
因此,读 STL 源码时看到 value_type 的成员函数调用,应把它理解成“容器把控制权交给元素类型维护自身状态”。容器负责存储位置、生命周期顺序和异常回滚;元素类型负责构造后状态有效、移动后状态可析构、析构时释放内部资源。
1.5 虚函数表与多态对象布局
带虚函数的类是多态类型。标准规定的是动态派发语义,常见实现形状是在每个多态对象中放入一个隐藏指针,指向该动态类型对应的虚函数表。正文后续称它为 vptr 和 vtable,但这是实现模型名称,具体布局、指针位置和优化方式由实现决定。
struct Plain {
int value;
void update() {
++value;
}
};
struct Poly {
int value;
virtual void update() {
++value;
}
virtual ~Poly() = default;
};
Plain 对象通常只需要保存 value 加上必要 padding。Poly 对象通常还需要保存支持动态派发的隐藏信息。sizeof(Poly) 往往大于 sizeof(int),并且对象搬迁、构造和析构过程中需要维护 vptr 指向的动态类型信息。读源码时看到 is_polymorphic、虚析构、基类指针删除、对象切片等主题,都应回到这一层布局差异。
动态派发的输入是表达式中的静态类型和对象中的动态类型。假设 Poly* p 指向某个派生类对象,p->update() 会通过对象里的动态类型信息选择最终覆盖函数。普通非虚成员函数调用则在编译期根据静态类型解析目标函数,运行时只把 this 传入函数体。
多态布局对资源管理的直接影响体现在基类析构。若程序通过基类指针管理派生对象,基类通常需要虚析构函数,这样 delete base_ptr 才能沿动态类型调用派生析构,再释放完整对象相关资源。STL 容器保存多态对象时也要明确保存方式:保存对象值会发生基类子对象复制,保存指针或智能指针则保存间接引用,动态对象由指针所有权模型管理。
#include <memory>
#include <vector>
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Square : Shape {
double side;
explicit Square(double s) : side(s) {}
double area() const override {
return side * side;
}
};
void own_polymorphic_objects() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Square>(3.0));
}
这段代码中 vector 拥有的是 std::unique_ptr<Shape> 元素。每个 unique_ptr 再拥有一个动态分配的 Square。vector 扩容时移动的是智能指针对象,Square 通常留在原动态存储地址。调用 shapes[0]->area() 时,通过 Shape 接口进入动态派发,最终执行 Square::area。
多态对象会改变容器选择判断。若需要连续存储真实派生对象,std::vector<Base> 无法表达不同动态大小和布局的派生对象集合;保存指针则牺牲一层间接访问,并引入动态分配和所有权判断。若性能目标是连续访问和缓存局部性,值语义容器更直接;若目标是运行时替换行为,多态指针容器更自然。两种选择的差异来自对象布局和生命周期责任,已经超出 API 表面差异。
构造和析构期间的虚调用还需要边界意识。对象正在构造基类子对象时,派生部分尚未完成;对象正在析构基类部分时,派生部分已经按顺序结束。虚调用在这些阶段的动态类型观察受构造析构规则限制。容器通常不主动在元素构造中调用业务虚函数,但元素自己的构造和析构可能包含虚调用风险。
本节给 STL 源码阅读的结论是:多态类型的对象值包含运行时类型支持信息,普通值类型通常只包含自身成员和 padding。容器按 T 的静态类型管理元素槽位;运行时多态若要跨派生类型保存对象,通常通过指针层表达,并把销毁责任交给虚析构和所有权类型闭合。
1.6 对象模型对 STL 实现的约束
STL 实现受到对象模型约束,核心原因是容器需要在“原始存储”和“有效对象”之间反复切换。vector 扩容、deque 新块分配、list 新节点插入、map 树节点构造都包含同一组动作:取得适合 T 的存储,构造元素,提交结构状态;失败时销毁已经构造的部分,并释放尚未提交的存储。
下面的简化代码属于教学代码,未对应某个标准库实现源码,只展示一个容器槽位必须处理的对象模型边界。
#include <cstddef>
#include <memory>
#include <new>
#include <utility>
template <class T>
class OneSlot {
alignas(T) std::byte storage_[sizeof(T)];
bool engaged_ = false;
T* ptr() {
return std::launder(reinterpret_cast<T*>(storage_));
}
public:
template <class... Args>
void construct(Args&&... args) {
std::construct_at(ptr(), std::forward<Args>(args)...);
engaged_ = true;
}
void destroy() {
if (engaged_) {
std::destroy_at(ptr());
engaged_ = false;
}
}
T& get() {
return *ptr();
}
};
storage_ 给出字节空间,alignas(T) 给出对齐,engaged_ 记录生命周期状态。construct 成功后才把槽位标记为有对象;destroy 只销毁已构造对象。真实容器会把这个逻辑扩展成数组、节点、异常回滚和 allocator traits,但判断核心相同:槽位存在不等于元素对象存在。
std::vector<T> 的常见三指针形状可以用对象模型解释。第一个指针指向已分配存储的开头,第二个指针指向已构造元素末尾,第三个指针指向已分配存储末尾。begin 到 end 是有效对象区间,end 到 cap 是原始存储区间。扩容时新存储取得后,容器把旧元素移动或拷贝构造到新存储,再销毁旧元素并释放旧存储。
std::list<T> 的节点式形状也受同一规则约束。节点存储中除了 T 元素,还包含前驱和后继指针。插入新节点时,常见安全顺序是先分配节点存储,再构造节点里的 T,然后接入链表结构。若构造 T 抛出异常,节点还没有进入链表,容器只需要释放节点存储。提交点安排来自对象生命周期和结构不变量的组合约束。
有序和无序关联容器还会把对象身份用于 iterator 稳定性。节点式容器修改指针关系时,未删除节点中的 T 对象地址通常保持稳定;连续容器扩容时,元素对象迁移到新存储,旧地址上的生命周期结束。iterator 失效规则因此可以从对象模型直接推出:底层对象地址和生命周期保持稳定时,iterator 更容易保持有效;对象被销毁或迁移时,iterator 指向的身份结束。
allocator 的角色是把内存来源从容器逻辑中分离出来。容器通过 allocator 取得适合 T 的原始存储,通过构造路径启动元素生命周期,通过销毁路径结束元素生命周期,通过释放路径交还存储。allocator 不能替容器决定 size,容器也不能跳过类型构造语义直接修改已分配字节。
异常安全同样建立在对象模型之上。假设 vector 扩容时要搬迁 10 个元素,新存储中已经成功构造 6 个,第 7 个构造抛出异常。容器必须销毁新存储中那 6 个已构造对象,释放新存储,并保留旧存储中的旧元素状态。这里的计数按“已经成功开始生命周期的对象数量”计算,容量计数另行处理。
读 STL 源码时可以按这组顺序检查对象模型约束:第一,看当前代码拿到的是原始存储、活对象还是已经结束生命周期的旧位置;第二,看地址是否满足 alignof(T);第三,看构造成功数量如何记录;第四,看失败路径是否销毁已构造对象;第五,看提交点是否在所有对象和结构不变量都成立之后;第六,看 iterator、引用和指针是否还指向活着的对象身份。
本章最终建立的理解是:STL 容器并不神秘,它们是在 C++ 对象模型允许的范围内批量管理存储、生命周期、布局和类型行为。只要能区分存储和对象、地址和身份、成员函数和对象状态、静态类型和动态类型,就能读懂后续 allocator、iterator、容器扩容和节点操作的底层原因。
最小自检任务
阅读下面代码,判断每一行注释后的说法是否成立,并说明原因。重点关注存储、生命周期、对齐、对象身份和容器所有权。
#include <cstddef>
#include <memory>
#include <vector>
struct Item {
int key;
double score;
Item(int k, double s) : key(k), score(s) {}
void add(double delta) {
score += delta;
}
};
void check() {
alignas(Item) std::byte raw[sizeof(Item)];
Item* p = reinterpret_cast<Item*>(raw);
// A: 此时可以读取 p->key。
std::construct_at(p, 1, 2.0);
// B: 此时 raw 内部有一个有效 Item 对象。
p->add(3.0);
std::destroy_at(p);
// C: 此时 raw 仍然存在,但 Item 生命周期已经结束。
std::vector<Item> items;
items.emplace_back(7, 8.0);
// D: vector 拥有一个 Item 元素对象,并负责在合适时间销毁它。
}
答案要点
A 不成立。raw 数组已经存在,但 Item 的生命周期尚未开始。p 只是把地址按 Item* 形式保存下来,不能把未构造的字节当成 Item 成员读取。alignas(Item) 只解决对齐前提,不能启动对象生命周期。
B 成立。std::construct_at(p, 1, 2.0) 在 raw 提供的对齐存储上调用 Item 构造函数,Item 对象生命周期开始。此后 p->key、p->score 和 p->add 都针对活着的 Item 对象。
C 成立。std::destroy_at(p) 结束 Item 生命周期,但 raw 数组对象本身仍在 check 的作用域内。后续可以在同一段对齐存储上再次构造新的 Item,前一个 Item 的对象身份已经结束。
D 成立。items.emplace_back(7, 8.0) 让 vector 在内部存储中构造一个 Item 元素。items 离开作用域时,vector 析构函数销毁已构造元素并释放内部存储。vector 管理元素对象生命周期,元素类型管理自己的成员状态。
本章知识点总结
- 对象身份:对象身份由地址、类型和生命周期共同确定,同一地址在不同时刻可以承载不同对象。
- 原始存储:原始存储只提供字节空间,构造动作才让这段存储进入某个类型的对象生命周期。
- 存储期:存储期描述存储存在区间,生命周期描述对象有效区间,两者需要分开判断。
- 有效对象:只有生命周期已经开始且尚未结束的对象,才能按对应类型读取成员或调用成员函数。
- 对齐要求:
alignof(T)决定对象起始地址约束,allocator 和手写 raw storage 都必须满足该约束。 - 对象大小:
sizeof(T)包含成员值字节和 padding,连续容器用它作为相邻元素步长。 - 所有权入口:所有权入口是资源责任开始的位置,容器需要把构造、销毁和释放路径配对闭合。
- 容器责任:容器拥有元素对象生命周期,元素类型负责维护自己的成员状态和内部资源。
- this 指针:非静态成员函数通过
this操作具体对象,函数实体通常不进入每个对象的存储。 - 成员状态:成员函数调用依赖目标对象仍然活着,生命周期结束后的旧地址不能继续当作对象使用。
- 多态布局:多态类型需要运行时派发支持信息,常见实现通过 vptr 和 vtable 完成动态调用。
- 虚析构:通过基类指针管理派生对象时,虚析构让销毁路径沿动态类型闭合。
- STL 约束:STL 实现必须同时满足存储分配、对象构造、异常回滚、结构提交和 iterator 有效性约束。
- 判断顺序:读容器源码时先看存储,再看生命周期,再看对齐和布局,再看所有权,最后看 iterator 指向的对象身份。