C++11 智能指针与 RAII 万字详解 | 从异常安全到 shared_ptr 控制块与循环引用,深度拆解现代 C++ 内存管理 Ownership 语义【现代 C++ 深度解析】

目录

引言:

1.智能指针的使用场景分析

1.1 一个典型的异常安全问题

1.2 手动处理:异常安全的"地狱模式"

1.3 核心矛盾总结

2.RAII和智能指针的设计思路

2.1 什么是RAII?

2.2 智能指针的雏形:一个RAII类

2.3 用智能指针重构异常场景

2.4 异常安全性(Exception Safety)的初步认识

2.5 当前SmartPtr的缺陷(为后续章节埋坑)

3.C++标准库智能指针的使用

3.1 所有权问题:智能指针的核心矛盾

3.2 auto_ptr(C++98):一个失败的设计

3.3 unique_ptr(C++11):独占所有权

3.4 shared_ptr(C++11):共享所有权

3.5 weak_ptr(C++11):弱引用(这里的弱是辅助的意思)

3.6 删除器(Deleter):定制资源释放方式

3.6.1 为什么需要删除器?

3.6.2 两种解决方案

方案二:自定义删除器(通用方案)

3.6.3 unique_ptr与shared_ptr的效率差异

3.7 make_shared与make_unique:更优的构造方式

3.7.1 make_shared

3.7.2 make_unique(C++14)

3.8 其他常用接口

3.8.1 operator bool

3.8.2 explicit构造函数

3.8.3 reset与swap

4.智能指针的原理

4.1 auto_ptr的模拟实现

4.2 unique_ptr的模拟实现

4.3 shared_ptr的模拟实现:引用计数的设计

4.3.1 引用计数该放在哪里?

4.3.2 基础版实现

4.3.3 支持定制删除器的优化版

4.4 weak_ptr的简化实现

5.shared_ptr和weak_ptr:循环引用问题

5.1 循环引用的产生

5.2 weak_ptr:打破循环的钥匙

5.3 weak_ptr的核心接口

5.4 weak_ptr 的底层实现原理:控制块(Control Block)

控制块里有什么?

为什么需要弱引用计数?

总结释放规则:

这与我们简化实现的区别

5.5 使用建议

6.shared_ptr的线程安全问题(了解)

6.1 引用计数的线程安全

6.2 解决方案

6.3 重要区分:引用计数安全 ≠ 对象安全

7.C++11和boost中智能指针的关系

8.内存泄漏

8.1 什么是内存泄漏?有什么危害?

8.2 内存泄漏的检测工具

8.3 如何避免内存泄漏

结语:


引言:

在现代 C++ 的语境下,手动管理内存早已被视为"反模式"。C++11 引入的 unique_ptrshared_ptrweak_ptr 不仅是一套工具,更是一种资源所有权(Ownership)语义的表达。它们背后的 RAII 思想,是 C++ 区别于 C、Java、Go 等语言的标志性设计哲学。

然而,很多开发者对智能指针的理解停留在"自动释放内存"的表层,对为什么需要删除器引用计数为何必须堆分配控制块如何支撑 weak_ptr循环引用的本质是什么等底层机制一知半解。一旦遇到自定义删除器、跨线程共享、双向链表循环引用等场景,往往束手无策。

本文将从异常安全这一原始痛点出发,系统推演 RAII 的设计逻辑,逐层拆解 auto_ptrunique_ptrshared_ptrweak_ptr 的演进脉络,并手写模拟实现揭示其底层原理。全文涵盖引用计数设计、控制块内存模型、循环引用破局、线程安全边界、删除器类型擦除等核心议题,力求让你对 C++ 智能指针建立起从使用到原理的完整认知闭环

那么话不多说,接下来进入正文—————————>


1.智能指针的使用场景分析

1.1 一个典型的异常安全问题

考虑下面这段代码:

cpp

double Divide(int a, int b)
{
    if (b == 0)
        throw "Divide by zero condition!";
    return static_cast<double>(a) / b;
}

void Func()
{
    int* array1 = new int[10];
    int* array2 = new int[10];

    int len, time;
    std::cin >> len >> time;
    std::cout << Divide(len, time) << std::endl;

    delete[] array1;
    delete[] array2;
}

Divide抛出异常时,函数栈会展开(stack unwinding),直接跳转到main中的catch块。此时Funcdelete[]之后的代码永远不会执行——array1array2指向的内存就此泄漏。


1.2 手动处理:异常安全的"地狱模式"

为了保证内存不泄漏,我们不得不用try-catch包裹可能抛异常的代码,并在catch中手动释放资源:

cpp

void Func()
{
    int* array1 = new int[10];
    int* array2 = new int[10];

    try {
        int len, time;
        std::cin >> len >> time;
        std::cout << Divide(len, time) << std::endl;
    }
    catch (...) {
        delete[] array1;
        delete[] array2;
        throw;  // 重新抛出,异常交给外层处理
    }

    delete[] array1;
    delete[] array2;
}

这段代码表面上解决了问题:正常流程和异常流程都执行了delete[]。但它隐藏着一个更深层的隐患:

new本身也可能抛异常std::bad_alloc)。

如果array1分配成功,但array2分配失败,异常会直接跳出Func,此时array1指向的内存无人释放——内存仍然泄漏了

为了覆盖这种情况,代码必须进一步嵌套:

cpp

void Func()
{
    int* array1 = new int[10];
    try {
        int* array2 = new int[10];
        try {
            int len, time;
            std::cin >> len >> time;
            std::cout << Divide(len, time) << std::endl;
        }
        catch (...) {
            delete[] array1;
            delete[] array2;
            throw;
        }
        delete[] array1;
        delete[] array2;
    }
    catch (const std::bad_alloc&) {
        delete[] array1;
        throw;
    }
}

这还仅仅是两个new的情况。 如果资源数量增加,这种嵌套会呈指数级膨胀,代码变得极其冗余、难以维护,而且每增加一个资源,就需要重新审视所有异常路径——这是不可持续的。


1.3 核心矛盾总结

这个问题的本质是:资源释放逻辑与业务逻辑高度耦合,而异常机制打破了正常的执行流,使得"在哪里释放"变得异常困难。

我们需要一种机制,让资源的释放自动绑定到某个对象的生命周期上,无论函数是正常返回还是异常退出,资源都能被可靠释放。这就是RAII(Resource Acquisition Is Initialization)思想,而智能指针正是RAII在内存管理上的典型应用。


2.RAII和智能指针的设计思路

2.1 什么是RAII?

RAIIResource Acquisition Is Initialization 的缩写,是C++资源管理的核心范式。其核心思想是:构造函数保存资源,析构函数释放资源

将资源的生命周期与对象的生命周期绑定。资源在对象构造时获取,在对象析构时释放。

无论函数是正常执行完毕,还是因异常导致栈展开(stack unwinding),局部对象都会被自动析构。这意味着:只要资源被委托给了对象,它的释放就是确定的。

RAII不仅适用于内存,也适用于文件句柄、网络连接、互斥锁等一切需要显式释放的资源。


2.2 智能指针的雏形:一个RAII类

基于RAII思想,我们可以设计一个极简的智能指针类模板:

cpp

template <class T>
class SmartPtr {
public:
    // explicit防止隐式转换,避免意外构造
    explicit SmartPtr(T* ptr = nullptr)
        : _ptr(ptr)
    {}

    // 析构时自动释放资源
    ~SmartPtr() {
        std::cout << "delete: " << _ptr << std::endl;
        delete _ptr;  // 注意:这里假设管理的是单个对象
    }

    // 重载运算符,使其行为像原生指针
    T& operator*() const {
        return *_ptr;
    }

    T* operator->() const {
        return _ptr;
    }

    T& operator[](size_t i) const {
        return _ptr[i];
    }

    // 提供原生指针访问接口(后续标准库中对应 get())
    T* get() const {
        return _ptr;
    }

private:
    T* _ptr;
};

关键设计点:

  • 构造函数接管指针:外部new出的内存交给SmartPtr对象管理

  • 析构函数释放资源:对象销毁时自动delete,无需手动调用

  • 运算符重载:支持*->[],访问资源的方式与原生指针一致


2.3 用智能指针重构异常场景

现在,让我们用SmartPtr重写第1节的代码:

cpp

void Func()
{
    SmartPtr<int> sp1(new int[10]);  // 注意:这里为了演示,实际应使用 delete[]
    SmartPtr<int> sp2(new int[10]);

    int len, time;
    std::cin >> len >> time;
    std::cout << Divide(len, time) << std::endl;

    // 不再需要任何 delete!
}

发生了什么变化?

场景行为
正常执行Func返回,局部对象sp1sp2析构,内存自动释放
Divide抛异常栈展开,sp1sp2被析构,内存自动释放
new抛异常(sp2sp1已成功构造,会正常析构;sp2构造失败,不存在析构问题

三行代码,覆盖了所有异常路径。 这就是RAII的威力。


2.4 异常安全性(Exception Safety)的初步认识

通过上面的对比,我们可以引入C++中一个重要的概念——异常安全性。它衡量的是当异常发生时,程序维持正确状态的能力。通常分为几个级别:

  • 基本保证(Basic Guarantee):异常发生后,程序不会泄漏资源,对象处于有效但不确定的状态。

  • 强保证(Strong Guarantee):异常发生后,程序状态回滚到操作之前。

  • 不抛异常保证(No-throw Guarantee):操作绝不抛异常。

RAII是达成基本保证的基石。智能指针通过RAII,让我们在不写一行catch的情况下,就能实现内存管理的基本异常安全。


2.5 当前SmartPtr的缺陷(为后续章节埋坑)

虽然我们实现了一个能用的SmartPtr,但它还有两个致命问题没有解决:

  1. 拷贝语义不明确:如果执行SmartPtr<int> sp2 = sp1;,默认生成的拷贝构造函数会进行浅拷贝,导致两个对象管理同一块内存,最终重复析构(double free)

  2. 不支持数组释放:上面代码中new int[10]delete是未定义行为,应该用delete[]

第一个问题正是C++标准库中auto_ptrunique_ptrshared_ptr采用不同策略的根源——智能指针的核心难点不在于"管理资源",而在于"定义所有权"。我们将在下一节详细展开。


3.C++标准库智能指针的使用

C++标准库中的智能指针定义在头文件 <memory> 中(<memory> - C++ Reference)。它们都遵循RAII原则,并支持像原生指针一样的访问语法(operator*operator->等)。智能指针之间的核心差异在于对"拷贝"这一行为的不同定义——即如何确定资源的所有权(Ownership)


3.1 所有权问题:智能指针的核心矛盾

回顾第2节末尾的SmartPtr,如果执行:

cpp

SmartPtr<int> sp1(new int(10));
SmartPtr<int> sp2 = sp1;  // 拷贝构造

默认的浅拷贝会让sp1sp2_ptr指向同一块内存。当两者析构时,会执行两次delete,导致重复释放(double free),程序直接崩溃。

原生指针允许多个指针指向同一对象,但原生指针不负责释放,所以不存在这个问题。智能指针自动释放的特性,使得"多个对象共同管理同一资源"成为一个必须显式设计的语义。

C++标准库通过四种智能指针,给出了四种不同的所有权策略:

智能指针所有权策略是否支持拷贝是否支持移动推荐使用
auto_ptr拷贝时转移所有权(被拷贝对象悬空)支持(危险)支持❌ 已废弃
unique_ptr独占所有权不支持支持✅ 首选(无需共享时)
shared_ptr共享所有权(引用计数)支持支持✅ 需要共享时
weak_ptr弱引用,不管理资源不支持支持✅ 辅助shared_ptr

下面我们逐一讲解。为了便于观察析构行为,先定义一个测试用的Date类:

cpp

struct Date {
    int _year;
    int _month;
    int _day;

    Date(int year = 1, int month = 1, int day = 1)
        : _year(year), _month(month), _day(day)
    {}

    ~Date() {
        std::cout << "~Date()" << std::endl;
    }
};

3.2 auto_ptr(C++98):一个失败的设计

auto_ptr是C++98引入的智能指针,其策略是:拷贝时转移资源管理权

cpp

std::auto_ptr<Date> ap1(new Date);
std::auto_ptr<Date> ap2(ap1);  // 拷贝构造:ap1的资源转移给ap2

// ap1已经悬空,内部指针被置为nullptr
// ap1->_year++;  // 危险!空指针访问

为什么这是糟糕的设计?

ap1是一个左值,程序员完全有理由在拷贝后继续使用它。但auto_ptr的拷贝语义是破坏性的——被拷贝对象被"偷走"了资源,变成了空指针。这与程序员对"拷贝"的直觉严重冲突。

注意:auto_ptr的转移语义与C++11的移动语义有本质区别。移动语义针对的是右值/将亡值(临时对象),转移其资源是合理的;但auto_ptr左值也执行破坏性拷贝,这是其致命缺陷。

结论:C++11起auto_ptr已被废弃,C++17起被彻底移除。任何新项目都不应使用。


3.3 unique_ptr(C++11):独占所有权

unique_ptr实现了严格的独占所有权语义:同一时刻,只有一个unique_ptr可以管理某块资源。他的特点是不支持拷贝,只支持移动,如果不需要拷贝的场景就非常建议使用它

cpp

std::unique_ptr<Date> up1(new Date);

// 不支持拷贝
// std::unique_ptr<Date> up2(up1);  // 编译错误!

// 支持移动:将所有权从一个unique_ptr转移给另一个
std::unique_ptr<Date> up3(std::move(up1));
// 移动后up1悬空,需谨慎使用

底层实现非常简单: 将拷贝构造函数和拷贝赋值运算符显式delete,同时提供移动构造函数和移动赋值运算符。

cpp

// 伪代码示意
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;

unique_ptr(unique_ptr&& other) noexcept;
unique_ptr& operator=(unique_ptr&& other) noexcept;

使用建议: 如果资源不需要被多个对象共享,unique_ptr是首选。它零开销(与原生指针大小相同),且语义清晰。


3.4 shared_ptr(C++11):共享所有权

shared_ptr通过引用计数(Reference Counting)实现共享所有权:多个shared_ptr可以共同管理同一块资源,当最后一个shared_ptr析构时,资源才被释放。

他的特点是是支持拷贝,也支持移动,如果需要拷贝的场景就需要使用它了

引用计数我在string部分也初步讲过了C++_string_c++ string-CSDN博客

cpp

std::shared_ptr<Date> sp1(new Date);
std::shared_ptr<Date> sp2(sp1);  // 拷贝:引用计数+1
std::shared_ptr<Date> sp3(sp2);  // 拷贝:引用计数+1

std::cout << sp1.use_count() << std::endl;  // 输出 3

sp1->_year = 2024;
std::cout << sp2->_year << std::endl;  // 输出 2024

// 支持移动,但移动后sp1悬空
std::shared_ptr<Date> sp4(std::move(sp1));

引用计数的工作机制:

  • 构造shared_ptr时,在堆上分配一个引用计数(初始值为1)

  • 拷贝构造/拷贝赋值时,引用计数+1

  • 析构/赋值给另一块资源时,引用计数-1

  • 引用计数减到0时,释放资源和引用计数本身(无weak_ptr下,有weak_ptr指向同一块空间时,只释放资源)

(引用计数的具体实现细节将在第4节展开。)


3.5 weak_ptr(C++11):弱引用(这里的弱是辅助的意思)

weak_ptr是一种特殊的智能指针:它不参与资源管理,不增加引用计数。它的设计目的是解决shared_ptr循环引用问题(详见第5节)。

cpp

std::shared_ptr<Date> sp(new Date);
std::weak_ptr<Date> wp(sp);  // 绑定到shared_ptr,不增加引用计数

// weak_ptr不支持直接访问资源
// wp->_year++;  // 错误!没有operator->

// 需要通过lock()获取shared_ptr
if (auto locked = wp.lock()) {
    locked->_year = 2024;  // 安全访问
}

weak_ptr的核心接口:

  • expired():检查绑定的资源是否已被释放

  • lock():返回一个管理该资源的shared_ptr;若资源已释放(指的weak_ptr),返回空shared_ptr

  • use_count():返回当前资源的引用计数


3.6 删除器(Deleter):定制资源释放方式

标准库智能指针默认使用delete释放资源。如果资源不是通过new分配的(如new[]fopenmalloc等),就必须提供自定义删除器

删除器本质就是⼀个可调用对象,这个可调用对象中实现你想要的释放资源的方式


3.6.1 为什么需要删除器?

cpp

// 错误:new[] 必须用 delete[] 释放
std::shared_ptr<Date> sp1(new Date[10]);  // 未定义行为,程序可能崩溃

new[]在分配的内存前会存储数组元素个数等元信息,delete[]会读取这些信息来逐个调用析构函数。如果用delete匹配new[],会从错误的内存位置开始释放,导致崩溃。


3.6.2 两种解决方案

方案一:特化的数组版本(推荐用于new[]

unique_ptrshared_ptr都提供了数组特化版本:

cpp

std::unique_ptr<Date[]> up1(new Date[5]);   // 析构时自动调用 delete[]
std::shared_ptr<Date[]> sp1(new Date[5]);   // 同上

方案二:自定义删除器(通用方案)

如下图,这种就是支持删除器的构造版本

如果我们给了删除器,底层走的时候就会用del去释放我们的ptr,即del(_ptr);

删除器可以是函数指针、仿函数、lambda表达式,或任何可调用对象。其签名必须是 void(T*)

cpp

// 1. 仿函数作为删除器
template <class T>
struct DeleteArray {
    void operator()(T* ptr) {
        delete[] ptr;
    }
};

// 2. 函数指针作为删除器
template <class T>
void deleteArrayFunc(T* ptr) {
    delete[] ptr;
}

// 3. lambda表达式作为删除器(最推荐,可读性最好)
auto delArr = [](Date* ptr) { delete[] ptr; };

shared_ptr如果想要定制删除器,三种方式都可以,建议lambda方式因为可读性好,然后unique_ptr如果要定制删除器,虽然三种方式都可以,但只建议用仿函数(原因在使用部分讲)

使用方式:

cpp

// unique_ptr:删除器通过模板参数指定
std::unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);

// shared_ptr:删除器通过构造函数参数指定
std::shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());

// lambda方式
std::unique_ptr<Date, decltype(delArr)> up3(new Date[5], delArr);//这边后面还要加delArr是因为lambda不支持默认构造,导致unique_ptr传lamdba过去后,需要构造删除器构造不出来,所以我们只能再把delArr传过去
std::shared_ptr<Date> sp3(new Date[5], delArr);

 // 函数指针做删除器  
std::unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);//这传俩个的原因也跟lambda类似
std::shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);


// 管理非内存资源:文件句柄
struct Fclose {
    void operator()(FILE* ptr) {
        std::cout << "fclose: " << ptr << std::endl;
        fclose(ptr);
    }
};

std::shared_ptr<FILE> sp4(fopen("test.cpp", "r"), Fclose());
std::shared_ptr<FILE> sp5(fopen("test.cpp", "r"), [](FILE* ptr) {
    std::cout << "fclose: " << ptr << std::endl;
    fclose(ptr);
});

注意: unique_ptrshared_ptr对删除器的支持方式不同:

  • unique_ptr:删除器类型是模板参数,存储在对象内部(通常通过空基类优化实现零开销)

  • shared_ptr:删除器类型是构造函数参数的类型擦除,通过类型擦除存储(有一定开销,但更灵活)

3.6.3 unique_ptr与shared_ptr的效率差异

unique_ptr未被shared_ptr取代,一个重要原因是:在不需要共享的场景下,unique_ptr效率更高

  • unique_ptr大小与原生指针相同(通常8字节),无额外开销

  • shared_ptr需要维护引用计数(堆分配),存在同步开销和内存碎片风险

因此,优先使用unique_ptr,仅在需要共享所有权时才使用shared_ptr


3.7 make_shared与make_unique:更优的构造方式

3.7.1 make_shared

shared_ptr支持通过make_shared直接构造资源,而非先new再传入指针:

cpp

// 方式一:先new,再构造shared_ptr(两次堆分配)
std::shared_ptr<Date> sp1(new Date(2024, 9, 11));

// 方式二:make_shared(一次堆分配,更优)
std::shared_ptr<Date> sp2 = std::make_shared<Date>(2024, 9, 11);
auto sp3 = std::make_shared<Date>(2024, 9, 11);

make_shared的优势:

  1. 性能更高make_shared将资源对象和引用计数分配到同一块连续内存中,只需一次堆分配;而new + shared_ptr需要两次(一次给对象,一次给引用计数)。

  2. 内存碎片更少:连续存储减少了内存碎片。

  3. 异常安全:在复杂表达式中,make_shared能避免new成功但shared_ptr构造前抛异常导致的泄漏。

cpp

// 异常安全问题示例
void process(std::shared_ptr<Date> sp, int val);

// 危险:如果foo()抛异常,new Date可能泄漏
process(std::shared_ptr<Date>(new Date), foo());

// 安全:make_shared保证原子性
process(std::make_shared<Date>(), foo());

3.7.2 make_unique(C++14)

C++14引入了make_unique,与make_shared类似:

cpp

// C++14起支持
auto up1 = std::make_unique<Date>(2024, 9, 11);

// 等价于
std::unique_ptr<Date> up2(new Date(2024, 9, 11));

注意: unique_ptr没有引用计数,所以make_unique的优势主要是代码简洁异常安全,而非内存布局优化。

一般不需要考量这些,除非对内存碎片和效率很敏感的时候


3.8 其他常用接口

3.8.1 operator bool

shared_ptrunique_ptr都支持隐式转换为bool:若管理了资源返回true,否则返回false

cpp

std::shared_ptr<Date> sp1 = std::make_shared<Date>();
std::shared_ptr<Date> sp2;

if (sp1) {
    std::cout << "sp1 is not nullptr" << std::endl;
}
if (!sp2) {
    std::cout << "sp2 is nullptr" << std::endl;
}

3.8.2 explicit构造函数

所有标准库智能指针的构造函数都是explicit的,禁止从原生指针隐式转换:

cpp

// 错误:explicit禁止隐式转换
std::shared_ptr<Date> sp = new Date(2024, 9, 11);
std::unique_ptr<Date> up = new Date(2024, 9, 11);

3.8.3 reset与swap

cpp

std::shared_ptr<Date> sp = std::make_shared<Date>(2024, 1, 1);

// reset:释放当前资源,接管新资源(或置空)
sp.reset();                          // 释放资源,sp变为空
sp.reset(new Date(2024, 2, 2));      // 释放旧资源,管理新资源

// swap:交换两个智能指针管理的资源
std::shared_ptr<Date> sp2 = std::make_shared<Date>(2024, 3, 3);
sp.swap(sp2);


4.智能指针的原理

本节我们手动模拟实现标准库智能指针的核心机制。理解底层实现,有助于更深刻地把握各种智能指针的设计权衡。

4.1 auto_ptr的模拟实现

auto_ptr的核心思路是拷贝时转移所有权:将被拷贝对象的指针置空,拷贝对象接管资源。

cpp

template <class T>
class Auto_ptr {
public:
    Auto_ptr()
        : _ptr(nullptr)
    {}

    explicit Auto_ptr(T* ptr)
        : _ptr(ptr)
    {}

    // 拷贝构造:转移所有权
    Auto_ptr(Auto_ptr& ap)  // 注意:非const引用!
        : _ptr(ap._ptr)
    {
        ap._ptr = nullptr;  // 被拷贝对象悬空
    }

    // 拷贝赋值:转移所有权
    Auto_ptr& operator=(Auto_ptr& ap)
    {
        if (this != &ap) {
            delete _ptr;           // 释放原有资源
            _ptr = ap._ptr;
            ap._ptr = nullptr;     // 被拷贝对象悬空
        }
        return *this;
    }

    ~Auto_ptr()
    {
        delete _ptr;
    }

    T& operator*() {
        return *_ptr;
    }

    T* operator->() {
        return _ptr;
    }

private:
    T* _ptr;
};

关键细节:

  • 拷贝构造函数参数是非const引用Auto_ptr&),因为需要修改被拷贝对象(置空其指针)

  • 赋值运算符需先delete自身原有资源,再接管新资源

再次强调:auto_ptr的设计已被否定,仅作历史了解,实际项目中禁止使用。


4.2 unique_ptr的模拟实现

unique_ptr的核心思路是禁止拷贝,允许移动

cpp

template <class T>
class Unique_ptr {
public:
    Unique_ptr()
        : _ptr(nullptr)
    {}

    explicit Unique_ptr(T* ptr)
        : _ptr(ptr)
    {}

    // 禁止拷贝
    Unique_ptr(const Unique_ptr&) = delete;
    Unique_ptr& operator=(const Unique_ptr&) = delete;

    // 允许移动
    Unique_ptr(Unique_ptr&& up) noexcept
        : _ptr(up._ptr)
    {
        up._ptr = nullptr;
    }

    Unique_ptr& operator=(Unique_ptr&& up) noexcept
    {
        if (this != &up) {
            delete _ptr;       // 释放原有资源
            _ptr = up._ptr;
            up._ptr = nullptr;
        }
        return *this;
    }

    ~Unique_ptr()
    {
        delete _ptr;
    }

    T& operator*() const {
        return *_ptr;
    }

    T* operator->() const {
        return _ptr;
    }

    T* get() const {
        return _ptr;
    }

private:
    T* _ptr;
};

关键细节:

  • noexcept修饰移动操作:移动不应抛异常,这是标准库的要求

  • 移动赋值需先释放自身资源,避免内存泄漏


4.3 shared_ptr的模拟实现:引用计数的设计

shared_ptr是最复杂的智能指针,其核心是引用计数机制

4.3.1 引用计数该放在哪里?

一份被共享的资源,需要对应一个引用计数。我们分析几种错误方案:

方案一:普通成员变量 int _count

每个shared_ptr对象独立存储计数。拷贝时把对方的计数复制过来再加一?不行——如果原始对象的计数被修改(如赋值操作),其他对象无法感知。

方案二:static int _count

静态成员属于类,而非对象。如果两个shared_ptr分别管理资源A和资源B,它们的引用计数会混在一起——这是类级别的计数,而非资源级别的计数

正确方案:堆上动态分配的引用计数

每个资源对应一个在堆上分配的计数器。所有管理该资源的shared_ptr都持有指向这个计数器的指针。


4.3.2 基础版实现

cpp

template <class T>
class Shared_ptr {
public:
    Shared_ptr()
        : _ptr(nullptr)
        , _pcount(new int(1))
    {}

    explicit Shared_ptr(T* ptr)
        : _ptr(ptr)
        , _pcount(new int(1))
    {}

    // 拷贝构造:共享资源,引用计数+1
    Shared_ptr(const Shared_ptr& sp)
        : _ptr(sp._ptr)
        , _pcount(sp._pcount)
    {
        ++(*_pcount);
    }

    // 拷贝赋值:释放原资源,共享新资源
    Shared_ptr& operator=(const Shared_ptr& sp)
    {
        if (_ptr != sp._ptr) {  // 避免自我赋值和同类资源赋值
            // 先释放原有资源
            if (--(*_pcount) == 0) {
                delete _ptr;
                delete _pcount;
            }
            // 接管新资源
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            ++(*_pcount);
        }
        return *this;
    }

    ~Shared_ptr()
    {
        if (--(*_pcount) == 0) {
            delete _ptr;
            delete _pcount;
        }
    }

    T& operator*() const {
        return *_ptr;
    }

    T* operator->() const {
        return _ptr;
    }

    T* get() const {
        return _ptr;
    }

    int use_count() const {
        return *_pcount;
    }

private:
    T* _ptr;
    int* _pcount;  // 指向堆上的引用计数
};

4.3.3 支持定制删除器的优化版

基础版只能使用delete释放资源。为了支持数组、文件句柄等,需要引入删除器

shared_ptr的删除器通过类型擦除实现:在类内部用一个std::function<void(T*)>成员保存任意类型的可调用对象。

cpp

#include <functional>

template <class T>
class Shared_ptr {
public:
    Shared_ptr()
        : _ptr(nullptr)
        , _pcount(new int(1))
    {}

    explicit Shared_ptr(T* ptr)
        : _ptr(ptr)
        , _pcount(new int(1))
    {}

    // 支持删除器的构造
    template <class D>
    Shared_ptr(T* ptr, D del)
        : _ptr(ptr)
        , _pcount(new int(1))
        , _del(del)
    {}

    Shared_ptr(const Shared_ptr& sp)
        : _ptr(sp._ptr)
        , _pcount(sp._pcount)
        , _del(sp._del)
    {
        ++(*_pcount);
    }

    Shared_ptr& operator=(const Shared_ptr& sp)
    {
        if (_ptr != sp._ptr) {
            release();
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            _del = sp._del;
            ++(*_pcount);
        }
        return *this;
    }

    ~Shared_ptr()
    {
        release();
    }

    T& operator*() const {
        return *_ptr;
    }

    T* operator->() const {
        return _ptr;
    }

    T* get() const {
        return _ptr;
    }

    int use_count() const {
        return *_pcount;
    }

private:
    void release()
    {
        if (--(*_pcount) == 0) {
            _del(_ptr);      // 使用删除器释放资源
            delete _pcount;
        }
    }

    T* _ptr;
    int* _pcount;
    std::function<void(T*)> _del = [](T* ptr) { delete ptr; };
};

关键设计:

  • 使用std::function<void(T*)>实现类型擦除,允许存储任意签名的删除器

  • 默认删除器是delete的lambda

  • 将释放逻辑抽取为release()私有函数,避免代码重复

注意:标准库的shared_ptr实现远比这复杂(考虑线程安全、多态删除、分配器定制等),此处仅为教学简化版。


4.4 weak_ptr的简化实现

weak_ptr不参与资源管理,只作为shared_ptr的观察者。简化版实现只需保存资源指针:

cpp

template <class T>
class Weak_ptr {
public:
    Weak_ptr()
        : _ptr(nullptr)
    {}

    Weak_ptr(const Shared_ptr<T>& sp)
        : _ptr(sp.get())
    {}

    Weak_ptr& operator=(const Shared_ptr<T>& sp)
    {
        _ptr = sp.get();
        return *this;
    }

    // 注意:此简化版未实现lock()和expired()
    // 实际实现需要访问shared_ptr的引用计数,判断资源是否存活

private:
    T* _ptr = nullptr;
};

完整的weak_ptr实现需要将引用计数封装为一个独立类型(包含强引用计数和弱引用计数),shared_ptrweak_ptr都指向该类型。当强引用计数归零时释放资源,但引用计数对象本身要等到弱引用计数也归零时才释放。这超出了入门教学的范围,有兴趣可以查阅标准库源码。


5.shared_ptr和weak_ptr:循环引用问题

shared_ptr在绝大多数场景下工作良好,但在特定数据结构(如双向链表、树形结构的父子节点互相引用)中,会出现循环引用(Circular Reference)导致的内存泄漏。

5.1 循环引用的产生

考虑一个双向链表节点的设计:

cpp

struct ListNode {
    int _data;
    ListNode* _next;
    ListNode* _prev;

    ~ListNode() {
        std::cout << "~ListNode()" << std::endl;
    }
};

如果两个节点由shared_ptr管理,且通过原生指针连接:

cpp

std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);

此时n1n2的引用计数均为1。程序结束时,两者正常析构,没有问题。

但如果我们希望_next_prev也参与智能管理,将它们改为shared_ptr

cpp

struct ListNode {
    int _data;
    std::shared_ptr<ListNode> _next;
    std::shared_ptr<ListNode> _prev;

    ~ListNode() {
        std::cout << "~ListNode()" << std::endl;
    }
};

然后建立双向连接:

cpp

std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);

n1->_next = n2;  // n2的引用计数变为2
n2->_prev = n1;  // n1的引用计数变为2

引用计数变化:

步骤n1引用计数n2引用计数说明
初始11shared_ptr构造
n1->_next = n212_next共享了n2的资源
n2->_prev = n122_prev共享了n1的资源

析构时的死锁:

main函数结束,局部变量n1n2被销毁:

  • n1shared_ptr析构:引用计数从2减到1

  • n2shared_ptr析构:引用计数从2减到1

此时两个节点的引用计数都还剩1,资源不会被释放

为什么?因为:

  1. 左边节点要释放,需要右边节点的_prev析构

  2. 右边节点的_prev要析构,需要右边节点本身释放

  3. 右边节点要释放,需要左边节点的_next析构

  4. 左边节点的_next要析构,需要左边节点本身释放

逻辑上形成了闭环,谁都无法释放——这就是循环引用导致的内存泄漏。(一般weak_ptr不出场,遇到这种情况了才会出场


5.2 weak_ptr:打破循环的钥匙

weak_ptr的设计哲学是:观察但不拥有。它绑定到shared_ptr时,不增加引用计数,因此不会参与资源释放的决策。

ListNode中的_next_prev改为weak_ptr

cpp

struct ListNode {
    int _data;
    std::weak_ptr<ListNode> _next;   // 弱引用
    std::weak_ptr<ListNode> _prev;   // 弱引用

    ~ListNode() {
        std::cout << "~ListNode()" << std::endl;
    }
};

重新执行:

cpp

std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);

n1->_next = n2;  // weak_ptr绑定,n2引用计数不变(仍为1)
n2->_prev = n1;  // weak_ptr绑定,n1引用计数不变(仍为1)

n1n2销毁时,引用计数直接从1减到0,资源被正确释放,循环引用被打破


5.3 weak_ptr的核心接口

weak_ptr 不支持RAII,也支持直接访问资源(没有operator*operator->),因为它管理的资源可能随时被释放。访问资源前,必须通过lock()进行安全检查。

cpp

std::shared_ptr<std::string> sp = std::make_shared<std::string>("hello");
std::weak_ptr<std::string> wp = sp;

// 检查资源是否已释放
if (!wp.expired()) {
    // lock()返回一个shared_ptr
    std::shared_ptr<std::string> locked = wp.lock();
    *locked += " world";  // 安全访问
    std::cout << *sp << std::endl;  // 输出 "hello world"
}

// 当所有shared_ptr都释放后
sp = std::make_shared<std::string>("new string");  // 原资源释放
std::cout << wp.expired() << std::endl;  // 输出 1(true,已过期)

// lock()返回空shared_ptr
auto empty_sp = wp.lock();
if (!empty_sp) {
    std::cout << "resource has been freed" << std::endl;
}

接口说明:

表格

接口作用
expired()检查绑定的资源是否已被释放。若已释放返回true(本质就是通过引用计数判断)
lock()返回一个管理该资源的shared_ptr。若资源已释放,返回空的shared_ptr
use_count()返回当前资源的强引用计数

为什么需要lock()而不是直接访问?

考虑以下场景:线程A持有weak_ptr,检查expired()返回false(资源未释放),但在A准备访问资源前,线程B释放了最后一个shared_ptr。如果weak_ptr直接提供访问接口,A就会访问到已释放的内存。

lock()的原子性保证了:一旦返回非空shared_ptr,该资源在整个shared_ptr的生命周期内必定有效


5.4 weak_ptr 的底层实现原理:控制块(Control Block)

要真正理解 weak_ptr,必须了解 shared_ptr 的底层内存布局。标准库的实现中,shared_ptrweak_ptr 并不直接只管理"资源对象",而是共同指向一个中间结构——控制块(Control Block)


控制块里有什么?

控制块是一个独立分配的对象(通常由 make_shared 与资源一起分配,或由 new 单独分配),内部至少包含:

成员含义
强引用计数(strong_ref)当前有多少个 shared_ptr 在管理资源
弱引用计数(weak_ref)当前有多少个 weak_ptr 在观察资源
资源指针指向实际管理的对象
删除器保存的自定义释放逻辑

内存布局示意:


为什么需要弱引用计数?

这是理解 weak_ptr 的核心。考虑以下场景:

  1. sp1shared_ptr)和 wp1weak_ptr)共同指向同一个控制块。

  2. sp1 析构时,强引用计数减到 0。此时:

    • 资源对象应该被释放(调用删除器)

    • 但控制块不能立即释放,因为 wp1 还需要通过控制块查询 expired() 的状态

  3. wp1 也析构时,弱引用计数减到 0。此时:

    • 强引用计数已经是 0,弱引用计数也变成 0

    • 控制块本身才能被释放


总结释放规则:

表格

操作强引用计数弱引用计数结果
shared_ptr 构造+1不变
shared_ptr 析构-1不变若强引用计数归零,释放资源对象
weak_ptr 构造不变+1
weak_ptr 析构不变-1若弱引用计数归零且强引用计数已为 0,释放控制块

这与我们简化实现的区别

在第4节的简化版 Shared_ptr 中,我们只实现了一个 int* _pcount(强引用计数),并且资源释放和计数器释放是同时发生的:

cpp

if (--(*_pcount) == 0) {
    delete _ptr;      // 释放资源
    delete _pcount;   // 释放计数器
}

这种简化实现不支持 weak_ptr。因为一旦 shared_ptr_pcountdelete 了,任何还指向这块内存的 weak_ptr 就会访问已释放的内存(use-after-free)。

因此,标准库必须把资源对象控制块的生命周期分离:

  • 强引用计数归零 → 释放资源对象

  • 弱引用计数归零(且强引用已为0)→ 释放控制块

简单概括:weak_ptr 不直接参与资源对象的管理,但参与控制块的管理。 它通过控制块得知资源是否存活(expired()),并通过原子操作安全地"升级"为 shared_ptrlock())。


5.5 使用建议

  • weak_ptr不直接管理资源,不能用于RAII场景

  • weak_ptr专门用于辅助shared_ptr,解决循环引用或需要"观察但不阻止释放"的场景

  • 访问weak_ptr指向的资源前,务必使用lock(),并检查返回值是否为空


6.shared_ptr的线程安全问题(了解)

6.1 引用计数的线程安全

shared_ptr的引用计数存储在堆上,由所有共享该资源的shared_ptr对象共同访问。当多个线程同时拷贝或析构shared_ptr时,会并发地修改引用计数,这就带来了线程安全问题

问题演示:

cpp

struct AA {
    int _a1 = 0;
    int _a2 = 0;
    ~AA() {
        std::cout << "~AA()" << std::endl;
    }
};

int main()
{
    std::shared_ptr<AA> p(new AA);
    const size_t n = 100000;

    auto func = [&]() {
        for (size_t i = 0; i < n; ++i) {
            std::shared_ptr<AA> copy(p);  // 拷贝:++引用计数
            copy->_a1++;
            copy->_a2++;
        }
    };

    std::thread t1(func);
    std::thread t2(func);
    t1.join();
    t2.join();

    std::cout << "a1 = " << p->_a1 << std::endl;
    std::cout << "a2 = " << p->_a2 << std::endl;
    std::cout << "use_count = " << p.use_count() << std::endl;
    return 0;
}

如果不加保护,可能出现以下问题:

  • 引用计数操作非原子化,导致计数丢失(如两个线程同时读-改-写,结果只增加了1而非2)

  • 最终引用计数不为0,资源无法释放,造成内存泄漏

  • 极端情况下可能重复释放,导致程序崩溃


6.2 解决方案

标准库实现: 实际的std::shared_ptr在标准库中已经通过原子操作(atomic)互斥锁保证了引用计数的线程安全。用户无需额外处理。

自定义实现的改进: 如果在第4节的简化版Shared_ptr中,将int* _pcount改为std::atomic<int>* _pcount,即可保证引用计数的原子性:

cpp

// 简化示意
std::atomic<int>* _pcount;

// 构造
_pcount = new std::atomic<int>(1);

// 拷贝构造
++(*_pcount);  // atomic的operator++是原子的

// 析构
if (--(*_pcount) == 0) { ... }

6.3 重要区分:引用计数安全 ≠ 对象安全

shared_ptr保证的是引用计数操作的线程安全,但不保证所管理对象的线程安全

cpp

std::shared_ptr<int> sp = std::make_shared<int>(0);

// 线程1和线程2同时执行:
// *sp += 1;  // 危险!对同一对象的写操作需要外部同步

如果多个线程并发读写shared_ptr管理的同一对象,仍需使用互斥锁(std::mutex)、原子变量或其他同步机制来保护。

总结:

  • shared_ptr控制块(引用计数等)是线程安全的

  • shared_ptr管理的资源对象的线程安全,由使用者负责

7.C++11和boost中智能指针的关系

Boost是为C++标准库提供扩展的程序库集合,其社区与C++标准委员会有深厚联系。C++11及之后的许多特性都源自Boost。

智能指针的演进历程:

时间事件
C++98引入第一个智能指针 auto_ptr(设计有缺陷)
Boost提供更实用的 scoped_ptr / scoped_array / shared_ptr / shared_array / weak_ptr
C++11正式引入 unique_ptr(对应Boost的scoped_ptr)、shared_ptrweak_ptr;废弃auto_ptr
C++14引入 make_unique

关键对应关系:

  • std::unique_ptrboost::scoped_ptr(独占所有权)

  • std::shared_ptrboost::shared_ptr(共享所有权)

  • std::weak_ptrboost::weak_ptr(弱引用)

C++11标准库智能指针的实现原理直接参考了Boost的设计。理解这一点有助于阅读历史代码或跨平台库。


8.内存泄漏

8.1 什么是内存泄漏?有什么危害?

内存泄漏(Memory Leak) 指程序在运行过程中动态分配了内存,但在使用完毕后未能释放,导致系统可用内存逐渐减少的现象。

内存泄漏不是指内存在物理上消失,而是指程序失去了对某段内存的控制能力,无法再次使用,造成了内存浪费。

危害:

  • 短期程序:运行片刻即退出的程序,内存泄漏影响较小。进程结束后,操作系统会回收其全部内存。

  • 长期运行程序:如服务器后台服务、操作系统内核、长时间运行的客户端等,内存泄漏会导致可用内存持续减少,系统响应变慢,最终可能因内存耗尽而卡死或崩溃。


8.2 内存泄漏的检测工具

平台工具说明
LinuxValgrind、AddressSanitizer (ASan)功能强大,推荐ASan(编译器内置,开销较小)
WindowsVisual Leak Detector (VLD)第三方工具,集成到Visual Studio

使用建议: 在项目上线前进行内存泄漏检测,作为质量保障的一环。但工具检测并非万能,部分场景可能漏报或误报。

Linux下几款C++程序中的内存泄露检查工具_c++内存泄露工具分析-CSDN博客

windows下的内存泄露检测工具VLD使用_windows内存泄漏检测工具-CSDN博客


8.3 如何避免内存泄漏

  1. 事前预防:使用智能指针

    • 优先使用unique_ptr,需要共享时使用shared_ptr

    • 遵循RAII原则,将资源管理交给对象

  2. 良好的编码规范

    • newdelete/new[]delete[]严格配对

    • 减少裸指针的使用,避免资源所有权模糊

  3. 事后检测

    • 定期使用内存泄漏工具检测

    • 代码审查时重点关注资源管理逻辑

总结: 内存泄漏的解决方案分为两类——事前预防型(智能指针、RAII)和事后查错型(检测工具)。前者是根治之道。


结语:

智能指针的本质从来不是"自动释放内存"这么简单,而是将资源的所有权语义化、显式化、安全化。从 auto_ptr 的失败实验中,我们学到了"破坏性拷贝"违背直觉;从 unique_ptr 的独占语义中,我们理解了零开销抽象的魅力;从 shared_ptr 的引用计数中,我们看到了共享所有权的代价与权衡;从 weak_ptr 的控制块中,我们掌握了打破循环引用的钥匙。

记住一条实践准则:优先使用 unique_ptr,必要时使用 shared_ptr,遇到循环引用时拿出 weak_ptr,永远不要让裸指针承担资源所有权。 配合 make_uniquemake_shared,你可以在不牺牲性能的前提下,写出异常安全、内存安全的现代 C++ 代码。

RAII 是 C++ 的基石,而智能指针是 RAII 在内存管理领域最优雅的实践。掌握它们,你才真正迈入了现代 C++ 的大门。

文章到这里结束,感谢阅读。若觉得写的还可以,可以分享给朋友一起来看,一起进步更有动力;当然,能关注一下就更好啦,我们下篇见。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值