用了智能指针为什么还会泄漏?从循环引用讲清 C++ 所有权

时间:2026/04/09

“把 new 换成 std::shared_ptr,内存就安全了。”这是一个很常见、也很危险的理解。下面两个对象全部由智能指针管理,没有出现一行手写 delete,离开作用域时却都不会析构:

1
2
3
4
5
6
7
8
9
struct User;

struct Session {
std::shared_ptr<User> user;
};

struct User {
std::shared_ptr<Session> session;
};

如果 UserSession 相互保存对方的 shared_ptr,它们的引用计数就永远不会归零。智能指针没有失效,它只是忠实执行了一个错误的所有权设计。

真正需要解决的不是“怎样自动 delete”,而是三个更基础的问题:谁拥有对象,谁只是使用对象,谁可以延长对象的生命周期。本文将从循环引用出发,梳理 unique_ptrshared_ptrweak_ptr、观察指针和自定义删除器各自应该表达什么。


1. 为什么一个地址无法说明所有权?

裸指针只保存地址:

1
Widget* widget = get_widget();

单看这行代码,我们无法回答:

  • widget 能否为空?
  • 调用者是否需要释放它?
  • 它指向单个对象还是数组?
  • 指针离开作用域后,对象是否仍然存在?
  • 其他地方会不会同时释放对象?

这些问题都属于所有权(ownership),而不是地址本身的能力。智能指针的首要价值,就是把一部分所有权规则编码进类型,让编译器帮助阻止错误操作。

工程中常见的关系可以分为三类:

关系 含义 常见表达
独占拥有 只有一个所有者负责延长和结束生命周期 值类型、std::unique_ptr<T>
共享拥有 多个所有者共同延长生命周期 std::shared_ptr<T>
非拥有观察 可以访问,但不负责释放或延长生命周期 T&T*std::weak_ptr<T>

选择智能指针前,应先确定关系,再选择类型。反过来“全部使用 shared_ptr”只会把设计问题藏进引用计数。

2. 为什么默认应该先考虑值和 unique_ptr

如果对象可以直接作为成员或局部变量保存,值语义通常最简单:

1
2
3
class Editor {
Document document_;
};

Editor 创建时 Document 创建,Editor 销毁时 Document 销毁,不需要动态分配,也没有额外的所有权图。

当对象必须动态创建,例如需要多态、对象很大或生命周期要脱离当前作用域时,优先考虑 std::unique_ptr

1
2
3
#include <memory>

auto document = std::make_unique<Document>();

unique_ptr 表达独占所有权:不能复制,但可以移动。

1
2
3
auto first = std::make_unique<int>(42);
// auto second = first; // 编译失败:不能产生第二个所有者
auto second = std::move(first); // 所有权转移

移动后,second 拥有对象,first 为空。这种限制不是不方便,而是编译器在阻止“两个对象都以为自己负责释放”的错误。

1
2
3
移动前:first  ──owns──> int
移动后:first ────────> nullptr
second ──owns──> int

创建对象时优先使用 std::make_unique,它能减少类型重复,并让资源立即进入 RAII 对象管理。

3. 函数参数怎样表达“接管”和“借用”?

不同参数类型应该传达不同的生命周期意图:

1
2
3
4
5
void consume(std::unique_ptr<Widget> widget); // 接管所有权
void inspect(const Widget& widget); // 必须存在,只读借用
void modify(Widget& widget); // 必须存在,可修改借用
void maybe_inspect(const Widget* widget); // 可以为空,只读借用
void retain(std::shared_ptr<Widget> widget); // 函数会共享所有权

如果函数只在调用期间使用对象,不应仅仅因为调用者手里是 shared_ptr 就按值接收它:

1
2
3
4
void draw(const Widget& widget);

auto widget = std::make_shared<Widget>();
draw(*widget);

按值传入 shared_ptr 会增加和减少引用计数,并暗示被调用者可能保存一份所有权。只借用对象时传引用或指针,接口会更准确。

这里有一个重要边界:引用和裸指针不会延长生命周期。调用方必须保证借用期间对象仍然存在。智能指针解决所有权表达,但不会自动证明每个观察者都没有悬空。

4. shared_ptr 解决了什么,又带来什么?

只有当多个参与者确实需要共同决定对象何时销毁时,才需要 std::shared_ptr

1
2
3
auto resource = std::make_shared<Resource>();
auto worker_a = resource;
auto worker_b = resource;

典型实现包含对象和控制块。控制块记录强引用计数、弱引用计数以及删除器等信息:

1
2
3
shared_ptr A ─┐
├──> 控制块 ──> Resource
shared_ptr B ─┘ 强计数:2

最后一个强引用消失时,对象被销毁;控制块通常要等弱引用也消失后才释放。

shared_ptr 的代价不仅是一次计数操作。它还会带来:

  • 控制块和引用计数所需的空间;
  • 复制、销毁时的同步计数开销;
  • 更难从代码局部判断对象究竟会活多久;
  • 循环拥有导致资源无法释放的可能。

对不同 shared_ptr 实例的引用计数操作具备必要的线程安全保证,但这不等于它指向的对象自动线程安全。多个线程仍然需要为对象内部的共享可变状态建立同步机制。

5. 循环引用为什么不会自动消失?

下面是一个完整的错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <memory>

struct Session;

struct User {
~User() { std::cout << "destroy User\n"; }
std::shared_ptr<Session> session;
};

struct Session {
~Session() { std::cout << "destroy Session\n"; }
std::shared_ptr<User> user;
};

int main() {
auto user = std::make_shared<User>();
auto session = std::make_shared<Session>();
user->session = session;
session->user = user;
}

离开 main 前,两个局部 shared_ptr 被销毁,但对象内部仍各自保留一个强引用:

1
2
3
User ──shared ownership──> Session
↑ │
└────shared ownership─────┘

两个引用计数都停在 1,没有任何一方能先析构,所以运行时看不到析构输出。引用计数只能统计引用,不能推断业务关系中哪条边应该是拥有、哪条边只是观察。

6. weak_ptr 怎样打破循环?

如果 Session 拥有其关联的 User,而 User 只需要在会话仍存在时访问它,那么反向关系应表达为观察,而不是共享拥有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <memory>

struct Session;

struct User {
~User() { std::cout << "destroy User\n"; }
std::weak_ptr<Session> session;

void print_session_state() const;
};

struct Session {
~Session() { std::cout << "destroy Session\n"; }
std::shared_ptr<User> user;
};

void User::print_session_state() const {
if (auto current = session.lock()) {
std::cout << "session is alive\n";
} else {
std::cout << "session has expired\n";
}
}

int main() {
auto session = std::make_shared<Session>();
session->user = std::make_shared<User>();
session->user->session = session;
session->user->print_session_state();
}

使用 C++17 即可编译:

1
2
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic ownership.cpp -o ownership
./ownership

预期输出为:

1
2
3
session is alive
destroy Session
destroy User

weak_ptr 不增加强引用计数,因此不会延长 Session 的生命周期。调用 lock() 时,它会尝试创建一个临时的 shared_ptr:对象仍存在就返回有效指针,否则返回空指针。检查和取得临时所有权作为一个操作完成,避免先判断存活、随后对象立即销毁的竞态窗口。

修改后的关系是:

1
2
3
Session ──shared ownership──> User
↑ │
└────weak observation──────┘

这里真正修复问题的不是机械地把某一边换成 weak_ptr,而是先决定 Session 是所有者,User 只是反向观察者。如果业务生命周期相反,就应该反过来设计。

7. get()release()reset() 有什么区别?

这些名字相近,所有权效果却完全不同:

操作 返回裸指针 转移所有权 原智能指针之后是否拥有对象
get()
release()(仅 unique_ptr
reset() 结束或替换所有权 视传入参数而定

get() 适合把观察指针临时传给不接管所有权的旧式 API:

1
legacy_draw(widget.get());

绝不能删除 get() 返回的指针,否则智能指针稍后还会再次释放它。

release() 会放弃所有权且不释放对象,调用者必须立刻把返回值交给另一个明确的所有者。随意调用它很容易重新制造泄漏,因此只应在需要把资源所有权移交给特定 C API 时使用。

8. 非 delete 资源怎样交给智能指针?

并非所有资源都通过 delete 释放。FILE* 需要 std::fclosemalloc 返回的内存需要 std::free,操作系统句柄也各有对应的关闭函数。这时可以给 unique_ptr 配置自定义删除器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <cstdio>
#include <memory>

struct FileCloser {
void operator()(std::FILE* file) const noexcept {
if (file != nullptr) {
std::fclose(file);
}
}
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "rb"));
}

调用者仍需检查打开是否成功:

1
2
3
4
auto file = open_file("data.bin");
if (!file) {
// 处理文件不存在或权限不足
}

FilePtr 离开作用域时会调用 FileCloser,包括异常退出路径。自定义删除器把 C API 的成对操作重新封装成了 RAII。

9. 工程中还要警惕哪些误区?

误区一:动态对象都应该使用 shared_ptr

动态分配不代表共享所有权。多数对象只有一个清晰所有者,应使用 unique_ptr;能直接保存为值时甚至不需要智能指针。

误区二:智能指针不会悬空

拥有对象的智能指针不会指向已被自己释放的对象,但通过 get()、引用或迭代器得到的观察者仍可能悬空。所有权安全不等于所有借用都自动安全。

误区三:用同一个裸指针构造多个 shared_ptr

1
2
3
Widget* raw = new Widget;
std::shared_ptr<Widget> first(raw);
std::shared_ptr<Widget> second(raw); // 错误:两个独立控制块

两个控制块都会尝试删除同一对象,造成双重释放。应使用 std::make_shared 创建并复制同一 shared_ptr

误区四:引用计数线程安全,所以对象也线程安全

计数安全只保护共享所有权的 bookkeeping。若多个线程同时修改 *pointer,仍要根据对象自身规则使用互斥锁、原子操作或线程隔离。

误区五:weak_ptr::expired() 后再访问就安全

expired() 只反映检查瞬间的状态。并发环境中,对象可能在下一刻销毁。需要访问对象时直接调用 lock(),并在得到的临时 shared_ptr 生命周期内完成操作。

10. 应该如何选择?

可以按下面的顺序判断:

  1. 对象能否直接按值保存?能就使用值语义。
  2. 必须动态分配且只有一个所有者?使用 unique_ptr
  3. 多个参与者是否真的需要共同延长生命周期?是才使用 shared_ptr
  4. 只在调用期间访问且对象必须存在?使用引用。
  5. 只在调用期间访问且允许为空?使用裸指针。
  6. 需要观察一个由 shared_ptr 管理、但可能提前销毁的对象?使用 weak_ptr

这不是绝对的类型清单,而是一种设计顺序:先画出生命周期和所有权关系,再让参数与成员类型准确表达关系。

11. 总结

开头的循环泄漏说明,智能指针不会替我们决定所有权。它只能执行类型已经表达出来的关系。

  1. 默认优先值语义,其次是独占所有权的 unique_ptr
  2. 只有真正共享生命周期时才使用 shared_ptr
  3. weak_ptr 用于观察共享对象,尤其适合打破有明确主从关系的循环;
  4. 引用和裸指针适合表达临时借用,但调用者必须保证其有效期;
  5. 自定义删除器可以把文件、句柄等非 delete 资源纳入 RAII。

最实用的检查方法不是搜索项目里有没有裸指针,而是为每条对象关系标注 ownsobserves。一旦一条边的含义说不清楚,那里往往就是泄漏、悬空或生命周期失控最先出现的地方。