C++ 智能指针的选择只看所有权:单一所有者用 unique_ptr,多个共同所有者用 shared_ptr,只观察不拥有用 weak_ptr。先确定谁负责释放,再写类型。

你把裸指针替换成 shared_ptr 后,程序可能不再崩溃,却出现对象迟迟不析构;把所有地方都改成 unique_ptr,又会卡在复制和共享关系上。问题不在语法,而在对象生命周期没有画清。本文基于 C++17 的智能指针语义,用 Repository、Service 和双向节点三个完整例子说明选择边界。本机没有 C++ 编译器,代码给出预期结果(未在本机执行),不声称完成本地编译。
一、先看结论:三类智能指针怎么选
| 关系 | 推荐类型 | 能否复制 | 是否影响对象寿命 | 典型场景 |
|---|---|---|---|---|
| 唯一负责人 | unique_ptr |
不能,只能 move | 是 | 工厂返回值、独占资源 |
| 多个共同负责人 | shared_ptr |
可以 | 是,引用计数归零时释放 | 会话、缓存、共享配置 |
| 非拥有观察者 | weak_ptr |
可以 | 否,需要 lock 后访问 |
反向引用、观察者、缓存 |
| 仅访问不拥有 | T& / const T& |
不适用 | 否 | 函数参数 |
| 可空访问不拥有 | T* |
不适用 | 否 | 可选参数、非拥有指针 |
一句话:先确定谁负责释放,再写类型;单一所有者优先 unique_ptr,真实共享才用 shared_ptr,只观察用 weak_ptr。
二、先把所有权和访问权分开
所有权回答“谁负责让对象活着并最终释放”;访问权只回答“谁现在可以使用对象”。裸指针和引用很适合表达访问,却不会自动说明释放责任。智能指针把所有权写进类型,让异常路径和提前返回也能依靠析构完成清理。
如果构造、析构和指针基础还不稳,先过一遍 C++ 基础教程。智能指针不是“更安全的星号”,而是 API 对生命周期的承诺。
| 概念 | 回答的问题 | 常见表达 |
|---|---|---|
| 所有权 | 谁负责释放? | unique_ptr、shared_ptr |
| 访问权 | 谁现在可以用? | T&、const T&、T* |
| 观察权 | 谁只想看看? | weak_ptr、裸指针 |
三、unique_ptr:默认从单一所有者开始
大多数动态对象都应从 unique_ptr 开始。下面的 Repository 只由 main 拥有,Service 暂时借用它,不需要参与释放。
#include <iostream>
#include <memory>
#include <string>
class Repository {
public:
std::string find_name(int id) const {
return id == 1 ? "w3cschool" : "unknown";
}
};
class Service {
public:
// Service 只借用 Repository 的引用,不拥有它
explicit Service(const Repository& repo) : repo_(repo) {}
void print(int id) const {
std::cout << repo_.find_name(id) << '\n';
}
private:
// 引用成员:表示“我能访问,但不负责释放”
const Repository& repo_;
};
int main() {
// 创建唯一所有者:main 负责释放 Repository
auto repo = std::make_unique<Repository>();
// 把引用传给 Service,Service 不参与所有权
Service service(*repo);
service.print(1);
}
预期输出(未在本机执行)是:
w3cschool
Service 使用引用表达“我能访问,但我不拥有”。若要把 Repository 的所有权转交给另一个组件,应使用 std::move;move 后旧 unique_ptr 为空,不能再解引用。
// 所有权从 repo 转移到另一个组件
auto another = std::move(repo);
// 此时 repo 为空,不能再解引用
// repo->find_name(1); // 错误:repo 已为空
unique_ptr 的优势不只是没有引用计数开销,更重要的是结构清楚:任何时刻只有一个释放负责人。工厂函数也应优先返回 unique_ptr,让调用方明确接管对象。
| 操作 | 是否允许 | 说明 |
|---|---|---|
复制 unique_ptr |
不允许 | 所有权唯一 |
移动 unique_ptr |
允许 | 所有权转移,原指针置空 |
| 解引用 | 允许,但需非空 | move 后不可解引用 |
| 作为函数参数 | 按需 | 转移所有权用 std::move,只访问用引用 |
四、shared_ptr:只有共同决定寿命时才使用
shared_ptr 适合多个独立组件都需要保证对象存活的场景。例如后台任务和界面控制器都持有同一 Session,任意一方结束都不能立即销毁对象。
#include <iostream>
#include <memory>
struct Session {
// 析构时打印,观察释放时机
~Session() { std::cout << "session closed\n"; }
};
void background_job(std::shared_ptr<Session> session) {
// use_count 仅用于演示,不应作为并发业务判断
std::cout << "job owners=" << session.use_count() << '\n';
}
int main() {
// 创建共享所有者:引用计数初始为 1
auto session = std::make_shared<Session>();
std::cout << "main owners=" << session.use_count() << '\n';
// 传值会增加引用计数,函数返回后恢复
background_job(session);
std::cout << "after job=" << session.use_count() << '\n';
// main 结束时引用计数归零,Session 析构
}
预期输出(未在本机执行):
main owners=1
job owners=2
after job=1
session closed
进入 background_job 时计数增加,函数返回后回到 1,main 结束时打印 session closed。use_count 适合演示,不适合作为并发业务判断,因为读取后计数可能立即变化。
⚠️ 注意:不要从同一个裸指针分别构造两个
shared_ptr。它们会形成两个控制块,最终对同一地址释放两次。优先使用make_shared。
// 错误:两个 shared_ptr 从同一个裸指针构造,会重复释放
// Session* raw = new Session();
// std::shared_ptr<Session> a(raw);
// std::shared_ptr<Session> b(raw);
// 正确:使用 make_shared 创建单一控制块
auto a = std::make_shared<Session>();
auto b = a; // 共享同一控制块
| 写法 | 控制块数量 | 是否安全 |
|---|---|---|
std::make_shared<T>() |
1 | 安全 |
shared_ptr<T>(new T) |
1 | 安全,但效率略低 |
同一裸指针构造两个 shared_ptr |
2 | 危险,重复释放 |
五、weak_ptr:打断循环引用并安全观察
shared_ptr 最大的边界是循环引用。父节点拥有子节点,子节点若也 shared_ptr 回指父节点,两边计数都无法归零。回指通常只表示观察关系,应改成 weak_ptr。
#include <iostream>
#include <memory>
#include <string>
struct Parent;
struct Child {
// 子节点只观察父节点,不拥有它
std::weak_ptr<Parent> parent;
};
struct Parent {
std::string name = "root";
// 父节点拥有子节点
std::shared_ptr<Child> child = std::make_shared<Child>();
};
int main() {
// 创建父节点,引用计数为 1
auto parent = std::make_shared<Parent>();
// 子节点弱引用父节点,不增加强引用计数
parent->child->parent = parent;
// lock 成功则返回 shared_ptr,失败则返回空
if (auto owner = parent->child->parent.lock()) {
std::cout << owner->name << '\n';
}
}
预期输出(未在本机执行)是:
root
weak_ptr 不增加强引用计数,lock 会在对象仍存活时返回 shared_ptr,已释放时返回空指针。它适合缓存、观察者和反向关系,不适合承担必然存活的前置条件。
| 操作 | shared_ptr |
weak_ptr |
|---|---|---|
| 增加强引用计数 | 是 | 否 |
| 直接解引用 | 可以 | 不可以 |
| 访问对象 | 直接访问 | 先 lock 再判断 |
| 影响对象寿命 | 是 | 否 |
理解裸指针、地址和值传递时,也可以旁查 C 语言指针教程。但在 C++ API 中,裸指针更适合表达非拥有访问,释放职责应由对象、容器或智能指针清晰承担。
六、C++ 智能指针的选型与常见失败
做设计评审时,可以按顺序提问:
| 顺序 | 问题 | 推荐选择 |
|---|---|---|
| 1 | 对象能否直接放在栈上或作为成员? | 不需要动态分配 |
| 2 | 如果必须动态创建,是否只有一个所有者? | unique_ptr |
| 3 | 多个组件是否真的共同决定寿命? | 是才用 shared_ptr |
| 4 | 某条关系是否只想观察? | 引用、裸指针或 weak_ptr |
| 5 | 图结构是否存在环? | 至少一条回边用 weak_ptr |
失败排查时,重复释放通常来自混用手动 delete 与智能指针,内存不释放通常来自 shared_ptr 环,偶发空访问则要检查 weak_ptr lock 的返回值。把所有权图画出来,往往比盯着崩溃栈更快。
| 常见失败 | 现象 | 原因 | 修复方向 |
|---|---|---|---|
| 重复释放 | 崩溃、double free | 手动 delete 与智能指针混用 |
只用智能指针管理释放 |
| 内存不释放 | 对象迟迟不析构 | shared_ptr 循环引用 |
回边改为 weak_ptr |
| 空访问 | 偶发崩溃 | weak_ptr lock 失败未判断 |
先判断再访问 |
| 复制错误 | 编译报错 | 试图复制 unique_ptr |
改为引用或 std::move |
| 控制块分裂 | 重复释放 | 同一裸指针构造多个 shared_ptr |
统一用 make_shared |
用一个生命周期测试验证选型
前置条件是准备支持 C++17 的编译器,并打开警告选项。先为示例对象增加构造、析构日志,再分别运行单一所有者、共同所有者和循环引用三组程序:
| 测试场景 | 预期结果 |
|---|---|
unique_ptr 移交后 |
原指针为空,新指针有效 |
最后一个 shared_ptr 离开作用域 |
析构日志恰好出现一次 |
双向关系一端改为 weak_ptr |
两个对象都正常析构 |
| 异常路径 | 栈展开仍释放对象 |
weak_ptr lock 失败 |
返回空,不崩溃 |
unique_ptr 移交后,原指针应为空;最后一个 shared_ptr 离开作用域后,析构日志应恰好出现一次;将双向关系的一端改为 weak_ptr 后,两个对象都应正常析构。若编译阶段报告复制 unique_ptr,应把接口改为引用或显式 std::move,而不是换成 shared_ptr 绕过错误。
验证结果还要覆盖异常路径:在创建资源后主动抛出异常,确认栈展开仍会释放对象;对 weak_ptr 调用 lock 前先让最后一个 shared_ptr 离开作用域,预期 lock 返回空。本文代码按 C++17 语义检查,但当前环境没有 C++ 编译器,因此没有声称实际执行;复制到项目后应以编译警告、析构次数和内存检测工具结果作为最终证据。

七、函数参数传递建议
| 参数类型 | 是否拥有 | 是否可空 | 适用场景 |
|---|---|---|---|
T& |
否 | 否 | 函数内访问,且对象必然存在 |
const T& |
否 | 否 | 只读访问,且对象必然存在 |
T* |
否 | 是 | 可选访问,不参与释放 |
unique_ptr<T> |
转移所有权 | 否 | 函数接管对象 |
shared_ptr<T> |
共享所有权 | 否 | 函数需要延长对象寿命 |
weak_ptr<T> |
否 | 是 | 函数需要观察但可能已释放 |
只有函数需要延长对象寿命或保存所有权时才传 shared_ptr。只在调用期间访问,优先传 T&、const T& 或指针表达可空访问。
总结
C++ 智能指针不是按“功能多少”选择,而是按所有权数量选择。unique_ptr 是默认起点,shared_ptr 只用于真实共同所有,weak_ptr 负责观察和打断环。
接口设计时先尝试值、引用和栈对象,再考虑动态分配;已经使用 shared_ptr 的代码,则重点审查控制块来源和循环引用。类型把生命周期说清之后,异常安全和资源释放才会自然变简单。
延伸学习
- 通过 C++ 入门课程 系统补齐类、构造函数与 STL;
- 阅读 C++ 入门路径笔记,安排指针和对象模型的学习顺序;
- 对照 Python 与 C++ 选择指南,理解自动内存管理与显式所有权的差异。
常见问题
Q:shared_ptr 是不是比 unique_ptr 更通用?
A:功能更多不代表更适合。shared_ptr 增加控制块、原子计数和环引用风险;单一所有权用 unique_ptr 语义更清楚、成本也更低。
Q:函数参数应该传 shared_ptr 还是引用?
A:只有函数需要延长对象寿命或保存所有权时才传 shared_ptr。只在调用期间访问,优先传 T&、const T& 或指针表达可空访问。
Q:weak_ptr 能直接解引用吗?
A:不能。先调用 lock 获取临时 shared_ptr,再判断是否为空。这样能避免观察对象已经析构后继续访问。
Q:可以对智能指针指向的对象调用 delete 吗?
A:不可以。对象的释放由智能指针控制,手动 delete 会造成重复释放或悬空指针。若需要定制释放方式,应在构造智能指针时提供删除器。

免费 AI IDE



