C++ 进阶终章:异常机制与智能指针全解 —— 从错误处理到 RAII 资源管理,打通现代 C++ 的核心命脉


观众老爷们大家好 我是邪修KING 本文属于系列C++ 进阶篇 ,欢迎来到C++进阶篇博客 C++重点语法运用! 本文是 C++ 进阶系统教程系列终章。前面我们已经系统学习了 STL 容器底层实现、哈希表原理、C++11 核心新特性(移动语义、Lambda、可变参数模板、包装器等),今天我们拿下现代 C++ 最后两块核心拼图:异常处理机制与智能指针。

异常是 C++ 官方推荐的错误处理方案,彻底替代了 C 语言繁琐易错的错误码模式;智能指针则是 RAII 思想的集大成者,从机制上解决了内存泄漏难题,是每一个 C++ 开发者必须掌握的核心技能,更是校招与社招面试的「必考题」。

本文将从「痛点引入 → 语法原理 → 代码实践 → 常见坑点 → 面试考点」五个维度,把异常与智能指针讲透,作为 C++ 进阶阶段的收官之作。\

第一部分:C++ 异常机制 ------ 更优雅的错误处理方式

1.1问题: 为什么需要异常?------ C 语言错误码的痛点

在 C 语言中,我们处理错误的主流方式是错误码:函数通过返回值标识错误,比如返回-1、返回NULL、设置全局errno变量。调用者拿到返回值后,需要查表才能知道具体是什么错误。

这种方式有三个非常明显的痛点:

信息表达能力弱 :一个整数错误码能承载的信息有限,想附带错误描述、错误位置等信息非常麻烦。

调用链传递繁琐 :如果函数调用层级很深,底层发生的错误需要层层向上传递,每一层都要判断返回值、透传错误码,代码冗余且容易遗漏。

错误处理与正常逻辑混杂 :返回值既要承载正常结果,又要承载错误信息,边界模糊,可读性差。

C++ 引入的异常机制,从设计上分离了「错误检测」和「错误处理」:

底层检测到错误时,直接throw抛出一个携带完整信息的异常对象

异常沿着函数调用链向上传播,直到找到匹配的catch处理模块

中间层函数不需要关心错误处理,专注业务逻辑即可

💡 类比理解:就像公司里基层员工遇到解决不了的问题,不用自己硬扛,也不用挨个部门去问,直接把问题上报(throw),系统会沿着管理层级(调用链)自动找到能负责的领导(catch)来处理。

1.2 异常基本语法:throw /try/catch

异常由三个关键字构成完整闭环:

throw:抛出异常,后面跟一个异常对象(可以是任意类型,int、字符串、自定义类都可以)

try:包裹可能抛出异常的代码块

catch:捕获并处理指定类型的异常

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 错误检测:检测到除零错误,抛出异常
double Divide(int a, int b) {
    if (b == 0) {
        // 抛出一个字符串异常对象,携带错误信息
        throw "Divide by zero condition!";
    }
    return (double)a / (double)b;
}

int main() {
    int len, time;
    cin >> len >> time;

    try {
        // 可能抛出异常的代码,放在try块中
        cout << Divide(len, time) << endl;
    }
    catch (const char* errmsg) {
        // 捕获到对应类型的异常,进行处理
        cout << "捕获到异常:" << errmsg << endl;
    }

    cout << "程序继续执行..." << endl;
    return 0;
}

两个核心注意点:

throw执行后,其后的代码将不再执行,程序控制权直接跳转到匹配的catch块。

抛出的异常对象会生成一份拷贝(类似传值返回),因为抛出的可能是局部对象,拷贝后的异常对象会在 catch 处理完后销毁。

1.3 核心机制:栈展开(Stack Unwinding)

栈展开是异常机制的底层核心,也是面试高频考点。

抛出异常后,程序会按照以下规则查找匹配的 catch:

首先检查throw是否在当前函数的try块内部,如果是,查找同层匹配的catch,找到就跳转处理。

如果当前函数没有匹配的 catch,当前函数栈帧销毁,所有局部对象自动析构,然后回到上层调用函数,重复第一步的查找。

这个逐层退出栈帧、查找 catch 的过程,就叫做栈展开。

如果一直找到main函数都没有匹配的 catch,程序会调用标准库的terminate()函数,直接终止运行。

📌 【面试高频考点】请简述异常的栈展开过程。

答:异常抛出后,从抛出位置开始,沿着函数调用链自底向上查找匹配的 catch 子句;每退出一层函数栈帧,都会先销毁该层的所有局部对象(保证析构函数执行);如果到 main 函数仍未找到匹配的 catch,则调用 terminate 终止程序。

1.4 异常类型的匹配规则

异常对象类型和 catch 参数类型并非必须完全一致,允许以下几种隐式转换

非常量 → 常量:抛出非 const 对象,可以被 const 引用的 catch 捕获(权限缩小,合法)。

数组 → 指向数组元素的指针、函数 → 函数指针。

派生类 → 基类:这是工程实践中最重要的匹配规则,也是标准异常体系的设计基础。

💡 最佳实践:通常我们会设计一个异常基类,各个模块的异常都继承它,捕获时只需要捕获基类引用,就能处理所有派生类异常,利用多态统一处理。

最后,catch(...) 可以捕获任意类型的异常,作为兜底防止程序崩溃,但缺点是无法知道异常的具体类型和信息。

cpp 复制代码
int main() {
    try {
        // 业务代码
    }
    catch (const Exception& e) { // 捕获自定义异常基类
        cout << e.what() << endl;
    }
    catch (...) { // 兜底:捕获所有未知异常
        cout << "未知异常" << endl;
    }
    return 0;
}

1.5 异常的重新抛出

有些场景下,当前层 catch 只能做部分处理(比如释放本层资源),不能完全解决问题,需要把异常继续向上层传递,这就需要异常重新抛出。

语法非常简单:在 catch 块中直接写throw;(不带任何参数),就会把当前捕获的异常原封不动地重新抛出。

cpp 复制代码
// 模拟消息发送:网络错误可以重试,其他错误直接上报
void _SendMsg(const string& s) {
    if (rand() % 2 == 0) {
        throw HttpException("网络不稳定,发送失败", 102, "put");
    } else if (rand() % 7 == 0) {
        throw HttpException("非好友关系,发送失败", 103, "put");
    }
    cout << "发送成功" << endl;
}

void SendMsg(const string& s) {
    for (size_t i = 0; i < 4; i++) {
        try {
            _SendMsg(s);
            break; // 发送成功,跳出循环
        }
        catch (const Exception& e) {
            if (e.getid() == 102) { // 102是网络错误,可以重试
                if (i == 3) { // 重试3次都失败,再抛出
                    throw; // 重新抛出异常
                }
                cout << "第" << i + 1 << "次重试" << endl;
            } else {
                throw; // 不是网络错误,直接重新抛出
            }
        }
    }
}

1.6 异常安全问题(重点 + 难点)

什么是异常安全?

异常抛出后,程序能保证:

不发生资源泄漏(内存、锁、文件句柄等)

对象状态保持一致,不出现半损坏的状态

这就是异常安全。最典型的异常安全问题就是异常导致的内存泄漏:

cpp 复制代码
void Func() {
    int* array = new int[10]; // 申请资源

    int len, time;
    cin >> len >> time;
    // 如果Divide抛出异常,后面的delete永远不会执行 → 内存泄漏
    cout << Divide(len, time) << endl;

    delete[] array; // 释放资源
}

更好的解法:RAII

这正是智能指针登场的舞台。用 RAII 思想封装资源,对象出作用域自动析构释放资源,不管是正常返回还是异常退出,都能保证资源一定释放,从机制上杜绝泄漏。

⚠️ 另一个重要注意点:析构函数尽量不要抛出异常。

如果析构函数抛出异常,在栈展开过程中,可能会同时有两个异常在传播,导致程序直接终止。《Effective C++》条款 08 明确指出:「别让异常逃离析构函数」。如果析构中可能出错,应该内部捕获处理,不能向外抛出。

📌 【面试高频考点】什么是异常安全?如何保证异常安全?

答:异常安全指程序抛出异常后,不会发生资源泄漏,对象状态保持有效。保证异常安全的核心手段是 RAII,用对象生命周期管理资源,利用析构函数自动释放,避免异常导致资源泄漏。

1.7 异常规范

为了让调用者知道一个函数会不会抛异常、会抛什么类型的异常,C++ 提供了异常声明规范。

C++98 的异常规范(已废弃)

cpp 复制代码
// 表示只会抛出bad_alloc一种异常
void* operator new (size_t size) throw (bad_alloc);
// 表示不会抛出任何异常
void* operator delete (void* ptr) throw();

C++11 的 noexcept

C++11 简化为noexcept关键字,声明一个函数不会抛出异常。

cpp 复制代码
// 声明size()函数不会抛出异常
size_t size() const noexcept;

⚠️ 重要细节:

编译器不会强制编译期检查 noexcept 函数是否真的不抛异常(最多给警告)。但如果 noexcept 函数真的抛出了异常,程序会直接调用terminate终止。

noexcept可以作为运算符,编译期判断一个表达式是否会抛出异常,返回 bool 值。

📌 【面试高频考点】移动构造函数为什么建议加 noexcept?

答:STL 容器(如 vector)扩容时,如果元素的移动构造是 noexcept 的,就会调用移动构造转移资源,效率更高;如果移动构造没有 noexcept,为了保证异常安全,容器会保守地调用拷贝构造。因此自定义类的移动构造建议加上 noexcept,配合 STL 发挥最佳性能。

1.8 标准异常体系 & 自定义异常最佳实践

C++ 标准库提供了一套完整的异常继承体系,基类是std::exception,其中定义了虚函数what(),返回异常描述字符串。

常用派生类:

bad_alloc:new 申请内存失败时抛出

bad_cast:dynamic_cast 转换失败时抛出

out_of_range:下标越界时抛出(如 vector::at)

runtime_error:运行时错误基类

logic_error:逻辑错误基类

工程级自定义异常设计

真实项目中,我们通常继承exception,按模块设计派生异常类,每个模块可以携带自己的专属错误信息。

cpp 复制代码
#include <iostream>
#include <string>
#include <exception>
using namespace std;

// 异常基类
class Exception : public exception {
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id)
    {}

    virtual string what() const {
        return _errmsg;
    }

    int getid() const {
        return _id;
    }
protected:
    string _errmsg;
    int _id;
};

// SQL模块异常
class SqlException : public Exception {
public:
    SqlException(const string& errmsg, int id, const string& sql)
        : Exception(errmsg, id), _sql(sql)
    {}

    virtual string what() const override {
        string str = "SqlException:";
        str += _errmsg;
        str += " -> SQL: ";
        str += _sql;
        return str;
    }
private:
    const string _sql;
};

// 缓存模块异常
class CacheException : public Exception {
public:
    CacheException(const string& errmsg, int id)
        : Exception(errmsg, id)
    {}

    virtual string what() const override {
        return "CacheException:" + _errmsg;
    }
};

// HTTP模块异常
class HttpException : public Exception {
public:
    HttpException(const string& errmsg, int id, const string& type)
        : Exception(errmsg, id), _type(type)
    {}

    virtual string what() const override {
        return "HttpException[" + _type + "]:" + _errmsg;
    }
private:
    const string _type;
};

// 模拟多模块调用
void SQLMgr() {
    if (rand() % 7 == 0) {
        throw SqlException("权限不足", 100, "select * from user where name='张三'");
    }
    cout << "SQL模块调用成功" << endl;
}

void CacheMgr() {
    if (rand() % 5 == 0) {
        throw CacheException("数据不存在", 101);
    }
    cout << "缓存模块调用成功" << endl;
    SQLMgr();
}

void HttpServer() {
    if (rand() % 3 == 0) {
        throw HttpException("请求资源不存在", 102, "GET");
    }
    cout << "HTTP服务调用成功" << endl;
    CacheMgr();
}

int main() {
    srand((unsigned)time(0));
    while (true) {
        this_thread::sleep_for(chrono::seconds(1));
        try {
            HttpServer();
        }
        catch (const Exception& e) { // 捕获基类,多态处理所有异常
            cout << e.what() << endl;
        }
        catch (...) { // 兜底未知异常
            cout << "未知异常" << endl;
        }
    }
    return 0;
}

这种设计的优势非常明显:新增模块只需要新增异常派生类,上层捕获逻辑完全不用改,完美符合开闭原则。

第二部分:智能指针 ------ RAII 思想的巅峰实现,彻底告别内存泄漏

承接上一部分的异常安全问题:手动管理堆内存,在异常场景下极易泄漏,代码还写得臃肿。有没有一种机制,能让堆内存也像栈对象一样,出作用域自动释放?

有,这就是RAII思想,而智能指针就是 RAII 在堆内存管理上的经典实现。

2.1 先搞懂灵魂思想:RAII

RAII 是 Resource Acquisition Is Initialization 的缩写,翻译为「资源获取即初始化」。

核心思想非常朴素:

用对象的生命周期来绑定资源的生命周期。

对象构造时,完成资源的申请与初始化

对象析构时,自动完成资源的清理与释放

只要对象能正常析构,资源就一定能释放,不管是正常返回还是异常退出

💡 类比理解:

住酒店时,你办理入住(构造)拿到房间使用权,到时间退房(析构)酒店自动收回房间。你不用时时刻刻记着「我要退房」,到了时间酒店自动处理。哪怕你中途有事提前走,只要办理了退房(对象析构),房间也会被收回。

RAII 不是什么复杂语法,就是一种利用构造析构管理资源的设计思想。我们之前接触过很多例子:

lock_guard 管理互斥锁:构造加锁,析构解锁

ifstream 管理文件:构造打开文件,析构关闭文件

智能指针管理堆内存:构造接管内存,析构释放内存

智能指针在 RAII 的基础上,还重载了*、->、\[\]等运算符,让它可以像原生指针一样访问资源,兼顾安全与易用。

2.2 C++ 智能指针总览

所有智能指针都在头文件中,C++ 标准一共推出过四种:

智能指针 出现版本 核心特点 拷贝策略 推荐程度
auto_ptr C++98 管理权转移 拷贝时转移所有权,原对象悬空 完全不推荐
unique_ptr C++11 独占所有权 禁止拷贝,只支持移动 大部分场景用它
shared_ptr C++11 共享所有权 引用计数,支持拷贝 需要共享资源时用
weak_ptr C++11 弱引用,不管理资源 辅助 shared_ptr,不增加计数 解决循环引用
📌 【面试高频考点】C++ 有哪几种智能指针?各自有什么特点?

2.3 被淘汰的前辈:auto_ptr(了解踩坑点)

auto_ptr是 C++98 最早的智能指针,设计思路非常简单粗暴:拷贝时把资源管理权转移给新对象,原对象置空。

cpp 复制代码
#include <memory>
int main() {
    auto_ptr<int> ap1(new int(10));
    auto_ptr<int> ap2(ap1); // 拷贝,管理权转移

    *ap2 = 20; // 正常
    // *ap1 = 30; // 崩溃!ap1已经悬空,变成空指针了
    return 0;
}

这是一个非常反直觉的失败设计:拷贝操作居然会修改原对象,非常容易写出悬空访问的 bug。因此 C++11 推出后,auto_ptr被彻底废弃,项目中绝对不要使用。

📌 【面试考点】为什么 auto_ptr 不推荐使用?

答:auto_ptr 采用管理权转移的拷贝策略,拷贝后原对象会变成空指针,非常容易造成悬空访问,语义反直觉,存在严重安全隐患。

2.4 简单高效的唯一指针:unique_ptr

设计思想

unique_ptr 秉承「独占所有权」的理念:一份资源只能由一个 unique_ptr 管理,禁止拷贝,只支持移动转移。

它的实现非常简单:直接把拷贝构造和拷贝赋值用=delete禁用,只提供移动构造和移动赋值。没有引用计数的开销,性能和原生指针几乎一致。

💡 类比理解:独家代理权,一个区域只能有一个代理商;代理权可以转让,但不能同时有两家。

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

int main() {
    // 1. 构造:用new出来的指针初始化
    unique_ptr<int> up1(new int(10));

    // unique_ptr<int> up2 = new int(20); // 错误:构造函数是explicit,禁止隐式转换

    // 2. 像指针一样使用
    *up1 = 20;
    cout << *up1 << endl;

    // 3. 禁止拷贝
    // unique_ptr<int> up2(up1); // 编译报错:拷贝构造被删除

    // 4. 支持移动:转移所有权
    unique_ptr<int> up3(move(up1));
    // 移动后up1悬空,不要再访问

    // 5. 判空:operator bool
    if (up1) {
        cout << "up1不为空" << endl;
    } else {
        cout << "up1已悬空" << endl;
    }

    // 6. 数组版本:自动调用delete[]释放
    unique_ptr<int[]> upArr(new int[10]);
    upArr[0] = 1;
    upArr[1] = 2;

    return 0;
} // up3、upArr出作用域,自动调用析构释放内存

定制删除器

默认情况下,智能指针析构时用delete释放资源。如果管理的是new\[\]的数组、文件指针、socket、互斥锁等其他资源,就需要定制删除器。

unique_ptr的删除器是模板参数,需要在模板类型中指定删除器类型:

cpp 复制代码
// 仿函数删除器:释放数组
template<class T>
struct DeleteArray {
    void operator()(T* ptr) {
        delete[] ptr;
    }
};

// 仿函数删除器:关闭文件
struct Fclose {
    void operator()(FILE* ptr) {
        cout << "关闭文件" << endl;
        fclose(ptr);
    }
};

int main() {
    // 1. 仿函数作为删除器
    unique_ptr<int, DeleteArray<int>> up2(new int[10]);

    // 2. Lambda作为删除器
    auto del = [](FILE* ptr) { fclose(ptr); };
    unique_ptr<FILE, decltype(del)> upFile(fopen("test.txt", "w"), del);

    // 3. 管理文件指针,出作用域自动关闭
    unique_ptr<FILE, Fclose> up3(fopen("test.txt", "r"));

    return 0;
}

📌 【面试考点】unique_ptr 如何实现禁止拷贝?

答:使用 C++11 的=delete语法,显式删除拷贝构造函数和拷贝赋值运算符,只提供移动构造和移动赋值。

2.5 支持共享的引用计数指针:shared_ptr(重中之重)

设计思想

很多场景下,一份资源需要被多个指针同时管理,unique_ptr 的独占语义就不够用了。shared_ptr采用引用计数机制,支持多个 shared_ptr 共享同一份资源。

原理:

每新增一个指向资源的 shared_ptr,引用计数 +1

每销毁一个指向资源的 shared_ptr,引用计数 -1

当引用计数减到 0 时,说明是最后一个管理者,释放资源

💡 类比理解:合租公寓,入住时登记人数 + 1,退房时登记人数 - 1,最后一个走的人负责关灯锁门、交还钥匙。

⚠️ 非常重要的细节:引用计数必须在堆上动态开辟,不能是类的静态成员。因为每一份资源对应一个独立的计数,静态成员是所有对象共享的,无法区分不同资源。

基本用法

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct Date {
    int _year;
    int _month;
    int _day;
    Date(int y = 1, int m = 1, int d = 1) : _year(y), _month(m), _day(d) {}
    ~Date() { cout << "~Date()" << endl; }
};

int main() {
    // 1. 普通构造
    shared_ptr<Date> sp1(new Date(2024, 1, 1));
    cout << "sp1计数:" << sp1.use_count() << endl; // 1

    // 2. 拷贝构造:计数+1
    shared_ptr<Date> sp2(sp1);
    shared_ptr<Date> sp3 = sp2;
    cout << "sp1计数:" << sp1.use_count() << endl; // 3

    // 3. 像指针一样访问
    sp1->_year = 2025;
    cout << sp2->_year << endl; // 2025,指向同一份资源

    // 4. 赋值:旧资源计数-1,新资源计数+1
    shared_ptr<Date> sp4(new Date);
    sp4 = sp1; // sp4原来的资源计数减到0,自动释放
    cout << "sp1计数:" << sp1.use_count() << endl; // 4

    // 5. 推荐:make_shared构造,更高效
    // 一次申请资源+计数的内存,减少内存碎片,异常安全更好
    auto sp5 = make_shared<Date>(2025, 10, 1);

    // 6. 移动:转移所有权,计数不变
    shared_ptr<Date> sp6(move(sp5));
    // 移动后sp5悬空

    // 7. get()获取原生指针(不推荐常用,容易破坏封装)
    Date* raw = sp1.get();

    return 0;
}

make_shared的优势:

一次内存分配同时申请资源和控制块(引用计数等),减少内存分配次数和碎片。

异常安全性更好,避免构造时抛异常导致的泄漏。

shared_ptr 模拟实现(完整带注释)

手写 shared_ptr 是面试手撕代码的高频题,核心要实现:引用计数管理、拷贝赋值、析构释放、运算符重载。

cpp 复制代码
#include <iostream>
#include <functional>
using namespace std;

namespace bit {

template<class T>
class shared_ptr {
public:
    // 普通构造:接管原生指针,初始化引用计数为1
    explicit shared_ptr(T* ptr = nullptr)
        : _ptr(ptr)
        , _pcount(new int(1))
        , _del([](T* ptr) { delete ptr; }) // 默认删除器:delete
    {}

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

    // 拷贝构造:共享资源,计数+1
    shared_ptr(const shared_ptr<T>& sp)
        : _ptr(sp._ptr)
        , _pcount(sp._pcount)
        , _del(sp._del)
    {
        ++(*_pcount);
    }

    // 赋值重载:先释放旧资源,再共享新资源
    shared_ptr<T>& operator=(const shared_ptr<T>& sp) {
        if (_ptr != sp._ptr) { // 防止自己给自己赋值
            release(); // 释放当前管理的资源

            // 共享新资源
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            _del = sp._del;
            ++(*_pcount);
        }
        return *this;
    }

    // 析构:计数-1,减到0则释放资源和计数
    ~shared_ptr() {
        release();
    }

    // 像指针一样使用
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }

    // 获取引用计数
    int use_count() const { return *_pcount; }
    // 获取原生指针
    T* get() const { return _ptr; }
    // 判空
    operator bool() const { return _ptr != nullptr; }

private:
    // 辅助函数:释放当前资源
    void release() {
        if (--(*_pcount) == 0) {
            _del(_ptr);      // 调用删除器释放资源
            delete _pcount;  // 释放引用计数
            _ptr = nullptr;
            _pcount = nullptr;
        }
    }

private:
    T* _ptr;                // 指向资源的原生指针
    int* _pcount;           // 指向堆上引用计数的指针
    function<void(T*)> _del;// 删除器,用function包装,支持任意可调用对象
};

} // namespace bit

注意:shared_ptr的删除器是构造函数参数,不是模板参数。这是和 unique_ptr 非常重要的区别,面试经常考。

原因:shared_ptr 的删除器被封装在控制块里,做了类型擦除,同一个shared_ptr类型可以搭配不同删除器,不影响外部类型,使用更灵活;代价是有一点点运行时开销。

📌 【面试高频考点】

shared_ptr 的实现原理?

答:基于引用计数实现共享所有权。每个 shared_ptr 对象包含资源指针和指向引用计数的指针;拷贝时计数 + 1,析构时计数 - 1;计数减到 0 时释放资源和计数空间。

shared_ptr 和 unique_ptr 删除器的区别?

答:unique_ptr 的删除器是模板参数,属于类型的一部分,零开销;shared_ptr 的删除器是构造函数参数,封装在控制块中,类型擦除,使用更灵活但有少量开销。

2.6 shared_ptr 经典问题一:循环引用与 weak_ptr

什么是循环引用?

当两个对象内部都用shared_ptr互相指向对方时,就会形成循环引用,导致引用计数永远无法减到 0,资源永远不会释放,造成内存泄漏。

最典型的场景就是双向链表节点:

cpp 复制代码
struct ListNode {
    int _data;
    shared_ptr<ListNode> _next;
    shared_ptr<ListNode> _prev;

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

int main() {
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    cout << "n1计数:" << n1.use_count() << endl; // 1
    cout << "n2计数:" << n2.use_count() << endl; // 1

    n1->_next = n2; // n2的计数变成2
    n2->_prev = n1; // n1的计数变成2
    cout << "赋值后n1计数:" << n1.use_count() << endl; // 2
    cout << "赋值后n2计数:" << n2.use_count() << endl; // 2

    return 0;
    // 程序结束,析构都没调用 → 内存泄漏!
}

为什么会泄漏?

我们拆解一下逻辑死锁:

n1要释放,需要引用计数减到 0 → 需要n2->_prev先释放

n2->_prev要释放,需要n2节点先释放

n2要释放,需要引用计数减到 0 → 需要n1->_next先释放

n1->_next要释放,需要n1节点先释放

绕了一圈又回来了,谁都等不到对方先释放,最终两个节点的引用计数永远是 1,永远不会析构。

💡 类比理解:两个人吵架,都说「你先道歉我就道歉」,结果谁都不肯先开口,一直僵着。

解决方案:weak_ptr 弱指针

weak_ptr 就是专门用来打破循环引用的。它的定位是「观察者」:

可以绑定到shared_ptr上,但不增加引用计数 ,不参与资源生命周期管理

不支持 RAII,不能直接管理资源,也没有重载*和->

可以检查资源是否已经释放,需要访问时通过lock()获取一个shared_ptr

把节点内部的指针换成weak_ptr,问题就解决了:

cpp 复制代码
struct ListNode {
    int _data;
    weak_ptr<ListNode> _next; // 改成weak_ptr
    weak_ptr<ListNode> _prev; // 改成weak_ptr

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

int main() {
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);

    n1->_next = n2; // weak_ptr赋值,不增加n2的计数
    n2->_prev = n1; // weak_ptr赋值,不增加n1的计数

    // n1和n2的计数始终是1,出作用域正常析构,没有泄漏
    return 0;
}

weak_ptr常用接口

cpp 复制代码
int main() {
    shared_ptr<string> sp1 = make_shared<string>("hello");
    weak_ptr<string> wp = sp1; // 用shared_ptr初始化weak_ptr

    cout << wp.use_count() << endl; // 1,获取引用计数
    cout << wp.expired() << endl;   // false,检查资源是否过期(已释放)

    // 安全访问资源:lock()返回shared_ptr,如果已过期则返回空
    shared_ptr<string> sp2 = wp.lock();
    if (sp2) {
        cout << *sp2 << endl;
    }

    // 释放sp1,资源销毁
    sp1.reset();
    cout << wp.expired() << endl; // true,资源已过期

    return 0;
}

📌 【面试高频考点】

weak_ptr 的作用是什么?如何解决循环引用?

答:weak_ptr 是弱引用指针,用于辅助 shared_ptr,绑定 shared_ptr 时不增加引用计数,不参与资源管理。将循环引用中的一方或双方换成 weak_ptr,就不会导致计数死锁,从而打破循环引用,避免内存泄漏。

weak_ptr 可以直接访问资源吗?

答:不能。weak_ptr 没有重载 * 和 ->,需要调用 lock () 方法获取一个 shared_ptr 后才能访问,保证访问时资源有效。

2.7 shared_ptr 经典问题二:线程安全

关于 shared_ptr 的线程安全,一定要分两部分说,不能一概而论:

引用计数是线程安全的:标准库的 shared_ptr 引用计数采用原子操作(atomic)实现,多线程下同时拷贝、析构智能指针对象是安全的,不会出现计数错乱。

指向的对象不是线程安全的:多线程同时修改智能指针指向的资源,和普通指针多线程访问一样,存在数据竞争,需要使用者自己加锁保护。

简单说:智能指针本身的管理是安全的,资源的访问不安全。

2.8 智能指针避坑指南

❌ 不要用同一个原生指针初始化多个智能指针 → 会导致重复释放

❌ 不要把栈对象的地址交给智能指针 → 析构时 delete 栈内存直接崩溃

❌ 不要在函数实参中 new 对象创建智能指针 → 可能因参数求值顺序导致异常泄漏,推荐用 make_shared

❌ 避免 shared_ptr 的 this 指针问题:类内部要返回自身的 shared_ptr,不要直接shared_ptr(this),会重复计数;要继承enable_shared_from_this,调用shared_from_this()

❌ 双向引用、环形结构注意循环引用,及时用 weak_ptr 打破

❌ unique_ptr 移动后、shared_ptr 被 move 后,不要再访问原对象

2.9 智能指针选型建议

优先选 unique_ptr:绝大多数场景下,资源都是独占的,unique_ptr 开销最小、语义最清晰,是默认首选。

需要共享时选 shared_ptr:只有当一份资源确实需要多个管理者时,才使用 shared_ptr。

打破循环引用选 weak_ptr:观察资源、打破循环引用时使用 weak_ptr,不要用它管理资源。

绝对不用 auto_ptr:语义有缺陷,已被废弃。

第三部分:终章总结 ------ 现代 C++ 的核心思想升华

3.1 异常与智能指针的联动

我们把两部分内容串起来看,会发现它们是相辅相成的:

异常机制给了我们优雅的错误处理方式,但也带来了异常安全的新问题

智能指针(RAII 思想)则从机制上解决了异常安全问题,让我们不用再小心翼翼地手动释放资源

二者结合,才能写出既优雅又健壮的现代 C++ 代码。

3.2 RAII:现代 C++ 的灵魂

RAII 绝不仅仅是智能指针,它是一种普适的资源管理思想。

内存 → 智能指针

互斥锁 → lock_guard /unique_lock

文件句柄 → fstream

数据库连接、socket 连接、线程句柄...... 所有需要申请和释放的资源,都应该用 RAII 封装。

它的伟大之处在于:不依赖程序员的细心和记忆力,而是利用语言机制(对象生命周期 + 析构函数)从根源上保证资源释放。人总会犯错,但机制不会。

相关推荐
三少爷的鞋1 小时前
LiveData 的缺陷:为什么我现在越来越少使用 LiveData?
android
梓䈑7 小时前
【算法题攻略】BFS 解决FloodFill 算法、最短路问题、多源BFS 和 拓扑排序
c++·算法·宽度优先
Adios7949 小时前
设置交集大小至少为2
数据结构·算法·leetcode
程序喵大人11 小时前
【C++进阶】STL容器与迭代器 - 01 STL 容器先解决元素放在哪里
开发语言·c++·stl
雨白13 小时前
NDK 初探:基于 C++ 实现参数哈希与签名校验
android
程序员正茂13 小时前
Android studio中初步使用OpenCV库
android·opencv
紫_龙14 小时前
window 维护多版本Android studio
android·ide·android studio
杉氧15 小时前
KMP 自动化之路 (4):iOS 自动化构建与签名 —— 攻克最硬的骨头
android·架构·android jetpack
野生风长15 小时前
C++入门基础:从命名空间到引用与指针的全面解析
开发语言·c++