本章深入探讨了 C++11 引入的四种智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr 以及过时的 std::auto_ptr,旨在帮助开发者摆脱原始指针带来的内存管理噩梦。

条款十八:对于独占资源使用 std::unique_ptr

std::unique_ptr 是管理独占资源的首选,高效且零开销。

  • 独占所有权:拥有资源的唯一所有权,不可拷贝(只可移动),析构时自动释放资源。

  • 零开销抽象:默认情况下,大小等同于原始指针,操作指令也基本一致。

  • 适用场景

    • 工厂函数返回类型:工厂函数创建对象后,由调用者接管所有权。std::unique_ptr 可以高效地转换为 std::shared_ptr,赋予调用者极大的灵活性。

    • Pimpl 惯用法:用于隐藏实现细节(详见条款二十二)。

  • 自定义删除器

    • 支持自定义资源释放逻辑(如写日志后再 delete)。

    • 类型即定义:删除器类型是 std::unique_ptr 类型的一部分(std::unique_ptr<T, DeleterType>),这可能导致对象尺寸增加(尤其是使用函数指针或有状态的函数对象时)。

    • 推荐使用无状态 Lambda:相比函数指针,无状态 Lambda 不会增加 std::unique_ptr 的大小。

  • 数组支持:提供 std::unique_ptr<T[]> 特化版本,但通常建议使用 std::vectorstd::array

条款十九:对于共享资源使用 std::shared_ptr

std::shared_ptr 通过引用计数实现共享所有权,提供类似垃圾回收的自动化内存管理。

  • 共享所有权:多个指针共同拥有一个对象,最后一个指针销毁时释放资源。

  • 性能开销

    • 内存:大小通常是原始指针的两倍(包含指向对象的指针和指向控制块的指针)。

    • 动态分配:引用计数存储在动态分配的控制块(Control Block)中。

    • 原子操作:引用计数的增减必须是原子操作,以保证线程安全,这比普通整数运算慢。

  • 控制块(Control Block)

    • 包含引用计数、弱引用计数(weak count)、自定义删除器、自定义分配器等。

    • 创建规则std::make_shared、从独占指针构造、从原始指针构造时会创建控制块。

    • 陷阱避免从同一个原始指针创建多个 std::shared_ptr,这会导致多个控制块和重复析构(Double Free)。应直接传递 new 的结果给构造函数,或使用 std::make_shared

  • 自定义删除器

    • 删除器不是 std::shared_ptr 类型的一部分,存储在控制块中。

    • 这意味着不同删除器的 std::shared_ptr 可以互相赋值或放入同一个容器。

  • std::enable_shared_from_this

    • 当需要在类成员函数中安全地创建指向 thisstd::shared_ptr 时使用。

    • 通过继承该模板类并调用 shared_from_this(),避免重复创建控制块。

条款二十:当 std::shared_ptr 可能悬空时使用 std::weak_ptr

std::weak_ptrstd::shared_ptr 的辅助者,用于观测资源但不持有所有权。

  • 不影响引用计数std::weak_ptr 指向由 std::shared_ptr 管理的对象,但不增加对象的引用计数(即不阻止对象被销毁)。

  • 解决悬空指针问题:能够检测所指对象是否已被销毁(expired())。

  • 使用方法

    • 不能直接解引用。

    • 必须通过 lock() 获取 std::shared_ptr 来访问对象(如果对象存在),或检查是否为空。

  • 典型应用场景

    • 缓存:缓存对象但不阻止其被回收。

    • 观察者模式:Subject 持有 Observer 的弱引用,避免循环引用和悬空访问。

    • 打破循环引用:在 AB 互相引用的场景中,一方使用 weak_ptr 可打破死锁,允许资源被正常回收。

条款二十一:优先考虑使用 std::make_uniquestd::make_shared,而非直接使用 new

推荐使用 make 函数系列(std::make_uniquestd::make_shared)来创建智能指针。

  • 优势

    • 代码简洁:避免了类型名称的重复书写(如 std::unique_ptr<Widget>(new Widget) vs std::make_unique<Widget>())。

    • 异常安全:防止在 new 对象和智能指针构造之间发生异常(如函数参数求值顺序导致的泄漏),make 函数将这两步合并为一个原子操作。

    • 性能提升(仅 std::make_sharedstd::make_shared 会分配单块内存同时存放对象和控制块,减少了内存分配次数和开销。

  • 不适用场景

    • 自定义删除器make 函数不支持指定自定义删除器。

    • 花括号初始化make 函数内部使用圆括号完美转发,无法直接使用花括号初始化列表(需通过 auto 变通)。

    • 内存敏感的大对象(仅 std::shared_ptr:使用 std::make_shared 时,由于对象和控制块在同一内存块,只要有 weak_ptr 存在(控制块存活),整个大对象的内存就无法释放,即使强引用计数已归零。

    • 自定义内存管理:类重载了 operator new/delete 时,make 函数的分配行为可能不符合预期。

条款二十二:当使用 Pimpl 惯用法,请在实现文件中定义特殊成员函数

Pimpl(Pointer to Implementation)惯用法用于减少编译依赖。

  • 问题:在使用 std::unique_ptr 实现 Pimpl 时,如果在头文件中使用默认析构函数,会导致编译错误(“不完整类型”错误)。

    • 原因:std::unique_ptr 的默认删除器在编译期检查类型完整性(sizeof),而头文件中 Impl 类尚未定义。

  • 解决方案

    • 在头文件中声明析构函数。

    • 在实现文件(.cpp)中,在 Impl 类定义之后,定义析构函数(即使只是 default)。

  • 移动操作:移动构造和移动赋值运算符也面临同样问题,需要在实现文件中定义。

  • 拷贝操作std::unique_ptr 仅支持移动,若需支持拷贝(深拷贝 Pimpl),必须手动实现拷贝构造和赋值运算符。

  • std::shared_ptr 的不同std::shared_ptr 不受此限制,因为其删除器是运行时绑定的,不需要在声明时检查类型完整性。