RAII、资源所有权与智能指针
本节目标
掌握 C++23 的 RAII、资源所有权模型和智能指针接口设计。
资源管理先回答“谁在何时释放它”。RAII 和标准智能指针把答案放进类型与作用域,而不是散落在每一条控制流分支中。
RAII 的获取与释放合同
RAII 把资源的获取放入管理对象的初始化,把释放放入其析构;资源有效期因此与对象生命周期绑定。局部锁包装器把两端放进同一个作用域:
{
std::lock_guard lock{mutex}; // 构造成功后持有锁
update_shared_state();
} // 析构释放锁
lock 构造成功后,活着的包装器负责维持“当前持有锁”这一不变量。控制流正常到达作用域末尾、从函数提前返回,或因异常展开栈时,已经构造完成的局部对象都会进入析构路径;RAII 对象若由容器持有,容器销毁也会结束这些元素的生命周期并触发相应清理。反过来,要求调用者在每个 return 和异常分支前手写解锁,会把同一项释放责任散落到多条路径上。
RAII 不限于堆内存:文件、互斥锁、socket、事务和会话句柄都可以有专门包装器。析构中的释放必须可靠;需要报告的关闭失败应由接口在更早的显式操作中处理,而不是让析构函数传播异常。
独占、共享与观察所有权
拥有者负责最终释放资源;借用者只在调用约定的有效期内使用资源;观察者不拥有资源,也不延长资源生命周期。三种角色的区别不在于都能否取得一个地址,而在于谁能决定资源继续存活、又由谁完成释放。
| 角色 | 是否拥有 | 是否延长生命周期 | 释放责任 |
|---|---|---|---|
拥有者(unique_ptr 或 shared_ptr) | 是 | 是,在其所有权有效期间保持资源存活 | 按独占或共享合同完成最终释放 |
借用者(T& 或 T*) | 否 | 否 | 不释放;只在拥有者保证的有效期内使用 |
观察者(weak_ptr) | 否 | 否 | 不释放;访问前重新取得短期共享所有权 |
T& 或 T* 通常表达借用,指针是否可为空还要由接口说明。“借用”并不自动等于安全:把借用指针保存得比拥有者活得更久,仍会越过生命周期前提。观察共享对象时也不能把观察本身当作对象仍存在的证明;必须先通过 lock() 取得短期拥有者,并只在它有效期间访问对象。
unique_ptr 的独占所有权
std::unique_ptr<T> 表示唯一所有者:不可复制、可移动,离开作用域时自动释放其管理对象。创建与转移可以沿两个具名对象观察:
auto source = std::make_unique<int>(7);
auto destination = std::move(source);
第一行建立唯一所有权;移动构造把释放责任交给 destination,source 随后仍是有效、可析构或可重新赋值的 unique_ptr,但已经为空,不再拥有这份 int。destination 离开作用域时会通过自己的删除器自动释放管理对象。若反而从同一个裸指针分别构造两个 unique_ptr,两个独占拥有者都会认为自己负责释放同一对象,最终会破坏唯一责任。
把 unique_ptr 按值传给函数通常表示转移所有权;返回 unique_ptr 也能清楚地交付新资源。只读或临时使用资源时,优先接受 T&、const T& 或按需要可为空的借用指针,而不是人为制造所有权迁移。
shared_ptr 的共享所有权
std::shared_ptr<T> 只用于确实需要多个独立拥有者共同延长同一对象生命周期的场景。像 auto secondOwner = firstOwner; 这样的复制会建立另一个独立的 shared_ptr 对象,让两个具名 owner 参与同一份共享所有权;最后一个共享 owner 释放所有权后,才按既定删除策略销毁受管理对象。复制不只是取得一个借用地址,它还要建立新的共享 owner 并更新共享 owner 数,因此为了方便传参而到处复制 shared_ptr,既改变接口语义,也带来所有权维护成本。
代表程序在读取 use_count() 时只有 firstOwner 和 secondOwner 两个共享拥有者,因此输出为 2;没有通过构造额外的 shared_ptr 临时值来测量。计数只适合解释当前演示,不应成为并发逻辑或一般接口的决策依据。
标准保证 shared_ptr 的共享所有权关系、use_count() 所报告的共享 owner 数,以及删除器与受管理对象销毁的效果。“控制块”可以用来解释 owner 共同维护生命周期的关系,但标准不规定物理控制块字段或弱观察计数的布局;实现可以采用不同表示。shared_ptr 可以由构造函数接管已有资源,或由工厂创建受管理对象;无论路径如何,都必须让每份资源只有一条最终释放策略。
按值接收 shared_ptr<T> 时,被调方的参数取得一个共享 owner;传入左值通常会复制并增加 shared owner count,传入右值或 std::move 则可把调用方 owner 转移给参数,使调用方源为空,不能假定双方同时拥有。
weak_ptr 的非拥有观察
std::weak_ptr<T> 是对 shared 对象的非拥有观察;lock() 成功时才得到一个短暂的 shared_ptr 所有者。访问路径必须同时处理成功与失败:
if (auto owner = observer.lock()) {
use(*owner); // owner 存活期间对象保持存活
} else {
handle_expired(); // 没有取得所有权,不访问对象
}
lock() 对“对象尚未过期”与“取得新的共享 owner”执行原子判断;成功返回的 owner 在自身生命周期内保持对象存活,失败则返回空的 shared_ptr。expired() 只报告在该观察瞬间是否已没有共享 owner,不能先检查它,再假定后续使用时对象不会变化。尤其在并发程序中,检查与访问不能拆成两步,必须持有 lock() 返回的共享 owner。
代表程序先在两个共享拥有者存在时得到 weak_alive=true,然后逐个 reset() 两个 owner,再得到 weak_expired=true;这两行固定输出只记录各自观察时点,不把 expired() 当作后续访问许可。
make_unique 与 make_shared
优先使用 std::make_unique 和 std::make_shared 构造智能指针,避免把裸 owning new 暴露在调用点。它们把对象构造和智能指针建立放在一个清晰的表达式中,并减少异常路径上的资源泄漏机会。
| 工厂 | 结果 | 注意 |
|---|---|---|
std::make_unique<T>(args...) | 创建唯一 owner。 | 需要自定义删除器或特殊分配时选择相应构造方式。 |
std::make_shared<T>(args...) | 创建共享 owner 与受管理对象。 | 推荐实践是至多一次内存分配,而不是分配次数或内存布局的保证。 |
对 make_shared,N4950 给出的是实现至多进行一次内存分配的推荐实践,不是强制的分配次数,也不是调用者可依赖的物理共置或布局保证。工厂并不替代所有权设计:先决定是否真的需要唯一或共享生命周期,再选择构造 API。不要先创建裸 owning 指针,再寄望后续代码一定会立即把它包进智能指针。
自定义删除器
自定义删除器把关闭文件、归还句柄等释放动作编码进智能指针的管理策略。文件句柄需要调用自己的关闭接口,而不是默认的 delete:
struct FileCloser {
void operator()(std::FILE* file) const noexcept {
if (file != nullptr) {
std::fclose(file);
}
}
};
std::unique_ptr<std::FILE, FileCloser> file{handle};
FileCloser 定义如何释放该文件句柄,包装器析构时会执行这项策略。默认删除器只适用于能够由对应 delete 或 delete[] 表达式正确释放的对象;用默认 delete 释放并非由 new 得到的文件或系统句柄,跨越了分配与释放必须配对的边界。如果关闭失败必须反馈给调用者,应在对象仍存活时提供可报告结果的显式操作,删除器只保留不抛出的最终收尾。
对 unique_ptr,删除器是类型的一部分,因此会影响类型和移动操作;对 shared_ptr,标准保证它可以拥有删除器,并在拥有对象且最后一个共享 owner 释放该所有权时调用相应删除器;get_deleter 只能查询它所拥有的匹配类型删除器。将删除器放在“控制块”中只是常见实现模型,标准不规定它的物理存放或布局。删除器应只做与释放资源相关的工作,且必须符合析构路径不抛出异常的要求。
非内存资源的 RAII 包装
非内存资源同样可以由局部 RAII 包装器管理;析构函数应保持 noexcept。代表程序把构造、作用域结束与关闭记录连成一条路径:
class SessionGuard {
public:
explicit SessionGuard(bool& closed) noexcept : closed_{closed} {}
~SessionGuard() noexcept { closed_ = true; }
private:
bool& closed_;
};
bool resourceClosed{false};
{
SessionGuard session{resourceClosed};
static_cast<void>(session);
}
SessionGuard 构造成功后持有关闭责任,离开内层作用域时由不抛出的析构函数把状态写为 true,所以固定输出包含 resource_closed=true。这个小模型说明释放与对象结束绑定,并不把布尔值当成真实资源 API;真实包装器可以持有文件描述符、锁令牌或会话状态,并执行对应的关闭动作。若依赖调用者记住每条分支都调用关闭函数,提前返回或异常就可能跳过收尾。
所有权转移与接口签名
接口签名应表达资源会被转移、共享还是借用。按值接收 unique_ptr<T> 表示接管唯一所有权;按值接收 shared_ptr<T> 表示被调方取得一个共享 owner;借用接口通常不需要智能指针。
| 需求 | 常见签名 | 含义 |
|---|---|---|
| 调用期间访问且不可为空 | void inspect(const T& value) | 借用。 |
| 调用期间访问且可以为空 | void inspect(const T* value) | 可空借用;不延长生命周期。 |
| 接管新资源 | void store(std::unique_ptr<T> value) | 转移唯一所有权。 |
| 被调方需要成为共享 owner | void retain(std::shared_ptr<T> value) | 取得或移动一份共享所有权。 |
| 返回可能存在的共享对象 | std::shared_ptr<T> find() | 返回共享所有权。 |
shared_ptr 不是默认的参数类型:按值传递它会表达并制造共享所有权;只借用时应选择引用、指针或 std::span 等与接口匹配的类型。若函数只读取一个已经共享管理的对象,const T& 往往比复制 shared_ptr 更准确。
按值调用的实际效果取决于实参值类别:左值为被调方复制一份 shared owner,右值或 std::move 可以移动 owner;接口设计不能把按值传递误读为调用前后双方必然同时拥有。
共享环与常见陷阱
两个 shared_ptr 相互拥有会形成环,引用计数无法归零;让其中一个方向保存 weak_ptr 才能打破环。共享不是“更安全的裸指针”,它是有成本且必须有真实语义依据的生命周期策略。
| 陷阱 | 后果 | 修正 |
|---|---|---|
父对象与子对象都保存 shared_ptr | 环中对象永远不销毁。 | 逆向或观察边使用 weak_ptr。 |
以 use_count() 判断业务真相 | 临时复制和并发都会改变结果。 | 设计明确的状态与同步协议。 |
| 从同一个裸指针分别建立多个智能 owner | 多条独立删除路径可能重复释放同一对象。 | 只建立一条拥有链,再从既有 owner 转移或共享。 |
用 get() 的结果延长借用生命周期 | owner 结束后裸指针悬垂。 | 让借用不越过 owner;需要共同存活时显式取得 owner。 |
把每一个参数都升级为 shared_ptr | 制造没有语义依据的共享和额外维护成本。 | 只借用时用引用、指针或 span,独占时保留唯一责任。 |
先让 RAII 类型表达唯一责任;只有多个独立组件确实需要共同决定存活期时,才引入共享所有权与弱观察。use_count() 可以解释受控示例,却不能代替业务状态与同步设计。
#include <iostream>
#include <memory>
class SessionGuard {
public:
explicit SessionGuard(bool& closed) noexcept : closed_{closed} {}
~SessionGuard() noexcept { closed_ = true; }
private:
bool& closed_;
};
int main() {
const auto uniqueValue = std::make_unique<int>(7);
std::weak_ptr<int> observer;
int sharedOwners{0};
bool weakAlive{false};
bool weakExpired{false};
{
auto firstOwner = std::make_shared<int>(42);
auto secondOwner = firstOwner;
observer = firstOwner;
sharedOwners = firstOwner.use_count();
weakAlive = !observer.expired();
firstOwner.reset();
secondOwner.reset();
weakExpired = observer.expired();
}
bool resourceClosed{false};
{
SessionGuard session{resourceClosed};
static_cast<void>(session);
}
std::cout << "unique_value=" << *uniqueValue << '\n';
std::cout << "shared_owners=" << sharedOwners << '\n';
std::cout << "weak_alive=" << (weakAlive ? "true" : "false") << '\n';
std::cout << "weak_expired=" << (weakExpired ? "true" : "false") << '\n';
std::cout << "resource_closed=" << (resourceClosed ? "true" : "false") << '\n';
}
输出:
unique_value=7
shared_owners=2
weak_alive=true
weak_expired=true
resource_closed=true