【C++】智能指针族谱:auto_ptr、unique_ptr、shared_ptr

C++标准库提供了多种智能指针,核心区别在于如何处理拷贝行为。这直接决定了它们在什么场景下能用、什么场景下会出问题。

目录

[1. auto_ptr:被时代抛弃的设计](#1. auto_ptr:被时代抛弃的设计)

[2. unique_ptr:独占,只移不拷](#2. unique_ptr:独占,只移不拷)

[3. shared_ptr:共享所有权与引用计数](#3. shared_ptr:共享所有权与引用计数)

[4. 模拟实现:引用计数的本质](#4. 模拟实现:引用计数的本质)


1. auto_ptr:被时代抛弃的设计

auto_ptr是C++98的产物,思路是"拷贝即转移管理权"。执行auto_ptr<T> ap2(ap1)之后,ap1内部的指针变成nullptr,资源归ap2。后续如果访问ap1,就是空指针行为,轻则崩溃,重则更难排查。

cpp

复制代码
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);
// ap1已经悬空
// ap1->_year++;  // 未定义行为

这个设计违背直觉------看起来是拷贝,却暗中修改了源对象。C++11引入移动语义后,auto_ptr被标记为废弃,现代代码不应该再用。

2. unique_ptr:独占,只移不拷

unique_ptr把"独占"语义用语言机制明确表达出来:禁止拷贝,只支持移动 。拷贝构造和拷贝赋值被= delete了,移动构造和移动赋值则转移所有权,源指针同时置空。

cpp

复制代码
unique_ptr<Date> up1(new Date);
// unique_ptr<Date> up2(up1);   // 编译错误
unique_ptr<Date> up2(move(up1));  // OK,但up1悬空了

移动之后up1也不可再访问,但至少移动需要显式写move,让使用者在代码层面意识到所有权在转移。这种设计明确、安全。对于不需要共享资源的场景,unique_ptr是首选:零额外开销,独占语义清晰,也可以平稳转换成shared_ptr

unique_ptr构造用explicit修饰,防止裸指针隐式转换,这是为了避免不经意间把资源交给智能指针而用户无感知。

自定义删除器方面,unique_ptr把它作为模板参数

cpp

复制代码
// 函数指针做删除器
unique_ptr<Date, void(*)(Date*)> up(new Date[5], [](Date* p){ delete[] p; });

// 仿函数做删除器
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);

因为删除器类型是模板参数的一部分,不同删除器的unique_ptr是不同类型。这一点在使用时需要注意:作为函数参数传递时,类型会比较严格。

另外,unique_ptrshared_ptr都对operator new[]做了特化,可以直接unique_ptr<Date[]> up(new Date[5]),析构时会调delete[],不需要指定删除器。

3. shared_ptr:共享所有权与引用计数

当多个所有者需要共享同一份资源时,shared_ptr上场。它的核心机制是引用计数 :每份资源有一块独立的内存记录当前有多少个shared_ptr指向它。拷贝时计数加一,析构时计数减一,减到零时释放资源。

cpp

复制代码
shared_ptr<Date> sp1(new Date(2024, 9, 11));
shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3 = sp1;
cout << sp1.use_count() << endl;  // 3

引用计数存在堆上 ,与管理的资源独立。为什么不能用静态成员?因为一份资源对应一个引用计数,不同的资源对象必须有不同的计数,静态成员是所有对象共享的,根本做不到。所以shared_ptr构造时会new int(1),然后所有拷贝构造和拷贝赋值都操作这个堆上的计数。

shared_ptr的自定义删除器通过构造函数参数 传递,并非模板参数,这一点与unique_ptr不同:

cpp

复制代码
shared_ptr<FILE> sp(fopen("test.cpp", "r"), [](FILE* f) { fclose(f); });

此外,shared_ptr提供了make_shared<T>(args...),直接用构造资源所需的参数来分配和构造,在堆上一次分配同时包含对象和引用计数控制块,比先new再传给shared_ptr构造函数少一次内存分配,也避免了部分异常安全问题。

4. 模拟实现:引用计数的本质

以下是shared_ptr核心骨架的简化实现,关键点在于引用计数是堆上的int*,以及拷贝、赋值操作对计数的正确维护:

cpp

复制代码
template<class T>
class shared_ptr {
public:
    explicit shared_ptr(T* ptr = nullptr)
        : _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<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;
            ++(*_pcount);
            _del = sp._del;
        }
        return *this;
    }

    ~shared_ptr() { release(); }

    T* get() const        { return _ptr; }
    int use_count() const { return *_pcount; }
    T& operator*()        { return *_ptr; }
    T* operator->()       { return _ptr; }

private:
    void release() {
        if (--(*_pcount) == 0) {
            _del(_ptr);
            delete _pcount;
            _ptr = nullptr;
            _pcount = nullptr;
        }
    }

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

release()是核心逻辑:每次计数减一,归零时代表当前对象是最后一个管理者,负责释放资源和计数本身。_del默认为delete,但可以通过构造时传入的删除器覆盖,实现文件句柄等非内存资源的统一管理。拷贝赋值先处理自己的release再指向新对象,顺序不能错------先加新计数再减老计数可以处理自身赋值自身的边界情况,但这里通过_ptr != sp._ptr的检查来避免。

相关推荐
JieE2122 小时前
LeetCode 101. 对称二叉树|JS 递归 + 迭代双解法,彻底搞懂镜像判断
javascript·算法
nanxun8867 小时前
记一次诡异的 Docker 容器"串包"故障排查
java
用户15630681035110 小时前
Day01 | Java 基础(Java SE)
java
行者全栈架构师11 小时前
Maven dependency:tree 的 8 个高级用法
java·后端
行者全栈架构师15 小时前
IDEA 中 Maven 项目的 15 个红色报错快速解决方法
java·后端
令人头秃的代码0_015 小时前
mac(m5)平台编译openjdk
java
JieE2121 天前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试
Jack201 天前
HarmonyOS开发中错误处理策略:网络异常统一处理
算法
小小杨树1 天前
读懂色彩:拍照调色不再难
算法·计算机视觉·配色
唐青枫2 天前
Java JDBC 实战指南:从 Connection 到事务和连接池
java