Day 03|自定义删除器:让 SharedPtr 管住数组 / 文件句柄 / void*

9 阅读7分钟

目标:用 7 天时间,从“最简引用计数”迭代到接近 Boost shared_ptr 的控制块架构:默认删除器、自定义删除器、线程安全、weak_ptr、make_shared、最终工程化。
Day03 只做一件事:在 Day02 控制块分离架构上,加入“自定义删除器(deleter)”。


1. 今日目标

今天要把“释放策略”从 Day02 的默认 delete 扩展为可自定义:

  • 新增带删除器的控制块:SpCountedImplPointerd<P, D>
  • 扩展 SharedCount:支持 (p, deleter) 构造,创建对应控制块
  • 扩展 SharedPtr:支持 SharedPtr(p, deleter)Reset(p, deleter)
  • 理解“类型擦除”的第二层:删除器类型也被擦除
  • 支持管理非 new/delete 资源:数组 delete[]、文件 fclosevoid* 自定义释放等

核心收获:SharedPtr 本体继续保持“薄”,释放策略跟随控制块生命周期。


2. 今日设计:控制块扩展(从 Day02 到 Day03)

2.1 Day02 的局限

Day02 的默认控制块是 SpCountedImplPointer<Y>Dispose() 里固定做 delete。这意味着:

  • 数组 new T[n] 需要 delete[],默认 delete 不匹配
  • 文件句柄 FILE* 需要 fclose,默认 delete 完全不对
  • void* 的真实类型丢了,需要自定义释放逻辑

本质:释放策略不应写死在 shared_ptr 里,更不应写死为 delete。

2.2 Day03 的方案:把删除器存进控制块

Day03 新增一条控制块实现:

  • SpCountedImplPointer<Y>:默认 delete(Day02)
  • SpCountedImplPointerd<P, D>:存 P ptr_ + D deleter_Dispose() 执行 deleter_(ptr_)

这样 shared_ptr 不需要知道“怎么删”,只要把释放动作交给控制块的虚函数即可。


3. 关键实现

关键文件:

include/sp_counted_base.hpp
include/sp_counted_impl.hpp
include/shared_count.hpp
include/my_shared_ptr.hpp
test/test_custom_deleter.cpp

3.1 自定义删除器控制块:SpCountedImplPointerd<P, D>

include/sp_counted_impl.hpp

template <typename P, typename D>
class SpCountedImplPointerd : public SpCountedBase {
 private:
  P ptr_;
  D deleter_;

 public:
  SpCountedImplPointerd(P p, D d) : ptr_(p), deleter_(d) {}
  void Dispose() noexcept override { deleter_(ptr_); }
};

要点:

  • P 是“资源句柄类型”,可以是 int* / FILE* / void*
  • D 是“删除器类型”,可以是函数指针/仿函数/lambda/带状态对象
  • 删除器按值存储,确保它能活到最后一次释放

3.2 SharedCount:新增 (p, deleter) 构造

include/shared_count.hpp

template <typename P, typename D>
explicit SharedCount(P p, D d) : control_block_(nullptr) {
  if (p) {
    control_block_ = new SpCountedImplPointerd<P, D>(p, d);
  }
}

这一步把“创建哪种控制块”的决策统一收口到 SharedCount

  • 默认 SharedCount(p)SpCountedImplPointer<T>
  • 新增 SharedCount(p, d)SpCountedImplPointerd<P, D>

3.3 SharedPtr:支持 SharedPtr(p, deleter) / Reset(p, deleter)

include/my_shared_ptr.hpp

template <typename Y, typename D>
explicit SharedPtr(Y* p, D d) : ptr_(p), count_(p, d) {}

template <typename Y, typename D>
void Reset(Y* p, D d) noexcept {
  SharedPtr(p, d).Swap(*this);
}

依然沿用 Day02 的“临时对象 + Swap”风格,状态切换干净、不容易写出泄漏/重入 bug。


3.4 SharedPtr<void>:禁用 operator*

为了支持 SharedPtr<void> 做“只管理生命周期”,同时禁止 *p 这种无意义操作,你用 SFINAE 把 operator*T=void 时移除:

template <typename U = T>
typename std::enable_if<!std::is_void<U>::value, U&>::type operator*() const noexcept {
  return *ptr_;
}

效果:

  • SharedPtr<int>operator*()
  • SharedPtr<void> 没有 operator*()(写 *p 会编译报错)

4. 单元测试:验证 Day03 闭环正确

test/test_custom_deleter.cpp 覆盖了 8 组典型资源:

  1. 函数指针删除器:SharedPtr<int>(new int(42), CustomDelete)
  2. 仿函数删除器:数组 delete[]
  3. lambda 删除器:数组 delete[]
  4. 文件句柄:FILE* + std::fclose
  5. 带状态删除器:LoggingDeleter("Memory-1/2")
  6. Reset(p, deleter):reset 先释放旧资源再接管新资源
  7. void* + 删除器:deleter 内部恢复真实类型并释放
  8. no-op 删除器:栈对象不 delete,退出作用域仍有效

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

问题 1:为什么会报 “no matching function for SharedCount(p, d)”?

因为 Day02 的 SharedCount 只有 SharedCount(T*)。当你新增 SharedPtr(p, d) 时,构造链会走到 count_(p, d),如果 SharedCount(P, D) 没实现,编译器就找不到匹配构造函数。
补上 SharedCount(P, D) 后,才能在这里创建 SpCountedImplPointerd<P, D> 控制块。

问题 2:为什么删除器必须放进控制块,而不是放在 SharedPtr 里?

释放动作发生在“最后一个 shared_ptr 释放时”。此时最后活着的是哪一个 SharedPtr 实例不可预测,如果删除器存放在某个 shared_ptr 对象里,它可能早已析构,导致释放时“找不到删除器/删除器悬空”。
控制块的生命周期与“所有权生命周期”同步,把删除器放在控制块里能保证:

  • 删除器一定活到最后一次释放
  • 所有副本共享同一释放策略
  • 复制 shared_ptr 仍然很轻(只增计数,不复制删除器)

问题 3:SharedPtr<void> 为什么要禁用 operator*

void 没有对象语义,解引用没有意义也不安全。你用 enable_if<!is_void>operator*T=void 时直接“消失”,从接口层面阻止误用:
SharedPtr<void> 只负责生命周期管理,访问必须通过用户自己显式转换/自定义逻辑完成。

问题 4:类型擦除是不是都这样实现的?

不一定都用虚函数,但核心结构几乎一致:外层只保存统一入口(基类指针/函数表),真实类型封装在模板生成的实现体里
你这里属于经典 “基类 + virtual Dispose()” 的做法:

  • Day02 擦除“真实对象类型 Y”(释放按 Y)
  • Day03 再擦除“删除器类型 D”(释放按 D)

问题 5:删除器能不能“移动”进控制块?

可以。你当前实现是按值传参,效果更像“拷贝进控制块”。如果要支持更广泛删除器(比如 move-only 删除器),就需要把接口升级成 D&& + decay + std::forward:右值删除器移动进控制块,左值删除器拷贝进控制块。


6. Day 03 检查清单(复盘 Q&A + 延伸)

Q1:理解 SpCountedImplPointerd 的两个模板参数 PD
A:P 是资源句柄类型(不一定是 T*,可以是 FILE* / void*),D 是删除器对象类型(函数指针/仿函数/lambda)。控制块把“资源 + 释放策略”绑定在一起,Dispose() 统一做 deleter_(ptr_)

Q2:能解释删除器是如何被“擦除”类型的?
A:SharedPtr/SharedCount 外层只持有 SpCountedBase*。真实删除器类型 D 被封装进 SpCountedImplPointerd<P, D>,析构时外层只调用 Dispose(),靠虚函数分发到正确的 deleter_(ptr_)

Q3:知道如何管理文件句柄、数组等非 new 资源?
A:关键是“释放方式要匹配创建方式”。数组用 delete[] 的 deleter,文件句柄用 fclose 的 deleter,void* 用 deleter 恢复真实类型后释放;栈对象或外部资源用 no-op deleter 防止误删。

Q4:理解为什么删除器要拷贝到控制块中?
A:因为释放发生在最后一个 shared_ptr 释放时,删除器必须活到那一刻;控制块与所有权同寿命且被所有副本共享,所以 deleter 放控制块最稳。拷贝/移动进控制块,本质都是“按值持有”。

Q5:能画出带删除器的 shared_ptr 内存布局图?

SharedPtr<T>:
  T* ptr_                // 访问按 T
  SharedCount count_      // 持有 SpCountedBase* 控制块入口

控制块(派生实现体):
  (SpCountedBase) use/weak
  P ptr_                  // 真实资源句柄
  D deleter_              // 删除器对象

延伸 Q6:删除器能不能移动进控制块?
A:能;升级为 D&& + decay + forward 即可支持右值移动、甚至 move-only 删除器。

延伸 Q7:为什么不在 SharedPtr 每次拷贝时也把删除器拷贝一份?
A:会让 shared_ptr 变大、拷贝变重,还会强迫删除器可拷贝;控制块只存一份删除器,shared_ptr 拷贝只做计数增减,更符合 shared_ptr 的设计目标。

延伸 Q8:SharedPtr<void> 为什么不提供 operator*
A:解引用 void 没语义且危险,禁用能把错误挡在编译期;如果确实要访问,需要用户显式转型或在 deleter/业务逻辑里保证类型正确。


7. 今日迭代进度

✅ Day03 已完成:自定义删除器支持

  • SpCountedImplPointerd<P, D> 控制块落地(控制块保存删除器对象)
  • SharedCount(P, D) 构造补齐(打通构造链)
  • SharedPtr(p, deleter)Reset(p, deleter) 跑通
  • 覆盖 8 类资源场景测试:数组 / 文件 / void* / no-op / 带状态删除器等
  • SharedPtr<void> 通过 SFINAE 禁用 operator*,避免误用