跳转至

Smart Pointers⚓︎

2892 个字 156 行代码 预计阅读时间 16 分钟

C++ 编程中,管理动态分配的内存是一项关键的任务。传统的手动内存管理(使用 newdelete)容易出错,常常导致内存泄漏(比如忘记释放内存)或悬挂指针(比如访问已释放的内存)等问题。智能指针(smart pointers) 的出现就是为了以一种更安全、更自动化的方式来应对这些挑战。

C++ 语境下的资源

C++ 语境下资源(resources) 一词通常指的是程序在运行时需要获取、使用并在不再需要时释放系统级或外部实体。这些实体通常是有限的,并且如果不正确管理(即不释放,可能会导致程序崩溃、性能下降、系统不稳定或资源耗尽。常见的资源包括:

  • 动态分配内存 (dynamic memory):使用 newdelete 分配的堆内存
  • 文件句柄 (file handles):使用 fopenstd::fstream 打开的文件
  • 网络套接字 (network sockets)
  • 互斥锁 (mutexes)
  • 线程句柄 (thread handles)
  • 数据库连接 (database connections)
  • 图形设备上下文 (graphics device context)
  • 系统句柄 (system handles)

RAII⚓︎

RAII(resource acquisition is initialization),即“资源获取即初始化”,是 C++ 中一种强大且被广泛应用的编程技术。其核心思想是将资源的生命周期与对象的生命周期绑定:

  • 资源获取发生在对象的构造阶段:
    • 当一个对象被创建时,它的构造函数负责获取所需的资源
    • 换句话说,获取资源后立刻把它放进对象内
  • 资源释放在对象的析构阶段:
    • 当对象离开其作用域(无论是正常执行完毕还是由于异常抛出)时,其析构函数会被自动调用
    • 析构函数会执行释放该对象所持有的资源的操作

RAII 带来的主要好处有:

  • 自动化的资源管理:程序员不再需要显式地编写释放资源的代码,减少了忘记释放、来不及释放(可能因为循环的 continuegoto,或者中途抛出异常而错过释放环节等等)或错误释放导致资源泄漏、未定义行为、程序崩溃等风险。
  • 异常安全:即使在发生异常导致程序执行路径改变时,对象的析构函数依然会被调用,从而保证了资源的正确释放。
  • 代码清晰性:资源管理的逻辑被封装在对象的构造函数和析构函数中,使得主逻辑代码更加简洁。
RAII 对象的复制

由于 RAII 对象管理着资源,简单的按位复制(浅拷贝)几乎总是错误的,因为它会导致多个 RAII 对象管理同一份资源,从而引发双重释放、资源泄漏或未定义行为。

因此,当设计一个 RAII 对象时,必须仔细考虑其复制(和移动)语义。主要有以下几种处理策略:

  • 禁止复制:当资源不应该被多个所有者共享,或复制成本过高时,我们应当禁止复制 RAII 对象,具体来说就是禁用拷贝构造函数和拷贝赋值运算符(private + 不实现或者 = delete
    • 举例:std::unique_ptr
  • 共享所有权:允许多个 RAII 对象共同管理同一份资源,通过引用计数等机制确保资源在最后一个所有者销毁时才被释放。
    • 举例:std::shared_ptr
  • 复制底部资源(即深拷贝:当资源本身可以被复制,并且复制一份独立的资源副本是合理的时候,可以采取这一策略:复制 RAII 对象时,不仅复制对象本身,还复制其所管理的资源,从而创建一份完全独立的资源副本。
    • 举例:std::stringstd::vector 虽然它们不直接管理系统资源,但它们管理动态内存,并且在拷贝时执行深度复制。
  • 转移所有权:允许 RAII 对象通过移动操作(比如使用移动构造函数和移动赋值运算符)转移其所管理的资源的所有权,而无需进行深度复制。源对象在移动后通常处于有效但未指定的状态(通常是“空”状态
RAII 类应该提供访问原始资源的接口

由于一些 API 要求访问原始资源 (raw resources),所以每一个 RAII 类都应该提供一个“获取其管理的原始资源”的方法。具体有两种做法(以接下来马上要介绍的智能指针为例

  • 显式转换:智能指针都有一个 get() 方法,返回智能指针内部的原始指针(的拷贝)
    • 其优势在于安全,
  • 隐式转换:智能指针还重载了指针解引用运算符 operator->operator*,它们允许隐式转换至底部指针
    • 其优势在于方便

更多内容:RAII

Smart Pointers in Standard Library⚓︎

智能指针正是 RAII 原则在动态内存管理上的具体应用。它们是行为类似于原始指针的类对象,但能够在其生命周期结束时自动释放所指向的动态分配的内存。

C++ 标准库提供了几种主要的智能指针类型,以满足不同的内存管理需求:

  • std::unique_ptr唯一指针

    • 独占所有权:std::unique_ptr 对其指向的对象拥有唯一的、排他的所有权,也就是说任何时候,只有一个 std::unique_ptr 实例可以指向并负责管理特定的动态内存。
    • 不可复制,但可以移动:
      • 为了保证所有权的唯一性,std::unique_ptr 不支持拷贝操作(即拷贝构造函数和拷贝赋值运算符被禁用
      • 但是它支持移动操作(std::move,允许将所有权从一个 std::unique_ptr 转移到另一个。当所有权转移后,原 std::unique_ptr 不再指向该对象。
    • 轻量级:std::unique_ptr 的开销非常小,通常与原始指针相当。
    • 创建方式:推荐使用 std::make_unique<T>(...) 来创建 std::unique_ptr。这种方式更安全,且代码更简洁。
      • 另一种创建方式是:std::unique_ptr<T> uniquePtr{new T},但显然这种语法更为麻烦,不推荐使用。
    例子
    #include <iostream>
    #include <memory>     // 需要包含此头文件以使用智能指针
    
    struct MyData {
        int value;
        MyData(int v) : value(v) {
            std::cout << "MyData(" << value << ") constructed." << std::endl;
        }
        ~MyData() {
            std::cout << "MyData(" << value << ") destructed." << std::endl;
        }
        void print() const {
            std::cout << "Value: " << value << std::endl;
        }
    };
    
    void process_unique_data(std::unique_ptr<MyData> ptr) {
        if (ptr) {
            ptr->print();
        } else {
            std::cout << "Pointer is null." << std::endl;
        }
    } // ptr 在这里离开作用域,如果它拥有对象,对象会被销毁
    
    int main() {
        // 使用 std::make_unique 创建 unique_ptr
        std::unique_ptr<MyData> u_ptr1 = std::make_unique<MyData>(10);
    
        if (u_ptr1) {
            u_ptr1->print(); // 通过 -> 访问成员
            std::cout << "u_ptr1's data value: " << u_ptr1->value << std::endl;
        }
    
        // std::unique_ptr<MyData> u_ptr2 = u_ptr1; // 编译错误:unique_ptr 不可复制
    
        // 转移所有权
        std::unique_ptr<MyData> u_ptr3 = std::move(u_ptr1); // u_ptr1 现在为空
    
        std::cout << "After move, u_ptr1 is " << (u_ptr1 ? "not null" : "null") << std::endl;
    
        if (u_ptr3) {
            u_ptr3->print();
        }
    
        // 将 unique_ptr 作为参数传递给函数(所有权被转移)
        process_unique_data(std::move(u_ptr3));
        // u_ptr3 在调用 process_unique_data 后也变为空,因为所有权已转移到函数内部
    
        std::cout << "Back in main, u_ptr3 is " << (u_ptr3 ? "not null" : "null") << std::endl;
    
        return 0; // u_ptr1(如果未被移动)和 u_ptr3(如果未被移动)在这里离开作用域
                // 它们所管理的对象(如果存在)会被自动销毁
    }
    
  • std::shared_ptr共享指针

    • 共享所有权:std::shared_ptr 允许多个实例共同指向并拥有同一个动态分配的对象。它内部通过引用计数(reference count) 机制来跟踪有多少个 std::shared_ptr 实例正共享该对象。
    • 引用计数
      • 当一个新的 std::shared_ptr 指向该对象(例如通过拷贝构造或赋值,引用计数会增加。
      • 当一个 std::shared_ptr 被销毁或不再指向该对象时,引用计数会减少。
    • 自动销毁:只有当最后一个指向对象的 std::shared_ptr 被销毁,使得引用计数降为零时,该对象才会被自动删除。
    • 创建方式:同样推荐使用 std::make_shared<T>(...) 来创建 std::shared_ptr。这种方式通常更高效,因为它可以在一次内存分配中同时为对象本身和引用计数控制块分配空间。
    例子
    #include <iostream>
    #include <memory>
    #include <vector>
    
    struct SharedResource {
        int id;
        SharedResource(int i) : id(i) {
            std::cout << "SharedResource(" << id << ") created." << std::endl;
        }
        ~SharedResource() {
            std::cout << "SharedResource(" << id << ") destroyed." << std::endl;
        }
        void use() const {
            std::cout << "Using SharedResource(" << id << ")." << std::endl;
        }
    };
    
    int main() {
        // 使用 std::make_shared 创建 shared_ptr
        std::shared_ptr<SharedResource> s_ptr1 = std::make_shared<SharedResource>(101);
        std::cout << "s_ptr1 use_count: " << s_ptr1.use_count() << std::endl; // 输出 1
    
        s_ptr1->use();
    
        std::shared_ptr<SharedResource> s_ptr2 = s_ptr1; // 拷贝构造,引用计数增加
        std::cout << "s_ptr1 use_count: " << s_ptr1.use_count() << std::endl; // 输出 2
        std::cout << "s_ptr2 use_count: " << s_ptr2.use_count() << std::endl; // 输出 2
    
        s_ptr2->use();
    
        std::vector<std::shared_ptr<SharedResource>> ptr_vector;
        ptr_vector.push_back(s_ptr1);
        ptr_vector.push_back(s_ptr2);
        ptr_vector.push_back(std::make_shared<SharedResource>(202));
    
        std::cout << "s_ptr1 use_count after vector push: " << s_ptr1.use_count() << std::endl; // 输出 4 (s_ptr1, s_ptr2, vector[0], vector[1])
        std::cout << "ptr_vector[2] use_count: " << ptr_vector[2].use_count() << std::endl; // 输出 1
    
        for (const auto& ptr : ptr_vector) {
            ptr->use();
        }
    
        s_ptr1.reset(); // s_ptr1 不再指向对象,引用计数减少
        std::cout << "After s_ptr1.reset(), s_ptr2 use_count: " << s_ptr2.use_count() << std::endl; // 输出 3
    
        ptr_vector.clear(); // vector 中的 shared_ptr 被销毁,引用计数相应减少
        std::cout << "After vector.clear(), s_ptr2 use_count: " << s_ptr2.use_count() << std::endl; // 输出 1
    
        // s_ptr2 在 main 函数结束时离开作用域,引用计数变为0,SharedResource(101) 被销毁
        // SharedResource(202) 在 ptr_vector.clear() 后引用计数变为0并被销毁
        return 0;
    }
    
  • std::weak_ptr弱指针

    • 非拥有型指针:std::weak_ptr 是一种观察者指针,它指向由 std::shared_ptr 管理的对象,但它本身并不参与对象的引用计数,即它不会增加或减少对象的强引用计数,所以这类指针的的存在不会阻止其所指向的对象被销毁。
    • 解决循环依赖(circular dependencies) 问题:
      • std::weak_ptr 的主要用途是打破 std::shared_ptr 可能导致的循环依赖问题:当两个或多个对象通过 std::shared_ptr 相互强引用时,即使外部不再有指向它们的指针,它们的引用计数也无法降为零,从而导致内存泄漏。
      • 通过在循环引用链中的一个或多个环节使用 std::weak_ptr,可以打破这个循环,使得对象能够被正常回收。
    • 检查对象存活性:
      • 由于 std::weak_ptr 不拥有对象,它所指向的对象可能在其生命周期内被销毁。
      • 因此,在访问 std::weak_ptr 指向的对象之前,必须通过调用其 lock() 方法将其转换为一个 std::shared_ptr
        • 如果对象仍然存在,lock() 返回一个有效的 std::shared_ptr;否则,返回一个空的 std::shared_ptr
    例子
    #include <iostream>
    #include <memory>
    
    struct NodeB; // 前向声明
    
    struct NodeA {
        std::shared_ptr<NodeB> b_ptr;
        NodeA() { std::cout << "NodeA created." << std::endl; }
        ~NodeA() { std::cout << "NodeA destroyed." << std::endl; }
    };
    
    struct NodeB {
        // 如果这里使用 std::shared_ptr<NodeA> a_ptr; 会导致循环依赖
        std::weak_ptr<NodeA> a_ptr; // 使用 weak_ptr 打破循环
        NodeB() { std::cout << "NodeB created." << std::endl; }
        ~NodeB() { std::cout << "NodeB destroyed." << std::endl; }
    
        void check_a() {
            if (auto locked_a = a_ptr.lock()) { // 尝试获取 shared_ptr
                std::cout << "NodeA is still alive." << std::endl;
                // 可以安全使用 locked_a
            } else {
                std::cout << "NodeA has been destroyed." << std::endl;
            }
        }
    };
    
    int main() {
        std::shared_ptr<NodeA> a = std::make_shared<NodeA>();
        std::shared_ptr<NodeB> b = std::make_shared<NodeB>();
    
        a->b_ptr = b; // NodeA 指向 NodeB
        b->a_ptr = a; // NodeB 指向 NodeA (通过 weak_ptr)
    
        std::cout << "a use_count: " << a.use_count() << std::endl; // 通常是 1 (main 中的 a)
        std::cout << "b use_count: " << b.use_count() << std::endl; // 通常是 2 (main 中的 b 和 a->b_ptr)
    
        b->check_a();
    
        // 当 a 和 b 离开作用域时:
        // 1. a 的 shared_ptr 销毁,NodeA 的引用计数变为0 (因为 b->a_ptr 是 weak_ptr),NodeA 被销毁。
        // 2. NodeA 销毁时,其成员 a->b_ptr (shared_ptr) 被销毁,NodeB 的引用计数减少。
        // 3. b 的 shared_ptr 销毁,如果此时 NodeB 的引用计数变为0,NodeB 被销毁。
        // 如果 b->a_ptr 是 shared_ptr,则 a 和 b 的引用计数都无法降为0,导致内存泄漏。
    
        return 0; // a 和 b 被销毁
    }
    

使用智能指针能给我们带来以下好处:

  • 减少内存泄漏:自动管理内存释放,显著降低了因忘记 delete 等原因而导致的内存泄漏风险
  • 防止悬挂指针:当最后一个拥有对象的智能指针被销毁时,对象也被销毁,有助于避免访问已释放内存的错误
  • 明确所有权:智能指针的类型(如 std::unique_ptr 的独占所有权和 std::shared_ptr 的共享所有权)清晰地表达了代码中对动态内存所有权的设计意图
  • 提高代码可读性和可维护性:将资源管理逻辑封装起来,使得代码更易于理解和维护

Custom Deleter⚓︎

前面提到的 std::shared_ptrstd::unique_ptr 都支持自定义删除器 (custom deleter)。它为这些智能指针提供一个可调用对象。当释放资源时,就会调用这个可调用对象来执行实际的释放操作,而不是默认的 delete。这样,我们就能管理那些不能简单地通过 deletedelete[] 释放的资源。

???+ info "

有许多资源不是通过 `new`/`delete` 分配的,而是通过特定的 C API 或库函数获取和释放的。例如:

- `FILE*` (C 文件句柄):通过 `fopen()` 获取,通过 `fclose()` 释放。
- `HANDLE` (Windows API 句柄):通过 `CreateEvent()` 等获取,通过 `CloseHandle()` 释放。
- `sqlite3*` (SQLite 数据库连接):通过 `sqlite3_open()` 获取,通过 `sqlite3_close()` 释放。
- 通过 `malloc()` 分配的内存:通过 `free()` 释放。

std::shared_ptr 中,自定义删除器作为构造函数的第二个参数传入:

std::shared_ptr<T> ptr(raw_pointer, custom_deleter);
  • raw_pointer 是指向要管理的资源的原始指针。
  • custom_deleter 是一个可调用对象,它接受一个 T* 类型的参数,并执行资源的释放。

std::unique_ptr 也支持自定义删除器,但它的语法略有不同,因为删除器是其模板参数的一部分。这使得 unique_ptr 在编译时就知道删除器的类型,从而可能实现更小的内存开销和更快的运行时性能。下面通过一个例子展示了它的用法:

例子
std::unique_ptr<FILE, decltype(&close_file_func)> unique_file_ptr(fopen("test_unique.txt", "w"), &close_file_func);
// 或者使用 lambda
std::unique_ptr<FILE, decltype([](FILE* f){ if(f) fclose(f); })> unique_file_ptr_lambda(fopen("test_unique_lambda.txt", "w"), [](FILE* f){ if(f) fclose(f); });

自定义删除器带来的好处是:

  • 通用性:允许 shared_ptr 等智能指针管理任何类型的资源,而不仅仅是 new 分配的内存。
  • RAII 扩展:将 RAII 思想扩展到非内存资源,确保这些资源也能在对象生命周期结束时自动、安全地释放。
  • 异常安全:即使在资源使用过程中发生异常,自定义删除器也会被调用,防止资源泄漏。
  • 封装性:将资源的获取和释放逻辑封装在一起,提高了代码的模块化和可读性。

评论区

如果大家有什么问题或想法,欢迎在下方留言~