C++中的智能指针

文章字数:2559

为了解决 new 出的对象被忘记 delete 导致的内存泄漏问题,C++11 引入了智能指针。核心思想是 RAII——将裸指针的生命周期绑定到栈对象的生命周期上,栈对象析构时自动释放堆内存。

unique_ptr —— 独占所有权

unique_ptr 独占对象所有权,不可拷贝(拷贝构造和拷贝赋值被 = delete),只能移动。这一限制使得它的开销和裸指针完全一致——没有引用计数,没有控制块,sizeof 就是裸指针大小。

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <memory>
using namespace std;

int main() {
    auto p1 = make_unique<int>(42);  // 推荐用 make_unique
    // auto p2 = p1;                  // 编译错误!不可拷贝
    auto p2 = move(p1);              // 所有权转移,p1 变为 nullptr

    // 自定义删除器
    auto deleter = [](FILE* f) { if (f) fclose(f); };
    unique_ptr<FILE, decltype(deleter)> fp(fopen("test.txt", "r"), deleter);
    // 带自定义删除器的 unique_ptr 会多占一个删除器的大小
    return 0;
}

使用场景: 工厂函数返回值、容器中存放对象、PIMPL 惯用法、函数内的局部动态分配。

shared_ptr —— 共享所有权

shared_ptr 允许多个指针共享同一个对象,通过引用计数管理生命周期。当最后一个 shared_ptr 析构时,对象被释放。

控制块(Control Block)

shared_ptr 的底层核心是一个控制块,分配在堆上,包含两个计数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
shared_ptr 实例:
┌──────────┐     ┌──────────────────┐
│ ptr      │ ──→ │  托管对象         │
├──────────┤     └──────────────────┘
│ ctrl_blk │ ──→ ┌──────────────────┐
└──────────┘     │ strong_count (1) │ ← shared_ptr 的数量
                 │ weak_count   (0) │ ← weak_ptr 的数量 + 1(如果有 weak_ptr)
                 │ deleter          │
                 │ allocator        │
                 └──────────────────┘

每个 shared_ptr 占两个指针大小(指向对象的指针 + 指向控制块的指针),共 16 字节(64 位系统)。

控制块的创建时机很关键:用裸指针构造 shared_ptr 时创建控制块。因此如果用一个裸指针构造多个 shared_ptr,会创建多个独立的控制块,导致 double-free:

cpp
1
2
3
4
// 危险!不要这样做
auto* raw = new int(42);
shared_ptr<int> sp1(raw);
shared_ptr<int> sp2(raw);  // 独立控制块!两个都会尝试 delete raw

正确做法是用 make_shared 或从一个已有的 shared_ptr 拷贝:

cpp
1
2
auto sp1 = make_shared<int>(42);  // 推荐:一次分配,对象+控制块合在一起
auto sp2 = sp1;                    // 共享同一个控制块

make_shared 还有额外的优势:它将托管对象和控制块合并在一次内存分配中,不仅减少分配次数,还能提升缓存局部性。代价是:如果有 weak_ptr 存在,即使 shared_ptr 全部析构了,整块内存也要等 weak_ptr 全消亡才能释放(因为对象和控制块是同一块内存)。

shared_from_this

当一个类需要在成员函数中返回自身的 shared_ptr 时,不能直接用 shared_ptr<T>(this)(会创建重复控制块),而应该继承 enable_shared_from_this<T> 并调用 shared_from_this()

cpp
1
2
3
4
5
6
7
8
9
class Node : public enable_shared_from_this<Node> {
public:
    shared_ptr<Node> getPtr() {
        return shared_from_this();  // 正确的做法
    }
};

auto n = make_shared<Node>();
auto n2 = n->getPtr();  // 共享同一个控制块

enable_shared_from_this 内部存了一个 weak_ptrshared_from_this() 从中构造出 shared_ptr。注意:调用 shared_from_this() 时对象必须已被 shared_ptr 管理,否则抛 bad_weak_ptr 异常。

weak_ptr —— 打破循环引用

weak_ptr 不增加强引用计数,它指向一个被 shared_ptr 管理的对象,但不拥有它。主要用途是打破循环引用

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class B; // 前向声明

class A {
public:
    shared_ptr<B> ptr_to_b;  // A 持有 B
    ~A() { cout << "A 析构" << endl; }
};

class B {
public:
    // shared_ptr<A> ptr_to_a; // 错误!循环引用导致永远不会析构
    weak_ptr<A> ptr_to_a;     // 正确:弱引用不阻止析构
    ~B() { cout << "B 析构" << endl; }
};

典型场景:双向关联(parent-child、observer 模式)、缓存系统、异步回调中避免延长对象生命期。

使用 weak_ptr 访问对象时,必须先调用 lock() 尝试提升为 shared_ptr

cpp
1
2
3
4
5
if (auto sp = weak.lock()) {
    sp->doSomething();           // 对象仍然存活
} else {
    // 对象已被销毁
}

性能对比

类型sizeof构造/析构解引用拷贝典型开销
unique_ptr裸指针大小(8B)O(1)等同于裸指针不可拷贝,移动 O(1)零额外开销(无自定义删除器时)
shared_ptr2×裸指针(16B)原子操作 ±1等同于裸指针原子 inc(+1)控制块堆分配 + 原子引用计数
weak_ptr2×裸指针(16B)不修改计数需 lock() 提升需要通过控制块访问

几个关键点:

  • shared_ptr 的引用计数操作是原子的(线程安全),原子操作比普通 ++/-- 贵几十个 CPU cycle。如果确认单线程使用,可以用 boost::local_shared_ptr 或直接传引用避免拷贝。
  • 解引用不受影响——无论是 unique_ptr 还是 shared_ptrptr->foo() 和裸指针一样快,开销只在构造/析构/拷贝/赋值时。
  • make_shared 优于 new + 构造 shared_ptr:减少一次堆分配,对象和控制块在同一缓存行。

游戏服务端的选择建议

游戏服务端对延迟和内存高度敏感,选择策略如下:

  1. 能用 unique_ptr 就不用 shared_ptr 大多数对象的生命周期是清晰的(谁创建谁销毁),独占所有权已经够用。unique_ptr 零开销——不花一分钱就换来内存安全。

  2. 需要共享时优先考虑传引用而非拷贝 shared_ptr 每拷贝一次 shared_ptr 就是一次原子加操作,热路径上积少成多。

  3. 异步回调 + LRU 缓存用 shared_ptr + weak_ptr 组合。 例如玩家数据在内存中的缓存:主表用 shared_ptr 持有,其他地方用 weak_ptr 观察,如果玩家下线了,weak_ptr.lock() 返回空,自然淘汰。

  4. 避免在对象内部用 shared_ptr 持有自己所属的容器/管理器。 这是循环引用的高发区,该用 weak_ptr 的地方不要偷懒。

  5. 对象池 + unique_ptr 是很适合游戏服务器的模式。 预分配一批对象,unique_ptr 管理生命周期,配合自定义删除器实现 release 回池而非真正 delete


Q&A

Q1: shared_ptr、unique_ptr、weak_ptr 各自的底层实现、使用场景和性能开销分别是什么?

unique_ptr: 独占所有权,底层就是裸指针(无自定义删除器时 sizeof 和裸指针一样),不可拷贝只能移动。零开销——解引用和裸指针完全一致。适用于:工厂函数返回、PIMPL、局部动态分配、对象池。

shared_ptr: 共享所有权,底层是一个指向对象的指针 + 一个指向控制块的指针(共 16 字节)。控制块里存着原子引用计数(strong_count)和弱引用计数(weak_count)。拷贝时原子 inc,析构时原子 dec 到 0 就释放对象。开销来自控制块的堆分配和原子操作。适用于:多所有者无法确定谁最后释放时。

weak_ptr: 不拥有对象,配合 shared_ptr 打破循环引用。底层和 shared_ptr 一样也是两个指针,通过控制块访问,使用时需 lock() 提升为 shared_ptr 检查对象是否存活。适用于:observer、缓存、双向关联中需要打破循环的场景。

Q2: 在游戏服务端中你会如何选择?

游戏服务端选择: 能用 unique_ptr 就不用 shared_ptr——独占所有权覆盖大部分场景且零开销;需要共享时优先传引用避免原子计数;异步回调/缓存用 shared_ptr + weak_ptr 组合;对象池配合 unique_ptr 的自定义删除器很实用。

本文包含一些 AIGC 辅助生成内容,由作者人工校对整理后发布
本文采用 CC BY-NC-SA 4.0协议,如果对您有帮助或存在意见建议,欢迎在下方评论交流。
本页面浏览次数 加载中...
本页面访客数 加载中...

加载中...