当前位置: 首页 > news >正文

从设计动机,决策链一步步推出 Shared ptr

你不是想“记住 shared_ptr 长什么样”,而是想知道:

如果我站在设计者的位置,只拿着需求,一步步会被逼到今天这个结构吗?

这才是真正学会。

那我们现在不直接讲“控制块是什么”。
我们换一种方式:

从 0 开始设计一个“可共享所有权”的智能指针。

我会严格按“动机 → 冲突 → 决策 → 新问题 → 再决策”这条链往下推。


0. 出发点:我们到底要解决什么问题?

先不要说 shared_ptr

先只说需求:

现在有一个堆对象:

T* p = new T(...);

我们希望做到:

  1. 这个对象可以被多个地方同时持有
  2. 这些地方都认为自己“有资格继续让对象活着”
  3. 最后一个持有者消失时,对象才被释放
  4. 用户不手动 delete

这四条,才是 shared_ptr 的原始需求。

注意这里的关键词不是“多个指针指向同一个对象”。

因为“多个裸指针指向同一个对象”太容易了:

T* p1 = p;
T* p2 = p;
T* p3 = p;

这根本不难。

真正难的是:

多个持有者如何协调“最后谁来释放”。

所以 shared_ptr 的核心问题不是“能不能共享访问”,而是:

能不能共享所有权。


1. 第一层推导:为什么 unique_ptr 那套不够了?

unique_ptr 的逻辑非常简单:

  • 只有一个 owner
  • 禁止拷贝
  • 允许移动
  • 析构时直接释放

它的成立前提是:

资源只有一个主人

但现在需求变了:

资源可能同时被 A、B、C 持有,而且他们都不是“借用者”,而是“拥有者”

这时你就不能再说:

  • “只保留唯一 owner”
  • “其他人都用裸指针观察”

因为这样会出现一个问题:


场景

auto owner = /* 某个独占指针 */;
T* observer = owner.get();

如果 owner 先死:

owner.reset();

observer 立刻悬空。

所以“一个 owner + 一堆观察者”的模型,并不能满足“多个人都能独立延长生命周期”这个需求。

于是我们被迫接受一个新语义:

复制一个智能指针时,不是复制资源,而是复制所有权资格。

这一步非常关键。

也就是说,从这里开始,shared_ptr 的 copy 行为和普通对象 copy 的含义已经不同了。


2. 第二层推导:那最朴素的方案是什么?

既然多个对象都要成为 owner,那最朴素的想法就是:

方案 A:每个智能指针对象里都存一个裸指针

比如:

template<typename T>
class SharedPtr {
private:T* ptr;
};

然后允许拷贝:

SharedPtr<T> p1(new T);
SharedPtr<T> p2 = p1;

拷贝以后:

  • p1.ptr == p2.ptr

这看起来像“共享”了。

但马上炸掉。

为什么?

因为如果 p1 析构时 delete ptr,那 p2 还拿着同一个 ptr

然后 p2 再析构时又 delete ptr

这就是:

double delete

所以我们立刻得到第一个结论:


结论 1

只共享“对象地址”是不够的。
还必须共享“这个对象还剩多少 owner”这一份状态。

也就是说:

shared_ptr 不只是一个“指向 T 的指针”,
它还必须和某个“共享状态”绑定。

这一步,就是控制块雏形的起点。


3. 第三层推导:那这个“共享状态”最少得有什么?

既然问题是“最后一个 owner 才能释放”,那就得知道:

当前还有多少 owner 活着

于是最先被逼出来的变量就是:

ref_count

也就是引用计数。

你可以先把它理解成:

有几个 shared_ptr 正在共同拥有这个对象

所以最小设计开始变成:

template<typename T>
class SharedPtr {
private:T* ptr;int* ref_count;
};

现在看它的行为:


创建时

SharedPtr<T> p(new T);

需要做:

  • ptr = new T
  • ref_count = new int(1)

拷贝时

SharedPtr<T> p2 = p1;

需要做:

  • p2.ptr = p1.ptr
  • p2.ref_count = p1.ref_count
  • (*ref_count)++

析构时

  • (*ref_count)--
  • 如果减到 0,才 delete ptr

这已经能解释 shared_ptr 的基本运行方式了。


4. 第四层推导:为什么“计数必须共享”,而不能各自独立?

这个点你必须彻底想透。

假设你写成这样:

class SharedPtr {T* ptr;int ref_count;
};

也就是每个 SharedPtr 自己内部有一个独立的计数器。

那么:

SharedPtr p1(new T);  // p1.ref_count = 1
SharedPtr p2 = p1;    // p2.ref_count = 2 ? 

问题来了:

p1.ref_count 怎么办?

你要让两个对象的 ref_count 始终同步,就意味着:

  • p1p2 这两个不同对象
  • 它们内部那两个整数
  • 必须像“一个整数”一样联动

这几乎不现实。

所以计数器不能是“每个指针对象自己的值”,而必须是:

多个 shared_ptr 共同指向的一份共享状态

这一步非常重要。

于是结构进一步收缩为:

template<typename T>
class SharedPtr {
private:T* ptr;       // 被管理对象int* count;   // 所有 shared_ptr 共享的一份计数
};

到这里,你已经把 shared_ptr 的第一性骨架推出来了。


5. 第五层推导:那为什么后来又不是 T* + int* 这么简单?

这是下一步。

如果你只想做一个“玩具版 shared_ptr”,那 T* + int* 确实差不多了。

但标准库不能停在这里,因为马上会碰到新问题。


新问题 1:对象怎么删?

最简单时你会写:

delete ptr;

但这太死了。

因为 shared_ptr 可能管理的不是普通 new T 得到的对象。

比如:

FILE* f = fopen(...);

这就不是 delete f,而是 fclose(f)

或者:

malloc(...)

那就得 free(...)

所以“最后一个 owner 消失时怎么释放资源”这件事,不能写死成 delete ptr

于是第二个被逼出来的东西是:

删除器(deleter)

也就是控制信息里必须保存:

最后该怎么销毁这个资源

新问题 2:计数和对象信息分散太碎了

现在你已经至少有这些数据:

  • ptr
  • count
  • deleter

如果还继续零散地存:

T* ptr;
int* count;
Deleter* deleter;

那拷贝、析构、异常安全、管理逻辑都会越来越乱。

设计上自然会收缩成:

把所有“共享所有权相关的元信息”打包成一个单独结构

这个结构后来就叫:

control block(控制块)

所以控制块不是“先验发明出来的高级概念”。

它其实是被需求一步步逼出来的“元信息聚合体”。


6. 第六层推导:控制块到底是怎么被逼出来的?

我们按决策链写得更死一点。


需求 A:多个 owner 共享同一份计数

推出:

count 必须独立于单个 shared_ptr 对象存在

需求 B:最后释放方式不能写死

推出:

需要存 deleter

需求 C:以后可能还要支持 allocator、weak_ptr 等扩展能力

推出:

共享状态不能只是一颗 int,必须是一个更通用的结构体

于是你自然会从:

int* count;

进化成:

struct ControlBlock {int strong_count;Deleter deleter;...
};

再进一步变成:

template<typename T>
struct ControlBlock {int strong_count;T* managed_ptr;Deleter deleter;
};

或者更抽象一点:

struct ControlBlockBase {int strong_count;virtual void destroy() = 0;
};

所以:

控制块的本质不是“高级技巧”
而是“把共享所有权协议中的公共状态集中放到一处”。


7. 第七层推导:shared_ptr 自己为什么还要保留一个 T*

到这里你可能会问:

既然控制块里都能放 managed_ptr
shared_ptr 自己还存什么 T*
直接只存控制块不行吗?

这个问题问得非常好。

答案分两层。


第一层:最简版里,确实可以只通过控制块拿到对象

比如你可以让控制块里存:

T* managed_ptr;

然后 shared_ptr::operator->() 每次都去控制块取。

逻辑上是可行的。


第二层:但标准实现里,shared_ptr 通常自己也保存一个“对外暴露的指针”

为什么?

因为 shared_ptr 管理的语义,不只是“控制对象生命周期”,还包括:

当前这个句柄对外表现成指向谁

这件事后面会牵涉到 aliasing constructor,但你现在先不用深入。

你只需要先接受一个设计事实:

“负责所有权”的对象,和“当前这个 shared_ptr 对外暴露的地址”,在更一般的设计里不必完全等同。

所以标准实现通常是:

  • shared_ptr 自己有一个 stored pointer
  • 同时有一个指向控制块的指针

于是形成经典两层结构:

shared_ptr:1. stored pointer2. control block pointer

这不是一开始就非这样不可。
而是为了支持更一般的语义,逐步演化到这样。


8. 第八层推导:为什么控制块通常不直接塞进对象里?

这个问题也很关键。

有人会想:

既然要计数,为什么不把 ref_count 直接放进 T 对象里?

比如:

class T {int ref_count;
};

看起来好像更直接。

但这条路走不通。


原因 1:shared_ptr 要能管理任意类型 T

标准库不能要求所有 T 都自带:

int ref_count;

因为 T 可能是:

  • 普通类
  • 第三方库类
  • 内置类型
  • 你根本不能改源码的类型

所以引用计数不能侵入 T 的定义。


原因 2:生命周期信息不属于对象本体语义

T 本身只描述“这个对象是什么”。

而:

  • 有多少 owner
  • 怎么销毁
  • 是否还有 weak_ptr
  • 用什么 allocator

这些都不是 T 的业务数据,而是“外部管理协议”。

所以它更应该放在对象外部。


原因 3:对象死了,控制信息有时还得活

这是后面 weak_ptr 的基础。

即使对象已经析构了,你可能还需要保留一份状态,说:

  • 对象已经没了
  • 但控制信息还在

如果把计数直接放进对象本体里,那么对象一死,这些状态全没了。

所以控制信息必须独立于对象本体存在。

这一步,也进一步巩固了:

控制块必须外置。


9. 第九层推导:为什么叫 strong_count?

到目前为止,其实你只需要一个计数器:

count

它表示 owner 的数量。

这时候如果我们只设计“没有 weak_ptr 的简化版 shared_ptr”,那这个计数器就够了。

所以你必须明确一件事:

在纯 shared_ptr 的最小设计中,只需要一个“拥有者计数”。

也就是说:

strong_count

这个名字是后来为了和 weak_count 对应,才更准确地这样叫。

所以不要倒因为果。

不是一开始就必须有 strong_count / weak_count 两套体系。

真正的推导顺序是:

  1. 先有“owner 数量”
  2. 后来引入 weak_ptr
  3. 才把原来的 owner count 更名为 strong_count

这个顺序要分清。


10. 第十层推导:所以 shared_ptr 的最小可行结构到底是什么?

如果我们严格按照刚才的决策链,只设计“没有 weak_ptr、没有 allocator、没有花哨特性”的最简版,那么它最自然地会长成这样:

template<typename T>
struct ControlBlock {size_t strong_count;T* managed_ptr;
};

然后 shared_ptr 自己是:

template<typename T>
class SharedPtr {
private:T* ptr;                  // 对外访问用ControlBlock<T>* cb;     // 共享控制信息
};

你看,这不是“背出来的结构”。

而是从需求一步步被逼出来的:


因为需要共享所有权

所以必须允许 copy 不是 deep copy,而是共享状态。


因为多个副本必须协调析构

所以必须有共享计数。


因为共享计数不能各自为政

所以计数必须脱离单个对象,单独存在。


因为共享信息会越来越多

所以必须聚合成控制块。


因为 shared_ptr 还要表现成“像指针一样访问对象”

所以句柄对象本身还要保留一个指向对象的入口。


11. 现在我们把“决策链”压缩成一条完整因果链

你可以把 shared_ptr 的诞生理解成下面这条链:


第 1 步:提出需求

我希望一个堆对象能被多个地方共同拥有。


第 2 步:发现 unique_ptr 模型不适用

因为 unique_ptr 的本质是唯一所有权,copy 会破坏语义。


第 3 步:允许 copy 共享同一对象

但一旦多个句柄共享同一个裸指针,就会 double delete。


第 4 步:引入“还剩多少 owner”的共享状态

于是需要引用计数。


第 5 步:发现计数不能是每个对象各自独立的成员

因为它必须对所有副本一致。


第 6 步:把计数放到堆上,共同指向一份状态

于是出现了“共享状态对象”。


第 7 步:继续发现除了计数,还要保存销毁逻辑等元信息

于是共享状态不再只是 int,而变成控制块。


第 8 步:shared_ptr 自己则变成“句柄”

它既要能访问对象,也要能找到控制块。


这条链走完,shared_ptr 的骨架就出来了。


12. 你要特别区分两件事:对象、所有权协议

这是 shared_ptr 最关键的抽象分层。


对象是什么?

就是那个 T

比如:

new Widget(...)

这只是“被管理资源”。


shared_ptr 在管理什么?

不是只管理 T* 这个地址。

它真正管理的是:

围绕这个资源的一份共享所有权协议

这份协议包括:

  • 谁拥有
  • 还剩几个 owner
  • 最后怎么销毁
  • 将来是否允许 weak 观察

而这份协议,正是控制块承载的。

所以从抽象上你必须这样看:

T                     // 资源本体
ControlBlock          // 所有权协议的状态
shared_ptr            // 持有协议句柄的对象

13. 现在再回答你一句最本质的话

你问的是:

“怎么一步步推出 shared_ptr 的结构?”

最核心的答案其实是:

shared_ptr 的结构不是从“指针”推出的,而是从“共享所有权协议”推出的。

这是最关键的一句。

如果你从“它是个更聪明的指针”出发,你会觉得控制块、计数器、删除器这些东西像补丁。

但如果你从“我要让多个 owner 正确协作”出发,那么:

  • 共享计数
  • 外置共享状态
  • 控制块
  • 句柄对象

这些都不是补丁,而是必然结果。


14. 给你一个“设计者视角”的最简伪代码

你现在可以这样看它的诞生。


第 0 版:失败版

template<typename T>
class SharedPtr {T* ptr;
public:~SharedPtr() { delete ptr; }
};

问题:拷贝后 double delete。


第 1 版:引入共享计数

template<typename T>
class SharedPtr {T* ptr;int* count;
};

创建时 count = new int(1)
拷贝时 ++(*count)
析构时 --(*count),减到 0 才删对象。

这已经解决核心问题。


第 2 版:发现销毁方式不能写死

template<typename T, typename Deleter>
class SharedPtr {T* ptr;int* count;Deleter* deleter;
};

问题:管理信息开始散乱。


第 3 版:聚合成控制块

template<typename T>
struct ControlBlock {size_t strong_count;T* managed_ptr;Deleter deleter;
};

然后:

template<typename T>
class SharedPtr {T* ptr;ControlBlock<T>* cb;
};

这就是现代 shared_ptr 骨架。


15. 你接下来最适合学什么

现在你不要急着看 weak_ptr

你最适合做的是这一步:

亲手写一个“只有 strong_count 的最小版 shared_ptr”

只实现:

  • 构造
  • 拷贝构造
  • 拷贝赋值
  • 析构
  • get
  • use_count
  • operator*
  • operator->

并且在实现过程中,你每写一个成员,都问自己:

它是为了解决哪一步推导中的哪个问题?

这样你写出来的就不是“仿写代码”,而是真正从设计链条里长出来的实现。


如果你愿意,下一步我就按你现在要的方式,继续带你做:

从这个决策链,手推一个最小 shared_ptr 的成员设计
也就是:为什么必须有这两个指针、拷贝构造为什么这样写、赋值为什么不能直接覆盖。

http://www.jsqmd.com/news/683843/

相关文章:

  • 2026年上海五大GEO优化服务商深度盘点TOP机构 - GEO优化
  • Mplus链式中介实战:从模型设定到效应检验的完整指南
  • DeepSeek V4 这周发!梁文锋扛不住了
  • 别再让NextCloud后台任务卡住了!Docker版保姆级Cron配置指南(附两种方法对比)
  • Qwen3.5-4B-Claude-Opus应用场景:高校编程课程助教——自动批改思路点评
  • Boss-Key老板键:终极窗口隐身术,5秒保护你的数字隐私空间
  • Alteryx:别让“集成难、数据乱” 吃掉AI回报
  • 从‘光速不变’到‘光速可变’:聊聊光纤色散对5G前传和数据中心互联的实际影响
  • KEIL下载程序无法运行,调试后却正常运行。
  • 无硬件学LVGL—定时器篇:基于Web模拟器+MicroPython速通GUI开发
  • 【App Service】排查App Service中发送Application Insights日志数据问题的神级脚本: Test-AppInsightsTelemetryFlow.ps1
  • 少儿中国舞老师的教学经验重要吗?
  • 从Blender到Vulkan:用tiny_obj_loader在C++中高效解析OBJ模型(附完整代码)
  • 裁剪到市!全球17种土地类型数据集(全球/中国/分省/分市/Tif)
  • 电路板振动如何“看”得见?揭秘DIC技术在模态分析中的实战应用
  • RWKV7-1.5B-world实战手册:huggingface-hub 0.27.1与transformers 4.48.3版本锁死验证
  • L1-019 谁先倒
  • 别再只调包了!手把手带你用Python复现DeepSort核心匹配逻辑(附完整代码)
  • 机器学习规模化实践:从规则引擎到生产部署
  • 告别龟速下载!手把手教你用清华镜像离线安装PyTorch 2.2.0 + CUDA 11.8(3DGS环境必备)
  • Phi-3-mini-4k-instruct-gguf效果惊艳:在HumanEval Python代码生成任务中通过率超72%
  • UIAbility生命周期全解析
  • 2026年Flutter热更新主流方案盘点与选型指南
  • 别再混淆了!一文讲透POCV文件、LVF库与AOCV在项目中的真实使用场景
  • 紫光同创PGL50H开发板PCIE通信实战:从IP核安装到设备识别的保姆级避坑指南
  • 别再只当Jira平替了!用OpenProject社区版搭建个人项目管理中心(附Docker Compose配置)
  • 告别H.265专利费!手把手教你用FFmpeg 5.0+libaom体验AV1编码(附性能对比)
  • 拉霸动画,老虎机滚动抽奖,cocos creator
  • 如何在无向图中找出从任意节点可达的所有节点(连通分量识别)
  • 20260422 紫题训练