
1. 项目概述为什么我们要手写一个智能指针在C的世界里内存管理一直是开发者绕不开的坎。从早期的new/delete手动管理到后来的RAII资源获取即初始化思想再到C11标准库引入的智能指针我们一直在追求更安全、更优雅的资源管理方式。std::shared_ptr无疑是其中最耀眼的明星之一它通过引用计数机制实现了多个指针共享同一块内存并在最后一个持有者销毁时自动释放资源极大地减少了内存泄漏和悬垂指针的风险。那么为什么我们还要“重复造轮子”去手写一个shared_ptr呢这绝不是为了替代标准库标准库的实现经过千锤百炼是生产环境的首选。手写的核心目的在于深度理解。通过亲手实现一个简化版的SharedPtr你能透彻地掌握引用计数的原理、线程安全的设计考量、自定义删除器的机制以及拷贝控制成员构造函数、析构函数、拷贝/移动赋值运算符如何协同工作来管理资源生命周期。这个过程是理解现代C资源管理思想最有效的途径也是面试中考察C功底和面向对象设计能力的经典题目。无论你是正在夯实基础的初学者还是希望深入STL内部机制的中高级开发者这个项目都能让你获益匪浅。2. 核心设计思路与类结构拆解一个最基本的SharedPtr需要解决几个核心问题1. 如何存储和管理裸指针指向的对象2. 如何记录有多少个SharedPtr共享这个对象3. 当计数归零时如何正确地释放资源4. 如何保证线程安全这是一个可选的进阶目标我们的设计将围绕一个核心的控制块Control Block展开。这个控制块独立于被管理的对象专门用于存储引用计数和其他元信息。2.1 核心数据结构引用计数控制块我们首先定义一个内部类RefCount作为引用计数的控制块。这是整个智能指针的“大脑”。template typename T class SharedPtr { private: // 内部引用计数控制块 struct RefCount { T* ptr nullptr; // 指向被管理对象的裸指针 int count 0; // 强引用计数 // 未来可扩展弱引用计数、自定义删除器、分配器等 explicit RefCount(T* p) : ptr(p), count(1) { std::cout RefCount created, count1 for object ptr std::endl; } ~RefCount() { std::cout RefCount destroyed for object ptr std::endl; delete ptr; // 核心当控制块销毁时释放被管理对象 } void increment() { count; } bool decrement() { --count; if (count 0) { delete this; // 计数归零销毁控制块进而销毁对象 return true; } return false; } }; RefCount* ref nullptr; // 指向控制块的指针设计解析分离存储RefCount对象与T对象是分离的。多个SharedPtr共享的是同一个RefCount对象的地址ref指针而非直接共享T对象。这为引用计数的原子操作和未来扩展如弱指针提供了基础。计数初始化在构造函数中我们将计数初始化为1表示第一个创建它的SharedPtr。资源释放析构函数是资源释放的关键。当count减到0时decrement()方法会delete this触发RefCount的析构函数进而delete ptr释放实际内存。这是一种“自毁”机制。为什么用struct在类内部定义为struct默认成员是public的方便SharedPtr类直接访问其ptr和count。当然你也可以用class并声明friend。2.2 类的基本框架与成员变量基于控制块我们的SharedPtr类框架如下template typename T class SharedPtr { public: // 构造函数 SharedPtr(T* p nullptr); // 拷贝构造函数 SharedPtr(const SharedPtr other); // 移动构造函数 SharedPtr(SharedPtr other) noexcept; // 拷贝赋值运算符 SharedPtr operator(const SharedPtr other); // 移动赋值运算符 SharedPtr operator(SharedPtr other) noexcept; // 析构函数 ~SharedPtr(); // 解引用操作符 T operator*() const; T* operator-() const; // 获取裸指针 T* get() const; // 获取引用计数 int use_count() const; // 判断是否唯一持有 bool unique() const; // 重置指针 void reset(T* p nullptr); private: struct RefCount { ... }; // 如前所述 RefCount* ref nullptr; // 核心指向控制块的指针 // 辅助函数增加引用计数 void incrementRef(); // 辅助函数减少引用计数并在归零时清理 void decrementRef(); };成员变量解析RefCount* ref这是每个SharedPtr对象唯一必需的成员。它指向堆上分配的RefCount控制块。所有共享同一对象的SharedPtr它们的ref成员都指向同一个控制块地址。为什么不直接存储T*和int*分开存储T*和int*当然可以但将计数和指针捆绑在一个RefCount对象中管理更为清晰也更容易实现原子操作和自定义删除器。3. 核心成员函数的实现与原理接下来我们深入每个关键成员函数的实现并解释其背后的原理和注意事项。3.1 构造函数与资源初始化构造函数负责接管一个裸指针并为其创建控制块。template typename T SharedPtrT::SharedPtr(T* p) { if (p ! nullptr) { try { ref new RefCount(p); // 关键创建控制块计数初始化为1 } catch (...) { delete p; // 异常安全如果new RefCount失败需释放传入的指针 throw; } } // 如果p是nullptrref保持为nullptr }原理与注意事项所有权转移这个构造函数接受一个new出来的裸指针并立即取得其所有权。调用者之后不应再使用或delete这个裸指针否则会导致双重释放。异常安全我们使用了try-catch块。如果new RefCount失败内存不足我们需要手动delete p来避免内存泄漏然后再重新抛出异常。这是实现强异常安全保证的基本操作。空指针处理允许用nullptr构造一个空的SharedPtr此时ref为nullptr是合法状态。实操心得在生产代码中更常见的做法是使用std::make_shared它能在单次内存分配中同时分配对象和控制块效率更高且异常安全。我们手写版本为了清晰分成了两次分配。3.2 拷贝构造函数与引用计数增加拷贝构造是共享所有权的核心。template typename T SharedPtrT::SharedPtr(const SharedPtr other) { ref other.ref; // 共享控制块 if (ref) { ref-increment(); // 引用计数加1 } }原理浅拷贝我们只拷贝了ref指针指向控制块这是一种浅拷贝。现在两个SharedPtr对象指向同一个控制块。增加计数因为多了一个共享者所以需要增加引用计数。注意我们操作的是other.ref指向的同一个RefCount对象。3.3 析构函数与引用计数减少析构函数负责减少计数并在必要时触发资源释放。template typename T SharedPtrT::~SharedPtr() { decrementRef(); } template typename T void SharedPtrT::decrementRef() { if (ref ref-decrement()) { // 减计数并判断是否归零 ref nullptr; // 归零后控制块已自毁将ref置空避免悬垂 } }原理调用decrement析构时表示当前SharedPtr对象不再持有该资源。因此需要让控制块的计数减1。检查归零decrement()方法返回true表示计数已归零。此时RefCount::decrement()内部已经delete this控制块对象包括其持有的T*已被销毁。置空指针在控制块自毁后必须将本地的ref指针置为nullptr这是一个良好的习惯可以防止后续误用尽管对象本身正在析构。3.4 拷贝赋值运算符处理自赋值与资源交接赋值运算符是最复杂的一个需要正确处理自赋值、释放原有资源、接管新资源。template typename T SharedPtrT SharedPtrT::operator(const SharedPtr other) { // 1. 防止自赋值如果自己赋给自己什么都不用做 if (this other) { return *this; } // 2. 先减少对原有资源的引用可能释放资源 decrementRef(); // 3. 共享新的控制块 ref other.ref; if (ref) { ref-increment(); } // 4. 返回自身引用以支持链式赋值 return *this; }原理与分步解析自赋值检查if (this other)。这是至关重要的第一步。如果没有这个检查在自赋值sp sp时decrementRef()会先释放资源然后ref other.ref中的other.ref已经是一个悬垂指针导致未定义行为。释放旧资源decrementRef()。在接管新资源前必须释放当前对象所持有的旧资源。这体现了RAII的“资源管理对象生命周期结束时释放资源”的思想。接管新资源拷贝ref指针并增加计数。逻辑与拷贝构造函数一致。返回引用返回*this允许进行链式赋值如a b c。踩坑记录忘记处理自赋值是手写赋值运算符最常见的错误之一。另一个常见错误是顺序不对如果先increment新资源再decrement旧资源在自赋值时会导致计数错误比如从1变为2再变回1虽然结果对但过程有风险。标准的“先拷贝后释放”或“先释放后拷贝”都需配合自赋值检查。3.5 移动语义的实现提升性能移动构造函数和移动赋值运算符允许“偷取”临时对象右值的资源避免不必要的引用计数增减提升性能。// 移动构造函数 template typename T SharedPtrT::SharedPtr(SharedPtr other) noexcept { ref other.ref; // 偷取控制块 other.ref nullptr; // 将源对象置为空状态 } // 移动赋值运算符 template typename T SharedPtrT SharedPtrT::operator(SharedPtr other) noexcept { // 同样需要处理自移动虽然不常见但安全起见 if (this ! other) { decrementRef(); // 释放当前资源 ref other.ref; // 偷取控制块 other.ref nullptr;// 置空源对象 } return *this; }原理“偷取”而非“共享”移动操作不增加引用计数。它直接将other.ref赋值给this-ref然后将other.ref置为nullptr。这意味着资源的所有权从other转移到了thisother变成一个空的、不持有任何资源的智能指针。noexcept关键字移动操作通常不应抛出异常标记为noexcept有助于标准库容器如std::vector在重新分配内存时使用移动而非拷贝从而提升效率。移动赋值中的释放和拷贝赋值一样移动赋值前也需要decrementRef()释放当前已持有的资源。3.6 解引用与访问操作为了让SharedPtr用起来像普通指针我们需要重载operator*和operator-。template typename T T SharedPtrT::operator*() const { if (!ref || !ref-ptr) { // 在实际实现中这里应该抛出std::bad_weak_ptr或终止程序 // 为简单起见我们假设指针有效 throw std::runtime_error(dereferencing a null SharedPtr); } return *(ref-ptr); } template typename T T* SharedPtrT::operator-() const { // 同样这里省略了空指针检查的细节 return ref ? ref-ptr : nullptr; } template typename T T* SharedPtrT::get() const { return ref ? ref-ptr : nullptr; }注意事项空指针检查解引用一个空的SharedPtr是未定义行为。健壮的实现应该像std::shared_ptr一样在调试版本或有检查的版本中进行断言或抛出异常。我们这里简化了处理。operator-的返回值这个操作符必须返回一个指针该指针会被用于进一步的-成员访问。因此我们直接返回裸指针ref-ptr。3.7 辅助功能reset与use_counttemplate typename T void SharedPtrT::reset(T* p) { // 先释放当前持有的资源 decrementRef(); // 然后接管新的指针如果非空 if (p ! nullptr) { ref new RefCount(p); } else { ref nullptr; } } template typename T int SharedPtrT::use_count() const { return ref ? ref-count : 0; } template typename T bool SharedPtrT::unique() const { return use_count() 1; }reset解析reset函数的行为类似于先析构当前对象释放旧资源再用新指针重新构造。它是改变SharedPtr所持资源的直接方法。4. 线程安全性的探讨与实现我们目前实现的引用计数增减操作count,--count是非线程安全的。如果多个线程同时拷贝或析构指向同一对象的SharedPtr会导致数据竞争Data Race引用计数可能出错进而引发重复释放或内存泄漏。4.1 问题分析假设引用计数count初始为1。线程A执行拷贝构造读取count(1) - 增加为2 - 写回count(2)。线程B同时执行析构读取count(1) - 减少为0 - 写回count(0) - 释放对象。由于操作不是原子的可能导致A读到的count是旧的1在B释放对象后A仍然认为计数是2实际内存已释放后续操作将访问非法内存。4.2 使用原子操作实现线程安全C11提供了std::atomic模板我们可以用它来改造RefCount。#include atomic // 需要包含头文件 template typename T class SharedPtr { private: struct RefCount { T* ptr nullptr; std::atomicint count; // 将int替换为std::atomicint explicit RefCount(T* p) : ptr(p), count(1) {} void increment() { // 原子地增加计数返回增加前的值这里我们不关心 count.fetch_add(1, std::memory_order_relaxed); } bool decrement() { // 原子地减少计数并返回减少后的新值 int old_count count.fetch_sub(1, std::memory_order_acq_rel); if (old_count 1) { // fetch_sub返回的是操作前的值 delete this; return true; } return false; } }; // ... 其他部分不变 };原理与内存序fetch_add和fetch_sub这些是std::atomic的成员函数执行原子的加/减操作并返回操作之前的值。内存序Memory Orderstd::memory_order_relaxed用于increment。只保证原子性不提供同步或顺序约束。对于单纯的计数增加这通常足够了。std::memory_order_acq_rel用于decrement。这是一个“获取-释放”操作。它保证1) 本次修改对后续的“获取”操作可见2) 本次操作能看见之前所有“释放”操作的结果。这对于确保“计数归零并释放对象”这个关键事件能被所有线程正确观察到至关重要。判断归零注意fetch_sub返回旧值。如果旧值是1说明减之前是1减之后就是0因此需要销毁。重要提示这只是引用计数本身的线程安全。它保证了多个线程并发增减计数时计数值是正确的从而确保对象只在最后一个SharedPtr销毁时被释放一次。但是这不保证被指向的对象T本身的线程安全。多个线程通过不同的SharedPtr访问同一个T对象仍然需要额外的同步机制如互斥锁。5. 扩展功能实现自定义删除器标准库的std::shared_ptr支持自定义删除器Deleter允许用户指定如何释放资源而不仅仅是delete。这对于管理文件指针fclose、动态数组delete[]、或其它第三方库分配的资源非常有用。5.1 设计支持删除器的控制块我们需要修改RefCount使其存储一个可调用对象作为删除器。template typename T class SharedPtr { public: // 默认删除器是 delete template typename Deleter std::default_deleteT SharedPtr(T* p, Deleter d Deleter()) { if (p) { // 需要一种方式将 Deleter 类型信息也保存下来 // 一种常见做法是使用类型擦除如std::function或模板化的RefCount // 这里展示一种简化思路定义带模板参数的RefCount } } private: // 将RefCount改为模板类以支持不同的删除器类型 template typename Deleter struct RefCount { T* ptr; std::atomicint count; Deleter deleter; // 存储删除器 RefCount(T* p, Deleter d) : ptr(p), count(1), deleter(std::move(d)) {} ~RefCount() { deleter(ptr); // 使用存储的删除器释放资源 } // ... increment, decrement 同上 }; // 此时SharedPtr需要一个类型擦除的指针来指向任意类型的RefCount // 这通常通过基类虚函数或std::function实现复杂度显著增加。 };实现难点每个SharedPtr可能用不同的删除器类型构造。为了能让所有SharedPtr无论删除器类型共享同一个控制块控制块的类型必须在运行时多态。这通常引入一个非模板的基类ControlBlockBase然后派生模板类ControlBlockDeleter并在基类中定义虚析构函数。SharedPtr内部存储一个ControlBlockBase*指针。5.2 简化版自定义删除器实现为了保持代码相对简洁我们可以采用一种常见策略将删除器作为SharedPtr类模板的一个类型参数但这会使得所有共享同一对象的SharedPtr必须具有相同的删除器类型限制较大。更接近标准库的做法较为复杂超出了基础教学的范围。一个折中的、教育意义的实现是使用std::functionvoid(T*)进行类型擦除#include functional // 需要包含头文件 template typename T class SharedPtr { private: struct RefCount { T* ptr nullptr; std::atomicint count; std::functionvoid(T*) deleter; // 类型擦除的删除器 template typename Deleter RefCount(T* p, Deleter d) : ptr(p), count(1), deleter(std::forwardDeleter(d)) {} ~RefCount() { if (deleter) { deleter(ptr); } else { delete ptr; // 默认行为 } } // ... }; public: template typename Deleter SharedPtr(T* p, Deleter d) { if (p) { ref new RefCount(p, std::forwardDeleter(d)); } } // ... 其他构造函数需要调整以适配默认删除器 };使用示例// 管理文件指针 FILE* fp fopen(data.txt, r); SharedPtrFILE filePtr(fp, [](FILE* f) { if(f) fclose(f); std::cout File closed.\n; }); // 管理动态数组 int* arr new int[100]; SharedPtrint[] arrPtr(arr, [](int* p) { delete[] p; std::cout Array deleted.\n; }); // 注意这里T是int[]我们的类需要特化支持此处仅为示意6. 常见问题、调试技巧与测试用例手写智能指针时很容易遇到一些隐蔽的问题。下面是一些常见陷阱和调试方法。6.1 常见问题排查表问题现象可能原因排查与解决方法程序崩溃访问非法内存1. 悬垂指针对象已被释放但仍有SharedPtr试图访问。2. 双重释放同一块内存被delete了两次。1. 检查所有SharedPtr的生命周期确保资源持有者存在。2. 在RefCount的析构函数和decrement中加入打印观察释放时机。确保拷贝/赋值操作正确增减计数。内存泄漏引用计数未归零导致控制块和对象永远不被释放。1. 检查所有退出路径包括异常是否都正确调用了析构函数或decrementRef。2. 使用use_count()在关键点打印计数观察其变化是否符合预期。3. 检查循环引用见下文。引用计数错误非线程安全版本多线程环境下并发修改count导致数据竞争。1. 使用线程安全版本将count改为std::atomicint。2. 使用互斥锁保护count的读写性能低于原子操作。自赋值导致资源提前释放赋值运算符未检查if (this other)。务必在赋值运算符开头添加自赋值检查。移动操作后源对象状态错误移动构造函数或移动赋值运算符未将源对象的ref置为nullptr。确保在“偷取”资源后将源对象的ref成员置空。6.2 循环引用问题这是shared_ptr的经典缺陷。如果两个对象互相用shared_ptr指向对方就会形成循环引用导致引用计数永远无法归零内存泄漏。struct Node { // SharedPtrNode next; // 如果这样定义形成循环引用就会泄漏 // 正确的做法是对于可能形成循环的“父指向子”或“所有者”关系使用shared_ptr // 对于“子指向父”或“观察者”关系使用weak_ptr。 // 手写weak_ptr是另一个更高级的课题。 };解决方案打破循环。通常将其中一个指针改为std::weak_ptr。weak_ptr不增加引用计数只观察资源不拥有资源。你需要手写一个完整的WeakPtr类来实现它这涉及到引用计数块中需要同时存储强引用和弱引用计数并在强引用为0时释放对象内存但可能保留控制块直到弱引用也为0。6.3 基础测试用例编写全面的测试是验证智能指针正确性的关键。void test_shared_ptr_basic() { std::cout \n Test 1: Basic Construction and Destruction std::endl; { SharedPtrint sp1(new int(42)); std::cout *sp1 *sp1 , use_count sp1.use_count() std::endl; assert(*sp1 42); assert(sp1.use_count() 1); { SharedPtrint sp2 sp1; // 拷贝构造 std::cout After copy, sp1.use_count sp1.use_count() , sp2.use_count sp2.use_count() std::endl; assert(sp1.use_count() 2); assert(sp2.use_count() 2); assert(*sp2 42); *sp2 100; assert(*sp1 100); // 修改共享对象 } // sp2析构 std::cout After sp2 destroyed, sp1.use_count sp1.use_count() std::endl; assert(sp1.use_count() 1); } // sp1析构对象被释放 std::cout Object should be deleted here. std::endl; } void test_shared_ptr_assignment() { std::cout \n Test 2: Assignment Operator std::endl; SharedPtrint sp1(new int(1)); SharedPtrint sp2(new int(2)); std::cout Initial: sp1 count sp1.use_count() , sp2 count sp2.use_count() std::endl; sp2 sp1; // 赋值sp2原来持有的int(2)应该被释放 std::cout After sp2 sp1: sp1 count sp1.use_count() , sp2 count sp2.use_count() std::endl; assert(sp1.use_count() 2); assert(sp2.use_count() 2); assert(*sp1 1 *sp2 1); sp1 sp1; // 自赋值应该安全 std::cout After self-assignment: count sp1.use_count() std::endl; assert(sp1.use_count() 2); } void test_shared_ptr_move() { std::cout \n Test 3: Move Semantics std::endl; SharedPtrint sp1(new int(88)); std::cout sp1 count before move: sp1.use_count() std::endl; SharedPtrint sp2(std::move(sp1)); // 移动构造 std::cout After move construct: sp1 count (sp1.get()? N/A (should be null) : 0) , sp2 count sp2.use_count() std::endl; assert(sp1.get() nullptr); // sp1被移空 assert(sp2.use_count() 1); assert(*sp2 88); SharedPtrint sp3; sp3 std::move(sp2); // 移动赋值 assert(sp2.get() nullptr); assert(sp3.use_count() 1); }运行这些测试观察控制台输出确保对象的创建、引用计数的变化以及最终的释放都符合预期。通过这个手写SharedPtr的过程你不仅实现了一个工具更重要的是深入理解了RAII、引用计数、拷贝控制、线程安全基础以及C模板编程的许多细节这些知识远比单纯会调用std::shared_ptr要宝贵得多。