对象生命周期、RAII 与所有权
现代 C++ 的资源管理可以从一个问题开始:
谁拥有资源,资源应该在什么时候释放?
这里的资源不只是堆内存,还包括文件句柄、Socket、互斥锁、GPU Texture、Buffer、音频设备和操作系统对象。它们都有明确的获取与释放协议。
一、对象生命周期
对象生命周期是对象从正式存在到不再存在的时间区间:
void update()
{
Player player; // 构造完成,生命周期开始
player.move();
} // 执行析构,生命周期结束概念上的过程是:
准备存储空间
↓
执行构造函数
↓
对象进入生命周期
↓
使用对象
↓
执行析构函数
↓
对象生命周期结束拥有一块足够大的原始内存,不代表其中已经存在某个类型的对象。对象只有经过符合语言规则的构造,才能按照该类型访问。
二、RAII:把资源绑定到对象
手动管理文件容易遗漏清理路径:
void loadFile()
{
FILE* file = std::fopen("data.bin", "rb");
if (!file) {
return;
}
if (!checkHeader(file)) {
std::fclose(file);
return;
}
if (!loadContent(file)) {
std::fclose(file);
return;
}
std::fclose(file);
}RAII(Resource Acquisition Is Initialization)把资源有效期绑定到 C++ 对象:构造时获取资源,析构时释放资源。
class File final {
public:
explicit File(const char* path)
: handle_(std::fopen(path, "rb"))
{
if (!handle_) {
throw std::runtime_error("failed to open file");
}
}
~File()
{
if (handle_) {
std::fclose(handle_);
}
}
private:
std::FILE* handle_ = nullptr;
};使用时不再需要在每条控制流上手动关闭:
void loadFile()
{
File file("data.bin");
checkHeader(file);
loadContent(file);
} // 正常返回、提前返回或异常展开都会触发析构RAII 的价值不是少写一行清理代码,而是把清理从“依赖调用者记住”变成由对象生命周期确定性保证。
同样的思想也适用于锁:
{
std::lock_guard lock(mutex);
updateSharedState();
} // 自动解锁三、所有权与借用
裸指针只保存地址,无法单独表达是否可空、由谁释放以及能够存活多久。接口应尽量表达语义:
| 表达方式 | 常见含义 |
|---|---|
T | 值语义,由当前对象直接拥有 |
T& | 必须存在的可修改借用 |
const T& | 必须存在的只读借用 |
T* | 可空的非拥有借用,或与旧接口交互 |
std::unique_ptr<T> | 唯一所有权 |
std::shared_ptr<T> | 共享所有权 |
std::weak_ptr<T> | 不延长生命周期的观察者 |
所有权通常分为三类:
唯一所有权:Owner ──owns──→ Resource
共享所有权:Owner A ──┐
├──owns──→ Resource
Owner B ──┘
非拥有借用:Owner ──owns──→ Resource
↑
Observer ─borrows─┘四、为什么资源包装器不能浅复制
如果让编译器直接复制前面的 File:
File fileA("data.bin");
File fileB = fileA;默认成员复制只会复制句柄地址:
fileA.handle_ ──┐
├──→ 同一个 FILE
fileB.handle_ ──┘两个对象析构时会重复关闭同一文件,产生未定义行为。唯一资源应禁止复制:
File(const File&) = delete;
File& operator=(const File&) = delete;但它可以允许移动,把所有权转交给另一个对象:
File(File&& other) noexcept
: handle_(std::exchange(other.handle_, nullptr))
{
}移动后,新对象拥有文件,源对象为空且仍可安全析构。
五、unique_ptr:动态对象的默认所有权
auto player = std::make_unique<Player>();unique_ptr 禁止复制、允许移动,并在析构时自动删除对象:
auto playerA = std::make_unique<Player>();
auto playerB = std::move(playerA);
// playerA == nullptr
// playerB 唯一拥有 Player优先使用 make_unique:
auto player = std::make_unique<Player>(name, health);接口形式可以表达所有权转移:
void update(Player& player); // 借用并修改
void inspect(const Player& player); // 只读借用
void addPlayer(std::unique_ptr<Player> player); // 接管所有权
std::unique_ptr<Player> createPlayer(); // 返回所有权get() 只取得非拥有地址:
Player* borrowed = player.get();不能通过 borrowed 删除对象。release() 会放弃所有权但不删除对象,容易造成泄漏,只适合把所有权交给明确接管裸指针的旧接口。
六、shared_ptr:真正的共享所有权
auto textureA = std::make_shared<Texture>();
auto textureB = textureA;此时两个 shared_ptr 共同拥有 Texture。最后一个强所有者离开时,Texture 才会析构。
控制块
shared_ptr 除了目标对象地址,还关联一个控制块。控制块通常包含:
- 强引用计数;
- 弱引用计数;
- 删除器;
- 分配器信息。
直接从裸指针构造通常涉及两次独立分配:
std::shared_ptr<Texture> texture(new Texture());第一次分配 第二次分配
┌──────────────┐ ┌─────────────────────┐
│ Texture │ │ Control Block │
│ 目标对象本身 │ │ strong / weak / ... │
└──────────────┘ └─────────────────────┘make_shared 通常一次申请一块足够大的内存,再在其中构造控制块和 Texture:
auto texture = std::make_shared<Texture>();一次堆分配得到的整块内存
┌─────────────────────┬──────────────┐
│ Control Block │ Texture │
│ strong / weak / ... │ 目标对象本身 │
└─────────────────────┴──────────────┘这只是概念图,具体顺序、间距和布局由标准库实现决定。一次分配表示只向堆分配器申请一次,不表示这块内存只能容纳一个 C++ 对象。
强引用归零时 Texture 会析构。如果仍有 weak_ptr,控制块必须继续存在;共同分配的整块内存也可能暂时无法归还给分配器。因此“对象已经析构”和“承载对象的内存已经释放”不一定发生在同一时刻。
引用计数安全不等于对象安全
不同 shared_ptr 副本对同一控制块进行引用计数操作,通常可以安全地发生在不同线程。但这不代表 Texture 自身可以被无锁并发修改:
texture->setWidth(100); // 线程 A
texture->setWidth(200); // 线程 B:仍可能产生数据竞争还要区分三层:
| 层次 | 是否由 shared_ptr 自动保证 |
|---|---|
| 控制块引用计数 | 通常安全 |
同一个 shared_ptr 变量被并发修改 | 不自动安全 |
shared_ptr 指向的对象 | 不自动安全 |
七、weak_ptr 与引用环
两个对象如果用 shared_ptr 相互拥有,会形成引用环:
struct Node {
std::shared_ptr<Node> next;
};Node A ──shared_ptr──→ Node B
Node A ←─shared_ptr─── Node B外部所有者离开后,两边强引用计数仍不为零,两个对象都无法销毁。
非拥有的反向关系可以使用 weak_ptr:
struct Node {
std::shared_ptr<Node> child;
std::weak_ptr<Node> parent;
};访问前使用 lock() 临时取得所有权:
if (auto parent = node.parent.lock()) {
parent->update();
}lock() 把“确认对象仍然存在”和“取得临时强引用”组合成一个操作。单独先检查 expired() 再访问,会在多线程环境中留下检查后对象被销毁的竞态窗口。
八、为什么引擎资源常用 Manager + Handle
如果材质、UI 和异步任务都保存同一 Texture 的 shared_ptr:
MaterialSystem ──shared_ptr──┐
UISystem ──shared_ptr──┼──→ Texture
AsyncTask ──shared_ptr──┘生命周期由所有系统共同决定。场景已经退出时,只要一个异步任务仍持有强引用,纹理就继续占用 CPU 与 GPU 内存。
集中资源管理采用:
TextureManager ──唯一拥有──→ Texture Storage
▲
│ Handle 查询
│
Material / UI / Entity / Task外部只保存轻量句柄:
struct TextureHandle {
std::uint32_t index;
std::uint32_t generation;
};Handle 为什么要增加一层间接访问
直接指针绑定的是某次加载产生的地址:
Material → Texture* → 地址 0x1000Handle 引用的是资源身份:
Material → Handle → Manager Slot → 当前 Texture所有访问经过管理器这个检查点,管理器因此可以:
- 检测旧引用是否已经失效;
- 在外部不改 Handle 的情况下热重载资源;
- 移动或重建内部对象;
- 统一执行加载、卸载、淘汰和内存预算;
- 查询失败时返回占位资源;
- 等待 GPU Fence 后再真正销毁资源。
index + generation 如何识别旧句柄
资源表中有一个槽位:
slot 2: generation=4, Texture B外部拿到:
TextureHandle { index=2, generation=4 }资源被销毁后,槽位的 generation 增加:
slot 2: generation=5, 空旧 Handle {2, 4} 再来查询时 generation 不匹配,因此失效。即使 slot 2 以后被 Texture C 复用,旧 Handle 也不会错误访问新对象:
旧 Handle {2, 4} → 无效
新 Handle {2, 5} → Texture C简化的查询逻辑:
Texture* TextureManager::get(TextureHandle handle)
{
if (handle.index >= slots_.size()) {
return nullptr;
}
Slot& slot = slots_[handle.index];
if (slot.generation != handle.generation || !slot.texture) {
return nullptr;
}
return slot.texture.get();
}间接层的成本
Handle 不是免费方案:
| 增加的成本 | 获得的能力 |
|---|---|
| 每次访问需要查表 | 检测失效引用 |
| 多一次间接寻址 | 热重载与替换资源 |
| 需要处理查询失败 | 占位资源和降级路径 |
| 管理器实现更复杂 | 集中预算、加载和卸载 |
| 需要额外销毁协议 | 等待渲染或 GPU 安全点 |
Handle 也不会自动保证并发安全。渲染线程通过 Handle 得到 Texture* 后,管理器不能在另一个线程立刻销毁 Texture;仍需帧延迟销毁、Fence 或临时 pin 等机制保证实际使用期间的生命周期。
选择原则是:
- 少量、生命周期天然由业务对象共同决定的对象,可以使用
shared_ptr; - 数量大、需要预算、流式加载、热重载和统一卸载的资源,更适合 Manager + Handle。
本章结论
- RAII 把资源释放绑定到对象析构。
- 所有权描述谁负责结束资源生命周期,借用只提供临时访问。
unique_ptr是动态对象的默认所有权模型。shared_ptr只应用于真正存在多个独立所有者的情况。weak_ptr观察共享对象但不延长其生命周期。- Manager + Handle 用一次间接访问换取身份稳定、失效检测和集中生命周期控制。
