10. shared_ptr 详解(下):循环引用问题、enable_shared_from_this 与 weak_ptr 的引入

上一讲我们把 shared_ptr 的引用计数原理、自定义删除器、线程安全边界都聊透了。今天要聊的这三个话题,说白了就是 shared_ptr 在实际项目中「翻车」最多的场景。

我个人习惯把这一讲称为「救火指南」。因为循环引用、裸指针返回、以及 weak_ptr 的正确用法,几乎每个 C++ 项目做到中期都会踩一遍。我自己就曾在线上服务里被循环引用搞出过内存泄漏,查了两天才定位到根因。

一、循环引用:shared_ptr 的阿喀琉斯之踵

先看一个最经典的场景:两个对象互相持有对方的 shared_ptr。

#include <memory>
#include <iostream>

struct B; // 前向声明

struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed\n"; }
};

struct B {
    std::shared_ptr<A> a_ptr;
    ~B() { std::cout << "B destroyed\n"; }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b;
    b->a_ptr = a;
    // 离开作用域,a 和 b 都不会被销毁!
    return 0;
}

运行这段代码,你会发现控制台没有任何输出。A 和 B 的析构函数都没被调用。

为什么会这样?

我们来画一下引用计数的变化:

  • 创建 a 时,A 的引用计数 = 1
  • 创建 b 时,B 的引用计数 = 1
  • 执行 a->b_ptr = b,B 的引用计数变成 2
  • 执行 b->a_ptr = a,A 的引用计数变成 2

当 main 函数结束,局部变量 a 和 b 被销毁。此时:

  • A 的引用计数从 2 减到 1(还有 b 的 a_ptr 指着它)
  • B 的引用计数从 2 减到 1(还有 a 的 b_ptr 指着它)

两边引用计数都没归零,所以两个对象都「赖着不走」。这就是循环引用导致的内存泄漏。

核心结论:shared_ptr 的引用计数只能处理「无环」的指向关系。一旦出现环,引用计数永远不会归零。

我的经验:我曾经在一个消息中间件项目里,用 shared_ptr 管理连接对象和会话对象。连接持有会话,会话也持有连接。结果服务跑了一周后内存暴涨,最后用 Valgrind 一查,全是循环引用。从那以后,我只要看到两个类互相包含对方的 shared_ptr,就会立刻警觉。

二、weak_ptr:打破循环的钥匙

weak_ptr 是 shared_ptr 的「小弟」。它不参与引用计数,只提供一种「弱引用」的观察能力。

用 weak_ptr 改写上面的例子:

struct A {
    std::weak_ptr<B> b_ptr;  // 改成 weak_ptr
    ~A() { std::cout << "A destroyed\n"; }
};

struct B {
    std::weak_ptr<A> a_ptr;  // 改成 weak_ptr
    ~B() { std::cout << "B destroyed\n"; }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b;
    b->a_ptr = a;
    // 现在可以正常析构了!
    return 0;
}

运行结果:

A destroyed
B destroyed

原理很简单:weak_ptr 赋值时不会增加引用计数。所以 a 和 b 的引用计数始终是 1。main 结束时,两个 shared_ptr 销毁,引用计数归零,对象正常释放。

但这里有个问题——weak_ptr 不能直接访问对象。你需要先把它「升级」成 shared_ptr:

if (auto sp = weak_b_ptr.lock()) {
    // 安全使用 sp
    sp->doSomething();
} else {
    // 对象已经被销毁了
}

lock() 方法会尝试创建一个新的 shared_ptr。如果原对象还活着,就成功返回;如果已经销毁,就返回空指针。

注意:千万不要直接调用 weak_ptr::expired() 判断后再 lock()。因为判断和 lock 之间,对象可能被其他线程销毁。正确的做法是直接 lock,然后检查返回值是否为空。

三、enable_shared_from_this:安全地返回 this

这是另一个高频翻车点。假设你有一个类,它需要把自己的 this 指针作为 shared_ptr 返回给调用方:

struct BadExample {
    std::shared_ptr<BadExample> getSelf() {
        return std::shared_ptr<BadExample>(this); // 危险!
    }
};

这段代码有什么问题?

如果同一个对象被多个 shared_ptr 管理,每个 shared_ptr 都会认为自己是「唯一的管理者」。当其中一个 shared_ptr 销毁时,它会 delete 对象。然后其他 shared_ptr 就成了悬空指针。

更糟糕的是,如果外部已经有一个 shared_ptr 管理着这个对象,你再用 this 构造一个新的 shared_ptr,就会有两个独立的控制块指向同一块内存——double free 等着你。

解决方案:让类继承 std::enable_shared_from_this<T>

struct GoodExample : std::enable_shared_from_this<GoodExample> {
    std::shared_ptr<GoodExample> getSelf() {
        return shared_from_this(); // 安全!
    }
};

shared_from_this() 会从对象内部已有的控制块中创建一个新的 shared_ptr,而不是重新创建一个。这样就保证了所有 shared_ptr 共享同一个控制块。

重要限制:必须在对象已经被 shared_ptr 管理之后,才能调用 shared_from_this()。如果对象是栈上分配的,或者还没有被任何 shared_ptr 接管,调用 shared_from_this() 会抛出 std::bad_weak_ptr 异常。

我踩过的坑:有一次我在工厂函数里返回了 std::make_shared<MyClass>(),然后在 MyClass 的构造函数里调用了 shared_from_this()。结果程序直接崩溃。原因就是:构造函数执行时,shared_ptr 还没完全构造好,控制块还没关联到对象上。正确的做法是在构造完成后,再调用需要 shared_from_this() 的方法。

四、知识体系总览

下面这张图把 shared_ptr、weak_ptr、enable_shared_from_this 的关系梳理清楚了:

shared_ptr 循环引用与解决方案 循环引用问题 对象 A shared_ptr<B> 对象 B shared_ptr<A> 引用计数永不归零 解决方案 对象 A weak_ptr<B> 对象 B weak_ptr<A> 不增加引用计数,正常析构 enable_shared_from_this 安全返回 this 指针,避免多重控制块 最佳实践 父子关系用 unique_ptr,双向引用用 weak_ptr,需要返回 this 用 enable_shared_from_this

五、最佳实践总结

聊了这么多,最后给几条我自己的铁律:

场景 推荐做法 原因
明确的所有权(父子关系) unique_ptr 零开销,语义清晰
共享所有权,无环 shared_ptr 引用计数自动管理
共享所有权,有环 一方用 weak_ptr 打破环 避免内存泄漏
需要返回 this 的 shared_ptr 继承 enable_shared_from_this 避免 double free
观察者模式 weak_ptr 不延长对象生命周期

再强调一次:weak_ptr 的 lock()expired() 不是原子组合。永远先 lock 再检查,不要先 expired 再 lock。

嗯,shared_ptr 的核心内容到这里就差不多了。循环引用、weak_ptr、enable_shared_from_this 这三个知识点,说白了就是「怎么安全地处理 shared_ptr 之间的复杂关系」。你在实际项目中只要记住一条原则:能用 unique_ptr 就别用 shared_ptr,必须用 shared_ptr 时警惕循环引用,就能避开 90% 的坑。

公众号:蓝海资料掘金营,微信deep3321