转发公众号文章:https://mp.weixin.qq.com/s/tuotoTSAEPCs3Cr1CaY-Ow

所谓资源就是获取后还要归还的东西,比如内存、文件描述符、socket、对象池中的对象和连接池中的连接等。

RAII(资源获取即初始化,对象在初始化时获取资源,同时隐含着不再使用资源了就销毁。它更多的是指自动销毁资源,但偏偏带有初始化字眼,不知道为什么起这样的名字)是 C++ 语言中一个非常重要的概念,它简化了资源管理的流程,能够防止资源泄漏,确保程序的异常安全。

RAII机制把资源管理与对象生存期绑定在了一起,它们生死相依,一荣俱荣一损俱损,借助于C++语言的构造函数和析构函数来管理资源,使得资源管理的代码逻辑不容易出错。它可以说是C++中资源管理的最佳实践,我们在程序开发中应该充分地利用 RAII机制来管理资源。

RAII机制利用了自动对象的生存期是由运行时(runtime)自动管理的特点。所谓自动管理,无非就是由编译器在恰当的地方生成调用自动对象的析构函数代码,从而安全地管理资源,解放了程序员管理资源时的心智负担。

RAII机制的核心特点:

1、使用构造函数来获取资源。

2、使用析构函数来释放资源。

3、自动对象,具有自动生存期管理的功能。

我们知道C++可以在栈中创建对象,也即自动对象,当自动对象失去生存期时,会有C++运行时调用它的析构函数来销毁对象,而在析构函数中又释放了资源。具体地说,在自动对象失去生存期的位置,由编译器生成了调用析构函数的代码,而调用析构函数就是释放资源。

自动对象的生存期和作用域息息相关,下面使用几个例子来看一下RAII机制是如何自动管理资源的。

假设有一个RAII类,如下:

struct raii_ptr {
    int *ptr;
    raii_ptr(int value) : ptr(new int(value)) {
    }

    ~raii_ptr() {
        delete ptr;
    }
    ...其它成员函数
};

该类在构造函数中初始化数据成员指针ptr,使用new为它分配一段内存空间,在析构函数中,使用delete释放ptr指向的内存资源,显然是一个RAII类。

1、程序作用域中的RAII,即“{}”

这是最常见的应用场景,看下面的示例代码:

void demo() {
    raii_ptr raii0(42);
    //...其它代码
    {
        raii_ptr raii1(24);
        //...其它代码
    } // A
} // B

当编译器在编译代码时,会在自动对象raii0和raii1失去生存期的地方,也就是在离开“{}”作用域的位置,生成调用它们的析构函数的代码。

即编译完后相当于下面的代码:

void demo() {
    raii_ptr raii0(42);
    //...其它代码
    {
        raii_ptr raii1(24);
        //...其它代码
        raii1.~raii_ptr(); // 编译器生成调用析构函数的代码
    }
    // ... 其它代码
    raii0.~raii_ptr(); // 编译器生成调用析构函数的代码
}

可见,编译器会在自动对象失去生存期的位置插入代码,让程序自动销毁对象,从而达到了自动释放资源的目的。在自动对象失去生存期的地方,也就是“}”的位置,如A、B处,这些地方的自动对象的析构行为都是确定性的,根据这个这个确定性,编译器可以保证安全的释放资源。

为了能尽快地释放资源,程序员也可以根据实际情况,在合适的位置手动添加“{}”,来指定RAII对象生存期的作用域。比如上例中的A处位置,程序员添加了“{}”,可以在A处销毁raii1对象,从而尽可能早地回收内存资源,而不是等到最后在B处再销毁raii1对象回收资源。

总之,在自动对象生存期结束的地方,编译器会生成对应的调用析构函数的代码。程序员也可以控制作用域的范围,也就是调整“{}”的范围大小,来为编译器指定销毁对象的位置,即“}”处。这也是RAII惯用法最常见的应用场景。

2、程序跳转中的RAII

自动对象的生存期不但可以由“{}”显式明确,一些程序的流程跳转也会导致自动对象在还没有遇到“}”,就失去了生存期。此时,在什么位置执行这些对象的析构操作也是确定的,因此,完全也可以由编译器自动处理。

看下面的示例代码:

void demo(int flag) {
begin:
    raii_ptr raii0(42);
    // ...其它代码
again:
    if (flag > 100) {
        return; // 中间退出
    } else if (flag > 10) {
        flag = 1;
        goto begin; // goto跳转到前面
    } else if (flag > 0) {
        goto end;// goto跳转到函数返回
    } else if (flag == 0) {
        throw "error"; // 抛出异常
    } else if (flag == -1) {
        exit(-1);
    }
    // ...其它代码
    for (int i=0; i<10; i++) {
        raii_ptr raii1(24);
        // ...其它代码
        if (...) {
            continue;
        } else if (...) {
            break;
        }
        // ...其它代码
    }
    // ...其它代码
end:
}

因为程序使用了return、goto、break、continue等流程跳转语句,会导致自动对象在跳转后失去生存期,编译器也会在自动对象失去生存期的位置,生成调用它们的析构函数的代码。即编译完后相当于产生了下面的代码:

void demo(int flag) {
begin:
    raii_ptr raii0(42);
    // ...其它代码
again:
    if (flag > 100) {
        raii0.~raii_ptr(); // 编译器生成调用析构函数的代码
        return;
    } else if (flag > 10) {
        flag = 1;
        raii0.~raii_ptr(); // 编译器生成调用析构函数的代码
        goto begin;
    } else if (flag > 0) {
        goto end; // 此处没有生成调用析构函数的代码,在end处销毁
    } else if (flag == 0) {
        raii0.~raii_ptr(); // 编译器生成调用析构函数的代码
        throw "error"; // 抛出异常
    } else if (flag == -1) {
        exit(-1); // 此处没有生成调用析构函数代码
    }

    // ...其它代码
    for (int i=0; i<10; i++) {
        raii_ptr raii1(24);
        // ...其它代码
        if (...) {
            raii1.~raii_ptr();  // 编译器生成调用析构函数的代码
            continue;
        } else if (...) {
            raii1.~raii_ptr();  // 编译器生成调用析构函数的代码
            break;
        }
        // ...其它代码
        raii1.~raii_ptr();  // 编译器生成调用析构函数的代码
    }
    // ...其它代码
end:
    raii0.~raii_ptr(); // 编译器生成调用析构函数的代码
}

可见,一些程序流程的跳转点,比如return、goto、循环语句中的break、continue,以及抛出异常等地方,编译器如果发现程序流程跳转后会导致自动对象失去生存期,就在这些跳转点处生成调用它们的析构函数的代码,让程序自动销毁对象。这些析构行为显然也都是确定性的,完全可以由编译器保证调用它们的析构函数。

从上面的示例中,我们可以看出,像return、goto、break、continue等控制程序流程的语句,它们无论怎么跳转,都是在同一个函数内部跳转,无非是向前、向后还是向外等位置跳转,这是结构化编程的特点。而在throw-catch异常时,虽然程序流程不是在函数内部跳转,而是函数间的跳转,是跨函数的跳转,但是跳转方向也是有规律的,都是从被调用方向外围的调用方跳转的,是单方向逐层向外跳转的。因此,编译器完全可以分析出在跳转过程中所涉及到的自动对象的生存期,可以对它们进行析构操作,这些析构行为也都是确定性的。

这些场景完全是编译器根据程序跳转的结果自动进行的,对程序员而言,完全是无感的,不需要程序员编写代码来参与自动对象生存期的管理。

3、不支持RAII机制的程序跳转

不过,还有一些特殊情况的程序跳转,编译器是无法销毁自动对象的,如果要让所涉及到的自动对象在失去生存期时进行析构,就得由程序员介入了,需要手动编写程序代码来析构对象。

(1)调用exit()等导致进程退出的系统调用函数时,编译器并不会生成调用析构函数的代码,毕竟进程都退出了,全部资源都会被操作系统回收,也就不用管了,也不会造成资源泄露。但是,如果是进程间的资源,比如进程间的互斥锁、或者进程启动时创建一个标记文件,在结束时要删除它;再者,有时候不一定是要销毁对象回收内存资源,而是为了保证对象按照预期的顺序进行析构销毁。这时就得需要程序员手动来添加销毁资源的代码了,否则可能会有资源泄露,比如,可以考虑使用atexit()来配合管理进程级资源的回收。

(2)C程序库还提供了一种跨函数的流程跳转函数,即setjmp()/longjmp()组合、以及用于信号处理的sigsetjmp()/siglongjmp()组合,遇到这种跨函数的流程跳转,编译器就无能为力了,它是不会生成调用析构函数的代码。如果程序通过调用longjmp()等函数发生流程跳转后,导致自动对象离开了生存期区域,是不会自动销毁对象的。编程时需要注意这点,需要程序员精心的组织代码结构和编排流程,编写销毁对象的代码,以防止发生资源泄漏。不过,这种流程跳转的场景非常罕见,在日常编程实践中一般不会使用这些跳转函数。

Logo

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

更多推荐