RAII惯用法:C++资源管理的利器(转)

2023-12-16 05:50

本文主要是介绍RAII惯用法:C++资源管理的利器(转),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

原文:http://www.cnblogs.com/hsinwang/articles/214663.html

RAII是指C++语言中的一个惯用法(idiom),它是“Resource Acquisition Is Initialization”的首字母缩写。中文可将其翻译为“资源获取就是初始化”。虽然从某种程度上说这个名称并没有体现出该惯性法的本质精神,但是作为标准C++资源管理的关键技术,RAII早已在C++社群中深入人心。

我记得第一次学到RAII惯用法是在Bjarne Stroustrup的《C++程序设计语言(第3版)》一书中。当讲述C++资源管理时,Bjarne这样写道:
使用局部对象管理资源的技术通常称为“资源获取就是初始化”。这种通用技术依赖于构造函数和析构函数的性质以及它们与异常处理的交互作用。
Bjarne这段话是什么意思呢?
首先让我们来明确资源的概念,在计算机系统中,资源是数量有限且对系统正常运转具有一定作用的元素。比如,内存,文件句柄,网络套接字(network sockets),互斥锁(mutex locks)等等,它们都属于系统资源。由于资源的数量不是无限的,有的资源甚至在整个系统中仅有一份,因此我们在使用资源时必须严格遵循的步骤是:

1.         获取资源

2.         使用资源

3.         释放资源

例如在下面的UseFile函数中:

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()) return;                // 如果操作g失败!
    // ...
    if (!h()) return;                // 如果操作h失败!
    // ...
    fclose(f);                       // 释放资源
}

在使用文件f的过程中,因某些操作失败而造成函数提前返回的现象经常出现。这时函数UseFile的执行流程将变为:


很明显,这里忘记了一个重要的步骤:在操作g或h失败之后,UseFile函数必须首先调用fclose()关闭文件,然后才能返回其调用者,否则会造成资源泄漏。因此,需要将UseFile函数修改为:
void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        // 获取资源
    // 使用资源
    if (!g()) { fclose(f); return; }
    // ...
    if (!h()) { fclose(f); return; }
    // ...
    fclose(f);                       // 释放资源
}

现在的问题是:用于释放资源的代码fclose(f)需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂。例如,在文件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写出的代码不仅简洁优雅,而且做到了异常安全。难怪微软的MSDN杂志在最近的一篇文章中承认:“若论资源管理,谁也比不过标准C++”。

这篇关于RAII惯用法:C++资源管理的利器(转)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/499348

相关文章

C++右移运算符的一个小坑及解决

《C++右移运算符的一个小坑及解决》文章指出右移运算符处理负数时左侧补1导致死循环,与除法行为不同,强调需注意补码机制以正确统计二进制1的个数... 目录我遇到了这么一个www.chinasem.cn函数由此可以看到也很好理解总结我遇到了这么一个函数template<typename T>unsigned

C++统计函数执行时间的最佳实践

《C++统计函数执行时间的最佳实践》在软件开发过程中,性能分析是优化程序的重要环节,了解函数的执行时间分布对于识别性能瓶颈至关重要,本文将分享一个C++函数执行时间统计工具,希望对大家有所帮助... 目录前言工具特性核心设计1. 数据结构设计2. 单例模式管理器3. RAII自动计时使用方法基本用法高级用法

深入解析C++ 中std::map内存管理

《深入解析C++中std::map内存管理》文章详解C++std::map内存管理,指出clear()仅删除元素可能不释放底层内存,建议用swap()与空map交换以彻底释放,针对指针类型需手动de... 目录1️、基本清空std::map2️、使用 swap 彻底释放内存3️、map 中存储指针类型的对象

C++ STL-string类底层实现过程

《C++STL-string类底层实现过程》本文实现了一个简易的string类,涵盖动态数组存储、深拷贝机制、迭代器支持、容量调整、字符串修改、运算符重载等功能,模拟标准string核心特性,重点强... 目录实现框架一、默认成员函数1.默认构造函数2.构造函数3.拷贝构造函数(重点)4.赋值运算符重载函数

C++ vector越界问题的完整解决方案

《C++vector越界问题的完整解决方案》在C++开发中,std::vector作为最常用的动态数组容器,其便捷性与性能优势使其成为处理可变长度数据的首选,然而,数组越界访问始终是威胁程序稳定性的... 目录引言一、vector越界的底层原理与危害1.1 越界访问的本质原因1.2 越界访问的实际危害二、基

c++日志库log4cplus快速入门小结

《c++日志库log4cplus快速入门小结》文章浏览阅读1.1w次,点赞9次,收藏44次。本文介绍Log4cplus,一种适用于C++的线程安全日志记录API,提供灵活的日志管理和配置控制。文章涵盖... 目录简介日志等级配置文件使用关于初始化使用示例总结参考资料简介log4j 用于Java,log4c

C++归并排序代码实现示例代码

《C++归并排序代码实现示例代码》归并排序将待排序数组分成两个子数组,分别对这两个子数组进行排序,然后将排序好的子数组合并,得到排序后的数组,:本文主要介绍C++归并排序代码实现的相关资料,需要的... 目录1 算法核心思想2 代码实现3 算法时间复杂度1 算法核心思想归并排序是一种高效的排序方式,需要用

DNS查询的利器! linux的dig命令基本用法详解

《DNS查询的利器!linux的dig命令基本用法详解》dig命令可以查询各种类型DNS记录信息,下面我们将通过实际示例和dig命令常用参数来详细说明如何使用dig实用程序... dig(Domain Information Groper)是一款功能强大的 linux 命令行实用程序,通过查询名称服务器并输

C++11范围for初始化列表auto decltype详解

《C++11范围for初始化列表autodecltype详解》C++11引入auto类型推导、decltype类型推断、统一列表初始化、范围for循环及智能指针,提升代码简洁性、类型安全与资源管理效... 目录C++11新特性1. 自动类型推导auto1.1 基本语法2. decltype3. 列表初始化3

C++11右值引用与Lambda表达式的使用

《C++11右值引用与Lambda表达式的使用》C++11引入右值引用,实现移动语义提升性能,支持资源转移与完美转发;同时引入Lambda表达式,简化匿名函数定义,通过捕获列表和参数列表灵活处理变量... 目录C++11新特性右值引用和移动语义左值 / 右值常见的左值和右值移动语义移动构造函数移动复制运算符