RAII简述

102 阅读12分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

概述

RAII 是 Resource Acquisition Is Initialization 的缩写,意为“资源获取即初始化”。它是C++之父 Bjarne Stroustrup 提出的设计理念,是C++语言的一种管理资源、避免泄漏的惯用法。其核心是把资源和对象的生命周期绑定,对象创建获取资源,对象销毁释放资源。在RAII的指导下,C++把底层的资源管理问题提升到了对象生命周期管理的更高层次。

说起来,RAII的含义倒也不算复杂。用白话说就是:在类的构造函数中分配资源,在析构函数中释放资源。这样,当一个对象创建的时候,构造函数会自动地被调用;而当这个对象被释放的时候,析构函数也会被自动调用。于是乎,一个对象的生命期结束后将会不再占用资源,资源的使用是安全可靠的。

什么是资源

在计算机系统中,资源是数量有限且对系统正常运行具有一定作用的元素。狭义的资源指内存,而广义的资源包括文件、网络连接、数据库连接、信号量、事件、线程、内存等,甚至可以是状态,它们属于系统资源。由于系统的资源是有限的,就好比自然界的石油,铁矿一样,不是取之不尽,用之不竭的,所以,我们在编程使用系统资源时,都必须遵循一个步骤:

  1. 申请资源;
  2. 使用资源;
  3. 释放资源。

第一步和第二步缺一不可,因为资源必须要申请才能使用的,使用完成以后,必须要释放,如果不释放的话,就会造成资源泄漏。

RAII 惯用法

当讲述C++资源管理时,Bjarne这样写道:

使用局部对象管理资源的技术通常称为“资源获取就是初始化”。这种通用技术依赖于构造函数和析构函数的性质以及它们与异常处理的交互作用。

考虑以下代码:

void UseFile(char const* fn) {

    FILE* f = fopen(fn, "r");        // 获取资源

    // 在此处使用文件句柄f...          // 使用资源

    fclose(f);                       // 释放资源

}

调用fopen()打开文件就是获取文件句柄资源,操作完成之后,调用fclose()关闭文件就是释放该资源。

资源的释放工作至关重要,如果只获取而不释放,那么资源最终会被耗尽。上面的代码是否能够保证在任何情况下都调用fclose函数呢?请考虑如下情况:

void UseFile(char const* fn) {

    FILE* f = fopen(fn, "r");        // 获取资源

    // 使用资源

    if (!g()) { fclose(f); return; }

    // ...

    if (!h()) { fclose(f); return; }

    // ...

    fclose(f);                       // 释放资源

}

在使用文件 f 的过程中,因某些操作失败而造成函数提前返回的现象经常出现。很明显,这里有一个重要的步骤:在操作 g 或 h 失败之后,UseFile 函数必须首先调用 fclose() 关闭文件,然后才能返回其调用者,否则会造成资源泄漏。现在的问题是:用于释放资源的代码 fclose(f) 需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂:

void UseFile(char const* fn) {

    FILE* f = fopen(fn, "r");        // 获取资源

    // 使用资源

    try {

        if (!g()) { fclose(f); return; }

        // ...

        if (!h()) { fclose(f); return; }

        // ...

    } catch (...) {

        fclose(f);                   // 释放资源

        throw;

    }

    fclose(f);                       // 释放资源

}

我们必须依靠 catch(...) 来捕获所有的异常,关闭文件 f,并重新抛出该异常。随着控制流程复杂度的增加,需要添加资源释放代码的位置会越来越多。如果资源的数量还不止一个,那么程序员就更加难于招架了。可以想象这种做法的后果是:代码臃肿,效率下降,更重要的是,程序的可理解性和可维护性明显降低。

是否存在一种方法可以实现资源管理的自动化呢?答案是肯定的。假设UseResources函数要用到n个资源,则进行资源管理的一般模式为:

void UseResources() {

    // 获取资源 1

    // ...

    // 获取资源 n



    // 使用这些资源



    // 释放资源 n

    // ...

    // 释放资源 1

}

不难看出资源管理技术的关键在于:要保证资源的释放顺序与获取顺序严格相反。这自然使我们联想到局部对象的创建和销毁过程。

在 C++ 中,定义在栈空间上的局部对象称为自动存储(automatic memory)对象。管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。我们只需在某个作用域(scope)中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关善后工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,从而销毁该对象。

如果系统中的资源也具有如同局部对象一样的特性,自动获取,自动释放,那该有多么美妙啊!这正是 RAII 的主要思想。既然类是 C++ 中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。这就是 RAII 惯用法的真谛!可以毫不夸张地说,RAII 有效地实现了 C++ 资源管理的自动化。

例如,我们可以将文件句柄 FILE 抽象为 FileHandle 类:

class FileHandle {

public:

    FileHandle(char const* n, char const* a) { p = fopen(n, a); }

    ~FileHandle() { fclose(p); }

private:

    // 禁止拷贝操作

    FileHandle(FileHandle const&);

    FileHandle& operator= (FileHandle const&);

    FILE *p;

};

FileHandle 类的构造函数调用 fopen() 获取资源;FileHandle 类的析构函数调用fclose()释放资源。请注意,考虑到 FileHandle 对象代表一种资源,它并不具有拷贝语义,因此我们将拷贝构造函数和赋值运算符声明为私有成员。 如果利用 FileHandle 类的局部对象表示文件句柄资源,那么前面的 UseFile 函数便可简化为:

void UseFile(char const* fn)

{

    FileHandle file(fn, "r");

    // 在此处使用文件句柄f...

    // 超出此作用域时,系统会自动调用file的析构函数,从而释放资源

}

现在我们就不必担心隐藏在代码之中的 return 语句了;不管函数是正常结束,还是提前返回,系统都必须“乖乖地”调用 f 的析构函数,资源一定能被释放。Bjarne所谓“使用局部对象管理资源的技术……依赖于构造函数和析构函数的性质”,说的正是这种情形。

且慢!如若使用文件file的代码中有异常抛出,难道析构函数还会被调用吗?此时RAII还能如此奏效吗?问得好。事实上,当一个异常抛出之后,系统沿着函数调用栈,向上寻找catch子句的过程, 称为栈辗转开解(stack unwinding)

C++标准规定,在辗转开解函数调用栈的过程中,系统必须确保调用所有已创建起来的局部对象的析构函数。 例如:

void Foo() {

    FileHandle file1("n1.txt", "r");

    FileHandle file2("n2.txt", "w");

    Bar();       // 可能抛出异常

    FileHandle file3("n3.txt", "rw")

}

当 Foo() 调用 Bar() 时,局部对象 file1 和 file2 已经在 Foo 的函数调用栈中创建完毕,而 file3 却尚未创建。如果 Bar() 抛出异常,那么 file2 和 file1 的析构函数会被先后调用(注意:析构函数的调用顺序与构造函数相反);由于此时栈中尚不存在 file3 对象,因此它的析构函数不会被调用。只有当一个对象的构造函数执行完毕之后,我们才认为该对象的创建工作已经完成。

栈辗转开解过程仅调用那些已创建对象的析构函数

RAII 惯用法同样适用于需要管理多个资源的复杂对象。例如,Widget 类的构造函数要获取两个资源:文件 myFile 和互斥锁 myLock。每个资源的获取都有可能失败并且抛出异常。为了正常使用 Widget 对象,这里我们必须维护一个不变式(invariant):当调用构造函数时,要么两个资源全都获得,对象创建成功;要么两个资源都没得到,对象创建失败。 获取了文件而没有得到互斥锁的情况永远不能出现,也就是说,不允许建立Widget对象的“半成品”。如果将RAII惯用法应用于成员对象,那么我们就可以实现这个不变式:

class Widget {

public:

    Widget(char const* myFile, char const* myLock)

    : file_(myFile),     // 获取文件myFile

      lock_(myLock)      // 获取互斥锁myLock

    {}

    // ...

private:

    FileHandle file_;

    LockHandle lock_;

};

FileHandle 和 LockHandle 类的对象作为 Widget 类的数据成员,分别表示需要获取的文件和互斥锁。资源的获取过程就是两个成员对象的初始化过程。在此系统会自动地为我们进行资源管理,程序员不必显式地添加任何异常处理代码。

例如,当已经创建完 file_,但尚未创建完 lock_时,有一个异常被抛出,则系统会调用 file_ 的析构函数,而不会调用 lock_ 的析构函数。Bjarne所谓构造函数和析构函数“与异常处理的交互作用”,说的就是这种情形。

本质

综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。 换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII惯用法是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。

特点

C++ RAII体现出了简洁、安全、实时的特点:

1.概念简洁性:让资源(包括内存和非内存资源)和对象的生命周期绑定,资源类的设计者只需用在类定义内部处理资源问题,提高了程序的可维护性

2.类型安全性:通过资源代理对象包装资源(指针变量),并利用运算符重载提供指针运算方便使用,但对外暴露类型安全的接口

3.异常安全性:栈语义保证对象析构函数的调用,提高了程序的健壮性

4.释放实时性:和 GC 相比,RAII 达到了和手动释放资源一样的实时性,因此可以承担底层开发的重任

注意

最后,不得不提醒RAII的理念固然简单,不过在具体实现的时候仍有需要小心的地方。比如对于 STL 的 auto_ptr 可以视为资源的代理对象, auto_ptr 对象间的赋值是一个需要特别注意的地方。简单说来资源代理对象间赋值的语义不满足“赋值相等”,其语义是资源管理权的转移

什么是“赋值相等”呢?比如:

int a; int b = 10; a = b; 这句话执行后 a == b 但对于资源代理对象,这是不满足的,比如:

auto_ptr<int> a(null); auto_ptr<int> b(new int(123)); a = b; 这句话执行后a != b, 赋值的语义是 b 把资源的管理权交给了 a !

auto_ptr是这样一种指针:它是“它所指向的对象”的拥有者。这种拥有具有唯一性,即一个对象只能有一个拥有者,严禁一物二主。当auto_ptr指针被摧毁时,它所指向的对象也将被隐式销毁,即使程序中有异常发生,auto_ptr所指向的对象也将被销毁。

关于auto_ptr的几种注意事项:

1、auto_ptr不能共享所有权。

2、auto_ptr不能指向数组

3、auto_ptr不能作为容器的成员。

4、不能通过赋值操作来初始化auto_ptr

std::auto_ptr p(new int(42)); //OK

std::auto_ptr p = new int(42); //ERROR

这是因为auto_ptr 的构造函数被定义为了explicit

5、不要把auto_ptr放入容器

但是,在如下的代码中,如果我们忘记写 delete,资源将不是安全的,尽管我们实现了RAII:

void fun3() {

    FileHandle *pfile = new FileHandle("my.txt");

    //操作文件

    delete pfile;

}

因为我们在堆上创建了一个对象(通过new),但是却没有释放它。我们必须运用delete操作符显式地加以释放。否则,非但对象中的资源得不到释放,连对象本身的内存也得不到回收。(将来,C++的标准中将会引入 GC(垃圾收集),但正如下面分析的那样,GC 依然无法确保资源的安全)。

现在,在 fun3(),资源是安全的,但却不是异常安全的。 因为一旦函数中抛出异常,那么 delete pfile;这句代码将没有机会被执行。

C++领域的诸位大牛们告诫我们:如果想要在没有GC的情况下确保资源安全和异常安全,那么请使用智能指针:

void fun4() {

    auto_ptr<FileHandle> spfile(new FileHandle("my.txt"));

    //操作文件

} //此处,spfile结束生命周期的时候,会释放(delete)对象

那么,智能指针又是怎么做到的呢?下面的代码告诉你其中的把戏(关于智能指针的更进一步的内容,请参考 std::auto_ptr,boost 或 shared_ptr 的智能指针)。

也就是说,智能指针通过 RAII 来确保内存资源的安全,也间接地使得对象上的 RAII 得到实施。不过,这里的 RAII 并不是十分严格:对象(所占的内存也是资源)的创建(资源获取)是在构造函数之外进行的。广义上,我们也把它划归 RAII 范畴。

但是,Matthew Wilson在《Imperfect C++》一书中,将其独立出来,称其为 RRID(Resource Release Is Destruction)。

RRID 的实施需要在类的开发者和使用者之间建立契约,采用相同的方法获取和释放资源。比如,如果在 shared_ptr 构造时使用 malloc(),便会出现问题,因为 shared_ptr 是通过 delete 释放对象的。