shared_ptr与weak_ptr

ZaynPei Lv6

std::shared_ptr:共享所有权的管理者

std::shared_ptr 是一种拥有共享所有权的智能指针。这意味着多个 shared_ptr 实例可以共同拥有和管理同一个动态分配的对象。当最后一个指向该对象的 shared_ptr 被销毁或重置时,该对象会被自动释放。

核心理念:引用计数

shared_ptr 的核心机制是引用计数(Reference Counting)。它通过一个“控制块”来实现这一机制。

控制块(Control Block)是一个与被管理对象分离的内存块, 存储在上。当第一个 shared_ptr 创建时,控制块也随之被创建。它包含以下关键信息:

  • 强引用计数(Strong Reference Count):记录有多少个 shared_ptr 正指向同一个对象。
  • 弱引用计数(Weak Reference Count):记录有多少个 weak_ptr 正在“观察”这个对象。
  • 指向被管理对象的指针。
  • (可选)自定义删除器的指针。

一般来说, 引用计数和控制块的工作流程如下:

  • 当一个新的 shared_ptr 创建或通过拷贝构造或拷贝赋值指向一个已有对象时,强引用计数 +1。
  • 当一个 shared_ptr 被销毁(例如离开作用域)、被重置(reset())或指向其他对象时,强引用计数 -1。
  • 强引用计数变为 0 时,shared_ptr 会自动调用删除器(默认为 delete)来释放被管理的对象
  • 当弱引用计数和强引用计数都变为 0 时,控制块本身才会被释放。

这种引用计数的加减还是线程安全的:shared_ptr 的引用计数增减操作是原子的,这意味着在多线程环境下,仅对 shared_ptr 对象本身进行拷贝、赋值和销毁是线程安全的,不会导致引用计数出错。但它并不保护被管理对象本身,如果多线程要修改对象内容,仍需手动加锁

创建 shared_ptr

函数模板 std::make_shared 的函数原型如下:

1
2
3
4
5
namespace std {
template <typename T, typename... Args>
std::shared_ptr<T> make_shared(Args&&... args);
}

T是要指向的对象的类型, args是传给T这个类构造函数的参数(如果有的话)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <memory>    // 存放智能指针相关
#include <iostream>

class MyClass {
public:
MyClass() { std::cout << "MyClass 构造" << std::endl; }
~MyClass() { std::cout << "MyClass 析构" << std::endl; }
void greet() { std::cout << "Hello!" << std::endl; }
};

int main() {
// 推荐方式:使用 std::make_shared
// 优点:1. 更高效(对象和控制块一次性分配内存) 2. 异常安全
std::shared_ptr<MyClass> sp1 = std::make_shared<MyClass>();

// 不太推荐的方式:使用 new, 虽然最终也能通过shared_ptr安全管理, 但是会分两次(一次 new T(),一次为控制块)分配内存
std::shared_ptr<MyClass> sp2(new MyClass());
auto sp3 = sp1; // 直接拷贝构造

// 创建完毕后, 这两个指针指向的对象都在堆上, 控制块也在堆上
}

对于std::shared_ptr, 还有一些需要了解但是不太常用的接口: - 通过 get() 方法来获取原始指针,适用于和旧版C语言库等只接受原始指针的接口交互 - 通过 reset() 来减少一个强引用计数, 适用于提前终止对资源的管理 - 通过use_count()来查看一个对象的引用计数。

共享所有权

shared_ptr是支持拷贝的智能指针, 这意味着多个shared_ptr实例可以共享同一个对象的所有权。每当一个shared_ptr被拷贝时,引用计数会增加;当一个shared_ptr被销毁或重置时,引用计数会减少。

另外, shared_ptr 也支持移动语义(move semantics),这允许你将所有权从一个 shared_ptr 转移到另一个,而不会增加引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建一个 shared_ptr
std::shared_ptr<MyClass> sp1 = std::make_shared<MyClass>();
std::cout << "sp1 创建后, 引用计数: " << sp1.use_count() << std::endl; // 输出: 1

{
// sp2 通过拷贝 sp1 创建
std::shared_ptr<MyClass> sp2 = sp1;
std::cout << "sp2 创建后, 引用计数: " << sp1.use_count() << std::endl; // 输出: 2

sp2->greet(); // 可以像普通指针一样使用
} // sp2 在这里离开作用域并被销毁

std::cout << "sp2 销毁后, 引用计数: " << sp1.use_count() << std::endl; // 输出: 1

// sp1 在 main 函数结束时销毁,引用计数变为0,MyClass 对象被析构

优点: - 自动管理内存,有效防止内存泄漏。 - 易于使用,可以像普通指针一样操作。 - 明确了资源的“共享所有权”语义。

但是, 如果只使用std::shared_ptr, 会存在一个难以察觉的陷阱: 循环引用(Circular Reference), 这是 shared_ptr 最大的问题,也是 weak_ptr 存在的原因。

循环引用(Circular Reference)

如下是一个双向链表的实现代码

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
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <memory>

struct Node {
int value;
// 错误的方式:前后指针都使用 shared_ptr
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;

Node(int v) : value(v) {
std::cout << "构造函数: Node(" << value << ")" << std::endl;
}
~Node() {
std::cout << "析构函数: ~Node(" << value << ")" << std::endl;
}
};

void create_leaky_list() {
std::cout << "--- 进入 create_leaky_list 函数 ---" << std::endl;

// 创建两个节点
auto node1 = std::make_shared<Node>(10); // Node(10) 的强引用计数为 1
auto node2 = std::make_shared<Node>(20); // Node(20) 的强引用计数为 1

// 将它们互相连接,形成双向链表
node1->next = node2; // Node(20) 的强引用计数变为 2 (来自 node2 和 node1->next)
node2->prev = node1; // Node(10) 的强引用计数变为 2 (来自 node1 和 node2->prev)

std::cout << "连接后, Node(10) 的引用计数: " << node1.use_count() << std::endl;
std::cout << "连接后, Node(20) 的引用计数: " << node2.use_count() << std::endl;

std::cout << "--- 准备离开 create_leaky_list 函数 ---" << std::endl;
}

int main() {
create_leaky_list();
std::cout << "\n--- create_leaky_list 函数已结束 ---" << std::endl;
std::cout << "程序即将退出,检查是否有析构函数被调用..." << std::endl;
return 0;
}
当 create_leaky_list 函数结束时,栈上的 node1 和 node2 两个 shared_ptr 指针变量被销毁(指向的堆上的对象并没有)。node1 销毁,导致 Node(10) 的强引用计数从 2 降为 1。但不为 0,因为 Node(20)->prev 仍然指向它; node2 销毁,导致 Node(20) 的强引用计数从 2 降为 1。但不为 0,因为 Node(10)->next 仍然指向它。

这导致两个 Node 对象的引用计数都无法归零,析构函数永远不会被调用,内存泄漏发生。

这里区分一下: 栈上的 shared_ptr 变量被销毁了, 但是堆上的 Node 对象并没有被销毁, 因为它们的引用计数都没有归零。

shared_from_this()

它的作用是: 在类的成员函数中安全地获取一个指向自己(this)的 std::shared_ptr 智能指针

先看一个错误示范:

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

struct A {
void foo() {
// ❌ 自己 new 一个 shared_ptr(this)
std::shared_ptr<A> p(this);
std::cout << "use_count = " << p.use_count() << std::endl;
}
};

int main() {
auto p1 = std::make_shared<A>();
p1->foo();
p1->foo(); // 错误!
}
main 函数创建了一个 A 对象,并用 shared_ptr(p1)管理它。此时引用计数为 1

p1->foo();时, std::shared_ptr<A> p(this);会新建一个 shared_ptr,管理同一个对象,但它和外部的 p1 不是同一个控制块各自有独立的引用计数

当 p 离开作用域时(foo() 结束),p 的引用计数归零,析构对象,对象内存被释放。

但外部的 p1 还以为对象存在,继续访问已被释放的内存,导致未定义行为(通常是崩溃)

正确的做法是让类 A 继承自 std::enable_shared_from_this<A>,这样就可以使用 shared_from_this() 方法来获取一个指向自己的 shared_ptr:

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

struct A : public std::enable_shared_from_this<A> {
void foo() {
std::shared_ptr<A> p = shared_from_this(); // ✅ 正确
std::cout << "use_count = " << p.use_count() << std::endl;
}
};

int main() {
auto p1 = std::make_shared<A>();
p1->foo(); // use_count = 2
// 当退出 foo() 时, p 的引用计数减1, 但对象仍然存在, 此时 p1 的引用计数为1
}

shared_from_this() 会返回一个新的 std::shared_ptr,它与外部的 p1 共享同一个控制块(引用计数)。

其内部实现大致如下:

1
2
3
4
5
6
7
8
9
class enable_shared_from_this {
protected:
std::weak_ptr<T> weak_this; // 不增加引用计数

public:
std::shared_ptr<T> shared_from_this() {
return std::shared_ptr<T>(weak_this);
}
};
当你用 std::make_shared() 创建对象时,shared_ptr 会自动把自己的弱引用 weak_this 填进去。这样 shared_from_this() 就能安全地从弱引用里恢复出共享指针。

一般来说, 只有当类的对象确实会被 shared_ptr 管理时,才应该使用 enable_shared_from_this,否则调用 shared_from_this() 会抛出异常。适用于需要在类的成员函数中获取 shared_ptr 的场景,比如实现观察者模式、回调函数等。

std::weak_ptr:打破循环引用的观察者

std::weak_ptr 是一种非拥有型的智能指针。它像一个“观察者”,可以指向一个由 shared_ptr 管理的对象,但不会增加对象的强引用计数。

也就是说, 这种指针是一种弱引用:

  • weak_ptr 的存在不会影响对象的生命周期。它只是监视对象是否存在。
  • 它指向与 shared_ptr 相同的控制块,并通过创建和销毁来增减控制块中的弱引用计数
  • 不能直接通过 weak_ptr 访问对象没有 * 或 -> 操作符),因为对象可能随时被销毁。

创建 weak_ptr 和访问对象(核心操作:lock())

weak_ptr 只能从 shared_ptr 或另一个 weak_ptr 间接构造, 而不能构造一个来指向某个新对象

1
2
std::shared_ptr<int> sp = std::make_shared<int>(10);
std::weak_ptr<int> wp = sp; // 从 shared_ptr 创建

weak_ptr 最重要的函数是 lock()。它会安全地检查被观察对象是否存在:

  • 如果对象仍然存在,lock() 会返回一个指向该对象的有效的 shared_ptr,并使对象的强引用计数 +1。
  • 如果对象已被销毁,lock() 会返回一个空的 shared_ptr

这种“检查并获取”的模式是原子操作,保证了线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::weak_ptr<MyClass> weak_p;
{
std::shared_ptr<MyClass> shared_p = std::make_shared<MyClass>();
weak_p = shared_p; // weak_p 观察 shared_p 管理的对象

// 尝试从 weak_p 获取一个 shared_ptr
if (auto sp_temp = weak_p.lock()) { // 看是否能够成功获取
std::cout << "对象仍然存在, 可以安全访问。" << std::endl;
sp_temp->greet();
}

} // shared_p 在此销毁, MyClass 对象也被析构

// MyClass 对象已被析构, weak_p无指向, 再次尝试 lock会失败
if (auto sp_temp = weak_p.lock()) {
// 这段代码不会执行
} else {
std::cout << "对象已被销毁, 无法访问。" << std::endl;
}

还有个常用的函数: std::weak_ptr::expired(), 用于判断当前 weak_ptr 是否已经失效,也就是它所观察的对象是否已经被销毁。如果返回 true, 表示对象已经被销毁,weak_ptr 不再指向有效资源; 返回 false则表示对象仍然存在,可以尝试通过 lock() 获取一个有效的 shared_ptr。

结合起来上面的代码还可以修改为:

1
2
3
4
5
if (!weak_ptr.expired()) {
weak_ptr.lock()->greet();
} else {
std::cout << "对象已被销毁, 无法访问。" << std::endl;
}

解决循环引用

只需将循环引用链中的任意一环从 shared_ptr 改为 weak_ptr 即可。通常,我们会选择从属关系中的“子”指向“父”的指针(在双向链表中通常是 prev 指针)改为 weak_ptr。

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
32
33
34
35
36
37
38
#include <iostream>
#include <memory>

struct CorrectNode {
int value;
std::shared_ptr<CorrectNode> next;
// 解决方案:将 prev 指针改为 weak_ptr
std::weak_ptr<CorrectNode> prev;

CorrectNode(int v) : value(v) {
std::cout << "构造函数: CorrectNode(" << value << ")" << std::endl;
}
~CorrectNode() {
std::cout << "析构函数: ~CorrectNode(" << value << ")" << std::endl;
}
};

void create_correct_list() {
std::cout << "--- 进入 create_correct_list 函数 ---" << std::endl;

auto node1 = std::make_shared<CorrectNode>(10); // Node(10) 强引用 = 1
auto node2 = std::make_shared<CorrectNode>(20); // Node(20) 强引用 = 1

node1->next = node2; // Node(20) 强引用 = 2
node2->prev = node1; // Node(10) 强引用不变,仍为 1 (因为 prev 是 weak_ptr)

std::cout << "连接后, Node(10) 的引用计数: " << node1.use_count() << std::endl;
std::cout << "连接后, Node(20) 的引用计数: " << node2.use_count() << std::endl;

std::cout << "--- 准备离开 create_correct_list 函数 ---" << std::endl;
}

int main() {
create_correct_list();
std::cout << "\n--- create_correct_list 函数已结束 ---" << std::endl;
std::cout << "程序即将退出,检查是否有析构函数被调用..." << std::endl;
return 0;
}

当 create_correct_list 函数结束时,栈上的 node1 和 node2 被销毁。因为 node1 销毁,Node(10) 的强引用计数从 1 降为 0。Node(10) 对象被析构, 导致其成员 next(一个指向 Node(20) 的 shared_ptr)被销毁。这导致 Node(20) 的强引用计数从 2 降为 1。

随后,node2 销毁,Node(20) 的强引用计数从 1 降为 0。Node(20) 对象被析构。最终结果是, 所有节点都被正确地、依次地销毁。

总结对比

特性 std::shared_ptr std::weak_ptr
所有权 拥有(共享所有权) 不拥有(观察者)
引用计数 创建/销毁/拷贝会改变强引用计数 创建/销毁/拷贝会改变弱引用计数
生命周期 它的存在会延长对象的生命周期 不影响对象的生命周期
直接访问 可以,通过 *-> 操作符 不可以
安全访问方式 直接使用 必须调用 lock() 获取一个临时的 shared_ptr
主要用途 管理具有共享所有权的动态资源 1. 打破 shared_ptr 的循环引用
2. 实现缓存系统
3. 观察者模式
如何创建 std::make_shared 或从原始指针构造 从 shared_ptr 或其他 weak_ptr 构造

智能指针这种技术并不新奇,在很多语言中都是一种常见的技术,现代 C++ 将这项技术引进,在一定程度上消除了 new/delete 的滥用,是一种更加成熟的编程范式。