【底层机制】std::weak_ptr解决的痛点?是什么?如何实现?如何正确用?

我们来深入剖析一下 std::weak_ptr。它常常被视为 std::shared_ptr 的"配角",但理解它正是区分中级和高级C++使用者的关键之一。weak_ptr 的设计精巧而实用,解决了 shared_ptr 模型中的一个核心缺陷。


1. 解决了什么痛点? (The Problem)

std::shared_ptr 的共享所有权模型非常强大,但它引入了一个新的、特定于引用计数的问题:循环引用(Cyclic Reference)

循环引用场景: 想象两个对象 AB 相互持有对方的 shared_ptr

cpp 复制代码
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"; }
};

void leak() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b; // b 的 use_count -> 2
    b->a_ptr = a; // a 的 use_count -> 2
}
// 函数结束:
// 1. 栈上的 `b` 析构,b 的 use_count 从 2 减为 1。不为零,B 对象存活。
// 2. 栈上的 `a` 析构,a 的 use_count 从 2 减为 1。不为零,A 对象存活。
// 3. 现在只剩下 A.b_ptr 和 B.a_ptr 相互指着对方,它们的 use_count 永远为 1。
// 4. 内存泄漏!A 和 B 都无法被释放。

std::weak_ptr 就是为了打破这种循环而生的。它提供了一种方式,可以"观察"或"引用"一个由 shared_ptr 管理的对象,但不参与所有权争夺,不贡献引用计数

此外,它还解决了另一个问题: 提供一种临时共享的访问方式,而不影响对象的生命周期(例如用于缓存),避免"悬挂指针"问题。


2. 是什么? (What is it?)

std::weak_ptr 是一种弱引用智能指针 。它不独立管理对象生命周期,而是绑定到一个 std::shared_ptr 上,来观察其管理的对象

你可以把它想象成一个"旁观者"或者"观察员证":

  • 它不能直接进入场馆(访问对象)。
  • 它只能告诉你场馆是否还在开放(对象是否存活)。
  • 如果你想进去,你必须凭这个"观察员证"临时兑换一张正式门票.lock() 方法得到一个 shared_ptr),然后才能进入。

它的核心特性是:

  • 不增加引用计数:它的存在与否,不影响其所指对象的生命周期。
  • 需要验证后使用 :不能直接通过 weak_ptr 访问对象,必须先将其"提升"为一个 shared_ptr
  • expired():可以快速检查其观察的对象是否已被销毁。

3. 内部是如何实现的? (Implementation)

std::weak_ptr 的实现与 std::shared_ptr 密不可分,它们共享同一个控制块(Control Block)

回顾一下 shared_ptr 的控制块结构:

  • use_count : 强引用计数(shared_ptr 的数量)
  • weak_count : 弱引用计数(weak_ptr 的数量 + 一些其他内部用途)
  • 其他数据:如删除器、分配器等。

weak_ptr 的实现简化概念如下:

cpp 复制代码
template<typename T>
class weak_ptr {
private:
    T* ptr;                 // 指向托管对象的指针(可能已失效)
    ControlBlock* control_block; // 指向控制块的指针

public:
    // 构造函数:通常由一个 shared_ptr 创建
    weak_ptr(const std::shared_ptr<T>& sp) noexcept : ptr(sp.get()), control_block(sp.control_block) {
        if (control_block) {
            // 关键:只增加弱引用计数,不增加强引用计数!
            ++control_block->weak_count;
        }
    }

    // 析构函数:减少弱引用计数
    ~weak_ptr() {
        if (control_block) {
            --control_block->weak_count;
            // 如果强引用和弱引用都归零,则销毁控制块本身
            if (control_block->use_count == 0 && control_block->weak_count == 0) {
                delete control_block;
            }
        }
    }

    // 最重要的方法:尝试获取一个共享所有权的 shared_ptr
    std::shared_ptr<T> lock() const noexcept {
        if (control_block && control_block->use_count > 0) {
            // 原子地检查use_count>0,如果成立则增加use_count并返回一个shared_ptr
            // 这是一个原子操作,防止竞态条件:
            // 线程1:判断 use_count > 0 成立
            // 线程2:最后一个shared_ptr析构,use_count=0,对象被销毁
            // 线程1:尝试增加use_count -> 因为use_count已为0,操作会失败或避免
            return std::shared_ptr<T>(*this); // 使用 shared_ptr 的弱指针构造方式
        } else {
            return std::shared_ptr<T>(); // 返回一个空的 shared_ptr
        }
    }

    bool expired() const noexcept {
        // 检查是否过期(对象是否已被销毁)
        return (control_block == nullptr) || (control_block->use_count == 0);
    }
};

关键实现要点:

  1. 共享控制块weak_ptr 和其来源 shared_ptr 指向同一个控制块。
  2. 操作弱计数weak_ptr 的构造和析构只影响控制块的 weak_count,不影响 use_count。因此它不会阻止对象的销毁。
  3. lock() 的原子性lock() 方法的核心是原子地 检查 use_count 是否仍大于零,如果是,则将其递增。这一步至关重要,它保证了在多线程环境中,即使对象正在被最后一个 shared_ptr 释放,lock() 也能安全地返回一个空指针或有效的 shared_ptr,而不会导致竞态条件。
  4. 控制块的生命周期 :对象内存(T)在 use_count 降为 0 时被销毁。但控制块内存 会一直保留,直到 use_countweak_count 都降为 0 。这是因为可能还有 weak_ptr 存在,它们需要访问控制块来检查 use_count。这就是为什么使用 std::make_shared(对象和控制块一起分配)有时会导致内存延迟释放的原因。

4. 应该如何正确使用? (Best Practices)

基本用法:打破循环引用

这是 weak_ptr 最经典和重要的用途。

cpp 复制代码
struct B;
struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed\n"; }
};
struct B {
    std::weak_ptr<A> a_ptr; // 关键修改:将强引用改为弱引用
    ~B() { std::cout << "B destroyed\n"; }
};

void no_leak() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b; // b.use_count -> 2
    b->a_ptr = a; // a.use_count 仍然为 1! (weak_ptr 不增加计数)

    // 访问弱引用的对象
    if (auto shared_a = b->a_ptr.lock()) { // 尝试提升为 shared_ptr
        shared_a->doSomething(); // 安全使用
    } // 临时 shared_ptr 析构,use_count 恢复为 1
}
// 函数结束:
// 1. 栈上 `b` 析构 -> b.use_count 从 2 -> 1 (因为 a->b_ptr 还在)
// 2. 栈上 `a` 析构 -> a.use_count 从 1 -> 0 -> A 对象被销毁!
//    -> 导致 A 的成员 b_ptr 析构 -> b.use_count 从 1 -> 0 -> B 对象被销毁!

其他用法

  1. 缓存(Cache)

    cpp 复制代码
    std::weak_ptr<CacheEntry> cache_entry_weak;
    
    // 客户端代码想要获取缓存
    std::shared_ptr<CacheEntry> get_cache() {
        if (auto entry = cache_entry_weak.lock()) {
            return entry; // 缓存命中,对象还在
        } else {
            // 缓存未命中或已被清理,重新加载...
            auto new_entry = std::make_shared<CacheEntry>(...);
            cache_entry_weak = new_entry; // 存储弱引用,不影响对象生命周期
            return new_entry;
        }
    }
    // 当所有使用者都忘记这个缓存项时,它会自动被释放。缓存系统只持有弱引用,不会阻止其释放。
  2. 观察者模式(Observer Pattern) : 主题(Subject)持有所有观察者(Observer)的 weak_ptr。当主题要通知观察者时,它遍历列表,用 .lock() 获取有效的 shared_ptr 然后调用方法。如果某个观察者已经被销毁(.lock() 失败),主题可以安全地将该 weak_ptr 从列表中移除。这避免了主题"持有"观察者导致其无法析构的问题。

重要准则与陷阱(Dos and Don'ts)

  • DO : 在可能存在循环引用 的地方,毫不犹豫地使用 std::weak_ptr 来替代 std::shared_ptr,打破循环。
  • DO : 使用 auto shared_ptr = weak_ptr.lock() 来安全地访问对象。永远不要假设 weak_ptr 观察的对象仍然存活。
  • DO : 使用 expired() 方法如果你只关心对象是否存在,而不需要立即使用它(但要注意,expired()lock() 之间可能有竞态条件,通常直接使用 lock() 检查返回值是更好的模式)。
  • DON'T : 不要直接解引用 weak_ptrweak_ptr 没有重载 operator->operator*。这是语法层面的保护,强制你进行安全检查。
  • DON'T : 不要尝试从裸指针或另一个 weak_ptr 创建 weak_ptrweak_ptr 必须总是从一个已存在的 shared_ptr 创建,以确保它共享正确的控制块。
  • DON'T : 注意控制块的生命周期。如果你非常关心内存的即时释放,并且使用了大量的 weak_ptr,了解 std::make_shared 会将对象和控制块内存捆绑这一点很重要。如果你想将对象内存和控制块内存分开释放,可以使用 std::shared_ptr<T>(new T(...)),但这牺牲了性能和异常安全性,通常不推荐。

总结

特性 std::weak_ptr
所有权 无所有权(弱引用)
用途 打破 shared_ptr 的循环引用、实现缓存、观察者列表
创建 必须从 std::shared_ptr 构造或赋值
访问对象 必须通过 .lock() 方法尝试获取一个 std::shared_ptr
开销 很小(与控制块的原子操作)
线程安全 .lock() 操作是原子的,线程安全

核心思想std::weak_ptr 不是一种独立的智能指针,而是 std::shared_ptr 生态系统的一个安全补充 。它通过放弃所有权来换取避免循环引用的能力,并通过严格的"先检查后访问"机制保证了安全性。熟练运用 weak_ptr 是构建复杂且无内存泄漏的C++对象关系模型的必备技能。


点个关注不迷路,定时更新底层机制与算法通俗讲解


C++底层机制推荐阅读**
【C++基础知识】深入剖析C和C++在内存分配上的区别
【底层机制】【C++】vector 为什么等到满了才扩容而不是提前扩容?
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
【底层机制】剖析 brk 和 sbrk的底层原理
【底层机制】为什么栈的内存分配比堆快?
【底层机制】右值引用是什么?为什么要引入右值引用?
【底层机制】auto 关键字的底层实现机制
【底层机制】std::unordered_map 扩容机制
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
【底层机制】【编译器优化】RVO--返回值优化
【基础知识】仿函数与匿名函数对比
【底层机制】【C++】std::move 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】emplace_back 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】【编译器优化】循环优化--为什么引入?怎么实现的?流程啥样?
【底层机制】std::string 解决的痛点?是什么?怎么实现的?怎么正确用?
【底层机制】std::unique_ptr 解决的痛点?是什么?如何实现?怎么正确使用?
【底层机制】std::shared_ptr解决的痛点?是什么?如何实现?如何正确用?

相关推荐
River4162 小时前
Javer 学 c++(十六):对象特性篇(上)
c++·后端
甜瓜看代码2 小时前
业务稳定性和性能稳定性做的工作
面试
甜瓜看代码2 小时前
安卓页面绘制流程
面试
感哥3 小时前
C++ 左值、右值、左值引用、右值引用
c++
小高0074 小时前
前端 Class 不是花架子!3 个大厂常用场景,告诉你它有多实用
前端·javascript·面试
感哥4 小时前
C++ 模板
c++
uhakadotcom6 小时前
什么是OpenTelemetry?
后端·面试·github
没有鸡汤吃不下饭6 小时前
前端【数据类型】 No.1 Javascript的数据类型与区别
前端·javascript·面试
知其然亦知其所以然6 小时前
MySQL 社招必考题:如何优化特定类型的查询语句?
后端·mysql·面试