PersonalCorpus 版 (精华区)
发信人: zhangyan (断电 刚哥诅咒), 信区: Programming
标 题: 编程中使用异常机制
发信站: 哈工大紫丁香 (2001年04月12日10:30:49 星期四), 站内信件
昨天翻译的一篇文章,好多地方都不通
大家将就看吧
编程中使用异常机制
by Bjarne Stroustrup
本文提到了两个系列的例子来促动标准C++对异常安全处理的保证的思想,并且说明了
这一提供基本保证的技术是怎样使程序设计变得更加简易的。
Bjarne Stroustrup是C++语言的创造者,并且是C++ Programming Language一书和其他
许多出版物的作者。
介绍
一个C++带给我们的好东西就是系统化的异常处理机制。然而,当你选择这一机制的时
候,你必须要关注一个异常在什么时候抛出,这带来的问题比它解决的要少一些。也就
是说你需要关注异常安全。非常有意思的是,异常安全往往带来的是简单的易管理的代
码。
在这篇文章中,我首先要说明的是基于异常的资源管理和类设计的概念和技术。在这一
部分,我将尽力用一些最简单的例子来说明。最后,我将解释这些是如何与C++标准库进
行关联的,从此你可以很快从中受益。
资源及资源漏洞
看看下面的传统代码:
void use_file(const char* fn)
{
FILE* f = fopen(fn,"r");
// use f
fclose(f);
}
这段代码开起来是似是而非的,如果在调用fopen()函数之后fclose()之前发生一些不
测的时候,很可能在调用fclose()之前就退出use_file()函数。特别是,如果在use f处
有异常抛出,或者有函数的调用。即使是一个简单return的都可以跳过fclose(f),但是
这些很可能程序员通过测试才注意的到。
第一种尝试use_file()容错的方法是这样的:
void use_file(const char* fn)
{
FILE* f = fopen(fn,"r");
try {
// use f
}
catch (...) {
fclose(f);
throw;
}
fclose(f);
}
对文件操作的代码被放置到一个可以捕获所有异常,关闭文件,重新抛出异常的块try中
。
这一方法的问题在于它过于特殊、冗长、单调并且潜在的开销很大。另外一个问题是程
序员必须记住在每次打开文件的时候都要进行这个操作,并且正确的给出每一次操作。
这种特殊的方法有这与生俱来的错误倾向,幸运的是,还有一个更加优雅的方法。
变量在超出作用域的时候它的析构函数被调用,这是一条基本的规则。甚至作用域是由
异常退出的,这条规则仍然适用。因此,如果在一个局域变量的析构函数中加入文件的
关闭操作,我们就有了解决的办法。例如,我们可以定义一个行为和FILE*类似的类Fil
e_ptr:
class File_ptr {
FILE* p;
public:
File_ptr(const char* n, const char* a) { p = fopen(n,a); }
// suitable copy operations
~File_ptr() { if (p) fclose(p); }
operator FILE*() { return p; } // extract pointer //for use
};
有了上面的这些,我们的代码收缩了许多:
void use_file(const char* fn)
{
File_ptr f(fn,"r");
// use f
}
无论函数是正常退出还是由于异常抛出而中断析构函数都会被调用。简而言之,异常处
理机制可以使我们能够从主算法中丢弃一些处理错误的代码。结果是代码简洁了,错误
发生的可能也比原来的副本降低了。
文件的例子是相当普遍的资源漏洞问题。资源是我们的代码从一些地方获得并需要归还
的任何东西。资源没有适当的归还(释放)就被称作是漏洞。其他一些常见资源的实例
如存储空间,Sockets,线程句柄。资源管理是许多程序的核心。通常来说,无论我们用
不用异常处理都要保证每一个资源被完全的释放。
你或许会指出我在从use_file()函数到File_ptr类的过程中略微的提高了程序的复杂度
。是的,但是我只需要在一个程序中使用一次File_ptr,并且我打开文件的次数要比这
个多得多。总的来讲,只需要为每一种资源建立一个小的资源管理类就可以使用这一技
术。一些库,为他们提供的所有资源都准备了一个这样的类,所以客户程序元省掉了这
项工作。
C++标准库中提供了auto_ptr来容纳个体对象。也为对象提供了容器,特别是为线性对
象提供了vector和string。
这种用构造函数申请资源用析构函数释放资源的做法通常被称为resource acquisitio
n is initialization。
类常量
看下面的这个向量类:
class Vector {
// v points to an array of sz ints
int sz;
int* v;
public:
explicit Vector(int n); // create vector of // n ints
Vector(const Vector&);
~Vector(); // destroy vector
Vector& operator=(const Vector&); // assignment
int size() const;
void resize(int n); // change the size to n
int& operator[](int); // subscripting
const int& operator[](int) const; // subscripting
};
类常量是类设计者规定的,无论何时成员函数被调用时刻的一个简单规定。这个Vecto
r类中有一个简单的常量v指向一个长度为sz的int数组。所有的成员函数都必须假设这是
正确的。就是,它们在被调用的时候需要假设常量合法。并且,在反对的时候要确保常
量合法。例如:
int Vector::size() const { return sz; }
函数size()的实现看起来非常清晰,并且它确实是这样的。常量要确保sz确储存着元素
的数量,因为size()不改变任何东西,常量性得以保证。
下面的操作更棘手一些:
struct Bad_range { };
int& Vector::operator[](int i)
{
if (0<=i && i<sz) return v[i];
trow Bad_range();
}
就是,如果索引在范围以内的时候,返回一个对象的引用;否则的话,抛出一个Bad_r
ange类型的异常。
这些函数都非常简单是因为他们都基于常量v指向一个大小为sz的int数组。如果他们没
有这样的话,代码将非常杂乱。但是它们是如何依赖常量的呢?因为构造函数确定了它
。例如:
Vector::Vector(int i) :sz(i), v(new int[i]) { }
特别的,注意如果new抛出一个异常,没有对象会被建立。因此就不可能创建出一个不
容纳任何需要元素的Vector。
前面部分的关键意图是我们需要避免资源漏洞。所以,非常明显,Vector需要一个析构
函数来释放Vector所申请的资源:
Vector::~Vector() { delete[] v; }
同样的,析构函数可以这样简单的原因是我们可以认为v指向一块已分配的内存。
现在,让我们看看对于赋值的一个天真的实现:
Vector& Vector::operator=(const Vector& a)
{
sz = a.sz; // get new size
delete[] v; // free old memory
v = new int[n]; // get new memory
copy(a.v,a.v+a.sz,v); // copy to new memory
}
在异常处理方面有经验的人会对这段代码产生怀疑。异常可以被抛出吗?即使是这样的
话,可以保证不变性吗?
事实上,这个赋值是一个等待发生的灾难:
int main()
try
{
Vector vec(10);
cout << vec.size() << '\n'; // so far, so good
Vector v2(40*1000000); // ask for 160 megabytes
vec = v2; // use another 160 megabytes
}
catch(Range_error) {
cerr << "Oops: Range error!\n";
}
catch(bad_alloc) {
cerr << "Oops: memory exhausted!\n";
}
如果你想要一个漂亮的出错信息Oops: memory exhausted!因为你没有320MB来开销,但
是,事实会令你失望。如果你没有160MB剩余,的构造函数会在控制局面的情况下失败,
产生出错信息。可是,如果你有160MB,但是又不足320MB(就像我的电脑)那就不会发
生。当赋值操作试图为元素的副本申请空间的时候,一个bad_alloc异常被抛出。异常处
理然后试图离开vec定义的块。在这个时候,vec的析构函数被调用,析构函数要回收ve
c.v的空间。可是,operator=()已经回收了那个数组。一些内存管理器无视这样的(非
法的)对内存进行两次回收的做法。一个系统会在两次删除同样一块内存后陷入无限的
循环中。
这里究竟错在哪里呢?operator=()的实现没有保证类常量v指向一个大小为sz的int数
组。这注定要完蛋的,灾难发生只是早晚的事情。但我们将问题指出后,修复它就是很
容易的了:确保在抛出异常之前常量起作用。或者,更简单地说:在你有选择之前,不
要抛弃一个好的方法。
Vector& Vector::operator=(const Vector& a)
{
int* p = new int[n]; // get new memory
copy(a.v,a.v+a.sz,p); // copy to new memory
sz = a.sz; // get new size
delete[] v; // free old memory
v = p;
}
现在,如果new不能够申请空间并抛出异常,向量不会变化。特别是,在我们上面的例
子中,程序会退出并返回:Oops: memory exhausted!.
请注意Vector是资源控制的一个实例;它简单的安全的通过上面提到的resource acqu
isition is initialization技术管理者它的资源(元素数组)。
异常安全
资源管理和常量的思想允许我们总结出C++标准库是如何实现最基本的异常安全的。简
单出发,我们不能保证任何的类都是异常安全的,除非类中存在常量并且甚至在异常发
生的时候一直维护它的常量性。此外,我们不可以认为每一段代码都是异常安全的,除
非它会释放所有它所申请的资源。
这样,标准库提供了这个保证:
● 对所有操作符的基本保证:标准库的基本常量性是被保证并维护的,并且没有资源,
比如内存,的漏洞。
标准库将要定义以下的保证:
● 对于关键操作提供强保证:除了基本保证外,操作符是否成功或者无效。这个保证是
为了库操作符提供的push_back(),比如和在list中的单个元素insert()。
● 对一些操作进行弱保证:除了提供基本保证外,一些操作并不被保证会抛出异常。这
些保证是一些简单的操作提出的,如swap()和清空内存。
这些概念在讨论异常安全的时候是没有价值的。在程序中加入足够多的try-block来处理
每一个问题过于复杂,过于凌乱,并且容易导致代码效率的下降。像上面提到的那样,
有目的在所有的地方提供保证,在可能的地方提供强保证,这样组织代码很容易写出强
壮的代码。注意Vector::operator=()事实上提供了强保证。往往在你需要在建立一个新
的表示之前保证不会删除就的的情况下,强保证来得很自然。基本保证也可以帮助你在
优化代码的时候避免重复信息。
更多信息
一些关于异常安全更加详细的讨论,编写异常安全性代码的技术在 The C++ Programm
ing Language, Special Edition (Addison-Wesley, 2000, ISBN 0-201-70073-5)中的
附录E中"Standard-Library Exception Safety,"文章出现。如果你的TC++PL没有这个附
录,你可以到http://www.research.att.com/~bs.下载一份。
如果你对C++异常处理并不熟悉的话,我强烈建议学习它并恰当的使用。异常可以显著
的简化代码。自然的,我会推荐TC++PL,而不是任何一本关于Modern C++的书,意味着
利用ISO C++的标准和它的标准库的书,应该涵括异常。
如果你现在对标准库的简便的string和vector感到不舒服的话,我强烈鼓励你去使用它
们。直接和内存管理或者数组元素打交道的代码最有资源泄漏和破坏异常安全的倾向。
这样的代码很少是系统化的引用的数据结构也是很少是简单的和不变的。对于标准库渐
变操作的简短的介绍再TC++PL第三章。这个也可以在我的主页得到。
--
※ 来源:·哈工大紫丁香 bbs.hit.edu.cn·[FROM: 202.118.170.172]
Powered by KBS BBS 2.0 (http://dev.kcn.cn)
页面执行时间:209.450毫秒