资源管理:强鲁棒性应用的基石 (1)

 

0. 引子

在正式讨论C++提供的资源管理技术之前,先简略回顾一下传统的C风格的资源管理技术。

 

本质上讲,C是非常初级的语言。实际的编程中,C程序员往往面临大量的机器相关的细节。诸多此类的细节中,最常见的,最令初学者迷惑的,令专家头痛的,就是内存管理问题。虽然C提供的内存管理接口极其简单,但是由于其灵活性以及内存管理固有的特性,使得大型程序的内存管理往往是最难以理解和容易出问题的地方。内存管理导致的问题层出不穷,除了最简单的内存泄露之外,我们还面临着:

* 内存溢出(上溢或者下溢)

* 内存分配失败(C语言的最佳实践之一:调用malloc或者realloc后立即检查指针的有效性)

* 内存释放异常(释放了不是先前分配的内存,导致此类问题往往是由于指针加减操作导致。)

* 重复释放(C语言的最佳实践之一:释放内存后立即置零指针,并且重复释放空指针是明确定义支持的行为)

* 使用已经释放的内存(C语言的最佳实践之一:使用内存前一定要检查其有效性。但是简单的与空指针比较只能有限地解决这类问题)

 

这类问题是如此的臭名昭著,我们可以轻松在市场上找到一大批协助解决这类问题的工具,从静态的源码分析工具刀动态的数据跟踪工具,应有尽有。随着人们对安全编程的深入关注,由于内存管理失败导致的安全缺陷被更多地发掘出来,人们对于如何有效的管理和使用内存比以往任何时候都更加关注。

 

很多其他的语言,从编译型的Java,C#到解释性的perl,python,ruby以及schema等,通过使用语言内存的被称为垃圾收集(GC,garbage collection)机制,完全把程序员从管理内存的繁复中释放出来。程序员可以假设目标机器有足够多的内存,使用的时候直接申请,而永远不用考虑释放的问题。后台的垃圾收集线程则悄悄地关注程序员使用的每一段内存,如果没有任何对象在引用一块内存,它就会把这块内存加到一个内部链表上,然后在合适的时候自动释放。从适用上讲,大部分对实时性以及性能没有严格要求的应用都可以从自动的的垃圾回收技术获益。

 

尽管垃圾回收技术在业界应用极广,不考虑上述的性能和实时性要求,使用它也不是可以一劳永逸地解决资源管理的问题。垃圾回收机制仅仅适用于内存的自动回收,并不能用于其它类型资源,如文件句柄,内核对象,数据库和网络连接之类的统称为资源的对象进行管理。

 

C++,从诞生的第一天起,就致力于在C的无所不在的适用基础上,通过增加语言的表达能力和完善标准库来提升工程师的效率。从带类的C,到引入异常,引入模板,无不是在此的指引下前进。C++语言中,任何一个对象的生命周期都是以构造函数开始,到析构函数结束,并且这一约定通过标准化而被固化。配合C++完善的作用域规则,我们在使用中逐渐发现,通过组合这两个普通的技术和简单的封装,我们几乎可以完美解决任何的资源管理问题而且没有在效率的实时性上没有任何的妥协。熟练掌握资源管理技术,成为一个C++程序员的必修课。我们今天就仔细地学习理解这一技术。不过在此之前,我们先对我们要讨论的问题做一个限定。

 

1. 定义

这里,我们把资源 定义成可以申请使用,但是使用完毕后必须释放的东西 。内存,以及上述的文件句柄,连接对象等很明显都属于资源。另一个常见的例子是锁。在多线程的实现中,我们常常使用锁来保证对象的同步,而锁是必须释放的东西。一块内存不释放仅仅是简单的内存泄露,短期并不会对程序的运行有显著的影响,但是没有有效释放一个锁则非常有可能导致程序死锁。

 

对于资源的释放过程,我们有一个要求或者约定,释放过程中不会使用新的资源 。初看这个限制似乎有些过于苛刻。但是仔细考虑以后就会 发现,假设一个定义的释放过程中需要申请新的资源,我们完全可以把这一步提取出来,作为资源释放前的一个独立操作。假设我们有一个文件句柄类,我们把打开文件定义为申请资源,把关闭文件,释放句柄定义为释放资源。但是在释放之前,我们必须做一个复杂的计算并且把计算的结果存到文件尾。这个计算过程显然需要使用其他资源(如使用额外的内存),一个幼稚的实现可能是:

这个实现的问题在于,尽管计算是关闭文件之前的必需一步,但是它并不是“关闭文件”语义的一部分。这就意味着这个实现尽管减少了客户端的代码重复,但是违背了“只做一件事”的基本封装原则。所以该实现应该被重构为:

这样,资源的清理操作就没有使用任何的新的资源(尽管这依赖于close的实现。然而,一层一层下去,只要我们保证在任何地方资源的释放都不会使用新资源,我们可以放心的假设这里是没有问题的,这也是基于契约的编程实践的基础)。

你可能已经注意到,由于资源的释放是保证成功的,资源的释放函数一般没有返回值(void)。然而一些释放资源的系统API并不遵循这个约定而指定了一个返回值。你可以想象,面对这样的错误(假设释放一个资源失败),你根本无能为力,也不可能因为释放资源失败而放弃已经成功的操作结果。这种情况下,除了简单记录日志没有任何办法。所以,对于这样的返回值,我们可以简单地忽略(在稍后的错误处理文章中,我们会更详细地讨论这个问题)。

 

据此,我们可以说,任何资源都可以提供如下的操作:

* 资源申请操作。该操作可以带任意个数(可以为零个)的参数。调用成功则返回一个可以控制资源的句柄。

* 资源的释放。该操作带一个参数,那就是控制资源的句柄。该函数没有返回值。

 

2. 实践

现在终于到了解释C++语言是如何有效地帮助我们管理资源了。C++一个基本的idiom是RAII(Resource Acquisition Is Initializated),其含义十分简单,那就是在类构造函数中获取资源,在析构函数中释放资源。用在我们这里,那就是,使用C++类来封装资源的使用,在封装类的构造函数中申请资源并且在封装累的析构函数中释放资源。补齐上述文件句柄的操作,我们可以把文件句柄简单封装为:

非常简单,不是吗?

 

我们先来review一下这个实现。

首先,为了简化资源的管理,我们把拷贝构造函数和赋值运算符声明为私有。这样任何试图复制my_file对象的行为(如函数调用)都会被编译器严格地拒绝。这个限制不大好,稍后我们会尝试解决它,目前暂时先这样;

其次,该实现重载了类型fhandle,这样在任何要求原生文件句柄的地方,我们可以直接传入一个my_file对象,编译器会自动调用这个转换函数。还有一种可能的实现是提供一个类似的与get_raw_handle()的方法。对于这种简单的封装类,使用类型转换重载是合适的;

第三,我们在构造函数中做了恰当的判断。首先验证传入的文件名是一个有效的字符串,而不是空。我们使用assert使用为传入有效的文件名是该实现接口定义的一部分,我们这里“重申”这一定义。另一方面,如果文件打开失败,我们则要仔细地处理。对象构造完全以后,用户可以使用对象来调用成员函数,如果不能保证对象状态的有效,那就有大麻烦了。这里一般有两种处理办法,第一种是定义一个额外的参数,表征对象状态,用户可以检查对象是否可用;一般来说,同时还可以提供到bool的转换函数,用户可以直接在条件判断中使用该对象(想一想标准库中文件流的行为);第二种就是上面实现中使用的办法,直接抛出一个异常,异常的类型或者携带的消息可以专门指定。用户需要在客户代码空间中处理这样可能的情况。这里使用什么的方法完全依赖于适用环境的选择。

 

现在考虑多个句柄的情况。

假设你要设计一个文件复制类,它的作用是从一个文件读取内存,然后写入另外一个文件。我们有如下的API支持:

* FILE* open(const char* name);

* void close(FILE* f)

* bool read(FILE* f, char* one_byte);

* bool write(FILE* f, char one_byte);

初始设计为:

 

请注意,这是一个非常粗略的实现,文件read/write必要的错误处理被略去。

 

这个实现完全使用了RAII技术,保证了file_copy对象在释放的时候,所有的文件句柄都会被合适释放,并且限制了对象的赋值和拷贝构造。但是它存在严重的问题,那就是当目标文件(第二个打开的文件)打开失败时,第一个文件句柄就不会被释放!

有的C++初学者可能会这样

或者直接捕捉异常,这样

这两个尝试都不够简单优雅,特别当同时要处理的对象变多时,可扩展性非常差(类似于C风格的前向goto)。

 

问题的根本在于,RAII只能处理单个的,原子的资源对象,多个对象的处理不在此列。解决这类问题的办法是:使用类来封装基本的资源对象,然后可以方便地组合使用这个封装后的对象。这是,如果构造函数中的第二个封装资源在初始化时失败并异常退出,第一封装对象由于作用域终止(异常抛出)会自然析构进而释放锁封装的资源。如果一个对象的构造需要n个资源,除非n的资源全部构造成功,对象才会成功构造出来,否则所有的医院都会保证被释放。读到这里,读者可以注意到,这非常类似于事务的处理,把一个一个的资源理解为一个事务的每一步,那就是说,除非每一步都执行成功(子资源对象全部构造完毕),事务才会提交(对象构造成功);否则就要退回到初始状态(释放全部子资源对象)。

 

另一种过个资源的情况是使用资源数组。对于数组,我们一般使用new操作符来动态申请,使用完毕后使用delete[]释放。如果数组中保存的是原始的资源句柄,那么上述的问题很显然会出现。如果动态数组的前部半部分对象构造成功,而下一个失败,整个new调用就会失败。new调用失败时,系统默认会抛出bad_alloc异常,这样我们根本就没有机会释放前半部分已经申请的资源。解决资源数组申请和释放的问题也需要对原子资源对象做封装。

 

------------------------------------------------------------------------------------------------------------------------------------------------

这是资源管理系列文章的第一部分,下一部分讲覆盖如下主题

* 标准库对资源管理的支持(auto_ptr和share_ptr)

* 资源封装的值语义实现

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值