C++之异常处理


我们承担ROS,FastDDS等通信中间件,C++,cmake等技术的项目开发和专业指导和培训,有10年+相关工作经验,质量有保证,如有需要请私信联系。

总说

  1. 异常安全两个条件:
      a. 不泄漏任何资源;
      b. 不允许数据败坏
  2. 异常安全函数提供以下三个保证之一:
      a. 基本承诺:
        (1). 异常被抛出时程序内的所有事物仍然保持在有效的状态下,没有任何数据结构遭到破坏。
        (2). 程序的现实状态可以出错,如抛出异常就难以拥有之前定好的背景图等业务类的东西
      b. 强烈保证:成功便罢,如果失败就回复到函数调用前的状态(可以通过copy and swap):为打算修改的对象做出一份副本,然后在副本上做必要的修改,待完全成功再置换
      c. 不抛掷异常
  3. 当一个异常发生时,编译系统必须完成以下事情:
      a. 检验发生throw操作的函数
      b. 决定throw操作是否发生在try区段内
        (1). 若是,把发生异常的类型和每一个catch子句进行对比
        (2). 吻合:交到catch子句中
        (3). 不吻合:
          □ 摧毁所有当前局部对象
          □ 从堆栈中将当前对象推出去
          □ 进行到程序堆栈的下一个函数中去,重复上述第二点开始的操作
  4. 异常安全编程基本原则
      a. 明确哪些操作不会抛出异常
      b. 确保析构函数不会抛出异常
      c. 避免异常发生时的资源泄露,这些资源包括动态内存,文件,互斥锁等

throw表达式

  • throw是抛出异常的唯一方法。throw关键字后紧随一个表达式,其中表达式的类型就是抛出的异常类型;如果抛出一个空的异常,是捕获不到的。
  • 当执行一个throw时,跟在throw后面的语句或抛出异常的函数后的代码将不再执行,程序的控制权从throw转移到与之匹配的catch模块,有两个重要含义:
    • 沿着调用链的函数会提早退出,前面如果分配了资源,然后发生了异常,则后面释放资源的操作不会执行,就会造成资源泄露,所以要注意
    • 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁
  • 如果没有找到catch的子句,程序将调用标准库函数terminate,terminate调用abort()终止程序。可以调用set_terminate()函数来设置自己的terminate_handler()。(terminate,set_terminate,terminate_handler都在<exception>中声明)。set_terminate会返回旧的terminate_handler,可以根据需要保存旧的terminate_handler。
try {
  func();
} catch (...) {
  if(terminate_handler != nullptr) {
    terminate_handler();
  } else {
    terminate();
  }
}

自定义回调函数必须要终止程序(通过abort()或者_Exit(),这两个函数定义在<cstdlib>中),如:

[[noreturn]] void myExterminate() {
  cout << "Uncaught exception" << endl;
  _Exit(1);
int main() {
  set_terminate(myExterminate);
  ... }
  • 重新抛出:
    • 使用throw重新抛出当前异常,将当前的异常对象沿着调用链向上传递
    • 捕获到异常对象w后再throw w,会额外多拷贝一次,且如果异常类型是派生类,捕获的是基类型,这个操作抛出去的是静态类型即基类型,这样就截断了异常对象。所以不要使用这种方式捕获异常
void g() { throw std::invalid_argument("Some exception"); }
void f() {
    try {
        g();
    } catch(const std::exception& e) {  // 注意这里的捕获类型是std::exception,这样使用throw e捕获就会截断
        throw e;  // 这里应该使用throw将当前std::invalid_argument异常重新抛出,
        std::cerr << "caught in f(): " << '\n';
    }
}
void h() {
    try {
        f();
    } catch(const std::invalid_argument& e) {
        std::cerr << "caught in h(): invalid_argument" << '\n';
    } catch(const std::exception& e) {
        std::cerr << "caught in h(): exception" << '\n';
    }
}

运行输出:caught in h(): exception

栈释放

当代码中抛出异常时,会在栈上寻找catch处理程序。当发现catch处理程序时,栈会释放所有中间栈帧,直接跳到定义catch处理程序的栈层。栈释放意味着调用所有局部作用域的名称的析构函数,并忽略抛出异常之后到catch之间的所有代码。

void test() {
  try {
    int* a = new int{3};
    ifstream f;
    f.open("file.txt");
    throw exception();
    f.close(); // 不会执行,但会调用ifstream的析构,所以没问题
    delete a;  // 不会执行,内存泄漏
    } catch (...) { ... }
}

所以在这种情况下,可以使用智能指针,或者使用RAII对象保证资源释放

异常对象

可抛出任何类型的异常,但异常未必是对象,也可以抛出一个简单的值。如:

void throwTest() {
    try {
        throw 5;
    } catch(int e) {
        std::cerr << "throw 5" << '\n';
    }
}
void throwTest2() {
    try {
        throw "Unable";
    } catch(const char* e) {  // catch (const char c)/catch (char* c)/catch (const string& c)捕获不到
        std::cerr << "e is: " << e << '\n';
    }
}

但通常应将对象作为异常抛出,因为:

  1. 对象的类名可以传递信息
  2. 对象可存储信息,包括描述异常的字符串
  • 编译器使用异常抛出表达式来对异常对象进行拷贝初始化,因此throw语句中的表达式必须要有完全类型,如果该表达式是类类型的话则相应的类必须拥有一个可访问的析构函数和一个可访问的拷贝构造或移动构造;如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型;
  • 异常对象位于编译器管理的空间中,编译器最终无论调用哪个catch子句都能访问该空间,当异常处理完后异常对象被销毁。如同从函数中返回指向局部对象的指针,抛出一个指向局部对象的指针也是错误的行为
  • 当抛出一条表达式时,该表达式的静态编译类型决定了异常对象的类型,如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切割掉一部分,只有基类部分被抛出
  • catch子句引用类型捕获和值类型捕获不能同时存在,否则编译器报错

try……catch语句

  • catch参数:
    • 参数声明的类型决定了所能捕获的异常类型。这个类型必须是完全类型,可以是左值引用,但不能是右值引用
    • 匹配规则:函数参数类似,但catch异常声明和匹配受到更多限制,调用函数时程序的控制权会返回到函数调用处,抛出异常时控制权不会回到抛出异常的地方:
      • 类型转换:
          □ 允许从非常量向常量的类型转换
          □ 允许派生类向基类转换,但如果catch的参数是非引用类型,异常对象将被切掉一部分
          □ 数组转换成指针,函数转换成函数指针
          □ 除此而外,包括算术类型和类类型转换在内,其他所有转换规则都不能在匹配catch的过程中使用,比如catch double无法捕获抛出的int
      • 不论通过传值捕获还是引用捕获,被抛出的异常对象不论是局部还是全局的(静态或全局变量)都将进行拷贝工作,C++规范要求为异常抛出的对象必须被复制
          □ 所以抛出异常运行速度要比参数传递慢
          □ 如果是传值捕获,会建立两个对象的拷贝:拷贝一个临时对象;将临时对象拷贝到catch的参数中;如果是传引用捕获,只拷贝一个临时对象(vs实测发现传值只有一次拷贝构造,传引用没有拷贝,是不是编译器做了优化?)
      • 当异常对象被拷贝时,拷贝操作是由对象的静态类型而不是动态类型的拷贝构造函数完成的
      • 捕获参数类型:
          a. 传指针捕获:优点:避免对象拷贝;缺点:异常对象很难管理,且不符合C++语言本身的规范,四个标准异常都不是指向对象的指针;结论:避免使用
          b. 传值捕获:缺点:异常对象被拷贝两次;会产生切割问题,派生类的异常对象被作为基类异常对象捕获时派生类的行为就被切掉了;结论:不推荐使用
          c. 传引用捕获:优点:异常对象只被拷贝一次,避免切割;结论:推荐使用
    • 匹配和const:增加const属性不会影响匹配的目的。比如:
      } catch (const exception& e) {} catch (exception& e) { 都可以与exception的任何异常匹配。
  • catch语句是按照出现的顺序逐一匹配,所以越是专门的catch越应该置于整个catch列表的前端
  • catch捕获异常并处理完之后不同于throw后面的语句不执行,catch所在的函数会正常走完
  • 可以通过只写一个throw不包含任何表达式在一条catch子句中重新抛出异常,将当前的异常对象沿着调用链向上传递;如果catch子句改变了参数的内容,则只有当catch异常声明是引用类型时对参数所做的改变才会被保留并继续传播,如下
catch(my_error &eObj) { eObj.status = errCodes::serverErr; throw; } // 修改了异常对象
catch(other_error eObj) { eObj.status = errCodes::badErr; throw; }  // 异常对象的status成员并没有改变
  • 捕获所有异常:既能单独出现,也能与其他几个catch子句一起出现
    • 用省略号,形如 catch(...)
    • 用所有异常的基类 std::exception

noexcept

跟在函数的参数列表后面,指定某个函数不会抛出异常

  • 好处:对编译器来说,预先知道某个函数不抛出异常大有裨益:
    • 有助于简化调用该函数的代码
    • 编译器确认不会抛出异常就能执行某些特殊的优化操作
  • 两种用法
     1. 跟在函数参数列表后面时是异常说明符;
       a. 必须要出现在该函数的所有定义和声明语句之后
       b. 可以在函数指针的声明与定义中指定noexpect
       c. 在typedef和类型别名中则不能出现
       d. 在类的成员函数中noexcept说明符需要在const及引用限定符之后,而在final,ovriride或虚函数的=0之前
       e. 接受一个可选的实参,是个常量表达式,该实参必须可转换为bool类型,如果实参是true则不会抛出异常,如果是false则函数可以抛出任何异常。noexcept等同于noexcept(true)
     2. 一元运算符:返回值是一个bool的右值常量表达式,用于表示给定的表达式会不会抛出异常,经常与noexcept说明符混合使用,例如:
void f1() noexcept {}
void f2() noexcept(false) {}
void f3() noexcept(noexcept(f1()));    // f和g的异常说明一致
void f4() noexcept(noexcept(f2()));
cout << noexcept(f1()); // true,因为f1()被noexcept说明符显式地标记过
cout << noexcept(f2()); // false
  • noexcept说明符使用场景:
    • 确认函数不会抛出异常
    • 根本不知道该如何处理异常
  • 仍然抛出异常的情况:
    • 编译器并不会在编译时检查noexcept,所以在一个函数说明了noexcept的同时又含有throw语句或调用可能抛出异常的其他函数,编译器是可以顺利通过编译的。
    • 一个noexcept抛出异常,程序就调用terminate以确保遵守不在运行时抛出异常的承诺
  • 异常说明与指针,虚函数和拷贝控制,析构:
    • 函数指针和该指针所指函数必须具有一致的异常说明
      • 如果某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数
      • 如果显式或隐式的说明指针可能抛出异常,则该指针可以指向任何函数,即使承诺了不抛出异常的函数也可以
    • 与虚函数:如果虚函数不抛出异常,派生的虚函数必须做出同样的承诺;如果虚函数运行抛出异常,派生类的随便
    • 当编译器合成拷贝控制成员时同时也生成一个异常说明
      • 如果对所有成员和基类的所有操作都承诺了不抛出异常,则合成的是noexcept的;
      • 如果合成成员调用的任意一个可能抛出异常,则合成的是noexcept(false)
      • 定义了一个析构而且没有为他提供异常说明,则编译器合成一个,合成的异常说明将假设由编译器为类合成析构函数时所得的异常说明一致——为类合成析构函数所得的异常说明又是什么:C++11默认析构函数是noexcept(true)的

构造函数异常

  • C++拒绝为没有完成构造的对象调用析构,所以如果构造中完成部分资源申请随后抛出异常而不在构造内部处理,因为不会调用析构,所以这些已经申请好的资源会得不到释放
    • 常用方法是在构造中捕获异常,然后执行一些清除代码,最后再重新抛出异常让它继续传递,但会产生一些重复代码,可以将通用的清除代码放在一个私有函数中让构造和析构调用
    • 如果是在构造函数的参数列表中无法使用try语句块,可以用私有成员初始化,参数列表中调用,参考more effective C++ item10(p49)中的例子
    • 最好的办法是使用RAII原则保证在发生异常时已申请完成的资源能得到及时释放
  • 处理构造函数初始值或语句异常的唯一方法是将构造函数写成函数try语句块,这样既能处理成员初始化列表抛出的异常也能处理构造函数抛出的异常。伪代码如下:
MyClass:MyClass() 
try
  : <initializer>
{ ... }
catch (const exception& e) { 
  ...
}
  • try语句块的限制和指导方针
    • catch语句块将捕获任何异常,无论是构造函数体还是ctor-initializer直接或间接抛出的异常
    • catch语句必须重新抛出当前异常或抛出一个新异常。如果catch语句没有这么做,运行时将自动重新抛出当前异常。
    • catch语句可访问传递给当前构造函数的参数
    • 当catch语句捕获try bolcks内的异常时,构造函数已构建的所有对象(除非类数据成员)都会在执行catch语句之前销毁。
    • 在catch语句中,不应访问对象成员变量,因为它们在执行catch语句前就销毁了。但是如果对象包括非类数据成员如裸指针,就必须要在catch中释放
    • catch中的函数不能使用return返回值

测试类

class MyTest001 {
public:
    MyTest001() {
        std::cout << "MyTest001::MyTest001\n";
    }
    void print() {
        std::cout << "MyTest001::print\n";
    }
    ~MyTest001() {
        std::cout << "MyTest001::~MyTest001\n";
    }
};

try语句块捕获异常类

class MyClass1 {
public:
  MyClass1(int x) try
  : x_(x) {
      throw std::exception();
  } catch(const std::exception& e) {
      m.print();
      std::cout << "非类类型: " << x_ << std::endl;
      std::cerr << "catch MyClass::MyClass" << '\n';
  }
private:
  int x_;
  MyTest001 m;
};

输出:
在这里插入图片描述

从输出结果中可以看到:

  1. 非类类型在catch前已经释放(调用了MyTest001的析构)
  2. 非类类型在catch中仍然可以调用(调用了e的print() 成员函数)
  3. try捕获的异常在catch中没有主动抛出,运行时自动重新抛出了当前异常

所以,try语句块应在少数情况下使用:

  • 将ctor-initializer抛出的异常转换为其他异常
  • 将消息记录到日志文件
  • 释放在抛出异常之前就在初始化列表中分配了内存的裸资源

析构函数异常

必须在析构函数内部处理析构函数引起的所有错误。析构函数不应该抛出任何异常,原因如下:

  1. 析构函数被隐式标记为noexcept,除非添加了noexcept(false),或者,类有子对象,且子对象的析构函数是noexcept(false)。
  2. 在栈释放过程中,析构函数可以运行,如果析构函数抛出异常,C++会调用std::terminate()来终止程序(因为析构是noexcept)
  3. 用户不会显式调用析构(如使用delete),所以捕获不到析构抛出的异常
  4. 析构是用来释放对象的资源的函数,如果异常将无法释放可能申请的资源。
    如果要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块中,并且在析构函数内部得到处理,确保析构函数能完成它应该做的所有事情

异常类型

自定义异常类

自定义异常类可以创建更有意义的类名,并在异常中加入自己的信息。
编写自定义异常类建议从标准的exception类直接或间接集成,这样就可以确保所有的异常都派生自exception派生
自定义异常类:

class MyException : public std::exception {
public:
  MyException(std::string message, std::source_location loc=source_location::current())
  : msg_(message), loc_(loc) { }
  const char* what() const noexcept override {
      return msg_.c_str();
  }
  virtual const source_location& where() const noexcept {
      return loc_;
  }
private:
  std::string msg_;
  std::source_location loc_;
};

使用:

void DoExceptionTest() {
    throw MyException("Throwing MyException");
}
void DoExceptionTest2() {
    try {
        DoExceptionTest();
    } catch(const MyException& e) {
        std::cerr << e.what() << '\n';
        std::cout << "file name is: " << e.where().file_name() << ", function name is: " << e.where().function_name() << ", line is: " << e.where().line() << ", column is: " << e.where().column() << '\n';
    }
}

标准异常类

  • exception仅仅定义了拷贝构造函数/拷贝赋值运算符,虚析构,和一个名为what的虚成员:const char* what(),一般输出异常信息,如果是自定义的异常类,把要输出的异常信息传给父类构造,通过该函数可以输出。该成员确保不会抛出任何异常
  • exception,bad_cast和bad_alloc定义了默认构造
  • runtime_error和logic_error没有默认构造,但有一个可接受c风格字符串或string类型实参的构造
    在这里插入图片描述
标准异常类描述头文件
exception最通用的异常类,只报告异常的发生而不提供任何额外的信息exception
runtime_error只有在运行时才能检测出的错误stdexcept
rang_error运行时错误:产生了超出有意义值域范围的结果stdexcept
overflow_error运行时错误:计算上溢stdexcept
underflow_error运行时错误:计算下溢stdexcept
logic_error程序逻辑错误stdexcept
domain_erro逻辑错误:参数对应的结果值不存在stdexcept
invalid_argument逻辑错误:无效参数stdexcept
length_error逻辑错误:试图创建一个超出该类型最大长度的对象stdexcept
out_of_range逻辑错误:使用一个超出有效范围的值stdexcept
bad_alloc内存动态分配错误new
bad_castdynamic_cast类型转换出错type_info

问题汇总

  • 数组越界没有异常,因为它不会抛出异常
  • abort:结束程序
  • assert(expr):表达式expr为0,先向stderr打印一条信息,然后调用abort终止程序
  • std::current_exception():将当前异常以类型std::exception_ptr生成出来,如果当前并无异常就生成nullptr
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值