Day 01|从零手写 shared_ptr:最小引用计数版本

3 阅读8分钟

目标:用 7 天时间,从“最简引用计数”迭代到接近 Boost shared_ptr 的控制块架构:默认删除器、自定义删除器、线程安全、weak_ptr、make_shared、最终工程化。
Day 01 只做一件事:把 shared_ptr 的“共享所有权 + 引用计数”跑通闭环,并用测试证明对象生命周期正确


课程架构概览(最终形态:Boost 风格控制块)

基于 Boost shared_ptr 的核心设计,我们将采用 控制块(control block)模式 实现引用计数智能指针。整体架构如下:


┌─────────────────────────────────────────┐  
│ shared_ptr │  
│ ┌───────────────┬──────────────────┐ │  
│ │ T* ptr │ shared_count │ │  
│ └───────┬───────┴────────┬─────────┘ │  
└──────────┼────────────────┼─────────────┘  
│ │  
│ └──────────┐  
▼ ▼  
┌──────────┐ ┌──────────────────────┐  
│ Object T │ │ sp_counted_base │  
└──────────┘ │ ┌────────────────┐ │  
│ │ use_count_ │ │  
│ │ weak_count_ │ │  
│ └────────────────┘ │  
└──────────────────────┘  
▲  
│  
┌──────────┴──────────┐  
│ │  
sp_counted_impl_p sp_counted_impl_pd<T,D>  
(默认删除器) (自定义删除器)

Day 01 我会先做一个“最小可运行版本”打地基:控制块先简化为“共享计数(ref_count_base 的最小形态)”。从 Day 02 开始逐步重构到上面这套 Boost 分层结构。


7 天学习路线图(完整大纲)

Day 1:基础引用计数 —— 最简 shared_ptr

  • 核心:shared_ptr + 最小控制块(ref_count_base 的简化形态)
  • 构造 / 析构 / 拷贝 / 赋值
  • get / operator* / operator-> / operator bool / use_count
  • ✅ 测试:创建、拷贝、赋值覆盖、自动释放

Day 2:控制块分离架构

  • 引入 sp_counted_base(虚函数接口)
  • 默认删除器 sp_counted_impl_p
  • shared_count 管理控制块
  • 重构 Day 1:分离关注点
  • ✅ 测试:控制块正确管理生命周期

Day 3:自定义删除器支持

  • sp_counted_impl_pd<T, D>
  • 构造函数支持(指针 + 删除器)
  • 类型擦除/多态删除策略
  • ✅ 测试:文件句柄、数组等非 new 资源

Day 4:线程安全引用计数

  • std::atomic
  • fetch_add / fetch_sub
  • memory_order 简介
  • ✅ 测试:多线程并发创建/销毁 shared_ptr

Day 5:weak_ptr 与循环引用

  • weak_ptr
  • weak_count_
  • lock() / expired()
  • ✅ 测试:循环引用可释放

Day 6:make_shared 优化

  • 单次分配(对象 + 控制块连续内存)
  • placement new
  • ✅ benchmark:new vs make_shared

Day 7:完善接口与工程化

  • reset/use_count/unique 等
  • 比较运算符、nullptr 支持
  • 异常安全保证
  • 单元测试套件 + 文档
  • ✅ 交付:教学级 shared_ptr 库

Day 01|最小引用计数闭环

1. 今日目标(非常聚焦)

实现一个最简版本 shared_ptr,具备核心引用计数功能:

  1. 构造接管裸指针时:count 初始化为 1
  2. 拷贝时:count + 1
  3. 析构/释放时:count - 1,为 0 才 delete 资源
  4. 用单元测试验证对象只析构一次(生命周期正确)

2. 今日设计:最小控制块

为了先把语义跑通,Day 01 不直接上 Boost 的 sp_counted_base,而是用两根指针做最小实现:

  • T* ptr_:指向对象
  • long* count_:指向共享引用计数(可以看作 Day 01 的 ref_count_base 最小形态)

内存关系:


shared_ptr  
├─ ptr_ ─────────► Object T  
└─ count_ ─────────► long (shared use_count)

关键决策:空指针时 count_ 也必须为空

  • ptr_ == nullptrcount_ == nullptr
  • 好处不止是省内存:语义更统一,空 shared_ptr 的 use_count 永远是 0,拷贝空指针也不会制造“无意义的控制块”。

3. 关键实现=

3.1 构造:接管裸指针(count=1)

explicit shared_ptr(T* p)
  : ptr_(p), count_(p ? new long(1) : nullptr) {}
  • explicit:禁止隐式接管(避免把接管所有权这件事“写得不显眼”)
  • p ? new long(1) : nullptr:空指针不分配计数器

3.2 拷贝构造:共享控制块(count +1)

shared_ptr(const shared_ptr& other) noexcept
  : ptr_(other.ptr_), count_(other.count_) {
  if (count_) ++(*count_);
}

语义:复制两根指针(浅拷贝)+ 对共享计数加一。


3.3 析构与 release:最后一个负责释放(count -1 归零 delete)

~shared_ptr() { release(); }

void release() noexcept {
  if (!count_) return;

  --(*count_);
  if (*count_ == 0) {
    delete ptr_;
    delete count_;
  }

  // Day1 过程里我补了一个更稳的修正:放手后把自身置空,避免悬空指针隐患
  ptr_ = nullptr;
  count_ = nullptr;
}

这段是 Day 01 的“生命线”:
所有正确性(不会泄漏、不会重复释放)都由它保证。


3.4 拷贝赋值:先释放旧资源,再共享新资源

shared_ptr& operator=(const shared_ptr& other) noexcept {
  if (this != &other) {
    release();                 // 关键:必须先处理旧资源
    ptr_ = other.ptr_;
    count_ = other.count_;
    if (count_) ++(*count_);
  }
  return *this;                // 返回自身引用,支持 a=b=c
}

这里也体现了“拷贝构造 vs 拷贝赋值”的本质差异:

  • 构造:新对象没有旧资源,不用 release
  • 赋值:对象已存在,必须先 release 否则泄漏/计数错乱

3.5 指针语义:operator-> 与 operator bool

T* operator->() const noexcept { return ptr_; }

explicit operator bool() const noexcept { return ptr_ != nullptr; }
  • p->print() 会被编译器展开为:p.operator->()->print()
  • explicit operator bool:只允许在布尔语境中“像指针一样判断是否为空”,避免 shared_ptr 被隐式转换参与奇怪表达式

3.6 use_count:调试接口

long use_count() const noexcept {
  return count_ ? *count_ : 0;
}

4. 单元测试:我怎么验证“生命周期闭环正确”

测试思路非常直接:写一个带构造/析构打印的类 TestObject,用断言 + 打印来验证:

  • 创建时 use_count = 1
  • 拷贝后两个指针 use_count = 2,且 get() 相等
  • 赋值覆盖旧资源:旧对象析构打印出现一次
  • 离开作用域:只有最后一个 shared_ptr 析构时才 delete 对象

我给测试覆盖了 5 类场景:

  1. 基本构造 + 出作用域自动释放
  2. 拷贝构造:2 → 1 → 0 的释放链
  3. 赋值覆盖:release 旧对象 + 共享新对象
  4. nullptr 行为:默认构造、显式 nullptr、赋值为空
  5. operator* / operator-> 能正常访问/修改成员

5. 开发过程中遇到的关键问题

1)同一裸指针能不能构造两次 shared_ptr?

不能。两次用同一个 T* 构造会产生两份“控制块/计数”,最后会 double delete(未定义行为)

T* raw = new T;
shared_ptr<T> a(raw);
shared_ptr<T> b(raw);  // ❌

正确做法:只接管一次,然后拷贝 shared_ptr 来共享同一计数:

shared_ptr<T> a(new T);
shared_ptr<T> b = a;   // ✅ count=2

2)头文件开头的 #ifndef/#define/#endif 是干嘛的?

这是 Header Guard,防止头文件被重复 include 导致重复定义编译错误。


3)noexcept / “异常”是啥?

  • 异常:运行时错误用 throw 抛出,可 try/catch 捕获。
  • noexcept:承诺该函数不抛异常;如果真抛了会直接终止程序。
    shared_ptr 里很多基础函数(拷贝、get、operator-> 等)写 noexcept,语义更明确,也更利于优化。

4)为什么空指针时 count_ 也要是空?

为了语义一致 + 避免无意义分配:空 shared_ptr 不应有控制块,use_count() 对空指针自然是 0。


5)operator= 为啥返回引用:shared_ptr& operator=(...)

这是拷贝赋值运算符。返回引用是为了:

  • 支持链式赋值 a = b = c;
  • 避免返回值产生额外拷贝/额外计数波动
    并且赋值时必须先 release() 旧资源,再共享新资源(这是和拷贝构造的本质区别)。

6)operator->() 到底怎么工作的?

p->print() 会被编译器改写为:

(p.operator->())->print();

因为 operator->() 返回的是 T*,拿到指针后还要用一次**内建的 ->**访问成员。
另外 -> 有“链式展开”规则:如果 operator->() 返回的还是类对象,会继续调用,直到拿到原始指针为止。


7)explicit operator bool() 的意义?

if (p) 像指针一样判断非空,同时用 explicit 禁止乱七八糟的隐式转换(避免 shared_ptr 在不该参与的表达式里被当成 bool/int 等)。


8)我在实现里做的两个小修正(为后续迭代铺路)

  • use_count() 补上 const noexcept
  • release() 在放手后把 ptr_/count_ 置空,避免后续扩展 reset/swap/move 时留下悬空状态

6. Day 01 检查清单(复盘 Q&A)

这四个问题是我用来判断“今天真的学会了”的标准。

Q1:为什么需要共享的引用计数,而不是每个 shared_ptr 独立计数?

**答:**独立计数无法让多个 shared_ptr 感知彼此,会导致计数不准确,最终产生 double delete 或内存泄漏。共享控制块才能保证“最后一个持有者释放”。

Q2:拷贝构造和拷贝赋值的区别?

**答:**拷贝构造发生在创建新对象;拷贝赋值发生在对象已存在时,必须先 release 旧资源再共享新资源,否则会泄漏/错计数。

Q3:析构函数什么时候释放对象,什么时候不释放?

**答:**析构时先 count--,减完为 0 才 delete 对象(最后一个持有者);否则还有其他 shared_ptr 持有,不释放。

Q4:explicit 的作用是什么?

答:

  • explicit shared_ptr(T*):禁止裸指针到 shared_ptr 的隐式构造,避免“悄悄接管所有权”的危险行为
  • explicit operator bool():让 shared_ptr 在 if(p) 里自然使用,但避免在其他场景被隐式转换导致奇怪表达式/重载匹配

7. 今日迭代进度

✅ Day 01 已完成:最小引用计数 shared_ptr + 测试闭环
已知局限(后续天数解决):

  • 非原子计数(Day 04)
  • 无删除器(Day 03)
  • 无 weak_ptr(Day 05)
  • 两次分配(Day 06)

下一步(Day 02)我会把 Day 01 的“最小控制块”重构成 Boost 风格:引入 sp_counted_base + shared_count,让 shared_ptr 本身变得更“薄”,为删除器/weak_ptr/线程安全打基础。