C++ 的内存管理争议了三十年,现代 C++ 给出的答案不是”再小心一点”,而是把所有权写进类型里。智能指针就是这套答案的载体:谁负责释放、什么时候释放,不再靠注释和约定,而是靠编译器检查。这篇文章把三种智能指针的语义、边界和取舍讲清楚。
一、所有权为什么值得单独说
先看没有所有权语义的代码长什么样:
Widget* w = new Widget();
// ... 三十行业务逻辑 ...
delete w;
三十行中间如果 return、抛异常、或者后人加了个分支提前退出,delete 就被跳过了。更糟的是,这段代码没有回答几个关键问题:w 能不能被别人 delete?函数返回后调用者要不要接管释放?把 w 塞进容器算谁的?
所有权语义要回答的正是这些问题。当类型本身就能表达”独占”还是”共享”,写错的机会就少了一大截。C++ 里裸指针的传统用法默认表达”非拥有观察”,即只是看着,不负责释放;负责释放的那一方应该用智能指针或者容器表达。
二、unique_ptr:独占且可移动
std::unique_ptr<T> 表达”这块资源归我,只有我能释放”。它没有引用计数,开销通常就是裸指针本身,析构时自动 delete。
auto w = std::make_unique<Widget>();
use(*w); // 用法和裸指针几乎一致,解引用即可
auto w2 = std::move(w); // 所有权转移
// w 现在是 nullptr,使用它之前必须检查或重新赋值
关键性质是可移动、不可复制。拷贝被 = delete 掉了,试图复制会直接编译失败——这正是想要的:独占语义必须由编译器强制,否则就退化成了口头约定。函数传参时同样如此,按值传 unique_ptr 意味着调用方放弃所有权,按引用传则只是借出去用一下:
void sink(std::unique_ptr<Widget> w); // 接管所有权,函数负责释放
void peek(const std::unique_ptr<Widget>& w); // 借用,不接管
void observe(Widget& w); // 非拥有观察,裸引用更诚实
第三种写法值得留意:如果函数不参与所有权,用裸引用或裸指针表达”我只是看看”,比传智能指针更准确。类型即文档。
三、shared_ptr:引用计数与控制块
资源需要多方共享时,用 std::shared_ptr<T>。它内部维护引用计数,最后一个持有者析构时释放资源。
auto a = std::make_shared<Widget>();
auto b = a; // 计数从 1 变 2
// a、b 任意顺序析构,计数归零时才释放
理解 shared_ptr 要知道它背后有一个控制块,存放引用计数、弱引用计数和删除器。make_shared 通常会把对象和控制块分配在一次内存申请里,而 shared_ptr<T>(new T) 是两次分配,缓存局部性也差一些。单次分配只在对象较大时优势明显,小对象上差别不大,但两次分配带来的双缓存不友好是实打实的。
引用计数本身不是免费的:拷贝要原子增,析构要原子减,多线程下这份原子操作有成本。在热路径上盲目复制 shared_ptr,比想象中更容易成为性能问题。
四、weak_ptr:打破循环引用
shared_ptr 有个经典陷阱:循环引用导致计数永远归不了零,内存泄漏。父子节点互相持有对方的 shared_ptr 是最常见的形态。
std::weak_ptr<T> 是不增加强引用计数的旁观者,要访问对象时得先 lock():
std::weak_ptr<Widget> weak = a;
if (auto sp = weak.lock()) { // lock 成功说明对象还活着
sp->use();
} else {
// 对象已释放
}
缓存、观察者、父子关系这些”需要访问但不该延长寿命”的场景,都适合 weak_ptr。判断标准很简单:如果 A 持有 B 是为了让 B 活得更久,用 shared_ptr;如果只是想在 B 活着的时候能摸到它,用 weak_ptr。
五、make_shared 与 make_unique 的取舍
优先用 make_unique / make_shared,理由有三条:
- 异常安全。
f(shared_ptr<A>(new A), g())这种写法里,参数求值顺序不确定,若g()先抛异常,new A可能泄漏。make_*把分配和包装合在一次调用里,规避了这类问题。 - 更少的重复。类型只写一次,重构时不用跟着改。
make_shared的单次分配,前面说过。
例外也存在:需要自定义删除器、需要把资源放进 shared_ptr 但对象不是 new 出来的(比如句柄、FILE*)、或者明确要分离分配与包装时机时,直接用构造函数更清楚。make_shared 还有个细节:它把对象和控制块放在一起,导致只要还有 weak_ptr 存活,对象占用的那块内存就不会归还——极端内存敏感的场景要留意。
六、什么时候不该用智能指针
智能指针不是越多越好。几类场景用它反而别扭:
- 非拥有观察。函数只是读一下传入的对象,用
T&或T*,把”不负责释放”写在签名里。 - 性能敏感路径。循环里每步拷贝
shared_ptr的原子计数,不如直接传引用。所有权清楚的局部对象,栈上直接放对象或值语义容器就行。 - 对象在容器里集中管理。
vector<Widget>本身就管理生命周期,再套一层unique_ptr只是多一层间接。 - 循环数据结构。与其硬用
shared_ptr缠成环,不如重新设计所有权,让某一方明确是”主”。
一句话原则:所有权必须有人负责,但不必人人负责。观察者越多,越应该用非拥有引用。
七、自定义删除器
资源不止 new 出来的内存。文件句柄、socket、锁、由 C 库分配的对象,都需要对应回收动作。智能指针支持自定义删除器:
auto fp = std::unique_ptr<FILE, decltype(&fclose)>(
fopen("a.txt", "r"), &fclose);
unique_ptr 的删除器是类型的一部分,写起来啰嗦一点但零开销;shared_ptr 的删除器在构造时指定、存在控制块里,类型擦除的代价换来接口统一。shared_ptr 这一特性还有个实用副产品:可以包装一块不是 new 出来的内存,只要删除器对得上就行。
删除器里要避免抛异常,析构路径上抛出的异常在很多情况下会直接导致终止。资源回收逻辑写得越简单越好,复杂的清理流程应该在析构之前显式完成,析构只做”关掉、释放”这类兜底动作。
八、几个容易踩的细节
unique_ptr置空后要判空再用,移动语义会把源置成nullptr,但裸的release()不会。shared_ptr的引用计数是线程安全的(计数本身原子增减),但指向的对象不是,多线程读写对象该加锁还得加锁。- 不要用裸
new的同一个指针构造两个shared_ptr,计数会错乱,这是运行时才爆的严重错误。 - 数组要用
unique_ptr<T[]>或容器,普通unique_ptr<T>默认按delete而不是delete[]释放。 - 从
this构造shared_ptr只在对象已经由shared_ptr管理时安全(enable_shared_from_this就是为此设计),否则会造出第二个控制块。 - 移动
unique_ptr之后原指针是nullptr,可以放心判空;但release()返回裸指针的同时不会释放内存,接管方忘了删就是泄漏,这个接口尽量少用。
九、一个综合例子
把前面的点串起来看一个父子节点的例子,顺带演示 weak_ptr 怎么拆环:
struct Node {
std::vector<std::shared_ptr<Node>> children;
std::weak_ptr<Node> parent; // 父节点用弱引用,避免环
void add_child(std::shared_ptr<Node> c) {
c->parent = shared_from_this(); // 前提:Node 由 shared_ptr 管理
children.push_back(std::move(c));
}
};
children 用 shared_ptr 是合理的:父节点希望子节点跟着自己活着。parent 反过来就不能用强引用,否则父子互指,整棵树的计数都归不了零。树、图、观察者列表里,这种”主从关系单向强引用、反向弱引用”的写法几乎是定式。
十、落在实处
智能指针这套东西,表面是几个类模板,实质是把”谁负责”这个问题前移到设计阶段。写代码时可以养成一个顺序:先想清楚资源归谁,再决定用 unique_ptr、值语义还是容器;有共享需求时再考虑 shared_ptr,并顺手检查有没有环;观察方一律用引用或 weak_ptr。
走到这一步,内存泄漏和双重释放的大多数形态都会在编译期就挡住。剩下的问题,交给工具——地址消毒器、静态分析、以及一份不介意重写的所有权设计。
评论(2)
实践了一个月,效果比预期好。
看完顺手把笔记补上了。
请 登录 后参与评论。