std::bad_weak_ptr 这玩意儿,但凡用 shared_ptr 写过点东西的人,大概率都撞到过。报错瞬间往往是一脸懵:明明代码编译过了,逻辑看着也没问题,运行时就给你抛个异常,而且还是个冷门异常。更气人的是,它不像 std::bad_alloc 那样查查文档就能明白,很多情况下是“知其然不知其所以然”,网上资料也少,翻半天找不着一个能把来龙去脉讲清楚的。
这篇文章就专门来拆解 std::bad_weak_ptr。我会从它的定义和抛出条件讲起,分析几个最常见的触发场景,再结合排查经验和防御性编程手段,把这玩意儿的底裤扒干净。无论你是刚接触智能指针的新手,还是被线上问题折磨过的老手,这篇文章都值得看完。
1. 异常的本质:std::bad_weak_ptr 到底在什么情况下抛出
先给不熟悉的读者打个底。std::bad_weak_ptr 是 C++ 标准库定义的一个异常类,继承自 std::exception,定义在 <memory> 头文件里。它的 what() 返回值通常是 "std::bad_weak_ptr" 或者 "bad_weak_ptr",具体字符串取决于标准库实现。
它的抛出条件其实非常明确:从一个 std::weak_ptr 构造 std::shared_ptr 的时候,如果这个 weak_ptr 已经失效(expired),也就是它所指向的对象已经被销毁,那么就会抛出 std::bad_weak_ptr。
听起来很简单对吧?但问题就出在很多程序员以为“我用 weak_ptr.lock() 来判断失效不就行了”,结果却栽在 shared_ptr 的另一个构造路径上。
1.1 构造函数中的隐式陷阱:shared_ptr(const weak_ptr&) 与 shared_ptr(weak_ptr&&)
std::shared_ptr 有两个构造函数接受 weak_ptr 参数:
cpp复制template<class Y>
explicit shared_ptr(const std::weak_ptr<Y>& r);
template<class Y>
explicit shared_ptr(std::weak_ptr<Y>&& r);
这两个构造函数的行为是:如果 r.expired() 为真,就抛出 std::bad_weak_ptr;否则,接管 r 所引用的对象的所有权。
注意,它们是 explicit 的,所以不能隐式转换,你不可能在代码里写 shared_ptr<int> sp = wp; 然后期望它默默转换——编译直接报错。但如果你写 shared_ptr<int> sp(wp);,那就是明确调用了这个构造函数,此时如果 wp 已失效,异常就来了。
这里有个关键点:weak_ptr 本身不持有对象的所有权,它只是观测者。 当它观测的对象被最后一个 shared_ptr 释放后,weak_ptr 就自动变为 expired 状态。此时你无论调用 lock() 还是用这个构造函数,都无法再获得有效的 shared_ptr。
区别在于:
lock()是安全的选择——如果 expired,返回一个空的shared_ptr,不抛异常。- 构造函数则直接抛异常。
很多老代码或者从别处抄来的代码,可能没注意到这个区别。我自己遇到过几次,都是因为重构时把 lock() 换成了构造函数,然后线上就开始疯狂抛异常。
1.2 异常抛出的底层机制:控制块与引用计数
要真正理解这个异常,还得从 weak_ptr 的底层机制说起。标准库实现中,shared_ptr 和 weak_ptr 共享一个控制块(control block),控制块里维护两个计数:强引用计数(use_count)和弱引用计数(weak_count)。
当强引用计数降为 0 时,被管理的对象就被销毁,但控制块本身还会保留,直到弱引用计数也降为 0。此时 weak_ptr 的 expired() 就会返回 true,因为它内部保存的指针已经指向一个被销毁的对象。
用伪代码来理解:
text复制控制块 {
T* ptr; // 被管理对象指针
size_t strong_count; // shared_ptr 数量
size_t weak_count; // weak_ptr 数量
};
当 strong_count 从 1 减到 0 时:
- 调用析构函数销毁对象。
- 如果 weak_count 也为 0,则销毁控制块。
- 如果 weak_count > 0,保留控制块,但 ptr 指向的对象已经没了。
此时任何通过该控制块创建新的 shared_ptr 的尝试都会失败,因为对象已经没了。这就是 std::bad_weak_ptr 发生的本质。
理解了这层机制,就能明白为什么 weak_ptr 不能像 shared_ptr 那样直接解引用——它根本没有保证对象存活的义务。你得先通过 lock() 或者构造函数“升级”它,升级成功后才能安全使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最常见的翻车现场:std::enable_shared_from_this 与 shared_from_this() 的误用
这个异常最经典的触发场景,就是 std::enable_shared_from_this 的误用。我敢说十个人踩坑,有八个都是因为这个。
2.1 正确用法和踩坑细节
std::enable_shared_from_this<T> 是一个辅助类模板,它让你的类能够从成员函数内部安全地获取一个指向自己的 shared_ptr。前提是:这个对象必须已经被某个 shared_ptr 管理。
正确做法:
cpp复制#include <memory>
#include <iostream>
class Foo : public std::enable_shared_from_this<Foo> {
public:
std::shared_ptr<Foo> getPtr() {
return shared_from_this();
}
};
int main() {
auto sp = std::make_shared<Foo>();
auto sp2 = sp->getPtr();
std::cout << sp.use_count() << std::endl; // 2
return 0;
}
这没问题。但如果你在对象还没被 shared_ptr 管理的时候就调用 shared_from_this(),就会抛出 std::bad_weak_ptr:
cpp复制int main() {
Foo* raw = new Foo(); // 栈上对象同样不行:Foo f; f.getPtr();
auto sp = raw->getPtr(); // 抛出 std::bad_weak_ptr!
delete raw;
return 0;
}
原理是:enable_shared_from_this 内部保存了一个 weak_ptr,这个 weak_ptr 只有在对象被 shared_ptr 接管时才会被初始化。如果对象是裸构造的,那个 weak_ptr 就是空的,shared_from_this() 自然就抛异常了。
2.2 构造函数中调用 shared_from_this() 的致命错误
更隐蔽的一个坑是在构造函数里调用 shared_from_this()。看下面的例子:
cpp复制class Foo : public std::enable_shared_from_this<Foo> {
public:
Foo() {
auto sp = shared_from_this(); // 永远抛 std::bad_weak_ptr
}
};
int main() {
auto sp = std::make_shared<Foo>(); // 构造过程中就崩了
return 0;
}
为什么在构造函数里调 shared_from_this() 必然失败?因为 enable_shared_from_this 内部的 weak_ptr 是在对象构造完成之后、shared_ptr 构造函数末尾才被初始化的。想想 make_shared 的执行流程:
- 调用
Foo的构造函数,创建对象。 - 此时
shared_ptr还没创建,weak_ptr自然没有初始化。 - 构造函数内部尝试
shared_from_this(),发现weak_ptr是空的,抛异常。
同样,析构函数里调用也会有问题:对象已经开始析构,控制块中的强引用计数可能已经清零,此时 shared_from_this() 同样会抛异常。
2.3 关于继承链上多个 enable_shared_from_this 的问题
还有一种情况容易踩坑:类继承体系中,如果父类和子类都继承了 enable_shared_from_this,可能会导致 shared_from_this() 返回错误的类型。
cpp复制struct Base : std::enable_shared_from_this<Base> {};
struct Derived : Base, std::enable_shared_from_this<Derived> {};
这种继承结构下,Derived 对象被 shared_ptr 接管时,会同时初始化两个 weak_ptr,分别对应 Base 和 Derived 的基类子对象。如果你不小心调用了错误基类的 shared_from_this(),可能拿到一个类型错误的 shared_ptr。虽然现代编译器对虚继承和多重继承的 enable_shared_from_this 处理越来越完善,但这种复杂继承结构最好还是避开,能简化就简化。
3. 隐蔽的生命周期竞态:多线程环境下 weak_ptr 的失效窗口
如果说 enable_shared_from_this 是“新手误伤”,那多线程环境下 weak_ptr 的竞态就是“老手翻车”的重灾区。这个问题的隐蔽程度极高,因为它不是每次都触发,而是偶发、间歇性出现,排查起来非常痛苦。
3.1 一个经典的竞态场景还原
假设你有两个线程:
- 线程 A 持有
shared_ptr,负责创建和销毁对象。 - 线程 B 持有
weak_ptr,需要间歇性地使用对象。
典型代码长这样:
cpp复制std::shared_ptr<Foo> global_shared = std::make_shared<Foo>();
std::weak_ptr<Foo> global_weak = global_shared;
// 线程 A
void threadA() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
global_shared.reset(); // 销毁对象
}
// 线程 B
void threadB() {
for (int i = 0; i < 100; ++i) {
// 通过构造函数升级
std::shared_ptr<Foo> sp(global_weak); // 可能抛出 bad_weak_ptr
if (sp) {
sp->doSomething();
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
这里的 threadB 可能会在某个循环迭代中抛出 std::bad_weak_ptr。为什么?
因为 threadA 的 reset() 操作和 threadB 的构造函数操作之间没有同步。当 threadA 将强引用计数减为 0 的那一刻,threadB 可能正在构造 shared_ptr。这个构造过程包含了检查 expired() 和增加引用计数两个步骤,如果检查时还没失效,但增加引用计数时已经失效了,标准库实现会检测到这个竞态并抛出 bad_weak_ptr。
从 C++ 标准的角度看,weak_ptr::lock() 和 shared_ptr 的构造函数提供了原子性的操作保证,但只能针对单个操作。如果你用 lock(),它是线程安全的(要么成功升级,要么返回空指针);但如果你用构造函数,它在检测到竞态时就是抛异常。
3.2 为什么不能简单地 catch 异常
也许你会说,异常就异常呗,我 catch 住不就行了?理论上可以,但你得小心异常被吞掉后的状态。
std::bad_weak_ptr 抛出时,weak_ptr 已经被判定为失效,所以即使你 catch 住了,这次升级也是失败的,拿不到有效的 shared_ptr。但这跟你调用 lock() 然后检查返回值是等价的——只是写法更难维护,而且会引入控制流上的跳转,让代码逻辑变得支离破碎。
更严重的问题是:如果你的代码中升级 weak_ptr 的操作嵌套在其他大量逻辑里,异常会沿着栈一路向上传播,可能导致整个线程崩溃。我见过有些项目在定时器回调里直接构造 shared_ptr 而不捕获异常,结果一个偶然的竞态直接让整个服务进程挂掉。
3.3 标准库实现的竞态检测细节
需要知道的是,标准库实现(libstdc++、libc++、MSVC)对这个竞态的处理策略略有差异。
- libstdc++(GCC)会检查
use_count()是否为 0,为 0 就抛异常。 - libc++(Clang)也类似,但实现细节更细致。
- MSVC 单独维护了一个状态位来标记
weak_ptr是否失效。
这种差异意味着同一份代码在不同平台上的行为可能不同——在某些平台上你可能得到空的 shared_ptr(如果实现只是返回空指针而不是抛异常),而在另一些平台上却稳定抛异常。所以最好的策略就是:不要依赖实现细节,统一用 lock() 来做升级。
4. 从源头上少踩坑:防御式使用与方案选型
讲完了底层原理和常见踩坑场景,来说说实际工作中怎么避免这个异常,以及如果真的遇到了,排查路径应该怎么走。
4.1 排查路径:一次典型故障的完整复盘
假设你收到线上告警,日志里出现大量 std::bad_weak_ptr。怎么排查?
第一步,先看堆栈。异常抛出时通常会打印调用栈,重点看是从哪个 shared_ptr 构造函数抛出的。
- 如果是从
enable_shared_from_this相关的shared_from_this()调用链抛出的,几乎可以确定是对象在没有shared_ptr管理的情况下被调用了。检查是不是在构造函数/析构函数里调用的,或者是不是某个裸指针直接调用了成员函数。 - 如果是从某个容器的
weak_ptr升级处抛出的,重点检查这个weak_ptr是从哪里来的,是否可能已经在其他线程中被 reset 了。
第二步,看触发时机。如果异常是偶发的、跟时间相关的,十有八九是竞态。用 thread sanitizer(TSan)跑一遍测试用例,多半能直接定位到数据竞争点。
第三步,审查代码中对 weak_ptr 的所有升级操作。把所有 shared_ptr<T> sp(wp) 的写法全部替换成 auto sp = wp.lock(),然后判断 sp 是否为空。这一步能消除大多数竞态导致的问题。
4.2 老代码中的定时器/回调陷阱
最后一个我特别想分享的坑是回调函数中的 shared_from_this()。很多项目用 weak_ptr 捕获 this 来避免回调中的悬垂指针,比如异步网络库、定时器框架中经常会看到这种模式:
cpp复制class Session : public std::enable_shared_from_this<Session> {
public:
void start() {
// 发起异步操作,回调中捕获 this
do_async_work([this]() {
auto sp = shared_from_this(); // 如果 Session 已被释放,这里抛异常
sp->process();
});
}
};
这个模式的问题在于,回调执行时,Session 对象可能已经被销毁。虽然你捕获了 this 指针,但没有捕获 shared_ptr,所以对象还是不安全的。正确做法应该是捕获 shared_ptr:
cpp复制class Session : public std::enable_shared_from_this<Session> {
public:
void start() {
auto self = shared_from_this();
do_async_work([self]() {
self->process(); // 持有 shared_ptr,对象不会销毁
});
}
};
或者是捕获 weak_ptr,然后在回调里 lock():
cpp复制void start() {
std::weak_ptr<Session> weak_self = shared_from_this();
do_async_work([weak_self]() {
if (auto self = weak_self.lock()) {
self->process();
}
});
}
两种方式都能避免对象被提前释放的问题,区别在于:捕获 shared_ptr 会延长对象的生命周期直到异步操作完成,捕获 weak_ptr 则不会。具体用哪个取决于业务需求——如果你希望异步操作完成前对象一定存在,用前者;如果允许对象提前销毁,异步操作自动取消,用后者。但无论如何,都不要直接用裸 this 捕获。
4.3 工程实践中的几条硬性规则
总结几条我在实际开发中逐渐建立起来的规则,基本都是血的教训换来的:
-
永远不要从
weak_ptr直接用构造函数升级。lock()是更安全的选择,唯一需要牺牲的只是一个空指针判断。如果你真的希望在升级失败时抛异常(比如这是业务逻辑的一部分),也应该是lock()之后手动判断并抛自己的业务异常,而不是让标准异常穿透。 -
构造函数和析构函数中绝对禁止
shared_from_this()。如果确实需要在构造期间获取shared_ptr,考虑用静态工厂函数,先new出裸指针,再包装成shared_ptr:
cpp复制class Foo : public std::enable_shared_from_this<Foo> {
public:
static std::shared_ptr<Foo> create() {
// 先用裸指针构造
Foo* raw = new Foo();
// 再包装成 shared_ptr,此时 enable_shared_from_this 的 weak_ptr 才会初始化
return std::shared_ptr<Foo>(raw);
}
private:
Foo() {}
};
注意,这个静态工厂函数在 C++17 之前是必要的,因为 std::make_shared 不会自动初始化 enable_shared_from_this 的 weak_ptr。C++17 开始,标准要求 make_shared 也支持 enable_shared_from_this,所以可以直接用 std::make_shared。但我个人的经验是,如果项目还在用 C++14 或更早标准,尽量用工厂函数模式,省心。
-
多线程环境下升级
weak_ptr,统一走lock(),并且对空指针做兜底处理。不要相信“这个对象在程序生命周期内一定存在”之类的假设,尤其是在高并发异步系统中。 -
如果
weak_ptr是从shared_ptr派生出来的,确认shared_ptr没有被提前 reset。这个听上去是废话,但往往就是某个地方reset()了全局变量,导致所有持有的weak_ptr全部失效。 -
留意
std::bad_weak_ptr是不是被标准库容器或者第三方库的内部代码抛出来的。有时候不是你自己的代码直接出错,而是你传入的weak_ptr在库内部被升级时失效了。这时候排查范围就得扩大到所有传入该库的weak_ptr。
4.4 用好 Sanitizer 和静态分析工具
最后推荐几个工具层面的防坑手段:
- AddressSanitizer(ASan)和 ThreadSanitizer(TSan):编译时加上
-fsanitize=address,thread,能让很多隐藏在竞态和垂悬指针下问题提前暴露。特别是竞态,TSan 能捕捉到普通调试手段根本发现不了的非法访问。 - 静态分析工具:Clang-Tidy 有一些针对智能指针的检查项,比如
cppcoreguidelines-owning-memory、cppcoreguidelines-avoid-goto之类的规则,能帮你提前发现shared_from_this()的误用。虽然静态分析不能覆盖所有场景,但可以作为 CI 流程的一道防线。 - 代码审查环节:把“禁止从
weak_ptr构造函数升级”写进团队代码规范,审查时重点看智能指针的使用。这比任何工具都可靠。
从我个人的实践看,std::bad_weak_ptr 这个异常不可怕,怕的是你根本不了解它背后的机制。一旦理解了 weak_ptr 的本质——它只是一个观测者,不保证对象存活——就不会写出那么多摸不着头脑的代码了。记住一句话:面对 weak_ptr,永远先 lock(),永远是空指针判断优先。
