异常捕获处理

异常抛出与捕获

在程序运行的过程中,因为各种各样的原因,可能会出现各种各样的问题。

如果一个大型项目遇到一个简单的问题就崩掉的话,将会造成一定的损失。所以让程序变得更加强壮,不容易崩掉就变得很重要。

当程序执行到某一步的时候,我们通过特判发现了问题怎么办?

int a[20];

void f(int index)
{
  if(index < 0 || index > 19)
    //do sth
  cout << a[index] << endl;
}

int main()
{
  f(40);
}

这段代码中,外部的 main 函数要求调用函数 f, 给出参数 index = 40, 但是 f 函数运行的时候通过特判发现了参数 index 不合法。

那么现在有三个选择:

  1. 忽略这个错误继续访问 a[index]。
  2. 终止执行 f 函数并报告给 main 错误信息。
  3. 重新向 main 函数索取一个合法的值。

第一种做法显然不行,访问越界的位置可能导致程序 RE。

第三种方法和第二种方法看起来都不错,C++ 最初的异常标准定义的时候,支持这两种做法的人为采用哪种方案开展过广泛的讨论(激烈的争吵),最终 C++ 采用的是第二种方案。

毕竟第三种可以通过第二种加上一个简单的循环操作实现,因此第二种更灵活一些。

总之,现在我们在 f 函数里抓住了一个错误(异常),现在我们要终止 f 函数了。

当然,因为异常终止 f 函数之后,像正常终止 f 一样,里面所有的临时变量也会被释放。

throw 语句来抛出异常

回到上面的代码,我们要抛出遇到的“数组越界“异常了。

  • 使用关键字 throw 来抛出一个异常。
void f(int index)
{
  if(index < 0 || index > 19)
  {
    string err = "Error:invalid array index.\n";
    throw err;
  }
  cout << a[index] << endl;
}

throw 关键字可以扔出一个对象,当程序执行遇到 throw 时,立即停止执行当前代码块,如果是函数则直接退出并返回调用它的位置。

注意:这意味着 throw 之后的代码即使正确也不会被执行了。返回之后,程序会尝试解决遇到的问题(怎么解决之后再说)。

这个例子中我们创建了一个 string 类型的字符串,并抛出了这个字符串,抛出 str 之后,f 终止执行并释放所有临时变量的内存(当然 str 也会被销毁)。

try-catch 语句来捕捉/处理异常

try-catch 语句基本使用规则

仅仅发现并抛出异常是不够的,我们还需要捕捉异常来对它进行一些处理,以便后续程序正确运行不受异常影响。

  • 使用 try-catch 语句来接受一个异常。

对于一个不安全的代码,我们应该去“尝试执行”。

当然,因为它不安全,所以可能会抛出错误,对于这些错误我们也要做好应对的措施。

于是 try - catch 语句应运而生。

int a[20];

void f(int index)
{
  if(index < 0 || index > 19)
  {
    string err = "Error:invalid array index.\n";
    throw err;
  }
  cout << a[index] << endl;
}

int main()
{
  int index;
  cin >> index;
  try
  {
    f(index);
  }
  catch(string str)
  {
    cout << str;
    //输出 Error:invalid array index.
  }
  catch(...)
  {
    cout << "Unknown error occured.\n";
  }
  return 0;
}

因为 f 是不安全的(可能导致越界),所以我们把它放到 try - catch 语句里。

f 如果遇到问题,可以抛出一个 string 类型的异常,我们用 catch(string str) 来抓住这个异常。

更一般的说:

  • try 内的语句可能会抛出异常。
  • catch(typename x) 一句中 typename 表示被抛出的异常对象的类型,它负责确定应该进入哪一个 catch 分支。
  • catch(typename x) 一句中变量名 x ,实际上是把抛出的异常对象赋值给 x, 此时拷贝构造被调用,然后异常对象随出现异常的函数的本地变量一起销毁(所以 catch 里面不能使用引用)。
    注意:x 不是必须的,程序员也可以选择不拷贝那个异常对象。
  • 使用 catch(...) 来捕获所有异常。

存在多个 catch 语句时,从上到下依次判断,选择第一个与抛出异常对象类型符合的分支进入。

判断一个 catch 分支是不是符合:首先检查直接匹配,然后找子类,然后看 ...

注意:程序从上到下检查,进入第一个符合上述三个条件之一的 catch 分支进入,所以 catch(...) 永远不能放在第一位。

如果一个异常被抛出了但是没有被捕获,将调用 terminate 来终止整个程序(因为异常通常是妨碍程序正常进行的)。

构造/析构函数中的异常处理

在执行构造和析构函数的时候,也有可能发生异常。

一个对象如果构造函数没有执行完,析构函数就无法执行。此时可能已经分配了一些内存(构造半路出问题了)给这个对象,这些内存因为无法调用析构函数从而无法被正确的释放。

正因如此,构造函数应该被放在 try 语句块中,一旦抛出异常,直接就地处理完成,不进一步抛出。

A::A()
try{
	//A()的函数体
}
catch(Error_1){
	// ...
}
catch(...){
	// ... 
}

C++ 允许构造函数抛出异常,但是最好不要在析构函数中抛出异常,因为析构函数涉及对象的销毁,一旦销毁失败,很有可能导致内存得不到释放而溢出。

抛出自定义类的对象以获得更多信息

此外,当然也可以创建自己的表示异常的类。

在上面的处理中,我们只获得了报错信息,而不知道具体是多少导致的报错,也不知道合法的数据范围是多少。

让我们来改进一下报错机制:

class Error
{
public:
  Error(string eMsg) : errorMsg(eMsg) {}
  void PrintErrorMsg() { cout << errorMsg << endl; }
private:
  string errorMsg;
};

class Index_OutofRange_Error : public Error
{
public:
  Index_OutofRange_Error(string eMsg, int eindex) : Error(eMsg), errorIndex(eindex) {}
  void PrintErrorIndex()
  { 
    PrintErrorMsg();
    cout << "Out of range index: " << errorIndex << endl; 
  }
private:
  int errorIndex;
};

int a[20];

void f(int index)
{
  if(index < 0 || index > 19)
  {
    throw Index_OutofRange_Error("Error: Index out of Range, expected index from 0 to 19.", index);
  }
  cout << a[index] << endl;
}

int main()
{
  int index;
  cin >> index;
  try
  {
    f(index);
  }
  catch(Index_OutofRange_Error err)
  {
    err.PrintErrorIndex();
    /*
      输出 
      Error: Index out of Range, expected index from 0 to 19.
      Out of range index: 40
    */
  }
  catch(...)
  {
    cout << "Unknown error." << endl;
  }
  return 0;
}

可以看到,我们一口气写了两个类,一个是基类 Error, 另一个是子类 Index_OutofRange_Error。

这么做的好处是以后如果要创建别的错误类型,也可以直接从基类 Error 派生得到。

catch 语句中继续 throw

有些时候,一个 catch 解决不了问题。因为可能出现异常的代码在一串代码调用链的底端,这时候需要把问题交给上层的代码处理。

比如某个负责通信的工程代码,底端代码 SendMsg.cpp 负责发送一个字符串,但是它发送失败了(异常)。

它可以选择直接弹出一个对话框,表示发射失败了。

这当然很好,但是实际上,整个工程可能在服务器上运行,没有人能看到这个对话框。

另外,管理代码的时候,通常都是由顶层代码调用下层代码,而下层代码之间不能直接互相调用,这样可以保证代码拥有清晰的调用链。

所以 SendMsg.cpp 就不能独自决策要做什么,它必须把问题回馈给调用链顶层做 UI 的代码,由它来决定如何处理。

这就像一个公司,当下属权能遇到问题的时候,必须把问题交由上级决定。

catch 语句中进一步的 throw 可以把它上一次接到(通过拷贝构造)的异常对象继续抛出,交给调用它的位置(有可能是别的程序的某个位置)。

具体语法如下:

catch(_typename x)
{
  //do sth;
  throw;
  //throw sth;如果你想抛出别的的话
}

这会形成一个调用链,如果它返回的位置捕获了这次的 throw 并继续 throw, 那么异常将继续返回,直到某个地方解决了这个异常。

函数异常声明

不抛出声明

如果对每一个函数都 try 一下再 catch 一下可能的异常,未免太麻烦了,因为我们需要判断这个函数可能抛出的异常类型并写出对应的解决方案。

如果能保证一个函数是安全的(绝对没有异常情况出现)或者一个函数只有可能抛出哪几种异常,那么可以选择在函数后面显式注明它们:

  • 使用 throw(), noexcept 来承诺函数不会抛出异常。
  • 如果不声明不抛出异常,则默认可能抛出异常。
void f1() throw();
void f2() noexcept;
// 声明 f1 和 f2 不会抛出异常
void f3() noexcept
{
// do sth...
}
// 定义 f3 承诺其不会抛出异常

如果违反了不抛出声明——即在一个承诺不抛出异常的函数中抛出异常,程序直接终止。

有的编译器可能会对违反不抛出声明的行为做出警告(比如我使用的 GCC)。

noexcept 运算符

实际上 noexcept 还是一个函数,它的返回值是一个 const bool 类型。

noexcept(f(40));
//如果 f 承诺不抛出异常,返回真,可能抛出异常则返回假
noexcept(e);
//当 e 调用的所有函数都做了不抛出声明且 e 本身不含有 throw 语句时,返回真,否则返回假。
void f() noexcept(g());
//使函数 f 和 g 的异常声明一致

注意:noexcept(f(40)) 的结果真假只和 f 是否承诺不抛出异常有关。如果 f 承诺不抛出异常,但最终违反了不抛出声明,noexcept(f(40)) 的返回值也仍然为真。此外,这里并不会真的调用 f 函数,只是检查 f 是否承诺不抛出异常。

posted @ 2023-12-15 18:48  ZTer  阅读(10)  评论(0编辑  收藏  举报