当前位置:

首页 > 编程开发 > C++中std::shared_ptr使用详解

C++中std::shared_ptr使用详解

std::shared_ptr通过引用计数实现共享所有权的自动内存管理,避免内存泄漏和悬空指针;推荐使用std::make_shared创建,注意循环引用等陷阱。

std::shared_ptr通过引用计数实现共享所有权的自动内存管理,避免内存泄漏和悬空指针;推荐使用std::make_shared创建,注意循环引用等陷阱。

如何在C++中使用std::shared_ptr_C++共享指针shared_ptr使用指南

std::shared_ptr在C++中提供了一种智能、自动管理动态内存的方式,它允许我们以共享所有权(shared ownership)的模式来管理堆上的对象。简单来说,它就像一个智能的引用计数器,当指向同一对象的最后一个shared_ptr被销毁时,它会自动释放所管理的对象内存,有效避免了内存泄漏和悬空指针的问题。

解决方案

使用std::shared_ptr,最直接的便是创建并让它管理一个对象。通常,我们推荐使用std::make_shared来创建shared_ptr,这不仅效率更高(单次内存分配),也能避免一些潜在的内存泄漏问题。

#include 
#include  // 包含 shared_ptr

class MyClass {
public:
    MyClass() { std::cout << "MyClass 构造\n"; }
    ~MyClass() { std::cout << "MyClass 析构\n"; }
    void doSomething() { std::cout << "MyClass 正在工作...\n"; }
};

int main() {
    // 推荐方式:使用 std::make_shared
    std::shared_ptr ptr1 = std::make_shared();
    ptr1->doSomething();
    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl; // 通常是1

    // 复制 shared_ptr,共享所有权
    std::shared_ptr ptr2 = ptr1;
    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl; // 此时是2
    std::cout << "ptr2 的引用计数: " << ptr2.use_count() << std::endl; // 此时是2

    // 另一个 shared_ptr 也指向同一个对象
    {
        std::shared_ptr ptr3(ptr1); // 或 std::shared_ptr ptr3 = ptr1;
        std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl; // 此时是3
    } // ptr3 在这里离开作用域,引用计数减1

    std::cout << "ptr1 的引用计数 (ptr3 离开作用域后): " << ptr1.use_count() << std::endl; // 此时是2

    // 可以通过 get() 获取原始指针,但要小心使用,不要手动删除
    MyClass* rawPtr = ptr1.get();
    if (rawPtr) {
        rawPtr->doSomething();
    }

    // 重置 shared_ptr,使其不再管理当前对象
    ptr1.reset(); // ptr1 现在为空,引用计数减1
    std::cout << "ptr1 重置后,ptr2 的引用计数: " << ptr2.use_count() << std::endl; // 此时是1

    // 当最后一个 shared_ptr (ptr2) 离开作用域时,MyClass 对象将被析构
    return 0;
}

这段代码展示了shared_ptr的核心机制:创建、复制、引用计数的变化以及最终的自动释放。通过->*运算符可以像使用普通指针一样访问其管理的对象。use_count()方法则可以查看当前有多少个shared_ptr实例共享同一个对象。

std::shared_ptr与普通指针有何不同?为何要选择它?

说实话,刚接触C++时,指针这东西确实让人又爱又恨。它强大,能直接操作内存,但同时也带来了无数的陷阱:内存泄漏、野指针、重复释放等等。std::shared_ptr的出现,在我看来,就是为了解决这些“人祸”而生。它和普通指针最大的不同,在于它拥有所有权,并且这种所有权是共享的

普通指针,说白了,就是个地址,它不关心它指向的内存是谁分配的,谁应该释放。你手动new一个对象,就得手动delete它,否则就是泄漏。如果你不小心delete了两次,程序就崩溃了。更别提多个指针指向同一块内存,你不知道什么时候这块内存被其他指针释放了,你的指针就成了“悬空指针”,访问它就是未定义行为。

std::shared_ptr则不然,它内部有一个引用计数器。当你创建一个shared_ptr并让它指向一个对象时,引用计数器就初始化为1。每当你复制这个shared_ptr(比如std::shared_ptr p2 = p1;),引用计数器就加1。当一个shared_ptr离开作用域或被重置时,引用计数器就减1。只有当引用计数器归零时,也就是没有shared_ptr再指向这个对象时,它才会自动调用对象的析构函数并释放内存。这简直是“懒人福音”,把内存管理的重担从程序员肩上卸了下来。

选择std::shared_ptr的理由很直接:

  1. 自动内存管理:这是核心优势。遵循RAII(Resource Acquisition Is Initialization)原则,资源(内存)的生命周期与对象的生命周期绑定,无需手动释放,大大降低了内存泄漏的风险。
  2. 共享所有权:在多处需要访问并共享同一对象,且对象生命周期不确定时,shared_ptr是理想选择。比如一个资源被多个模块使用,只要有一个模块还在用,资源就不会被释放。
  3. 避免悬空指针:当一个shared_ptr管理的对象被释放后,所有指向它的shared_ptr都会变成空(虽然它们本身并不会自动感知,但use_count为0后,访问行为会更可控,至少不会访问到已释放的内存)。更重要的是,它保证了对象在还有“主人”时不会被提前释放。
// 示例:普通指针可能带来的问题
void process_raw_ptr(int* data) {
    if (data) {
        // ... 使用 data
        delete data; // 假设这里要释放
    }
}

int main_raw() {
    int* my_data = new int(100);
    process_raw_ptr(my_data);
    // 此时 my_data 已经是一个悬空指针,再次访问或 delete 会出问题
    // std::cout << *my_data << std::endl; // 未定义行为
    // delete my_data; // 双重释放

    // 使用 shared_ptr 则不会有这些烦恼
    std::shared_ptr shared_data = std::make_shared(100);
    std::shared_ptr shared_data_copy = shared_data;
    // 无论哪个 shared_ptr 离开作用域,只要还有其他 shared_ptr 引用,内存就不会被释放
    // 只有当 shared_data 和 shared_data_copy 都失效后,int(100) 才会析构
    return 0;
}

这种由shared_ptr提供的“智能”和“安全”,在现代C++编程中是极其宝贵的。

std::shared_ptr的常见陷阱与最佳实践是什么?

虽然shared_ptr极大地简化了内存管理,但它也不是万能的,甚至可以说,如果用得不好,它也会引入一些新的复杂性。

常见陷阱:

  1. 循环引用 (Circular References):这是shared_ptr最臭名昭著的陷阱。当两个或多个对象相互持有对方的shared_ptr时,它们会形成一个引用环。即使外部没有其他shared_ptr指向它们,它们的引用计数也永远不会降到零,导致这组对象永远不会被释放,造成内存泄漏。

    class B; // 前向声明
    class A {
    public:
        std::shared_ptr b_ptr;
        ~A() { std::cout << "A 析构\n"; }
    };
    
    class B {
    public:
        std::shared_ptr a_ptr;
        ~B() { std::cout << "B 析构\n"; }
    };
    
    void test_circular_ref() {
        std::shared_ptr a = std::make_shared();
        std::shared_ptr b = std::make_shared();
        a->b_ptr = b; // b 的引用计数变为 2
        b->a_ptr = a; // a 的引用计数变为 2
        // 当 a 和 b 离开作用域时,它们的引用计数都还是 1,导致 A 和 B 都不会被析构
    } // A 和 B 都没有析构,内存泄漏!

    解决循环引用通常需要引入std::weak_ptrweak_ptr是一种不增加引用计数的智能指针,它“观察”shared_ptr管理的对象,但不拥有它。

  2. 从裸指针多次构造shared_ptr:如果你有一个裸指针,然后用它创建了多个独立的shared_ptr,而不是通过复制一个已存在的shared_ptr,那么每个shared_ptr都会有自己的引用计数器,并认为自己是该对象的唯一所有者。当它们各自的引用计数归零时,就会尝试多次释放同一块内存,导致双重释放错误。

    int* raw_ptr = new int(42);
    std::shared_ptr p1(raw_ptr); // p1 认为自己是所有者
    // std::shared_ptr p2(raw_ptr); // 错误!p2 也会认为自己是所有者,导致双重释放
    // 正确的做法是:
    // std::shared_ptr p2 = p1;
  3. this指针创建shared_ptr:在类的成员函数中,如果你需要返回当前对象的shared_ptr,直接使用std::shared_ptr(this)也是错误的,因为它同样会创建一个独立的shared_ptr,导致与外部已有的shared_ptr冲突。正确的方法是让类继承std::enable_shared_from_this,并通过shared_from_this()方法获取。

  4. 管理非堆内存或数组shared_ptr默认调用delete来释放内存。如果它管理的是一个数组(new T[N]),则需要提供一个自定义的deleter来调用delete[]。对于非堆内存(如栈上的对象),shared_ptr也会尝试delete,导致未定义行为。

最佳实践:

  1. 优先使用std::make_shared:这是最重要的一条。std::make_shared在一个内存块中同时分配对象和其控制块(包含引用计数等信息),减少了一次内存分配,提高了效率,并且避免了在构造对象失败时,控制块仍然被分配的潜在内存泄漏。

    // 推荐
    std::shared_ptr p = std::make_shared();
    // 不推荐,虽然功能上没问题,但效率较低,且可能出现异常安全问题
    // std::shared_ptr p(new MyClass());
  2. 使用std::weak_ptr解决循环引用:当两个对象需要相互引用,但又不希望形成强所有权循环时,让其中一方持有weak_ptr

    class B_fixed;
    class A_fixed {
    public:
        std::shared_ptr b_ptr;
        ~A_fixed() { std::cout << "A_fixed 析构\n"; }
    };
    
    class B_fixed {
    public:
        std::weak_ptr a_ptr; // 使用 weak_ptr
        ~B_fixed() { std::cout << "B_fixed 析构\n"; }
    };
    
    void test_no_circular_ref() {
        std::shared_ptr a = std::make_shared();
        std::shared_ptr b = std::make_shared();
        a->b_ptr = b;
        b->a_ptr = a; // 这里不会增加 a 的引用计数
    } // a 和 b 都会正常析构
  3. 理解所有权语义shared_ptr意味着共享所有权。如果一个对象应该只有一个所有者,或者其生命周期由其创建者严格控制,那么std::unique_ptr可能是更好的选择。

  4. 自定义deleter:如果shared_ptr需要管理非标准方式分配的内存(如mallocnew[])或者需要执行特殊的清理操作,可以提供一个自定义的deleter。

    // 管理数组
    std::shared_ptr arr_ptr(new int[10], std::default_delete());
    // 或使用 lambda 作为 deleter
    std::shared_ptr file_ptr(fopen("test.txt", "w"), [](FILE* f) {
        if (f) { fclose(f); std::cout << "文件关闭\n"; }
    });
  5. 避免在shared_ptr管理的对象内部持有其自身的shared_ptr:这通常会导致循环引用或不必要的复杂性。如果需要,请使用std::enable_shared_from_this

std::shared_ptr在实际项目中的应用场景与性能考量

std::shared_ptr在实际项目中有着非常广泛的应用,尤其是在需要对象生命周期管理复杂、多模块共享资源、或者难以明确单一所有者的场景。

应用场景:

  1. 工厂模式 (Factory Pattern):当工厂函数创建对象并返回给调用者时,通常会返回一个shared_ptr。这样,工厂本身不需要关心对象的生命周期,调用者可以安全地使用,并在不再需要时自动释放。
    // 假设有一个接口 Product
    // std::shared_ptr createProduct(ProductType type);
  2. 缓存管理 (Cache Management):在缓存系统中,数据项可能会被多个消费者同时使用。shared_ptr可以确保只要有任何一个消费者还在使用某个数据项,该数据项就不会被从缓存中移除或销毁。当所有消费者都释放了对它的shared_ptr时,缓存系统可以安全地将其清除。
  3. 多线程环境下的资源共享:在多线程应用中,多个线程可能需要访问同一个数据结构或资源。shared_ptr可以管理这些资源的生命周期,确保资源在所有使用它的线程都结束后才被释放。当然,shared_ptr本身是线程安全的(引用计数的增减是原子操作),但它所管理的对象的数据访问仍需要额外的同步机制(如互斥锁)。
  4. 图形渲染与游戏引擎:纹理、模型、着色器等资源常常被多个游戏对象或渲染通道共享。shared_ptr在这里可以有效地管理这些资源的生命周期,避免重复加载和提前释放。
  5. 事件系统或观察者模式:当一个事件源需要通知多个观察者时,观察者通常会以shared_ptr的形式注册到事件源。这样,只要有观察者存在,事件源就可以安全地向它们发送通知。

性能考量:

shared_ptr的便利性并非没有代价,它相比于裸指针和unique_ptr,会引入一些性能开销:

  1. 内存开销shared_ptr不仅仅是一个指针。它需要一个额外的“控制块”来存储引用计数(强引用和弱引用)、自定义deleter等信息。这个控制块通常与对象本身分开分配(除非使用make_shared),因此每个shared_ptr实例会占用更多的内存。
  2. 运行时开销
    • 引用计数操作:每次shared_ptr的复制、赋值、销毁都会涉及到引用计数的原子增减操作。原子操作虽然比非原子操作慢,但在多线程环境下是必要的。
    • 构造/析构shared_ptr的构造和析构比裸指针或unique_ptr更复杂,因为它需要管理控制块和引用计数。
    • make_shared的优势:如前所述,std::make_shared能够将对象和控制块分配在同一块内存上,这不仅减少了内存碎片,也提升了缓存局部性,从而在一定程度上缓解了内存开销和部分运行时开销。

何时选择shared_ptr,何时选择unique_ptr

这是一个很实际的问题。我的经验是,如果一个对象只有一个明确的、排他的所有者,并且所有权可以转移,那么std::unique_ptr是首选。它零运行时开销,内存开销也最小(只比裸指针多一个指针大小)。它强调“独占所有权”,语义更清晰。

只有当一个对象确实需要被多个部分共享,并且其生命周期难以由单一所有者决定时,才考虑使用std::shared_ptr。比如上面提到的缓存、多线程共享资源等场景。

总结来说,shared_ptr是一个强大的工具,它在C++中提供了自动的内存管理和共享所有权语义。理解其工作原理、优缺点以及潜在陷阱,并结合实际需求选择合适的智能指针,是编写高效、健壮现代C++代码的关键。

本文内容来源于互联网,如有侵权请联系删除。
作者最新文章
编程开发
相关文章 更多
C++动态数组初始化怎么写?常用语句与代码示例
C++动态数组初始化怎么写?常用语句与代码示例

深入解析C++中动态数组的初始化机制,涵盖new操作符的不同用法、基本类型与类对象的初始化差异,以及为何在现代C++开发中应优先使用std::vector。

using namespace 使用中遇到的问题怎么解决
using namespace 使用中遇到的问题怎么解决

命名空间的基本概念与常见引入问题在C++等编程语言中,命名空间(namespace)是一种将代码标识符(如变量、函数、类名)封装在特定名称下的机制,其主要目的是避免命名冲突,尤其是在大型项目或使用多个第三方库时。使用“using namespace”指令可以将指定命名空间中的所有名称引入当前作用域,

c语言函数递归 实操经验总结:这些技巧很实用
c语言函数递归 实操经验总结:这些技巧很实用

理解递归的基本原理在C语言中,递归是一种函数调用自身的编程技术。要掌握它,首先需要理解其核心思想:将一个复杂的大问题,分解为一个或几个与原问题相似但规模更小的子问题,直到子问题足够简单,可以直接求解。这个过程通常包含两个关键部分:递归出口和递归体。递归出口定义了问题何时不再继续分解,即最简单、可直接

c语言函数递归 怎么选?常见方案对比分析
c语言函数递归 怎么选?常见方案对比分析

递归函数的基本概念与适用场景在C语言编程中,递归是一种函数调用自身的编程技巧。它并非适用于所有问题,但在处理某些具有自相似结构的问题时,能提供极其清晰和优雅的解决方案。递归的核心思想是将一个大规模问题分解为一个或多个同类型但规模更小的子问题,直到子问题简单到可以直接求解。典型的适用场景包括树形结构的

Objective-C 内存管理入门:从 alloc 到 dealloc 的生命周期详解
Objective-C 内存管理入门:从 alloc 到 dealloc 的生命周期详解

理解内存管理的基石在Objective-C的编程世界中,内存管理是开发者必须掌握的核心技能之一。它直接关系到应用的性能、稳定性与资源利用效率。与一些采用自动垃圾回收机制的语言不同,Objective-C在很长一段时间里,依赖一套基于引用计数的、需要开发者部分介入的管理规则。这套规则的核心思想是明确的

如何正确使用 dealloc 以避免 iOS 应用中的内存泄漏
如何正确使用 dealloc 以避免 iOS 应用中的内存泄漏

理解 dealloc 的角色与时机在 iOS 应用开发中,内存管理是保障应用性能与稳定性的基石。dealloc 方法是 Objective-C 中对象生命周期结束时的关键回调,它标志着对象即将被系统回收内存。正确理解其触发时机至关重要:当一个对象的引用计数降为零时,运行时系统会自动调用该对象的 de

深入理解 Objective-C 中的 dealloc 方法:内存管理核心机制
深入理解 Objective-C 中的 dealloc 方法:内存管理核心机制

内存管理的基石在Objective-C的世界里,内存管理是开发者必须掌握的核心技能之一。作为一门在手动引用计数(MRC)时代诞生的语言,Objective-C要求程序员对对象的生命周期有清晰的认识。dealloc方法正是这一生命周期中至关重要的终点站。它是一个实例方法,当对象的引用计数降为零时,系统

理解 native2ascii:Java 国际化开发中的字符编码工具
理解 native2ascii:Java 国际化开发中的字符编码工具

native2ascii 工具的基本定位在Ja va应用程序的国际化与本地化开发过程中,处理非拉丁字符集是一个常见且关键的环节。Ja va内部使用Unicode字符集来统一表示全球各种语言的文字,但其属性文件(.properties)在历史上要求使用ASCII编码,或者更准确地说,要求非ASCII字

如何使用 native2ascii 转换中文字符为 Unicode 转义序列
如何使用 native2ascii 转换中文字符为 Unicode 转义序列

理解 native2ascii 工具的基本用途在软件开发,特别是涉及国际化处理的场景中,开发者常常需要处理不同编码的文本资源。native2ascii 是 Ja va 开发工具包(JDK)中提供的一个命令行实用程序,其主要功能是将包含本地字符编码(非ASCII字符)的文件,转换为包含 Unicode

Java native2ascii 命令详解:解决属性文件乱码问题
Java native2ascii 命令详解:解决属性文件乱码问题

native2ascii 命令的由来与作用在Ja va开发中,处理国际化资源文件是一个常见需求。资源文件通常以.properties格式存储,用于支持多语言界面。然而,Ja va属性文件默认采用ISO-8859-1字符集编码,这导致了一个直接的问题:当文件中包含非拉丁字符(如中文、日文、韩文等)时,

查看更多
精品专题 更多
装机必备
装机必备

正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

Windows
Windows

正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

macOS软件
macOS软件

正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

Mac软件 更多
灵活计算器
灵活计算器
macOS/iOS/Android

灵活计算器是一款笔记式算数应用,支持实时计算、动态关联和云端同步功能。记录、整理和输出之间的过渡会更自然,适合长期写作、做笔记或持续沉淀个人内容。

赤友清理大师
赤友清理大师
macOS

赤友清理大师是一款为 Mac 设计的智能清理优化工具,可精准扫描垃圾、大文件、重复文件等,释放磁盘空间。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

极度公式
极度公式
Windows/macOS/Linux

极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

WINDOWS 更多
Windows 10
Windows 10
Windows

Windows 10 是一款微软推出的经典操作系统,拥有硬件兼容性与多任务处理能力。它更偏向把系统状态查看和常用调节动作放在一起,适合需要持续观察和微调设备状态的场景。

极度公式
极度公式
Windows/macOS/Linux

极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

密码键盘
密码键盘
Windows/macOS/iOS/Android

密码键盘是一款兼具安全性与便捷性的高效密码管理器。日常使用里的持续防护和信息管理会更突出,适合把安全控制放进长期使用流程中的场景。

网站备案号:苏ICP备2026018738号-1 联系邮箱:bd@zhengruan.com 网站地图

Copyright ©2018-2026