C++智能指针shared_ptr实现原理与手写教程 📅 2026/7/21 14:00:08 1. 项目概述从“裸奔”到“智能管家”的指针进化史在C的世界里指针一直是个让人又爱又恨的角色。爱它是因为它给了我们直接操作内存、实现高效数据结构的自由恨它是因为稍有不慎就会引发内存泄漏、悬垂指针、重复释放这些令人头疼的“内存事故”。想象一下你是一个项目经理把一份重要文档内存资源交给多个同事指针去处理。如果没有明确的规则很容易出现文档被一个同事修改后另一个同事还在用旧版本数据不一致或者所有同事都以为对方会处理最后谁也没去归档文档就丢了内存泄漏更糟的是两个同事同时去碎纸机销毁文档导致机器卡住重复释放程序崩溃。std::shared_ptr就是为解决这类“团队协作”问题而生的“智能管家”。它不是C语言内置的特性而是标准库在memory头文件中提供的一个模板类。它的核心使命是通过引用计数机制自动化管理动态分配对象或资源的生命周期确保资源在最后一个使用者离开时被自动、安全地释放。简单说它让多个指针可以安全地“共享”同一个对象并且能自动记账知道什么时候该“关门清场”。为什么需要了解甚至自己实现它对于初学者理解shared_ptr是深入现代C内存管理思想的必经之路也是面试中高频出现的“八股文”考点。对于有经验的开发者剖析其实现能让你在遇到复杂资源管理场景如环形引用时不再把它当黑盒而是能精准诊断和解决。自己动手实现一个简化版更是理解RAII资源获取即初始化、引用计数、线程安全等核心概念的绝佳实践。无论你是正在用VSCode配置C环境的新手还是被“C面试题”困扰的求职者或是想优化“C小游戏”中资源管理的爱好者彻底搞懂shared_ptr都将让你对C的理解提升一个维度。2. 核心原理与设计思路拆解要自己造一个“轮子”首先得彻底理解原版“轮子”的构造蓝图。shared_ptr的设计哲学深深植根于C的RAII原则并巧妙地运用了“引用计数”这一经典算法。2.1 RAII资源管理的基石RAII是shared_ptr的灵魂。其核心思想是将资源的生命周期与对象的生命周期绑定。在构造函数中获取资源在析构函数中释放资源。这样只要对象本身遵循作用域规则例如局部对象在离开作用域时析构资源管理就完全是自动的、异常安全的。我们自己实现的SharedPtr类其最根本的任务就是封装一个原生指针raw pointer。当SharedPtr对象被创建时它接管该指针所指资源的所有权当这个SharedPtr对象被销毁时它的析构函数必须负责检查并可能释放资源。这从根本上避免了因忘记delete而导致的内存泄漏。2.2 引用计数机制详解单个对象的RAII容易但如何让多个SharedPtr对象共享同一资源呢这就需要引入一个共享的“账本”——引用计数。基本模型每个被管理的资源或资源块都关联一个独立的引用计数器。当一个新的SharedPtr指向该资源时计数器加1通常称为use_count。当一个SharedPtr不再指向该资源例如被赋值给其他资源、被销毁、被重置reset时计数器减1。当计数器减到0时意味着没有任何SharedPtr再需要这个资源此时安全地释放资源调用delete或自定义删除器。关键设计决策计数器的存储位置计数器本身也是一个需要动态管理的资源。它必须独立于被管理的对象并且能被所有共享该对象的SharedPtr实例访问。因此最直接的方式是在堆上动态分配这个计数器并用一个原生指针指向它。这就是我们实现中_pCount的由来。拷贝与赋值这是引用计数逻辑的核心。拷贝构造函数和拷贝赋值运算符不是简单地复制指针而是要让新对象和原对象指向同一个控制块包含计数器和资源指针然后递增计数器。析构析构函数不总是释放资源。它先递减计数器然后检查计数器是否归零。只有归零时才执行实际的资源释放操作。2.3 线程安全考量简化版 vs 完整版标准库的std::shared_ptr在一定程度上是线程安全的。其保证是多个线程同时读写不同的shared_ptr对象是安全的多个线程同时读写同一个shared_ptr对象则需要外部同步。更重要的是它对引用计数use_count的操作是原子的通常使用std::atomic这保证了即使在多线程环境下引用计数的增减也是安全的从而防止资源因计数错误而被提前释放或泄漏。在我们自己实现的简化版本中为了聚焦于核心逻辑通常会暂时忽略线程安全即不对计数器的操作进行原子保护。这是一个重要的简化意味着我们的SharedPtr不适用于多线程共享场景。但在学习阶段这有助于我们更清晰地理解核心流程。在后续的“高级实现与扩展”部分我们会讨论如何引入原子操作来完善它。2.4 控制块与自定义删除器一个工业级的shared_ptr管理的信息不止一个计数器。它通常有一个“控制块”里面可能包含强引用计数即我们通常说的use_count管理对象本身的生命周期。弱引用计数用于支持weak_ptr解决循环引用问题这部分在我们基础实现中暂不涉及。删除器一个可调用对象用于释放资源。默认是delete但用户可以自定义用于管理数组delete[]、文件句柄fclose、或其他任何需要特殊清理方式的资源。这是我们实现中可以添加的一个重要扩展点。被管理对象的指针。我们的基础实现将聚焦于强引用计数和默认删除器建立最核心的模型。3. 基础版SharedPtr实现步骤拆解我们将一步步构建一个名为SharedPtr的简化智能指针。这个版本实现了核心的引用计数生命周期管理但暂不考虑线程安全、弱引用、别名构造等高级特性。3.1 类模板定义与成员变量首先我们的SharedPtr需要是一个模板类以管理任意类型的资源。templatetypename T class SharedPtr { private: T* _ptr; // 指向被管理资源的原始指针 int* _pCount; // 指向引用计数器的指针位于堆上 public: // 构造函数、析构函数、拷贝控制成员将在后续实现 // ... };成员变量解析_ptr这是智能指针包装的核心指向用户真正想要管理的动态分配对象。_pCount这是实现共享的关键。注意它指向一个整数int*而不是一个整数。所有共享同一资源的SharedPtr对象它们的_pCount成员都指向堆上的同一个整数。这样通过这个共享的整数它们才能协同更新引用计数。3.2 构造函数与资源初始化我们需要多种构造函数来满足不同的使用场景。// 1. 默认构造函数 SharedPtr() : _ptr(nullptr), _pCount(nullptr) {} // 2. 从原生指针构造接管所有权 explicit SharedPtr(T* ptr) : _ptr(ptr), _pCount(nullptr) { if (_ptr) { _pCount new int(1); // 资源首次被管理计数器置为1 } } // 3. 拷贝构造函数实现共享 SharedPtr(const SharedPtrT other) : _ptr(other._ptr), _pCount(other._pCount) { if (_pCount) { (*_pCount); // 共享资源计数器加1 } }关键点说明explicit关键字在从原生指针构造的构造函数上使用explicit可以防止隐式类型转换。例如避免void foo(SharedPtrint sp); foo(new int(42));这种容易导致内存泄漏的写法。强制使用者显式构造foo(SharedPtrint(new int(42)))或者更推荐使用make_shared。空指针处理如果传入的原生指针是nullptr我们就不分配计数器。这符合std::shared_ptr的行为一个空的shared_ptr的use_count()是0。拷贝构造这是实现“共享”语义的核心。它不复制资源只复制指针和计数器指针然后递增计数器。这意味着两个SharedPtr对象现在指向同一个资源并且计数器准确地反映了共享者的数量2。3.3 析构函数与资源释放析构函数负责递减引用计数并在计数归零时清理资源。~SharedPtr() { _release(); } private: void _release() { if (_pCount) { --(*_pCount); // 减少引用计数 if (*_pCount 0) { // 如果我是最后一个持有者 delete _ptr; // 释放管理的资源 delete _pCount; // 释放计数器资源 } // 清空当前对象的指针避免成为悬垂指针 _ptr nullptr; _pCount nullptr; } }析构逻辑解析检查_pCount是否有效非空。如果为空说明这是一个空的SharedPtr无事可做。对计数器执行减1操作。注意这个操作在基础版中不是原子的因此非线程安全。判断减1后是否归零。如果归零说明当前对象是最后一个持有该资源的SharedPtr必须履行“管家”的最终职责调用delete _ptr释放用户资源同时delete _pCount释放我们内部管理的计数器资源。最后将当前对象的两个指针成员置为nullptr。这是一个良好的习惯可以防止后续误操作尽管对象即将销毁。3.4 拷贝赋值运算符赋值操作比拷贝构造更复杂因为它需要先释放当前对象可能持有的旧资源再关联新资源。SharedPtrT operator(const SharedPtrT other) { // 1. 防止自赋值如果自己给自己赋值直接返回 if (this ! other) { // 2. 释放当前对象持有的旧资源 _release(); // 3. 共享新资源 _ptr other._ptr; _pCount other._pCount; if (_pCount) { (*_pCount); } } return *this; // 支持链式赋值 a b c }赋值运算符要点自赋值检查if (this ! other)至关重要。如果没有这个检查在sp sp这样的自赋值中第一步_release()会销毁资源第二步试图访问other也就是自己已经无效的资源导致未定义行为。先释放再关联这是正确的顺序。如果先关联新资源再释放旧资源在自赋值且该资源引用计数为1时会导致资源被错误地释放。异常安全这个实现提供了基本的强异常安全保证吗考虑一下_release()不会抛出异常我们假设delete不抛异常后续的指针复制和递增操作也不会。所以在这个简化模型中它是异常安全的。但如果引入自定义删除器就需要更仔细的设计。3.5 移动语义支持C11及以上为了提高效率我们应该实现移动构造函数和移动赋值运算符。移动操作“窃取”右值对象的资源并将其置于有效但可析构的状态通常为空从而避免不必要的引用计数操作。// 移动构造函数 SharedPtr(SharedPtrT other) noexcept : _ptr(other._ptr), _pCount(other._pCount) { other._ptr nullptr; other._pCount nullptr; // 将源对象置为空状态 } // 移动赋值运算符 SharedPtrT operator(SharedPtrT other) noexcept { if (this ! other) { _release(); // 释放当前资源 _ptr other._ptr; _pCount other._pCount; other._ptr nullptr; other._pCount nullptr; } return *this; }移动语义的价值当从一个临时对象右值构造或赋值时编译器会优先选择移动操作。例如在函数返回SharedPtr时移动构造可以避免一次拷贝构造带来的计数器增减开销提升性能。noexcept声明有助于标准库容器如std::vector在重组内存时进行优化。3.6 常用成员函数实现为了让我们的SharedPtr好用需要模拟一些std::shared_ptr的接口。T operator*() const { return *_ptr; } T* operator-() const { return _ptr; } T* get() const { return _ptr; } int use_count() const { return (_pCount) ? *_pCount : 0; } bool unique() const { return use_count() 1; } explicit operator bool() const { return _ptr ! nullptr; } void reset(T* ptr nullptr) { // 释放当前持有的所有资源 _release(); // 如果传入新指针则接管它 if (ptr) { _ptr ptr; _pCount new int(1); } // 如果ptr是nullptr那么当前对象就变成了空状态 }函数解析operator*和operator-提供类似原生指针的解引用和成员访问语法这是智能指针表现“智能”的同时保持“指针”语感的关键。get()返回内部保存的原生指针。要谨慎使用此函数因为获取到的原生指针脱离了引用计数的管理。use_count()和unique()用于调试和了解当前资源的所有权状态。operator bool()允许SharedPtr在布尔上下文中使用如if (sp) { ... }用于检查是否持有资源。reset()一个多功能函数。不带参数时释放当前资源并将自身置空带参数时释放旧资源并接管新资源的所有权。4. 完整基础版代码示例与测试将上述所有部分组合起来我们就得到了一个基础可用的SharedPtr模板类。#include iostream templatetypename T class SharedPtr { private: T* _ptr; int* _pCount; void _release() { if (_pCount) { --(*_pCount); if (*_pCount 0) { delete _ptr; delete _pCount; } _ptr nullptr; _pCount nullptr; } } public: // 构造函数 SharedPtr() : _ptr(nullptr), _pCount(nullptr) {} explicit SharedPtr(T* ptr) : _ptr(ptr), _pCount(nullptr) { if (_ptr) { _pCount new int(1); } } // 拷贝控制 SharedPtr(const SharedPtr other) : _ptr(other._ptr), _pCount(other._pCount) { if (_pCount) { (*_pCount); } } SharedPtr operator(const SharedPtr other) { if (this ! other) { _release(); _ptr other._ptr; _pCount other._pCount; if (_pCount) { (*_pCount); } } return *this; } // 移动语义 (C11) SharedPtr(SharedPtr other) noexcept : _ptr(other._ptr), _pCount(other._pCount) { other._ptr nullptr; other._pCount nullptr; } SharedPtr operator(SharedPtr other) noexcept { if (this ! other) { _release(); _ptr other._ptr; _pCount other._pCount; other._ptr nullptr; other._pCount nullptr; } return *this; } // 析构函数 ~SharedPtr() { _release(); } // 访问接口 T operator*() const { return *_ptr; } T* operator-() const { return _ptr; } T* get() const { return _ptr; } // 容量查询 int use_count() const { return (_pCount) ? *_pCount : 0; } bool unique() const { return use_count() 1; } explicit operator bool() const { return _ptr ! nullptr; } // 修改器 void reset(T* ptr nullptr) { _release(); if (ptr) { _ptr ptr; _pCount new int(1); } } }; // 一个简单的测试类用于观察构造和析构 class TestObject { public: TestObject(int id) : _id(id) { std::cout TestObject[ _id ] constructed.\n; } ~TestObject() { std::cout TestObject[ _id ] destroyed.\n; } void sayHello() const { std::cout Hello from Object[ _id ]\n; } private: int _id; }; int main() { std::cout 基础功能测试 \n; { SharedPtrTestObject sp1(new TestObject(1)); std::cout sp1 use_count: sp1.use_count() std::endl; // 应为1 SharedPtrTestObject sp2 sp1; // 拷贝构造 std::cout After copy, sp1 use_count: sp1.use_count() std::endl; // 应为2 std::cout After copy, sp2 use_count: sp2.use_count() std::endl; // 应为2 sp1-sayHello(); // 通过 - 操作符访问成员 { SharedPtrTestObject sp3; sp3 sp2; // 拷贝赋值 std::cout After assignment, sp2 use_count: sp2.use_count() std::endl; // 应为3 } // sp3 离开作用域析构计数减1 std::cout After sp3 destroyed, sp1 use_count: sp1.use_count() std::endl; // 应为2 } // sp1和sp2离开作用域计数归零对象被销毁。此处应看到析构输出。 std::cout \n 移动语义测试 \n; { SharedPtrTestObject sp1(new TestObject(2)); SharedPtrTestObject sp2 std::move(sp1); // 移动构造 std::cout After move, sp1 is (sp1 ? not null : null) std::endl; // 应为 null std::cout sp2 use_count: sp2.use_count() std::endl; // 应为1 // sp1 不再拥有资源 } std::cout \n reset 功能测试 \n; { SharedPtrTestObject sp(new TestObject(3)); sp.reset(new TestObject(4)); // 释放对象3接管对象4 // 此处应看到对象3被销毁对象4被创建 sp.reset(); // 释放对象4sp变为空 // 此处应看到对象4被销毁 std::cout After reset to null, sp is (sp ? not null : null) std::endl; } return 0; }运行上述测试程序你可以清晰地观察到对象的创建、引用计数的变化以及最终的自动销毁过程直观地验证我们实现的SharedPtr的核心生命周期管理功能。5. 高级实现与扩展要点基础版实现了核心功能但距离std::shared_ptr还有差距。以下是几个关键的高级扩展方向。5.1 线程安全原子引用计数在多线程环境下两个线程可能同时操作指向同一资源的不同SharedPtr对象例如一个线程拷贝另一个线程析构这会导致对同一个引用计数器的读写竞争。为了解决这个问题必须使用原子操作来保证计数器增减的原子性。在C11及以上我们可以使用std::atomicint或者std::atomiclong等。修改方案将成员变量int* _pCount;改为std::atomicint* _pCount;。在构造函数中使用new std::atomicint(1)。将所有的(*_pCount)和--(*_pCount)改为_pCount-fetch_add(1, std::memory_order_relaxed)和_pCount-fetch_sub(1, std::memory_order_acq_rel)。读取计数时使用_pCount-load(std::memory_order_acquire)。注意即使引用计数是原子的std::shared_ptr也不保证其指向对象的多线程访问是安全的。它只保证控制块主要是引用计数的线程安全。对*sp的读写仍需用户自己加锁同步。5.2 支持自定义删除器默认的delete并不能满足所有资源类型。例如管理动态数组需要用delete[]管理FILE*需要用fclose。std::shared_ptr通过模板参数支持自定义删除器。实现思路为SharedPtr类增加一个模板参数Deleter默认为std::default_deleteT。在控制块中或作为另一个成员变量存储一个删除器对象。在_release()函数中当计数归零时不再直接调用delete _ptr而是调用存储的删除器对象如_deleter(_ptr)。一个简化的实现方式是将删除器与计数器放在一起构成一个完整的控制块结构体。templatetypename T, typename Deleter std::default_deleteT class SharedPtrWithDeleter { private: struct ControlBlock { T* ptr; std::atomicint count; Deleter deleter; ControlBlock(T* p, Deleter d) : ptr(p), count(1), deleter(std::move(d)) {} }; ControlBlock* _cb; // ... 其他成员函数需要相应修改通过_cb来访问ptr和count };5.3 解决循环引用问题与weak_ptr这是引用计数智能指针的经典难题。考虑两个对象互相持有对方的shared_ptrstruct Node { SharedPtrNode next; SharedPtrNode prev; }; auto node1 SharedPtrNode(new Node); auto node2 SharedPtrNode(new Node); node1-next node2; node2-prev node1; // 循环引用形成当node1和node2离开作用域时它们的引用计数都从2减为1彼此还互相引用着永远不会变为0导致内存泄漏。解决方案weak_ptr。weak_ptr是一种不增加引用计数的智能指针它“观察”一个由shared_ptr管理的对象但不会阻止其被销毁。要使用被观察的对象需要将weak_ptr临时转换为shared_ptr通过lock()方法如果此时对象还存在则转换成功引用计数增加否则返回一个空的shared_ptr。在上面的例子中如果prev成员改为WeakPtrNode就可以打破循环。实现WeakPtr需要更复杂的控制块其中包含强引用计数和弱引用计数。只有当强引用计数为0时才销毁对象本身只有当强引用和弱引用计数都为0时才销毁控制块。这是std::shared_ptr和std::weak_ptr协同工作的完整机制实现起来相对复杂是区分基础实现和工业级实现的重要标志。5.4 实现make_shared直接使用new创建对象然后传给shared_ptr的构造方式存在潜在的内存泄漏风险如果在构造shared_ptr之前发生异常并且会导致两次内存分配一次对象一次控制块。std::make_shared通过一次分配就同时获得对象内存和控制块内存提高了性能和异常安全性。简化版MakeShared实现思路templatetypename T, typename... Args SharedPtrT MakeShared(Args... args) { // 1. 分配一块足够大的内存既能容纳T对象也能容纳引用计数器。 // 2. 在这块内存的适当位置使用placement new构造T对象 (std::forwardArgs(args)...)。 // 3. 在同一块内存的另一部分初始化计数器。 // 4. 构造一个SharedPtrT其_ptr指向对象部分_pCount指向计数器部分。 // 5. 返回这个SharedPtr。 }这涉及到自定义的内存对齐和布局是一个高级主题。在实际项目中通常直接使用标准库的std::make_shared。6. 常见问题、陷阱与最佳实践即使有了shared_ptr使用不当依然会掉进坑里。下面是一些实战中总结的经验和教训。6.1 典型使用陷阱不要混用原生指针和智能指针管理同一块内存int* raw new int(100); SharedPtrint sp1(raw); SharedPtrint sp2(raw); // 灾难两个独立的SharedPtr各自创建了计数器。 // sp1和sp2析构时都会尝试delete raw导致重复释放。正确做法一块内存只交给一个shared_ptr“管家”初始化之后通过拷贝来共享。小心get()返回的裸指针SharedPtrint sp(new int(42)); int* p sp.get(); { SharedPtrint sp2(p); // 又是一个独立的管家 } // sp2析构delete了p // 此时sp内部的_ptr已经成了悬垂指针规则绝对不要用get()返回的指针去创建另一个智能指针。get()仅用于向那些只接受原生指针的API传递临时使用权。循环引用如前所述这是shared_ptr的“绝症”必须通过weak_ptr来预防。在设计具有双向或多向关联的数据结构时要仔细分析所有权关系明确哪一方用shared_ptr拥有所有权哪一方用weak_ptr或原生指针仅表示关联。避免创建指向栈内存的shared_ptrint x 10; SharedPtrint sp(x); // 错误栈内存会被自动释放shared_ptr的delete会导致未定义行为。shared_ptr默认使用delete只适用于堆内存。6.2 性能与开销分析内存开销每个被shared_ptr管理的对象至少额外增加一个控制块包含引用计数等的开销通常是两个指针的大小在64位系统上是16字节。如果使用make_shared对象和控制块内存连续可以减少一次内存分配并可能提高缓存局部性。时间开销拷贝、赋值、析构都需要原子地或非原子地修改引用计数这比原生指针操作慢。在性能极度敏感的代码路径中需要权衡。适用场景shared_ptr适用于共享所有权语义明确、对象生命周期不确定、且不涉及高频拷贝的场景。对于明确的独占所有权应优先考虑std::unique_ptr它开销更小。6.3 与std::unique_ptr的对比与选择std::unique_ptr独占所有权。一个资源在任何时刻只能被一个unique_ptr拥有。开销小通常只比原生指针多一点点可能包含一个自定义删除器的空基类优化。支持移动不支持拷贝。选择时机当你能够明确资源的单一所有者时首选unique_ptr。例如在工厂函数中返回对象在类内部管理动态成员等。std::shared_ptr共享所有权。多个智能指针可以共享同一资源。开销较大控制块、原子操作。支持拷贝和移动。选择时机当多个对象需要共享同一资源且无法确定哪个对象最后使用它时。例如缓存系统中的对象、观察者模式中的主题、以及一些复杂的图结构配合weak_ptr。黄金法则默认使用unique_ptr仅在确实需要共享所有权时再使用shared_ptr。清晰的资源所有权设计是编写健壮C程序的关键。通过从零开始实现一个SharedPtr我们不仅深入理解了其自动内存管理的魔法更对RAII、引用计数、线程安全、移动语义等现代C核心概念有了具象化的认识。这个练习的价值不在于造出一个替代标准库的轮子而在于通过动手揭开黑盒让你在未来使用std::shared_ptr时更加自信和精准能够有效规避陷阱并能在复杂场景下做出正确的架构选择。当你再面对“C八股文”中关于智能指针的连环问时这些从底层挖掘出的理解将成为你最坚实的底气。