智能指针:C++ 内存管理

一、为什么需要智能指针?

传统内存管理的痛点

看下面这段代码,我们明明写了 delete,但内存还是泄漏了:

void Func() {
    int* array1 = new int[10];
    int* array2 = new int[10];  // 如果这里抛异常...
    
    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl;  // 或者这里抛异常...
    
    delete[] array1;
    delete[] array2;
}

问题本质:异常就像“突然的跳槽”,程序执行路径被打断,后面的 delete 永远等不到执行的机会。

传统解决方案的“丑陋”

void Func() {
    int* array1 = new int[10];
    int* array2 = new int[10];
    try {
        // 业务逻辑
    }
    catch (...) {
        delete[] array1;  // 到处都要写,代码膨胀3倍
        delete[] array2;
        throw;
    }
    delete[] array1;
    delete[] array2;
}

这就像出门前反复检查门窗——既繁琐又容易遗漏


二、RAII:智能指针的“灵魂思想”

什么是 RAII?

Resource Acquisition Is Initialization —— 资源获取即初始化。

通俗点说:把资源“包养”给对象,让对象的生命周期来管理资源的一生。

template<class T>
class SmartPtr {
public:
    // 构造函数:获取资源
    SmartPtr(T* ptr) : _ptr(ptr) {}
    
    // 析构函数:释放资源(自动调用,异常也不怕!)
    ~SmartPtr() {
        cout << "释放资源:" << _ptr << endl;
        delete _ptr;
    }
    
    // 像指针一样使用
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    
private:
    T* _ptr;
};

RAII 的威力

void Func() {
    SmartPtr<int> sp1(new int[10]);  // 自动管理
    SmartPtr<int> sp2(new int[10]);  // 自动管理
    
    // 哪怕这里抛异常,析构函数也会自动释放内存!
    Divide(len, time);
}
// 离开作用域,sp1和sp2自动释放,就像“自动垃圾分类”

类比:RAII 就像请了一个靠谱的管家,你不用操心什么时候打扫房间(释放内存),管家(析构函数)会在你离开时自动搞定。


三、C++ 标准库智能指针全家福

对比一览表

智能指针 核心特点 使用场景
auto_ptr 拷贝时转移管理权,原指针悬空 强烈不建议使用
unique_ptr 独享所有权,不允许拷贝 不需要共享资源时
shared_ptr 引用计数,支持拷贝 需要多个指针共享资源
weak_ptr 不增加引用计数,配合 shared_ptr 解决循环引用

1. auto_ptr:C++98 的“失败尝试”

auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);  // 管理权转移!ap1变成空壳

ap1->_year++;  //  访问空指针

形象比喻auto_ptr 就像一个“不负责任的转让”。你把房子(资源)交给中介(ap2)后,原来的钥匙(ap1)就失效了,但你不知道,还拿着旧钥匙去开门——结果当然打不开!

结论:永远不要使用 auto_ptr,它已经被 C++11 抛弃。


2. unique_ptr:独占资源

unique_ptr<Date> up1(new Date);
// unique_ptr<Date> up2(up1);  //  编译错误!不允许拷贝

unique_ptr<Date> up3(move(up1));  //  移动语义,up1主动放弃

特点

  • 拷贝构造函数被 =delete 删除
  • 只能通过 std::move 转移所有权
  • 性能极佳,没有引用计数开销

形象比喻unique_ptr 就像一个“独占钥匙的保险箱”。只有一个人能持有钥匙,想给别人?可以,但你得先把钥匙交出去(move),自己就不再拥有了。

适用场景:工厂函数返回值、容器中管理资源、不需要共享的场景。


3. shared_ptr:共享资源的“众筹模式”

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

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

sp2.reset();  // 引用计数减到2
sp3.reset();  // 引用计数减到1
// sp1离开作用域,引用计数变0,资源释放
引用计数原理
template<class T>
class shared_ptr {
private:
    T* _ptr;        // 指向资源
    int* _pcount;   // 指向堆上的引用计数(注意:不能是静态成员!)
};

为什么引用计数要在堆上

shared_ptr<Date> sp1(new Date);  // 引用计数 = 1
shared_ptr<Date> sp2(sp1);       // 引用计数 = 2

形象比喻shared_ptr 就像“共享单车”。一辆车(资源)可以被多人共享使用,车上有二维码(引用计数)记录当前有多少人持有钥匙。每多一个人扫码(拷贝),计数+1;每还一辆,计数-1。当计数归零时,车就被回收维护了。


4. weak_ptr:打破循环引用的“破局者”

循环引用问题(内存泄漏的元凶)
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);
    
    n1->_next = n2;  // n2的引用计数变成2
    n2->_prev = n1;  // n1的引用计数变成2
    
    // 离开作用域:n1和n2析构,引用计数各减到1
    // 但节点永远不会释放!因为_next和_prev还在互相持有!
}

循环引用的死锁图解

    n1 (引用计数=2)  ←───  n2 (引用计数=2)
       ↓                    ↑
      _next ─────────────→  _prev
      
    两个节点互相指着对方,谁也不肯先松手 → 内存泄漏!

形象比喻:这就像两个互相“甩锅”的人。A 说“B 不放手我就不放”,B 说“A 不放手我就不放”——结果两人都被困住了,谁也走不了。

解决方案:weak_ptr
struct ListNode {
    int _data;
    weak_ptr<ListNode> _next;  //  不增加引用计数
    weak_ptr<ListNode> _prev;  //  不增加引用计数
};

int main() {
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    
    n1->_next = n2;  // n2的引用计数还是1
    n2->_prev = n1;  // n1的引用计数还是1
    
    // 离开作用域,引用计数归零,正常释放!
}

weak_ptr 的特点

  • 不参与资源管理(不实现 RAII)
  • 不增加 shared_ptr 的引用计数
  • 不能直接访问资源,需要调用 lock() 获得 shared_ptr
weak_ptr<ListNode> wp = n1;  // 绑定到 shared_ptr

if (auto sp = wp.lock()) {   // 尝试获取 shared_ptr
    sp->_data = 100;          // 安全访问
} else {
    cout << "资源已被释放" << endl;
}

四、删除器:定制资源的“释放方式”

问题:智能指针默认用 delete,但 new[] 怎么办?

// ❌ 错误:用 delete 释放 new[],程序崩溃
shared_ptr<Date> sp(new Date[10]);

解决方案对比

方案 代码 适用场景
特化版本 shared_ptr<Date[]> sp(new Date[5]) 数组资源
仿函数 shared_ptr<Date> sp(new Date[5], DeleteArray<Date>()) 需要重用的删除逻辑
函数指针 shared_ptr<Date> sp(new Date[5], DeleteArrayFunc<Date>) C风格函数
Lambda shared_ptr<Date> sp(new Date[5], [](Date* p){delete[] p;}) 简单场景,推荐!

管理非内存资源

// 管理文件指针
shared_ptr<FILE> sp(fopen("test.txt", "r"), 
    [](FILE* p) { 
        cout << "关闭文件" << endl;
        fclose(p); 
    }
);

// 管理数据库连接
shared_ptr<Connection> sp(new Connection("localhost"),
    [](Connection* p) {
        p->disconnect();
        delete p;
    }
);

形象比喻:删除器就像“垃圾分类指南”。不同的资源(干垃圾、湿垃圾、可回收)需要用不同的方式处理,删除器告诉智能指针具体怎么做。


五、shared_ptr 的线程安全问题

两层含义

层面 是否安全 说明
引用计数的操作 安全 使用原子操作或互斥锁保护
指向对象的操作 不安全 需要用户自己加锁
shared_ptr<AA> p(new AA);

// 多线程中同时操作 p 指向的对象,需要加锁
thread t1([&]() {
    for (int i = 0; i < 100000; i++) {
        shared_ptr<AA> copy(p);  // 引用计数操作是安全的
        // 但访问对象需要加锁
        lock_guard<mutex> lock(mtx);
        copy->_a1++;
    }
});

形象比喻shared_ptr 像个负责任的小区物业,它保证“入住人数统计”(引用计数)准确无误。但住户(对象)之间打架,它可管不了,需要你自己协调。


六、智能指针选择指南

开始
 │
 ▼
是否需要共享资源?
 │
 ├── 否 ──→ unique_ptr(独占、零开销)
 │
 └── 是 ──→ shared_ptr
              │
              ▼
         是否有循环引用风险?
              │
              ├── 是 ──→ 使用 weak_ptr 打破循环
              │
              └── 否 ──→ 直接使用 shared_ptr

最佳实践

  1. 优先使用 unique_ptr:性能好,语义清晰
  2. 需要共享时用 shared_ptr:注意避免循环引用
  3. weak_ptr 打破循环:观察者模式、父子关系等场景
  4. 使用 make_shared 而不是 new
    auto sp1 = make_shared<Date>(2024, 9, 11);  //  推荐
    shared_ptr<Date> sp2(new Date(2024, 9, 11)); //  不推荐
    
  5. 永远不要用 auto_ptr

七、内存泄漏:沉默的资源杀手

什么是内存泄漏?

申请的内存因为设计错误,失去了控制,再也无法释放。

类比:内存泄漏就像家里水龙头没关紧,水(内存)一滴一滴地流走。短期看不出问题,但时间长了,水费(内存)会让你崩溃!

泄漏的危害

// 普通程序:泄漏1G也没事,反正马上就结束
int main() {
    char* ptr = new char[1024 * 1024 * 1024];  // 1G
    return 0;  // 进程结束,系统回收内存
}

// 长期运行的程序:泄漏哪怕1KB都很可怕
// 运行一个月后,内存被耗尽,程序卡死!

如何避免?

策略 方法 效果
事前预防 使用智能指针、RAII思想 最推荐
良好习惯 new/delete 配对、资源管理规范 容易遗漏
事后检测 VLD、Valgrind 等工具 上线前检查

八、总结:智能指针的核心要点

// 核心思想:RAII
// 用对象生命周期管理资源,资源自动释放,异常也不怕

// 三兄弟对比
unique_ptr<T>   // 独生子:独占资源,不能拷贝
shared_ptr<T>   // 共享资源:引用计数,自动释放
weak_ptr<T>     // 弱引用:不参与管理,打破循环

// 一句话口诀
// "独用 unique,共用 shared,weak 打破循环,auto 是个坑"

记住:现代 C++ 中,裸指针只用来观察,不拥有资源。拥有资源的地方,请交给智能指针!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐