31 / 80 · C++20 · 约 10 分钟
内存模型与对象生存期 Object lifetime
一块地址可用的存储,不等于其中已经存在可访问的对象。判断访问是否合法,要同时检查大小、对齐、对象生存期、访问类型和边界;构造与析构决定对象的阶段,分配与释放决定底层存储的阶段。
先分清存储、对象与值
存储是容纳对象的区域,对象具有类型、生存期和表示,值则由对象的状态决定。通常取得大小与对齐都合适的存储,并完成初始化后,对象生存期才开始。仅有一个数值看起来合理的地址,并不能证明那里有一个可访问的对象。
对象表示由字节组成,但字节并不是任意类型之间的通行证。把地址强转成另一个指针类型,不会自动完成构造、保证对齐或许可违反类型访问规则。复制可平凡复制类型的表示有专门保证,不应据此用字节复制模拟字符串的复制构造。
构造与分配可以分开
容器通常先取得可容纳多个元素的存储,再逐个构造元素。C++20 的 std::allocator<T>::allocate 为数组提供存储并开始数组对象的生存期,但不构造其中的元素;std::construct_at 才在指定位置构造目标元素。容量存在不等于元素已经存在。
示例只分配一个位置,构造一个不抛异常的 Item,访问其成员,再用 std::destroy_at 结束它的生存期,最后归还存储。析构后不再读取成员。这里故意展开低层步骤以解释容器原理,业务代码应优先直接使用容器。
沿时间线审查一次访问
审查指针时先问对象在哪里创建,接着问哪个操作结束其生存期,最后检查所有借用是否跨过该节点。类对象的生存期在析构函数开始调用时结束;析构期间另有访问规则,不能把这个表述误读成析构函数不许处理成员。
存储可以被复用,因此地址相等不代表对象身份一直不变。透明替换和 std::launder 属于有严格条件的底层机制,不能修复已经释放的内存。这里讨论对象与存储;并发语境下的内存模型还涉及数据竞争和可见性,生存期正确并不自动保证线程安全。
容易答错的地方
vector::reserve只增加容量,不能随后用下标访问尚未构造的元素;需要resize或插入。- 显式析构普通局部对象后若不依法重建,同一对象在离开作用域时可能再次被析构;不要把手工析构当作提前释放的通用方法。
运行一个例子
最低标准 C++20 · 完整程序 · 下载 .cpp
#include <cassert>
#include <memory>
struct Item {
int value;
explicit Item(int n) noexcept : value(n) {}
~Item() noexcept {}
};
int main() {
std::allocator<Item> allocator;
Item* storage = allocator.allocate(1);
Item* item = std::construct_at(storage, 42);
assert(item->value == 42);
std::destroy_at(item);
allocator.deallocate(storage, 1);
}
在本地编译
g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread memory-memory-model.cpp -o example && ./example预期结果
预期:正常退出、无输出;所有 assert 通过。
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
如果 Item 的构造函数可能抛异常,怎样保证 allocate 得到的存储不会泄漏?
查看参考答案
把 construct_at 放入 try 块,catch (...) 中调用 allocator.deallocate(storage, 1) 后重新抛出;成功后才进入访问、destroy_at、deallocate 路径。构造失败的 Item 不需要 destroy_at,因为它没有完成构造,但已分配的存储仍须归还。若成功后的工作也可能抛异常,还要为已构造对象增加析构保护,实际代码通常交给容器完成。
继续查证
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。