一、 为什么要革裸指针的命?

在 C++11 之前,程序员都在玩火。我们用 new 申请内存,用 delete 释放内存。这个过程看似简单,实则危机四伏:

  1. 忘记释放:内存泄漏,服务器跑几天就崩。

  2. 提前释放:导致悬挂指针(Dangling Pointer),后面再用就是未定义行为。

  3. 重复释放:Double Free,程序直接崩溃。

  4. 异常不安全:这是最隐蔽的。在 newdelete 之间如果抛出了异常,后面的 delete 根本执行不到。

为了解决这个问题,Bjarne Stroustrup 提出了 RAII (资源获取即初始化)。 哪怕不懂 RAII 的定义,也只要记住一句口诀:把堆(Heap)上的资源,托付给栈(Stack)上的对象。

栈对象的生命周期是自动的(函数结束自动销毁),利用这一特性,让栈对象在析构时顺手把持有的堆内存释放掉,这就是智能指针的本质。


二、 unique_ptr:不仅是独占,更是零开销

这是我最喜欢用的指针,简单粗暴。它的哲学是**“独占所有权”**——车钥匙只有一把,车在人在,人走车销。

1. 为什么它是“零开销”?

很多初学者担心智能指针慢。但 unique_ptr 在 64 位系统下大小就是 8 字节,和裸指针一模一样。 它的内部实现非常简单,几乎就是裸指针穿了个“马甲”。编译器生成的汇编代码中,unique_ptr 的操作和裸指针几乎没有区别。

2. std::move 的真相

我们知道 unique_ptr 禁止拷贝(Copy),只能移动(Move)。

auto p1 = std::make_unique<Car>();
// auto p2 = p1; // 报错!禁止拷贝
auto p2 = std::move(p1); // 合法

这里有个误区:std::move 并没有真的“移动”内存。它只是把 p1 强转成了右值引用。 底层发生了什么? unique_ptr 的移动构造函数被触发,它把 p1 里的地址赋值给 p2,然后立刻把 p1 里的指针置为 nullptr。这就完成了所有权的“窃取”。

3. 为什么一定要用 make_unique

除了少写两次类型名,更重要的是异常安全。 如果是 unique_ptr<T>(new T()),这一步其实分两动作:先 new,再构造 unique_ptr。如果 new 成功了,但在构造智能指针前发生了异常,那块内存就泄露了。make_unique 是原子操作,杜绝了这种可能。


三、 shared_ptr:复杂的“控制块”与引用计数

当资源需要共享时(比如多线程环境,或者像“公交车”那样有多个乘客),我们用 shared_ptr。 它的核心是引用计数,但它在内存里长得并不像我们想的那么简单。

1. 内存布局:胖指针与控制块

shared_ptr 其实是“胖指针”,通常占用 16 字节(两个指针的大小):

  • 指针 1:指向真实对象(比如 Car)。

  • 指针 2:指向控制块(Control Block)

控制块才是核心,它存在堆上,包含两部分计数:

  • Strong Count(强引用计数):记录有多少个 shared_ptr 活着。归零时,释放对象

  • Weak Count(弱引用计数):记录有多少个 weak_ptr 活着。归零时,释放控制块

注意:对象内存和控制块内存的生命周期是不一样的!这一点非常关键。

2. make_shared 的性能优势

直接 shared_ptr<T>(new T()) 会造成两次堆内存分配(一次分配对象,一次分配控制块)。 而 make_shared<T>()一次性申请一块大内存,同时存放对象和控制块。这不仅减少了分配开销,还提高了 CPU 缓存命中率。

3. 赋值时的 Copy-and-Swap

在源码层面,shared_ptr 的赋值操作用了一个很骚的操作叫 Copy-and-Swap。 它不是手动去加减计数,而是先用旧对象拷贝构造一个临时对象(自动加计数),然后交换身份,最后让临时对象析构(自动减计数)。这种写法天然就是异常安全的。


四、 weak_ptr:解开“死亡拥抱”的钥匙

shared_ptr 有个致命死穴:循环引用。 A 对象里有 B 的 shared_ptr,B 对象里有 A 的 shared_ptr。就像两个人互相死死拽着对方,谁也不肯先松手,导致引用计数永远是 1,内存永远泄露。

1. 弱指针的哲学

weak_ptr 就是为了打破这个僵局。它是一种**“旁观者”**:

  • 它指向控制块,但不增加 Strong Count

  • 它增加了 Weak Count

在循环引用场景下,只要把其中一方改成 weak_ptr,链条就断了。当外部销毁对象时,由于 weak_ptr 不占强引用计数,对象可以直接析构,从而释放掉另一方的强引用,实现全员解脱。

2. lock() 的本质

weak_ptr 不能直接访问对象(它连 -> 运算符都没重载)。你想用对象,必须先通过 lock() 方法去“兑换”一个 shared_ptr底层逻辑: 它去查看控制块里的 Strong Count。

  • 如果 > 0:说明对象还在,原子操作将 Strong Count +1,返回一个有效的 shared_ptr

  • 如果 = 0:说明对象早挂了,返回 nullptr

这就是它叫“弱”指针的原因——它观察生命周期,但不控制生命周期。

3. “僵尸”控制块

这里有一个很底层的细节:即使对象被销毁了(Strong Count = 0),只要还有 weak_ptr 指向它,控制块本身就不会被释放。因为 weak_ptr 还需要查数。只有当 Weak Count 也归零时,控制块这块小内存才会被回收。


五、 总结

这三种指针构成了一个严密的逻辑闭环:

  1. unique_ptr:默认选择。性能最强,语义最清晰(专属)。

  2. shared_ptr:确实需要共享时再用。记得它的成本(原子操作、控制块内存)。

  3. weak_ptrshared_ptr 的补丁。专门解决循环引用,或者作为“探测资源是否存在”的观察者。

写 C++,本质上就是管理资源。理解了内存模型,这些工具就不再是语法负担,而是手中的利器。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐