智能指针(smart pointer) 用对象表达指针的所有权和释放规则。std::unique_ptr独占对象,std::shared_ptr共享对象,std::weak_ptr只观察由shared_ptr管理的对象。
shared_ptr不是通用的安全升级。为同一个裸指针创建两个控制块会重复释放,强引用环则会让计数永远无法归零。
默认从make_unique开始,只有确实存在多个共同所有者时才使用make_shared。非拥有关系使用引用、裸指针或weak_ptr,并在访问前用lock()取得临时强所有权。
是什么,为什么存在
C++智能指针是定义在<memory>中的指针式RAII对象。它把“谁负责销毁对象”写进类型及其复制、移动和析构行为,因而能让正常返回、提前返回和异常路径遵循同一套释放规则。它不会替你判断业务上应该由谁拥有对象;这项设计仍由程序员完成。
std::unique_ptr<T>表达 独占所有权(exclusive ownership) :同一时刻只有一个所有者负责销毁T。它不能复制,但可以移动;移动把释放责任交给目标,并把源指针置空。工厂返回值、类的拥有型成员和多态对象容器通常都应先考虑它。
std::shared_ptr<T>表达 共享所有权(shared ownership) :多个指针共同延长一个对象的生命周期,最后一个强所有者离开时销毁对象。它适合生命周期没有天然单一上级的任务、订阅或跨异步操作状态,不适合仅仅因为调用关系尚未梳理清楚就到处复制。
std::weak_ptr<T>是由shared_ptr建立的非拥有观察句柄。它不阻止对象销毁,也不能直接解引用;调用lock()可以在对象仍存活时原子地取得一个临时shared_ptr。父子反向链接、观察者列表和不保活的回调经常需要这种关系。
智能指针管理的是所有权,不是所有访问。只在调用期间使用对象时,T&或T*往往比const shared_ptr<T>&更清楚;接口要延长生命周期或接受所有权时,才应在参数或返回类型中传递智能指针。
工作原理
每个unique_ptr保存一个指针和一个删除器。析构或reset()会在非空时调用删除器,release()则只交出裸指针而不删除对象。默认删除器对单对象调用delete;unique_ptr<T[]>的数组特化调用delete[]并提供下标访问。
make_unique<T>(args...)把构造和接管放在一个表达式中,并避免重复写类型。C++23代码除非要接管已有资源或指定自定义删除器,通常不必直接写new。自定义删除器还能让unique_ptr管理FILE*、操作系统句柄或其他采用成对释放函数的资源。
一个普通shared_ptr在概念上关联两个地址:get()返回的存储指针,以及一个共享的 引用计数控制块(reference-count control block) 。控制块记录被管理对象或其删除器、强所有者数量,以及仍需保留控制块的弱观察者数量。
复制shared_ptr会共享同一个控制块并增加强计数,移动则转交现有关系。最后一个强所有者消失时,被管理对象销毁;仍有weak_ptr时,控制块继续存在,直到最后一个弱观察者也离开。weak_ptr不参与决定被管理对象何时销毁。
从weak_ptr访问对象必须把“检查是否存活”与“延长到本次使用结束”合成一个操作。if (auto owner = weak.lock())成功时,局部owner在作用域内保持对象存活;先调用expired()再另行访问会留下竞争窗口。
三种类型的选择取决于所有权关系:
- 一个位置负责最终销毁时,使用
unique_ptr。 - 多个独立参与者都必须让对象存活时,使用
shared_ptr。 - 某个链接可以失效且不应延长生命周期时,使用
weak_ptr。 - 调用者只在已知生命周期内访问时,使用引用或裸指针表达借用。
接口中的传递方式同样构成契约:
| 形参或结果 | 表达的语义 | 调用方可预期的变化 |
|---|---|---|
std::unique_ptr<T>按值传入 | 转移独占所有权 | 调用方通常传入std::move(pointer) |
std::unique_ptr<T>返回 | 产生新的独占所有者 | 返回值接管对象 |
std::shared_ptr<T>按值传入 | 函数取得一份共享所有权 | 调用期间至少增加一个强所有者 |
T&或T* | 在约定期限内借用 | 不改变所有权 |
std::weak_ptr<T> | 可失效的观察关系 | 使用前必须调用lock() |
make_shared<T>(args...)通常把对象与控制块放在一次分配中,但标准只规定可观察语义,不要求某一种内存布局。这个组合分配也意味着:最后一个强引用销毁对象后,只要弱引用仍在,包含对象存储的整块分配可能还不能释放。
示例
下面三个程序依次展示独占转移、用弱链接打断环,以及不保活的延迟回调。它们都使用GCC 13.3.0按-std=c++23 -Wall -Wextra -Wpedantic -Werror编译并运行,输出来自实际执行。
转移独占所有权
工厂创建唯一所有者,std::move把报告交给下一个位置。按值接收unique_ptr的archive明确消费所有权,函数返回时报告就在参数析构期间关闭。
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Report {
public:
explicit Report(std::string name) : name_(std::move(name)) {
std::cout << "open " << name_ << '\n';
}
~Report() { std::cout << "close " << name_ << '\n'; }
const std::string& name() const { return name_; }
private:
std::string name_;
};
void archive(std::unique_ptr<Report> report) {
std::cout << "archive " << report->name() << '\n';
}
int main() {
auto draft = std::make_unique<Report>("quarterly");
std::cout << "owner " << draft->name() << '\n';
auto active = std::move(draft);
std::cout << "draft empty: " << std::boolalpha << !draft << '\n';
archive(std::move(active));
std::cout << "active empty: " << !active << '\n';
}open quarterly
owner quarterly
draft empty: true
archive quarterly
close quarterly
active empty: true移动后的draft和active都处于可析构、可赋值的空状态。这里可以检查它们是否为空,但实际业务代码一般应立即停止依赖已经转移所有权的变量。
用弱链接表达反向关系
团队拥有成员,成员只观察团队。如果两个方向都使用shared_ptr,两个对象会形成强引用环;把反向链接写成weak_ptr后,释放最后一个团队所有者会正常销毁团队。
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Team;
struct Member {
explicit Member(std::string value) : name(std::move(value)) {}
~Member() { std::cout << "destroy member " << name << '\n'; }
void show_team() const;
std::string name;
std::weak_ptr<Team> team;
};
struct Team {
explicit Team(std::string value) : name(std::move(value)) {}
~Team() { std::cout << "destroy team " << name << '\n'; }
std::string name;
std::vector<std::shared_ptr<Member>> members;
};
void Member::show_team() const {
if (auto owner = team.lock()) {
std::cout << name << " works in " << owner->name << '\n';
} else {
std::cout << name << " has no team\n";
}
}
int main() {
auto team = std::make_shared<Team>("compiler");
auto member = std::make_shared<Member>("Ada");
member->team = team;
team->members.push_back(member);
std::cout << "team owners: " << team.use_count() << '\n';
std::cout << "member owners: " << member.use_count() << '\n';
member->show_team();
auto saved_member = team->members.front();
member.reset();
team.reset();
saved_member->show_team();
}team owners: 1
member owners: 2
Ada works in compiler
destroy team compiler
Ada has no team
destroy member Adasaved_member让成员继续存活,但成员的弱链接不会反过来保活团队。lock()失败是正常状态,不是异常;调用方必须决定跳过、删除记录还是报告目标已经离开。
建立不保活的回调
对象若把捕获自身shared_ptr的lambda保存在自己的成员中,就会形成自环。这个回调捕获weak_from_this(),执行时再尝试升级,因此任务队列不会仅仅因为保存了回调就让Worker永久存活。
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Worker : public std::enable_shared_from_this<Worker> {
public:
explicit Worker(std::string value) : name_(std::move(value)) {}
~Worker() { std::cout << "destroy " << name_ << '\n'; }
std::function<void()> callback() {
std::weak_ptr<Worker> self = weak_from_this();
return [self] {
if (auto worker = self.lock()) {
std::cout << "run " << worker->name_ << '\n';
} else {
std::cout << "worker expired\n";
}
};
}
private:
std::string name_;
};
int main() {
auto worker = std::make_shared<Worker>("index");
auto run_later = worker->callback();
run_later();
worker.reset();
run_later();
}run index
destroy index
worker expiredweak_from_this()在对象尚未由兼容的shared_ptr管理时会得到空弱指针。若回调的契约要求对象必须存活,就应让注册者明确持有shared_ptr;不要把失败升级悄悄解释为成功执行。
陷阱
把共享所有权当作默认值
修复: 先画出谁必须让对象存活。存在唯一自然所有者时用unique_ptr,只有多个参与者确实能独立延长生命周期时才用shared_ptr,只访问则传T&或T*。
从同一个裸指针建立两个控制块
修复: 在一个地方使用make_shared,随后只复制所得的shared_ptr。成员函数需要共享自身时继承enable_shared_from_this<T>,并保证对象已经由兼容的shared_ptr管理,再调用shared_from_this()。
留下强引用环
修复: 根据领域关系选出不拥有的一边,并改用weak_ptr。测试中释放所有外部强所有者并观察析构,同时检查容器中的过期弱项是否需要定期清理。
在检查后继续使用弱观察结果
修复: 每次使用都调用一次lock(),并在一个局部shared_ptr的作用域内完成访问。把升级失败设计成显式分支,不要用use_count()猜测接下来是否安全。
让get()或release()模糊责任
修复: 只把get()用于不会接管且不会保存地址的旧接口。确实需要转移到接受裸指针的API时,先确认该API的接管契约,再调用release()并记录新的唯一释放者。
误解共享指针的线程安全
修复: 为T的状态建立自己的同步策略;多个线程需要读写同一个智能指针变量时,使用锁或std::atomic<std::shared_ptr<T>>。不要把原子引用计数误当成对象内部的数据竞争保护。
控制块身份与别名
shared_ptr的共同所有权由控制块身份决定,不由get()的数值决定。两个shared_ptr即使存储相同地址,也可能属于不同控制块;反过来,别名构造函数可以让它们共享控制块却让get()指向被管理对象的某个成员。
别名构造形式std::shared_ptr<Member>(owner, member_address)让结果与owner共同拥有原对象,但解引用结果访问的是成员地址。只要别名指针存在,完整拥有对象就保持存活,因此成员地址仍然有效。这个工具适合把子对象视图与父对象生命周期绑定,但API文档必须说明返回值拥有的是谁、指向的又是谁。
owner_before和std::owner_less比较的是所有权关系,可用于按控制块身份组织关联容器。C++23没有提供对应的所有权哈希接口,因此本主题的示例不能假定它存在。
use_count()主要适合诊断。它返回调用瞬间观察到的强所有者数量,不能证明调用者是唯一所有者,也不能替代同步或生命周期协议;判断结果到下一条语句之间,其他线程可能复制或销毁自己的指针。
make_shared通常减少一次独立分配并让构造过程保持异常安全,但自定义删除器、需要单独控制对象存储释放时机,或接管既有资源时可能需要直接构造shared_ptr。无论采用哪种创建方式,同一个对象都只能进入一个所有权组。
创建与接管边界
创建函数应返回最弱但足够的所有权类型。如果新对象天然只有一个接收者,返回unique_ptr能保留调用方的选择;调用方可以继续独占,也可以用移动构造把它提升为shared_ptr。工厂若直接返回shared_ptr,所有调用者从一开始就承担共享控制块语义。
从unique_ptr移动构造shared_ptr会建立一个新控制块,并把对象与删除器一同转交。源unique_ptr随后为空,这次转换仍保持唯一的释放路径。标准库没有把任意shared_ptr降回unique_ptr的操作,因为即使此刻看似只有一个强所有者,也无法恢复原来的独占契约。
接管裸指针是一项边界操作,不是普通复制。只有一个位置可以把某个动态对象首次放进拥有型智能指针,而且删除器必须匹配它的创建方式。其他位置只能复制现有shared_ptr,或在原所有者保证的期限内借用地址。
所有权接口可以按承诺强度排列:
| 接口需求 | 合适的形式 | 不应暗示的事情 |
|---|---|---|
| 创建并交出单一所有者 | 返回unique_ptr<T> | 调用方必须共享 |
| 接受并消费单一所有者 | 按值接收unique_ptr<T> | 调用后源对象仍可访问 |
| 加入共同所有权 | 按值接收shared_ptr<T> | 被管理对象自动同步 |
| 可能在以后使用但不保活 | 保存weak_ptr<T> | 回调执行时对象一定存在 |
| 只完成当前调用 | 接收T&或T* | 被调用方会保存或释放对象 |
const shared_ptr<T>&可以避免一次临时计数变化,却没有自动说明函数是否会把它复制到别处。若函数只操作对象,接收T&会把非拥有语义写得更直接;若函数必须在返回后继续保活对象,按值接收并移动到成员中更符合契约。
enable_shared_from_this<T>不会自行创建所有权。它让已经加入某个兼容控制块的对象取得同一所有权组中的新shared_ptr;在构造函数中或栈对象上调用shared_from_this()会因为尚未建立这种关系而抛出std::bad_weak_ptr。
weak_from_this()适合生成允许失效的句柄,它在尚未受管理时返回空弱指针而不抛出。空结果仍需要符合业务契约:初始化顺序错误不应被一概当成“对象稍后自然消失”。
释放、重置与关闭协议
unique_ptr::reset()会替换保存的地址,并在旧地址非空时对它调用删除器;不带参数时把指针清空。release()只清空并返回地址,不调用删除器。两者名称相近但责任完全不同,审查生成代码时应逐个追踪返回的裸地址。
空状态仍有明确语义
默认构造、从空指针构造或移出后的智能指针都可以安全析构、重新赋值和查询。空unique_ptr不会调用删除器;不关联控制块的空shared_ptr,其use_count()为零。空状态可用于表达没有对象,但不应在未检查时解引用。
shared_ptr的布尔转换只检查存储指针是否为空,不直接检查控制块。别名构造甚至能产生get() == nullptr、布尔值为假但仍共同拥有另一个对象的指针,因此所有权身份判断不能简化为地址或布尔值比较。
shared_ptr::reset()只放弃当前这一个强所有权。对象是否立即析构取决于其他shared_ptr,包括函数参数、lambda捕获、异步任务和别名指针;调用reset()后观察到对象仍存在并不表示释放失败。
析构发生在让强计数降为零的那次操作中。因此,最后一个所有者位于哪个线程,会影响析构在哪个线程执行。需要线程亲和性、低延迟队列或显式关闭错误处理的资源,应增加专门的close()或调度协议,而不能把全部语义压在删除器上。
弱回调只解决“排队任务是否保活对象”,不能取消已经成功lock()的任务。注销逻辑还要定义是否等待正在执行的回调、回调能否重入,以及对象状态由什么同步机制保护。
所有权测试应观察析构条件,而不只看最终输出。至少覆盖移动后源值、最后一个强引用离开、弱升级失败、强环断开和接管失败;并用AddressSanitizer等动态工具检查已执行路径中的重复释放与释放后使用。
数组、删除器与并发边界
动态数组需要匹配的类型与释放操作。使用std::make_unique<T[]>(count)得到unique_ptr<T[]>,用operator[]访问;不要让单对象unique_ptr<T>接管new T[count]。C++20起也有make_shared<T[]>(count),但固定大小的std::array或可变大小的std::vector通常携带更完整的容器接口。
删除器是智能指针类型或控制块语义的一部分。unique_ptr<T, D>把删除器类型放进指针类型,因而不同删除器通常产生不同类型;shared_ptr<T>擦除删除器类型,把具体删除器保存在控制块中。删除器必须与资源的取得方式匹配,并且不应让异常逃出析构路径。
unique_ptr可以持有指向不完整类型的指针,这对Pimpl很有用,但默认删除器实际执行删除的位置必须能看到完整类型。常见做法是在头文件中声明实现类和外层析构函数,再在能看到实现定义的源文件中定义析构函数。
引用计数操作保证控制块本身在并发复制和销毁中保持一致。它不为*pointer的字段、通过get()得到的裸地址,或同一个普通shared_ptr对象上的并发reset()建立同步。需要发布或替换共享指针值时,C++23的std::atomic<std::shared_ptr<T>>提供相应原子操作。
共享所有权会让析构发生在最后一个强引用释放的线程和位置,这往往不是创建者能直接预测的。析构若延迟很高、必须在特定线程执行,或会触发线程连接等阻塞操作,就需要额外的调度与关闭协议,不能只依赖引用计数达到零。
4个问题 · 1道输出预测题 · 1道找错题