C++、异常处理

1 标准库异常类型

标准库中提供了若干常用的异常类型,所有这些类型都直接或间接地继承自 exception 类,且除了构造和析构函数只有一个可访问的方法,即 const char *exception::what() const; 用于返回描述异常信息的字符串。基本的异常类型定义如下(以 runtime_error 为例,可自定义更改类名):

class runtime_error
   : public _XSTD exception
{	// base of all runtime-error exceptions
public:
   typedef _XSTD exception _Mybase;

   explicit runtime_error(const string& _Message)
      : _Mybase(_Message.c_str())
   {	// construct from message string
   }

   explicit runtime_error(const char *_Message)
      : _Mybase(_Message)
   {	// construct from message string
   }

#if _HAS_EXCEPTIONS

#else /* _HAS_EXCEPTIONS */
protected:
   virtual void _Doraise() const
   {	// perform class-specific exception handling
   _RAISE(*this);
   }
#endif /* _HAS_EXCEPTIONS */
};

要创建一个异常,直接调用其构造函数,即传入所要描述的异常信息的字符串即可,比如 runtime_error(“self-defined messages”);

标准库提供的常用异常类型有:

  • runtime_error, range_error, overflow_error 等,在 <stdexcept> 中;
  • bad_alloc 等,在 <new> 中;
  • bad_cast 等,在 <type_info> 中。

2 异常抛出与捕获

 try {
    throw runtime_error("message");
 } 
 catch (runtime_error &e) {
    cout << e.what() << endl;
 } 
 catch (exception &e) {
    cout << e.what() << endl;
 }
 int a = 1;

以上是异常处理的一个简单的例子。try{} 表示块内的语句可能会抛出异常,其后可跟随一个或多个带有参数类型的 catch 块。throw 语句抛出了一个 runtime_error 类型的异常后,catch 块会从上至下将抛出的异常的类型与自身类型匹配。当存在类型匹配的 catch 块时,该 catch 块内的语句将会被执行,然后跳出异常处理块,继续执行后续的语句,比如示例中的 int a=1; 如果不存在任何类型匹配的 catch 块,则程序会自动调用标准库 terminate 函数,程序非正常退出。

当异常被抛出后,必须要通过类型匹配的 catch 块进行处理,否则将会导致程序提前终止运行。因为派生类对象或者指针可以安全地转换为基类对象或指针,所以派生类类型到基类类型是匹配的,但反之不行。而对于基本数据类型,如 short 到 int 的转换,尽管可以隐式地转换,但他们的类型是不匹配的。

try 块可进行嵌套,当内层的 try 块所跟随的 catch 块中存在与异常类型匹配的块时,异常会由该 catch 块进行处理,跟随在内层 try-catch 块之后的普通语句也会被正常执行。而如果内层不存在与异常类型匹配的 catch 块时,异常会被直接传递到上一层的 try 块所跟随的 catch 块进行匹配,这时跟随在内层 try-catch 块之后的普通语句不会被执行。

如果外层 try 块存在与异常类型匹配的 catch 块,则由该 catch 块对异常进行处理;否则继续交由再上一层的 try-catch 进行匹配。如果到了最外面的一层 try-catch 块依然无法匹配异常,则程序调用 terminate 函数提前终止。同理,如果内层 catch 块处理完匹配的异常后,又抛出了一个异常,同样是交由上一层的 try-catch 块来处理。

尽管标准库提供了一些常用的标准异常类型,但异常并不需要局限于这些类型,更不一定需要为类类型,也可以是一些普通的数据类型,如 int, float 等等,甚至还可以是指针。但指针作为异常通常是不被建议的,具体后面有提及。

3 throw语句

异常通过 throw 语句抛出,格式为 throw value; value 以类似于函数中实参的传递方式在 throw 和 catch 中传递,需要进行类型匹配与转换,也可以在 catch 块中进行修改。当执行 throw 以后,try 块内的局部存储将会被释放,而动态分配的内存则不会被删除,因此要注意内存泄漏的问题。

因为局部存储被释放,如果 value 属于局部存储则也会被释放,因此编译器实际上是在所有可能被激活的 catch 块都能访问的空间中创建了一个称为异常对象的 value 副本,是通过复制 value 的值而初始化的,当该异常被完全处理后就会被销毁。因此,value 必须为可复制的类型,包括内建类型如 int, float 等,以及类对象,还可以是对象指针、数组指针或者函数指针。当抛出的是对象实例的时候,即便该对象的作用域在 try 块之上,也会调用拷贝构造函数进行复制。注意,当 throw 一个数组的名字时,其抛出的实际上是数组的指针,而不是整个数组本身,因此不要抛出局部定义的栈上数组。同理,不要试图返回一个局部变量的指针。

当 throw 出一个对象指针时,注意这时可能造成静态类型的不匹配。一个基类的指针可以指向一个派生类的对象实例,即派生类的指针可以自动转换为基类的指针,然而反过来基类的指针是不能自动转换为派生类指针的(尽管某些情况下可以进行显式强制转换),即便其动态类型本身就是派生类型。如果直接抛出基类指针,即便该指针的动态类型为派生类型,也无法匹配到派生类型的 catch 块,而只能由基类的 catch 块进行处理。如果抛出的是派生类指针,其会被基类指针的 catch 块匹配到,如果在该 catch 块内继续抛出该指针,且抛出时没有强制转换回派生类类型,那么在上一层的 try-catch 中,该异常同样不会被派生类指针类型的 catch 块匹配到。以上指针类型强制转换的问题在编程中往往容易被忽略,所以说抛出指针作为异常通常是不被建议的。

4 构造函数的异常处理

构造函数没有返回值,而构造函数也不可避免地会出现构造失败的情况,比如堆内存分配失败,数据成员构造失败,输入不合法等等。这时我们有两种处理方法。一种方法是通过一个状态变量来记录构造失败的位置,然后返回一个不完整但在计算机看来已经构造完成的对象,然后通过合法性检查来确定对象构造是否成功,对于构造失败的对象可以通过调用析构函数来销毁已经分配的资源。注意这种方法只适用于对象能够被正常构造并初始化,然后进入了构造函数体的情况。如果在对象构造和初始化的过程中就出现了异常,例如初始化列表初始化失败的情况,我们就无法进入函数体修改对象的内容。

另一种方法是对构造函数的调用进行异常捕获,这种方法既可以处理对象在构造和初始化时出现的异常,也可以手动地在构造函数体内部抛出一个异常,从而能够更加灵活地处理创建对象时可能出现的问题。

当构造函数抛出异常并被捕获后,对象中已经被构造的数据成员的内存将会被依次自动释放,于是有:

  1. 对于类数据成员中已经构造的对象实例(非指针变量),当进入匹配的 catch 块后,由于其内存被自动释放,其析构函数也会被自动调用,所以这时候不需要担心内存泄漏的问题。

  2. 对于类数据成员中指向动态分配内存的指针,当进入匹配的 catch 块后,指针变量本身会被自动销毁,然而其所指向的堆内存却不会被自动释放,因为其需要手动的 delete 才能实现,这时就有可能存在内存泄漏的问题。

因此,如果想通过抛出异常来指示构造函数失败,该类的数据成员应该尽可能以对象实例的形式存在。但这种方式的问题是,构造的对象会被变得很大,而且当类数据成员的内存结构发生改变时,所有的代码都需要重新编译。这时建议使用智能指针对指向动态分配内存的指针进行封装,智能指针的生命期结束时就会自动调用析构函数对所封装的指针进行 delete 释放。

5 new 异常

通过 new 分配堆内存绝大多数时候是不会出问题的,出现问题一般意味着内存耗尽或者内存碎片过多,无法分配一块足够大的连续空闲内存。这时程序继续运行的意义已经不大,所以 new 出现异常时通常会选择直接终止程序。但是我们也可以选择处理这个异常。当 new 失败时,会抛出 bad_alloc 异常,而不是像 malloc 等返回空指针,所以如 NULL == new int; 等检查是没有意义的。如果希望 new 失败时返回空指针,可以使用如 int *p = new (std::nothrow) int; 来避免异常的产生。

另外,我们也可以通过 set_new_handler(void (*fun)()) 系统函数来自定义 new 失败时的应对策略。该函数接受一个函数指针的输入,所指向的函数声明形式为 void fun(); 即没有形参,也没有返回值。当 new 失败时,会自动调用 fun,此时我们可以尝试在 fun 里面对内存进行整理以腾出足够的内存。

当 fun 执行完毕返回,即里面没有终止程序运行的指令,new 命令会再一次尝试分配内存。如果分配成功,则返回内存指针;如果分配失败,则再次调用 fun 函数进行内存整理。如此重复,直到 new 成功分配内存;或者 fun 中存在终止程序运行的指令,且通过如计数器等条件触发了该指令使得程序提前终结。一种避免过多内存碎片的方法是,在程序运行初期申请一块大的内存,然后立即或者等到合适时机进行释放,这样程序后面需要申请内存时就能比较轻松地找到一块连续的内存,避免 new 失败的情况。

更准确地说,new 其实有三种调用形式(不考虑数组[]的区别):

  1. int *x = new int[16];
  2. char *z = new (x) char[16];
  3. int *y = new (std::nothrow) int;

第 2 种形式称为 placement new,即在已有的内存地址 x 上构造新的对象或者数组,x 既可来自堆内存也可来自栈内存。在这期间没有内存的申请,也就不会出现 bad_alloc 异常。同时不建议使用 delete 来释放内存,尽管这不是一个编译错误,但我们不知道 x 的内存是来自栈还是堆,或者该 x 是否为堆内存块的合法起始地址,使用 delete 有可能引发意想不到的问题,所以 placement new 内存的释放应该交由原来的指针来处理,但注意如果创建的是类对象则需要手动调用析构函数。

对于第 1 和第 3 种形式,我们可认为两者都是首先从堆内存上申请所需的空间,然后通过 palcement new 进行对象构造。但对于内存申请失败的情况,前者选择抛出 bad_alloc 异常,后者选择返回 NULL 指针。也就是说,只有第 1 种形式才会抛出 bad_alloc 异常。但所有 3 种形式都不能保证在构造具体对象时构造函数不会出现错误或者抛出异常,但这时已不属于 bad_alloc 的范畴了。

注意,单个对象的创建即 new 应该使用 delete 来释放内存,而对象数组的创建即 new[] 应该使用 delete[] 来释放内存,它们从规范上讲应该是相互匹配的,如果进行混用则会导致未定义的行为。对于未定义的行为,不同的编译器可能有着不同的实现方法,最终可能导致程序在移植时出现严重的错误问题。另外,delete 或者 delete[] 的指针必须是 new 或者 new[] 直接返回的指针,不能存在地址偏移,更不能是非堆内存的指针,因为每一个动态分配的内存块都有着合法性校验信息,其他的指针绝大多数时候都会导致校验失败,从而导致程序崩溃。

另外还要注意的是,不要把派生类对象数组赋值给基类指针,因为数组不具有多态特性,当基类指针试图基于下标索引或者指针加减来进行派生类对象的选择时,其所偏移的字节是基类而不是派生类的大小。由于派生类可能会定义新的数据成员,两个相邻的派生类对象的基类部分内存之间会间杂着派生类对象的内容,所以经过偏移后的基类指针就不再指向一个合法的派生类对象的初始内存地址,这时候进行数据访问就是非法的。同理,当我们试图通过 delete[] 基类指针来释放派生类数组的内存也会遇到以上的问题。不过,如果我们还是想直接通过基类指针来合法地访问派生类的对象数组,一种通常可行的方法是先将基类指针强制转换为整型数值,然后加上派生类对象大小的字节,最后再强制转换回基类指针。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值