第二章 智能指针深度剖析:从裸指针到安全内存管理

智能指针这个话题,我每次讲课时都会多说几句。为什么?因为太多线上事故跟内存管理有关了。我自己早年也踩过坑——有一次在某个高并发服务里,一个裸指针被多处共享,结果某处提前 delete 了,另一处还在用,查了整整两天才定位到问题。从那以后,我对智能指针的态度就变成了:能不用裸指针,就尽量别用。

今天咱们就把 C++ 的三种智能指针彻底聊透:unique_ptrshared_ptrweak_ptr,再加上自定义删除器这个实用技巧。

2.1 unique_ptr:独占所有权的利器

unique_ptr 说白了就是「这把钥匙只有一个人拿着」。它不允许拷贝,只能移动。你想想看,如果一份资源同时被两个 unique_ptr 指向,那到底谁负责释放?所以 C++11 的设计者干脆禁止了拷贝。

核心语义:独占所有权 + 移动语义。离开作用域自动释放,零额外开销。

#include <memory>
#include <iostream>

class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; }
    void doWork() { std::cout << "Working...\n"; }
};

void demo() {
    auto ptr = std::make_unique<Resource>();
    ptr->doWork();
    
    // auto ptr2 = ptr;  // 编译错误!不能拷贝
    auto ptr3 = std::move(ptr);  // 可以移动
    // 此时 ptr 为空,ptr3 持有资源
}  // 离开作用域,ptr3 自动释放 Resource

我个人习惯在工厂函数里直接返回 unique_ptr。这样调用方一眼就能看出:这个对象归我管,别人别碰。

小技巧:std::make_unique 而不是 new。前者更安全,避免异常导致的内存泄漏。C++14 才引入,C++11 需要自己实现一个。

2.2 shared_ptr:引用计数的双刃剑

shared_ptr 允许多个指针共享同一份资源。它内部维护一个引用计数,每多一个 shared_ptr 指向资源,计数加一;每销毁一个,计数减一。计数归零时,资源自动释放。

听起来很美好对吧?但我在项目中遇到过一个问题:引用计数本身是线程安全的,但指向的对象不是。很多人以为用了 shared_ptr 就万事大吉了,结果多线程下数据竞争照出不误。

#include <memory>
#include <thread>

void shared_ptr_demo() {
    auto sp1 = std::make_shared<int>(42);
    std::cout << "引用计数: " << sp1.use_count() << "\n";  // 1
    
    {
        auto sp2 = sp1;
        std::cout << "引用计数: " << sp1.use_count() << "\n";  // 2
    }  // sp2 销毁,计数变回 1
    
    std::cout << "引用计数: " << sp1.use_count() << "\n";  // 1
}  // sp1 销毁,计数归零,int 被释放

性能警告:引用计数的增减是原子操作,有性能开销。高频场景下,shared_ptr 的拷贝可能比裸指针慢一个数量级。我建议只在真正需要共享所有权时才用,否则优先用 unique_ptr

2.3 weak_ptr:破解循环引用的钥匙

循环引用是 shared_ptr 最经典的坑。两个对象互相持有对方的 shared_ptr,结果引用计数永远不为零,内存泄漏了。

我曾经在一个图形引擎项目里遇到过这个问题:场景中的节点互相引用父节点和子节点,结果整个场景树析构时,节点一个都没释放。排查了半天才发现是循环引用。

#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    ~Node() { std::cout << "Node destroyed\n"; }
};

void circular_reference() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    a->next = b;
    b->next = a;  // 循环引用!两个 Node 都不会被释放
    // 离开作用域后,a 和 b 的引用计数都是 1,内存泄漏
}

解决办法?用 weak_ptr。它不增加引用计数,只是「弱弱地」观察资源是否还活着。使用时需要先通过 lock() 提升为 shared_ptr,如果资源已被释放,lock() 返回空。

struct NodeFixed {
    std::shared_ptr<NodeFixed> next;
    std::weak_ptr<NodeFixed> prev;  // 用 weak_ptr 打破循环
    ~NodeFixed() { std::cout << "NodeFixed destroyed\n"; }
};

void fixed_circular() {
    auto a = std::make_shared<NodeFixed>();
    auto b = std::make_shared<NodeFixed>();
    a->next = b;
    b->prev = a;  // weak_ptr,不增加计数
    
    // 使用前需要 lock()
    if (auto sp = b->prev.lock()) {
        std::cout << "a 还活着\n";
    }
}  // 正常释放,没有泄漏

黄金法则:在双向关系中,让「父」持有「子」的 shared_ptr,「子」持有「父」的 weak_ptr。这样既安全又不会泄漏。

2.4 自定义删除器:掌控资源释放的最后一公里

默认情况下,unique_ptrshared_ptrdelete 释放资源。但现实世界哪有这么简单?文件句柄要用 fclose,套接字要用 close,malloc 出来的要用 free。这时候就需要自定义删除器了。

我记得有一次对接一个 C 库,它返回一个 FILE*,要求调用方用 fclose 关闭。用裸指针很容易忘记,但包一层 unique_ptr 加自定义删除器,就再也不用担心了。

#include <memory>
#include <cstdio>

// 自定义删除器:函数对象
struct FileDeleter {
    void operator()(FILE* fp) const {
        if (fp) {
            fclose(fp);
            std::cout << "File closed\n";
        }
    }
};

void file_demo() {
    // 使用自定义删除器
    std::unique_ptr<FILE, FileDeleter> fp(fopen("test.txt", "w"));
    if (fp) {
        fprintf(fp.get(), "Hello, smart pointer!\n");
    }
    // 离开作用域自动 fclose
}

// 也可以直接用 lambda
void lambda_deleter() {
    auto deleter = [](FILE* fp) {
        if (fp) fclose(fp);
    };
    std::unique_ptr<FILE, decltype(deleter)> fp(fopen("test.txt", "r"), deleter);
    // 用法同上
}

注意:shared_ptr 的自定义删除器类型不会影响 shared_ptr 的类型,但 unique_ptr 的删除器是类型的一部分。所以 unique_ptr<FILE, FileDeleter>unique_ptr<FILE, decltype(lambda)> 是两种不同的类型。

2.5 知识体系总览

下面这张图把三种智能指针的关系和适用场景梳理清楚了。我建议你把它存下来,写代码前瞄一眼,能少走很多弯路。

智能指针知识体系 unique_ptr 独占所有权 零开销抽象 shared_ptr 共享所有权 引用计数 weak_ptr 弱引用 打破循环 可转换 可构造 特性对比 特性 unique_ptr shared_ptr weak_ptr 所有权 独占 共享 性能开销 原子计数 极低 适用场景 工厂、局部资源 多所有者 观察、缓存

2.6 避坑指南

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 不要混用智能指针和裸指针管理同一份资源。 我曾经在一个遗留代码里看到有人 new 了一个对象,既传给 shared_ptr 又自己拿着裸指针用。结果 shared_ptr 析构时把对象删了,裸指针成了野指针。
  • 小心 shared_ptr 的循环引用。 特别是回调、观察者模式、树形结构里。一旦发现对象析构函数没被调用,先怀疑循环引用。
  • 自定义删除器不要抛异常。 智能指针的析构函数默认不抛异常,如果删除器抛了,程序会直接 terminate。
  • 能用 make_shared 就用它。 它把控制块和对象分配在同一块内存里,减少一次分配,还能提高缓存局部性。

一句话总结:unique_ptr 管独占,shared_ptr 管共享,weak_ptr 破循环。自定义删除器管特殊资源。记住这三句话,智能指针基本不会用错。


公众号:蓝海资料掘金营,微信deep3321