C++
手写 std::weak_ptr
实现非拥有观察者、强弱计数协作与无竞态提升的 lock,并用 weak_ptr 打破 shared_ptr 引用环。
发布于 2026年7月23日
手写 std::weak_ptr
实现非拥有观察者、强弱计数协作与无竞态提升的 lock,并用 weak_ptr 打破 shared_ptr 引用环。
本系列代码使用 C++20 和
oc::handmade命名空间,目标是解释实现机制、复杂度和工程边界,不是替代标准库。智能指针管理生命周期,但不会自动解决业务对象的线程安全。
一、学习目标
- 实现不延长对象生命周期的 weak_ptr
- 使用 compare_exchange 实现不会从零复活对象的 lock
- 理解对象销毁与控制块释放为何发生在不同时间
二、前置条件
完成 shared_ptr 篇,并能画出控制块中的强计数、显式弱计数与隐式弱引用。
Linux/macOS:
g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror smart_ptrs_test.cpp -o smart_ptrs_test
./smart_ptrs_test
Windows PowerShell:
cl /std:c++20 /W4 /WX /EHsc smart_ptrs_test.cpp
.\smart_ptrs_test.exe
三、问题与设计选择
每个 weak_ptr 增加显式弱计数,但不增加强计数。lock 只有在强计数非零时通过 CAS 增加强计数并采用该份所有权;当强计数已经为零时必须返回空指针。最后一个强所有者释放隐式弱引用,最后一个弱观察者释放控制块。
教学实现覆盖核心所有权语义、复制或移动、观察接口、异常回滚与主要辅助函数;不复刻标准库的完整转换构造矩阵、allocator 定制、enable_shared_from_this、owner 比较器和 ABI 细节。
四、内存布局与核心不变量
弱引用绝不能让强计数从 0 重新变为 1;对象只由最后一个强所有者销毁;控制块只在对象已销毁且所有显式弱观察者都离开后释放。
判断实现是否正确时,不只数对象指针,还要分别追踪“资源是否活着”“拥有者数量”和“控制块是否活着”。三者的生命周期边界并不相同。
五、核心实现
bool try_add_strong() noexcept {
std::size_t observed = strong_.load(std::memory_order_acquire);
while (observed != 0) {
if (strong_.compare_exchange_weak(
observed, observed + 1,
std::memory_order_acquire,
std::memory_order_relaxed)) {
return true;
}
}
return false;
}
shared_ptr<T> lock() const noexcept {
if (control_ && control_->try_add_strong()) {
return shared_ptr<T>(
control_, pointer_, detail::adopt_strong);
}
return {};
}
这段代码展示最容易写错的状态转换。完整实现放在下一节,并与另外两种智能指针共用同一份 smart_ptrs.hpp。
六、完整教学实现
namespace oc::handmade {
template<class T>
class weak_ptr {
public:
constexpr weak_ptr() noexcept = default;
weak_ptr(const shared_ptr<T>& owner) noexcept
: pointer_(owner.pointer_), control_(owner.control_) {
if (control_) control_->add_weak();
}
weak_ptr(const weak_ptr& other) noexcept
: pointer_(other.pointer_), control_(other.control_) {
if (control_) control_->add_weak();
}
weak_ptr(weak_ptr&& other) noexcept
: pointer_(std::exchange(other.pointer_, nullptr)),
control_(std::exchange(other.control_, nullptr)) {}
~weak_ptr() {
if (control_) control_->release_weak();
}
weak_ptr& operator=(const weak_ptr& other) noexcept {
weak_ptr(other).swap(*this);
return *this;
}
weak_ptr& operator=(weak_ptr&& other) noexcept {
weak_ptr(std::move(other)).swap(*this);
return *this;
}
weak_ptr& operator=(const shared_ptr<T>& owner) noexcept {
weak_ptr(owner).swap(*this);
return *this;
}
void reset() noexcept { weak_ptr().swap(*this); }
void swap(weak_ptr& other) noexcept {
using std::swap;
swap(pointer_, other.pointer_);
swap(control_, other.control_);
}
[[nodiscard]] std::size_t use_count() const noexcept {
return control_ ? control_->use_count() : 0;
}
[[nodiscard]] bool expired() const noexcept { return use_count() == 0; }
[[nodiscard]] shared_ptr<T> lock() const noexcept {
if (control_ && control_->try_add_strong()) {
return shared_ptr<T>(control_, pointer_, detail::adopt_strong);
}
return {};
}
private:
template<class U>
friend class shared_ptr;
T* pointer_{};
detail::control_block* control_{};
};
template<class T>
shared_ptr<T>::shared_ptr(const weak_ptr<T>& weak) {
if (!weak.control_ || !weak.control_->try_add_strong()) {
throw std::bad_weak_ptr();
}
pointer_ = weak.pointer_;
control_ = weak.control_;
}
} // namespace oc::handmade
统一测试覆盖移动、复制、数组、别名所有权、对象析构、过期观察者和失败提升;实现没有向 std 命名空间注入任何类型。
七、使用示例与输出
预期输出或状态:
对象存活时 lock 返回非空且强计数增加;最后一个强所有者销毁后 expired 为 true,lock 返回空,显式构造 shared_ptr 抛 bad_weak_ptr。
示例使用断言验证状态,因此成功时不打印额外文本。析构计数必须回到 0,才能说明资源和控制块路径已经闭合。
八、复杂度与失效规则
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 从 shared_ptr 构造 | O(1) | 原子增加弱计数 |
| 复制/移动 weak_ptr | O(1) | 复制增加弱计数 |
| use_count/expired | O(1) | 只读取强计数快照 |
| lock | O(1) 期望 | CAS 竞争时可能重试 |
| reset/析构 | O(1) | 最后弱引用释放控制块 |
智能指针本身没有容器迭代器;这里的“失效”指资源销毁后,裸指针、引用或观察者能否继续安全使用。任何从 get()、operator* 或 operator-> 取得的非拥有访问,都不能延长对象生命周期。
九、异常安全与资源管理
lock把“检查未过期”和“增加强计数”合并为一个 CAS 循环,避免检查后对象立即销毁的竞态。- CAS 失败会把最新强计数写回 observed;只有 observed 非零才继续尝试。
- 成功 CAS 已经取得一份强所有权,构造返回值时必须使用 adopt 标记,不能再次增加计数。
weak_ptr保存的对象指针在过期后不可解引用,只能用于构造空结果或释放控制块引用。
析构和引用计数释放路径必须为 noexcept。用户 deleter 若从析构路径抛出,程序无法可靠恢复,因此教学实现要求 deleter 的调用和移动不抛异常。
十、常见错误
1. 先 expired 再解引用旧裸指针
检查与使用不是原子事务;正确做法是保存 lock() 返回的 shared_ptr。
2. 用普通 load 加 fetch_add 实现 lock
对象可能在两步之间被销毁,随后把已经为零的计数错误复活。
3. 双向关系全部使用 shared_ptr
强引用环让每个计数都无法归零;至少一条非拥有边应改为 weak_ptr。
十一、面试追问
- 为什么控制块的弱计数需要一个隐式弱引用?
weak_ptr::lock如何保证不会复活已经销毁的对象?expired()返回 false 后为什么仍不能直接使用旧裸指针?- 循环引用和内存泄漏之间的因果链是什么?
回答时先说明所有权,再画出对象与控制块,最后给出原子计数、异常和并发边界。
十二、练习与自测
- 实现 parent 使用 weak_ptr、children 使用 shared_ptr 的树节点
- 用两个线程分别 reset 与 lock,重复运行并开启 TSan
- 增加 weak_ptr 的派生类到基类转换构造
自测标准:能够不看代码画出每次复制、移动、reset、lock 和析构后的强弱计数,并解释最后由谁销毁对象、由谁释放控制块。
十三、官方资料与延伸阅读
上一篇:手写 std::shared_ptr | 下一篇:手写内存池