7AA7 Blog

C++ 智能指针入门:unique_ptr、shared_ptr 与所有权

Corin UID:76
2026-10-15 16:50
38 2 2

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,理由有三条:

  1. 异常安全。f(shared_ptr<A>(new A), g()) 这种写法里,参数求值顺序不确定,若 g() 先抛异常,new A 可能泄漏。make_* 把分配和包装合在一次调用里,规避了这类问题。
  2. 更少的重复。类型只写一次,重构时不用跟着改。
  3. 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。

走到这一步,内存泄漏和双重释放的大多数形态都会在编译期就挡住。剩下的问题,交给工具——地址消毒器、静态分析、以及一份不介意重写的所有权设计。

498Views
38Likes
2Comments
2Participants
11 minread time

Comments (2)

  • 晚棠 · 2026-10-15 19:50

    实践了一个月,效果比预期好。

    Reply
  • Bit 序章 · 2026-10-16 02:50

    看完顺手把笔记补上了。

    Reply