
1. 项目背景与整体设计思路1.1 为什么我建议每个C开发者都手写一次shared_ptr如果你正在刷 C 相关的代码挑战或者刚进入需要大量使用现代 C 的团队shared_ptr绝对是你绕不开的一个组件。很多人用shared_ptr用得飞起但问他引用计数是怎么维护的、控制块里到底放了什么、为什么shared_ptr比unique_ptr重那么多往往答不上来。这个 Week 1 Day 4 的挑战本质上就是逼你把shared_ptr这层皮扒开看里面的肉到底是怎么长的。手写一个简化版shared_ptr价值不在于你真的要在生产环境里再造一个轮子——标准库的实现远比我们下面要写的复杂和健壮。价值在于你会真正理解引用计数的工作原理、析构时机、拷贝和移动的语义差异、线程安全问题的来源。这些理解会直接反馈到你日常使用shared_ptr的每一个细节里什么时候该传引用、什么时候用std::weak_ptr打破循环引用、为什么shared_ptr在多线程下性能会打折。从这个角度看这个挑战的价值远超“完成一个作业”本身。1.2 简化版shared_ptr要完成的核心目标先明确需求。我们要实现一个简化版shared_ptr核心功能包括基础的 RAII 语义构造时获取资源析构时释放资源引用计数多个shared_ptr共享同一个裸指针最后一个持有者负责删除资源拷贝与拷贝赋值计数器递增资源不复制移动与移动赋值所有权转移计数器不递增与裸指针交互get()获取原始指针reset()重置指向use_count()查看当前引用计数解引用操作operator*和operator-线程安全这里需要提前说明标准库的shared_ptr控制块是线程安全的即多个线程同时拷贝、析构同一个shared_ptr时引用计数的增减是原子的不会出现计数错乱。但控制块所指向的对象本身是否线程安全那是另一回事。我们的简化版需要做到前者即计数本身要支持原子操作。初版可以用非原子的int计数方便理解逻辑然后再升级成原子计数对比着看线程安全的意义。1.3 技术选型和取舍说明在设计时我做了几个取舍先讲清楚理由第一引用计数放哪里我选择放到堆上单独分配而不是作为shared_ptr对象的成员变量。原因很简单多个shared_ptr实例需要共享同一个计数如果放到成员变量里每个实例自己持有一份拷贝时就完全没法同步了。放到堆上所有实例都指向同一块计数的内存拷贝时说到底是“多个指针指向同一个计数器”。第二计数和指针怎么组织标准库用的是“控制块control block”的概念里头包含强引用计数、弱引用计数、删除器、分配器等。简化版不必那么复杂但至少要把“计数”和“资源指针”分开管理。我设计了ControlBlock结构体内部只放两个字段ref_count和ptr。后续要扩展支持自定义删除器时往控制块里加东西也方便。第三要不要一开始就上std::atomic我建议分两步走。第一步用普通int实现先把逻辑理通。第二步将计数换成std::atomicint然后写一个多线程的测试程序用-fsanitizethreadThreadSanitizer跑一遍直观感受数据竞争的存在。这个前后对比比直接看文档理解深得多。2. 核心原理拆解引用计数到底是怎么“记住”资源的2.1 从裸指针的问题说起想象一个很典型的场景两个类需要共享同一个网络连接对象。用裸指针的话你得盯着谁在用、谁该释放、释放之后另一个还能不能用。假如类 A 先析构了把连接delete掉类 B 再想用这个连接访问的就是已释放的内存——经典悬垂指针问题。反过来如果谁都不管释放又会有内存泄漏。shared_ptr的解法思路很直白引入一个“计数器”让每个持有者都在计数器上加一释放时减一当计数器归零说明没人再用了这时才真正释放资源。这个“计数器”就是引用计数的核心。它像是“资源使用人数”的看板每个人进门时出门时--当数字变回 0店就可以关门了。搞懂这个模型之后你会发现shared_ptr其实是个很薄的管理层真正干活的还是裸指针和new/delete。管理层的全部工作就是两件事正确地维护计数以及确保计数归零时只delete一次。2.2 控制块和指针的分离在实现里我把控制块设计成独立堆对象templatetypename T struct ControlBlock { T* ptr; int ref_count; };每次通过裸指针构造shared_ptr时做两件事一是让控制块里的ptr指向裸指针二是把ref_count初始化为 1。后续每拷贝一次就把ref_count加一每析构一个实例就把ref_count减一。这里有个容易踩坑的细节当ref_count减到 0 时不仅需要delete ptr还需要delete控制块本身。控制块也是new出来的堆对象它自己不释放也会泄漏。我见过不少初学者实现了引用计数的增减却忘了最后释放控制块结果valgrind一跑全是内存泄漏的报错。2.3 为什么拷贝构造函数要递增计数先看一段最基础的代码auto p1 make_shared(42); auto p2 p1; // 此时发生了拷贝构造p2和p1应该共享同一个资源所以拷贝构造做的三件事是把自己内部的ptr指向p1的那个裸指针让自己内部的ctrl指向p1的那个ControlBlock然后把ctrl-ref_count加一。这样p1和p2虽然各自是独立的shared_ptr对象但它们的内部控制块指针都指向同一个堆上的控制块。这个设计有一个好处无论是p1先析构还是p2先析构都只是把ref_count减一直到最后一个析构的人发现ref_count 0才真正执行清理。清理时也不需要判断“我是不是创建者”——谁最后一个走谁负责锁门。移动构造则完全相反。移动是“你手里的东西给我你的就作废”。所以移动构造只需要把p1的ptr和ctrl指针直接搬到自己身上然后把p1的这两个成员置空。整个过程ref_count不变——因为资源的所有权移动了但没有新增持有者。同理被移动后的p1析构时因为它的ctrl是空指针不能也不该去减计数。2.4 循环引用问题简化版的天然盲区这里必须提前预警手工实现shared_ptr也好标准库的shared_ptr也好都有一个共同的架构性问题——循环引用。比如两个对象互相持有对方的shared_ptr它们的引用计数互为对方的“保命符”最终谁都不会让计数归零资源就泄漏了。标准库的解法是weak_ptr弱引用不增加强引用计数只是作为一个“观察者”需要时临时提升成shared_ptr再访问。我们的简化版不实现weak_ptr但你在理解引用计数时必须把“计数归零条件”放在心里只有当整张“谁在用”的关系图中不再存在任何一条从根节点出发的路径时计数才会归零。循环引用的存在会让这条路径永远断不开。所以我想强调手写简化版的过程是一个理解shared_ptr原理的好机会但它也暴露了引用计数方案本身的天花板。如果你在做实际项目遇到对象之间互相引用先想想能不能改成单向依赖或者用weak_ptr打破环路而不是盲目地把所有指针都换成shared_ptr。3. 动手实现从最简非线程安全版到线程安全版3.1 第一步实现一个最简的非线程安全版我建议按照“成员变量定义 - 构造函数/析构函数 - 拷贝语义 - 移动语义 - 操作符重载”的顺序来写每一步都配合一个小的测试代码。先定义类模板#include iostream #include utility templatetypename T class shared_ptr_simple { private: struct ControlBlock { T* ptr; int ref_count; }; ControlBlock* ctrl_; public: // 从裸指针构造 explicit shared_ptr_simple(T* ptr nullptr) : ctrl_(nullptr) { if (ptr) { ctrl_ new ControlBlock{ptr, 1}; } } // 析构 ~shared_ptr_simple() { release(); } // 拷贝构造 shared_ptr_simple(const shared_ptr_simple other) : ctrl_(other.ctrl_) { if (ctrl_) { ctrl_-ref_count; } } // 拷贝赋值 shared_ptr_simple operator(const shared_ptr_simple other) { if (this ! other) { release(); // 先释放自己原来持有的资源 ctrl_ other.ctrl_; // 指向对方的控制块 if (ctrl_) { ctrl_-ref_count; } } return *this; } // 移动构造 shared_ptr_simple(shared_ptr_simple other) noexcept : ctrl_(other.ctrl_) { other.ctrl_ nullptr; } // 移动赋值 shared_ptr_simple operator(shared_ptr_simple other) noexcept { if (this ! other) { release(); ctrl_ other.ctrl_; other.ctrl_ nullptr; } return *this; } // 解引用 T operator*() const { return *(ctrl_-ptr); } T* operator-() const { return ctrl_-ptr; } // 获取裸指针 T* get() const { return ctrl_ ? ctrl_-ptr : nullptr; } // 当前引用计数 int use_count() const { return ctrl_ ? ctrl_-ref_count : 0; } // 重置为空 void reset() { release(); ctrl_ nullptr; } // 重置为指向新的裸指针 void reset(T* ptr) { release(); if (ptr) { ctrl_ new ControlBlock{ptr, 1}; } else { ctrl_ nullptr; } } private: void release() { if (ctrl_) { --ctrl_-ref_count; if (ctrl_-ref_count 0) { delete ctrl_-ptr; delete ctrl_; } } } };写完后我用一个测试程序验证基础语义struct TestObj { int value; explicit TestObj(int v) : value(v) { std::cout TestObj constructed: value std::endl; } ~TestObj() { std::cout TestObj destructed: value std::endl; } }; int main() { { shared_ptr_simpleTestObj p1(new TestObj(10)); std::cout p1 use_count: p1.use_count() std::endl; // 1 shared_ptr_simpleTestObj p2(p1); std::cout p1 use_count: p1.use_count() std::endl; // 2 // 拷贝构造和移动构造 shared_ptr_simpleTestObj p3(std::move(p2)); std::cout p1 use_count: p1.use_count() std::endl; // 2移动不增加计数 std::cout p2 use_count: p2.use_count() std::endl; // 0被移动后置空 } // 离开作用域时p1、p2、p3 依次析构 // 预期最终只有一次 TestObj destructed return 0; }这个测试里最重要的一行是移动构造之后的p2.use_count()输出为 0。p2被移动后已经从管理职责中退出它的析构不会再去减计数——如果减了计数会变成 1最终的delete时机就乱了。移动语义的实现难点不在搬而在把源对象的内部状态“清空”。3.2 第二步升级到线程安全版本非线程安全版的问题在哪里把多个线程同时拷贝同一个shared_ptr实例时比如线程 A 执行ctrl_-ref_count线程 B 也执行ctrl_-ref_count这两个操作不是原子的可能出现同时读到一个相同的旧值、各自加一后再写回结果只加了一次。类似的道理两个线程同时析构持有同一个控制块的不同shared_ptr实例时可能出现两个线程同时读到ref_count 1然后都认为自己该执行delete ptr—— 这就是双重释放程序直接崩溃。修复方案很直接把计数从int换成std::atomicint并用原子的fetch_add来完成递增。std::atomic的加减操作在底层会生成带锁前缀的指令比如 x86 上的lock inc保证多个线程同时操作计数时不会互相覆盖。#include atomic templatetypename T class shared_ptr_atomic { private: struct ControlBlock { T* ptr; std::atomicint ref_count; }; ControlBlock* ctrl_; public: explicit shared_ptr_atomic(T* ptr nullptr) : ctrl_(nullptr) { if (ptr) { ctrl_ new ControlBlock{ptr, 1}; } } ~shared_ptr_atomic() { release(); } shared_ptr_atomic(const shared_ptr_atomic other) : ctrl_(other.ctrl_) { if (ctrl_) { ctrl_-ref_count.fetch_add(1, std::memory_order_relaxed); } } shared_ptr_atomic operator(const shared_ptr_atomic other) { if (this ! other) { release(); ctrl_ other.ctrl_; if (ctrl_) { ctrl_-ref_count.fetch_add(1, std::memory_order_relaxed); } } return *this; } shared_ptr_atomic(shared_ptr_atomic other) noexcept : ctrl_(other.ctrl_) { other.ctrl_ nullptr; } shared_ptr_atomic operator(shared_ptr_atomic other) noexcept { if (this ! other) { release(); ctrl_ other.ctrl_; other.ctrl_ nullptr; } return *this; } T operator*() const { return *(ctrl_-ptr); } T* operator-() const { return ctrl_-ptr; } T* get() const { return ctrl_ ? ctrl_-ptr : nullptr; } int use_count() const { return ctrl_ ? ctrl_-ref_count.load(std::memory_order_relaxed) : 0; } private: void release() { if (ctrl_) { int old_count ctrl_-ref_count.fetch_sub(1, std::memory_order_acq_rel); if (old_count 1) { delete ctrl_-ptr; delete ctrl_; } } } };这里有个非常重要的实现细节release()中要用fetch_sub的返回值来判断是否是最后一个。fetch_sub(1)返回的是修改前的旧值。如果旧值是 1说明当前线程恰好把计数从 1 减到 0这个线程是最后一个持有者应该执行真正的清理。如果旧值是 2 或更大说明还有人持有当前线程只需要做减法即可。你可能会想用--ctrl_-ref_count 0这种方式但在原子类型上--返回值是修改后的新值逻辑等效可读性上fetch_sub更直白。关于内存序简化版里我还原了一个细节拷贝构造函数里的计数增加用memory_order_relaxed就够因为拷贝操作本身没有共享数据的释放和获取需求——计数加一不影响资源本身的可见性。release()里的fetch_sub用memory_order_acq_rel确保当前线程对被管理对象的写操作在最后一个释放者真正delete之前对其他线程可见同时确保能看到之前所有持有者对被管理对象的写操作。实际标准库实现里还会更细致地区分强弱计数和不同操作的内存序但对我们这个挑战级别relaxedacq_rel的组合已经能正确工作了。3.3 第三步测试多线程行为为了验证线程安全版本的确实解决了数据竞争问题我写了一个多线程压测程序。思路是多个线程同时对同一个shared_ptr进行拷贝和析构最后检查对象的析构次数是否为 1以及整个过程中use_count()的峰值是否等于线程数。#include atomic #include cassert #include iostream #include thread #include vector std::atomicint object_destructed{0}; struct TestObj { int value; explicit TestObj(int v) : value(v) {} ~TestObj() { object_destructed; } }; void worker(shared_ptr_atomicTestObj sp) { // 故意做1000次拷贝和释放增加竞争概率 for (int i 0; i 1000; i) { auto temp sp; // 拷贝构造 (void)temp; // 作用域结束即析构 } std::cout thread done, use_count sp.use_count() std::endl; } int main() { { shared_ptr_atomicTestObj sp(new TestObj(42)); std::vectorstd::thread threads; for (int i 0; i 8; i) { threads.emplace_back(worker, sp); // 每次线程传入拷贝 } for (auto t : threads) { t.join(); } std::cout after threads, use_count sp.use_count() std::endl; } std::cout object_destructed object_destructed.load() std::endl; assert(object_destructed.load() 1); return 0; }注意threads.emplace_back(worker, sp)这里向每个线程传入的是sp的一个拷贝也就是说每个线程进来时手里已经有了一个独立的shared_ptr_atomicTestObj实例它们共享同一个控制块。线程启动时计数已经是 1 8 9然后每个线程在循环里不断拷贝、释放计数此时会上下跳动但最后所有线程结束时sp的计数应该恢复到 1。我运行了几十次输出稳定如下thread done, use_count3 thread done, use_count6 thread done, use_count5 ... after threads, use_count1 object_destructed1用非线程安全版跑同一个程序用-fsanitizethread编译后几乎必定会报告data race的警告。如果没有 sanitizer程序甚至可能在运行过程中直接崩溃表现为双重释放或计数错乱。如果你没有装 TSAN也可以反复跑人工观察不稳定的输出。这个对比实验做完你对“为什么计数器必须是原子操作”的理解会非常深刻。4. 需要防住的坑测试中发现的高频错误4.1 忘了释放控制块导致的内存泄漏我在最初版本里踩过一个很低级的坑release()里只写了delete ctrl_-ptr忘了delete ctrl_。结果对象析构了控制块还留在堆上每次构造、析构一轮就泄漏一块ControlBlock大小的内存。初次用valgrind --leak-checkfull跑测试时看到一行行definitely lost才反应过来。排查思路明确泄漏的内存大小为sizeof(ControlBlock)的整数倍定位到ControlBlock后检查它的分配和释放是否成对出现。这个问题其实就是提醒你——控制块本身也是资源同样是 RAII 的对象它的生命周期由最后一个持有者负责。写这个类的时候脑子里时刻要有一张图shared_ptr对象 - 控制块 - 实际资源对象三层结构缺一不可。4.2 自家拷贝赋值中的自赋值判断你在operator里通常会习惯性地写“先 release 再拷贝”但自赋值场景下这个逻辑会炸shared_ptr_simpleTestObj p(new TestObj(10)); p p; // 自赋值release()先把计数减一如果此时计数为 1直接就把资源释放了然后你又执行ctrl_ other.ctrl_把已经释放的空指针又赋给自己后续再用*p就是解引用空指针。标准库对自赋值有明确的保证即使不自检结果也是正确的。所以要么像我前面写的那样加if (this ! other)判断要么先增加新计数再减少旧计数——后者更健壮不需要特判。简化版里我选择了加判断代码更直白易懂。4.3 从同一个裸指针构造两个shared_ptr导致的双重释放这是最经典的 misuse也是make_shared存在的重要原因TestObj* raw new TestObj(1); shared_ptr_simpleTestObj p1(raw); shared_ptr_simpleTestObj p2(raw); // 错误p1 和 p2 各自建了一个控制块但指向同一裸指针你会在程序退出时看到两次delete raw第二次delete直接触发堆损坏或崩溃。原因不复杂p1和p2的控制块是独立的它们互不知晓对方的存在各自持有“我是唯一持有者”的假象。这给了我们一个很重要的工程启示永远不要用裸指针去初始化两个shared_ptr。正确做法是auto p make_sharedTestObj(1); std::shared_ptrTestObj p2 p;或者用shared_ptr_simpleTestObj p1(new TestObj(1)); shared_ptr_simpleTestObj p2(p1);即通过已有的shared_ptr去拷贝而不是再喂一次裸指针。标准库的std::make_shared之所以被推荐核心原因之一就是它把“分配资源”和“创建控制块”合并成一次分配从根源上杜绝了裸指针的二次包装问题。4.4 析构函数里delete顺序的重要性release()里你先delete ctrl_-ptr再delete ctrl_顺序不能反。为什么因为delete ctrl_-ptr执行对象的析构函数这个析构函数在执行过程中可能还会尝试读取控制块的信息比如打印日志、检查use_count()等。如果先delete ctrl_那ctrl_-ptr本身就成了悬垂指针的悬垂指针任何访问都是未定义行为。还有一个隐蔽点如果被管理对象的析构函数中又创建了指向它自己的shared_ptr虽然这很不常见但技术上可行顺序错乱会导致无法预期的行为。这个在简化版里我不会深入实现但顺序问题确实值得作为代码评审的重点检查项。4.5 移动构造和析构的联合测试很多人写完移动构造后不测试“被移动后的对象析构”导致 bug被移动后的p2的ctrl_如果没置空它析构时会再去减一次计数把资源的存活期错误地缩短一分钟。所以我建议每个实现者都写一个显式测试{ shared_ptr_simpleTestObj p1(new TestObj(10)); shared_ptr_simpleTestObj p2(std::move(p1)); // p1 析构时不应该执行任何 release 相关操作 } // 这里只应有一次 TestObj destructed5. 测试策略与调试技巧5.1 单元测试怎么设计我给简化版shared_ptr写了 8 个测试用例建议你也按这个覆盖面来一是基本构造/析构验证对象构造和析构各一次二是拷贝构造后use_count为 2两者指向同一地址p1.get() p2.get()三是移动构造后源对象的get()为nullptr目标对象的get()保持原地址四是赋值前后的计数变化五是reset()后旧资源被释放新资源开始接管六是空指针构造时use_count 0、get() nullptr七是多个shared_ptr按任意顺序析构对象只析构一次八是多线程压力测试验证原子计数。每个测试最好用断言或assert检查结果而不是只靠肉眼观察输出。我写代码时常犯一个毛病控制台输出正常就认为功能正确实际上可能只是“碰巧看起来对”。比如计数错乱但恰好没有触发崩溃进度容易被掩盖。5.2 使用AddressSanitizer和ThreadSanitizer构建编译时建议加上这两个 sanitizer它们对付的内存错误和数据竞争问题是手写智能指针最容易两个灾难。示例编译命令g -stdc17 -Wall -Wextra -g -fsanitizeaddress -fsanitizeundefined shared_ptr_simple.cpp -o sp_simple_asan g -stdc17 -Wall -Wextra -g -fsanitizethread shared_ptr_atomic.cpp -o sp_atomic_tsanAddressSanitizer 抓的是堆越界、双重释放、释放后使用等ThreadSanitizer 抓的是数据竞争。跑一遍 TSAN如果它报告WARNING: ThreadSanitizer: data race就能精确定位到发生竞争的行号。非线程安全版在这个检查下必然报风险。5.3 一个真实调试案例拷贝赋值中的计数异常我遇到过一个问题代码逻辑看起来完全正确但use_count总是比预期多一。排查时我打印了每个shared_ptr构造和析构的行号发现在某个作用域里shared_ptr被意外拷贝了一次而那个拷贝操作是隐式的——因为我给shared_ptr_simple写了接受裸指针的构造函数而构造函数不是explicit。比如某处调用foo(p.get())函数参数类型是shared_ptr_simpleTestObj编译器悄悄地把裸指针隐式转换成了一个新的shared_ptr多出来一个独立的控制块。这让我意识到两个问题第一接受裸指针的构造函数必须加explicit防止隐式转换第二函数参数如果不需要参与所有权管理应该用const shared_ptr_simpleT接而不是按值传——按值传必然触发一次拷贝增加一次计数带来无谓的原子操作开销和潜在的语义混淆。5.4 常见问题速查表症状可能原因排查手段程序退出时崩溃报 double free两个 shared_ptr 各自持有同一个裸指针的控制块全局搜索裸指针初始化点改用拷贝或 make_sharedvalgrind 报 definitely lostrelease 里忘了 delete ctrl_检查 ControlBlock 的 new/delete 是否配对use_count 始终比预期大函数参数按值传 shared_ptr或隐式转换给裸指针构造函数加 explicit参数改用 const多线程下相同代码结果不稳定引用计数不是原子操作编译时开 ThreadSanitizer定位竞争行移动后原对象析构导致计数加倍移动构造/赋值未将源 ctrl_ 置空检查移动操作后源对象的 get() 是否为 nullptr自赋值时崩溃operator 未处理自赋值增加 this ! other 判断对象生命周期比预期长存在循环引用用 weak_ptr或重新设计单向依赖6. 扩展思考从简化版到标准库版本的差距6.1 make_shared 分配策略的优化我们上面的手动版用了两次堆分配一次是资源对象本身一次是控制块。标准库的std::make_shared则把两者合并到一次分配中连续内存里先放资源对象再放控制块。这带来两个好处一是内存分配次数从两次降为一次吞吐量小幅提升二是控制块和资源对象在内存上接近缓存局部性更优访问路径更短。代价是控制块的长度必须包含一个alignas适配的存储区复杂度上去了。我们的简化版不需要做到这一点但理解这个差异能解释“为什么make_shared比new 包装shared_ptr更受推荐”。6.2 弱引用计数与 weak_ptr标准库控制块里除了强引用计数use_count还有一个弱引用计数它专门服务于weak_ptr。weak_ptr是旁观者不参与资源生命周期但当最后一个强引用消失时资源对象被释放控制块却不能马上释放因为可能还有weak_ptr正在尝试lock()。控制块此时要等到弱引用计数也归零才回收。这个机制在我们的简化版里可以后续扩展加了weak_ptr才会真正明白“强引用”和“弱引用”是两个独立的计数。6.3 自定义删除器和数组支持标准库shared_ptr的删除器类型是std::function或者模板参数允许你用自定义的释放逻辑替换默认delete。典型场景是管理用fopen打开的FILE*或者通过某个 C API 分配的句柄。我们的简化版如果要支持删除器需要在控制块里保存一个可调用对象析构时调用它而不是直接delete ptr。这个扩展不难但会让控制块的内存布局变得更复杂。还有数组支持标准库shared_ptrT[]会使用delete[]而不是delete。这提醒我们在实现下去之前你得明确控制块的职责边界——它管理的不仅仅是裸指针还有一组与之配套的“销毁策略”。6.4 enable_shared_from_this当你需要在类内部获得一个指向自己的shared_ptr时标准做法是继承std::enable_shared_from_thisT然后调用shared_from_this()。这背后的实现机制是控制块里保存一个对“this”的弱引用shared_from_this()内部把它提升为强引用返回。如果没有这个辅助设施你在类内部回到裸指针再包装成shared_ptr又会制造出独立的控制块——就是我们前面提到的坑。理解了控制块是什么这个机制就顺势理解了。7. 后续扩展与训练建议这个挑战做完后我强烈建议你再往下走三步第一步给简化版加上make_shared功能。即提供一个类似make_shared_simpleT(args...)的函数模板内部用new ControlBlockWithObjectT(std::forwardArgs(args)...)一次性完成分配。这一步会逼你接触内存布局和完美转发。第二步在控制块里加一个weak_count字段实现一个简化版weak_ptr。此时你会发现当强引用归零后不同设计下“资源何时释放”和“控制块何时释放”变成两个独立事件你的所有权模型会清晰很多。第三步对比std::shared_ptr的 API把遗漏的常见操作补上——比如operator bool、比较运算、std::hash特化支持、类型擦除的删除器等。每补一个操作你都会对标准库的缜密多一分理解。我个人的体会是手写智能指针是我初学 C 时收获最大的一次练习之一。它的挑战点不在于语法而在于你需要始终心里有一张内存模型图什么东西是栈上的对象什么东西是堆上的控制块谁在什么时候释放什么。这张图一旦建立起来你再看任何智能指针相关的代码都不会发怵。