详解C++异常处理

Last Updated: 2023-05-16 09:37:23 Tuesday

-- TOC --

老而弥坚的C语言是没有异常处理的,而风情万种的C++有,它支持try...catch...和throw语句。

异常处理深刻影响了代码风格,当然,好的影响是显而易见的。灵活应用异常的代码,会少很多对返回值的判断逻辑,代码更清爽,整体流程更加流畅和直接。throw是另一种更加灵活高效的高维return...。但是这套异常处理机制,非常影响C++的性能。

C++异常基础

Python的异常处理一样,C++也有一组标准的异常类,也都是继承自一个异常基类。C++与Python不一样的地方在于,C++用的throw语句,可以throw出任意类型的对象,不限制在异常类这个范围内。

下面是一段测试代码,包括如下C++异常知识点:

  1. 抛出标准异常;
  2. 抛出string对象,或C风格的字符串,或int数据;
  3. 多catch分支;
  4. catch所有异常;
  5. 自定义异常类及使用;

代码如下:

#include <iostream>
#include <string>
using namespace std;


struct exceptX: std::exception {
    const char* what() const noexcept {
        return "My First C++ Exception X.";
    }
};


int main(void) {
    // throw a string object, C++ style
    try {
        throw string{"i am throwing..."};
    } catch(const string& msg) {
        cerr << msg << endl;
    }

    // throw char*, C style
    try {
        throw "C string style...";
    } catch(const char* msg) {
        cerr << msg << endl;
    }

    // throw int object
    try {
        throw 12345;
    } catch(const int errcode) {
        cerr << errcode << endl;
    }

    // throw std exceptions
    try {
        time_t t;
        time(&t);
        srand((unsigned int)(t%0xFFFFFFFF));
        int r = rand()%3;

        if (r == 0)
            throw std::length_error("length error...");
        else if (r == 1)
            throw std::range_error("range error...");
        else
            throw std::runtime_error("runtime error...");
    } catch(const std::exception& e) {
        cerr << e.what() << endl;
    }

    // multi catch clause
    try {
        time_t t;
        time(&t);
        srand((unsigned int)(t%0xFFFFFFFF));
        int r = rand()%3;

        if (r == 0)
            throw std::length_error("length error...");
        else if (r == 1)
            throw string{"here r is 1..."};
        else
            throw 999;
    } catch(const std::exception& e) {
        cerr << e.what() << endl;
    } catch(const string& msg) {
        cerr << msg << endl;
    } catch(const int errcode) {
        cerr << errcode << endl;
    }

    // test self define exception type
    try {
        throw exceptX();
    } catch(exceptX& ex) {
        cerr << ex.what() << endl;
    }

    // catch all
    try {
        time_t t;
        time(&t);
        srand((unsigned int)(t%0xFFFFFFFF));
        int r = rand()%3;

        if (r == 0)
            throw std::length_error("length error...");
        else if (r == 1)
            throw string{"here r is 1..."};
        else
            throw 999;
    } catch(...) {
        cerr << "haha...exceptions.."  << endl;
    }

    return 0;
}

C++标准异常类的基类是std::exception,但如果只catch这个类型,并不能catch到所有的异常。上面的代码已经展示了,C++可以抛出任意类型对象作为异常。因此如果想catch到所有异常,需要使用catch(...)这个语法。

无法捕获的Segmentation Fault

C++只能捕获throw抛出来的异常,但无法捕获类似segmentation fault(非法访问)这类错误。C++程序runtime期间如果出现segmentation fault,try...catch...语句没有任何作用,直接挂!(Python调用C/C++扩展模块时,如果segmentation fault发生在扩展模块中,也是一样挂)

同学们可以自己试试看:

#include <iostream>
using namespace std;

int main(void) {
    int *a { new int[4] };
    try{
        cout << a[44444] << endl;
    } catch (...) {
        cerr << "you cannot see this...\n";
    }
    return 0;
}

C++98中的异常规范

早期C++语法,阅读代码必备。

在C++98中,throw关键字除了可以用在函数体中抛出异常,还可以用在函数头和函数体之间,指明当前函数能够抛出的异常类型,这称为异常规范,有些教程也称为异常指示符或异常列表。如:

double func(char param) throw(int);

函数func只能抛出int类型的异常,如果抛出其他类型的异常,try也将无法捕获,程序会被终止。

申明函数可以抛出多种类型异常:

double func(char param) throw(int, char, std::exception);

申明函数不会抛出异常:

double func(char param) throw();

此时如果申明不会抛出异常的func,在运行时抛出了异常,try无法捕获,程序终止。

异常规范在C++11中被摒弃

异常规范的初衷是好的,它希望让程序员看到函数的定义或声明后,立马就知道该函数可能会抛出什么类型的异常,这样程序员就可以使用 try...catch... 来捕获了。如果没有异常规范,程序员必须阅读函数源码才能知道函数会抛出什么异常。

但是,这个美好的初衷并不容易做到。例如,func_outer()函数可能不会引发异常,但它调用了另外一个函数func_inner(),这个函数可能会引发异常。再如,编写的一个函数调用了老式的一个库函数,此时不会引发异常,但是老式库更新以后这个函数却引发了异常。

其实,不仅仅如此:

  1. 异常规范的检查是在运行期而不是编译期,因此程序员不能保证所有异常都得到了妥善处理。
  2. 由于第一点的存在,编译器需要生成额外的代码,在一定程度上妨碍了优化。
  3. 模板函数中无法使用。与具体类型有关,无法给出异常列表。
  4. 实际使用中,我们只需要两种异常说明:抛或不抛,也就是 throw(...) 和 throw()。

所以C++11摒弃了throw异常规范,而引入了新的异常说明符 noexcept

详解C++11引入的noexcept

noexcept紧跟在函数的参数列表后面,它只用来表明两种状态:不会抛异常可能抛异常

void func_not_throw() noexcept;       // 保证不抛出异常
void func_not_throw() noexcept(true); // 同上
void func_throw() noexcept(false);    // 可能会抛出异常
void func_throw();                    // 同上,默认

在成员函数中,noexcept说明符需要跟在const及引用限定符之后,而在final、override或虚函数的=0之前。

如果一个虚函数承诺了它不会抛出异常,则后续派生的虚函数也必须做出同样的承诺。与之相反,如果基类的虚函数允许抛出异常,则派生类的虚函数既可以抛出异常,也可以不允许抛出异常。

注意:编译器会检查带有noexcept修饰的函数,是否存在throw语句,如果有,也仅仅只是给出warning!因为存在的throw,可以在内部被catch,不会抛到外面来。但是,当这样的函数真的throw异常到外面的时候,程序直接终止,外面有try也没有用。

#include <iostream>
using namespace std;


void func_not_throw(void) noexcept {
    throw 1;
}


int main(void) {
    try {
        func_not_throw(); // 直接terminate,不会被catch
    } catch(...) {
        cout << "catch int" << endl;
    }

    return 0;
}

这段代码编译时有个warning,执行直接挂调:

$ g++ -Wall -Wextra test.cpp -o test
test.cpp: In function ‘void func_not_throw()’:
test.cpp:6:5: warning: ‘throw’ will always call ‘terminate’ [-Wterminate]
    6 |     throw 1;
      |     ^~~~~~~
$ ./test
terminate called after throwing an instance of 'int'
Aborted (core dumped)

destructor默认为noexcept(true),除非显示指定为noexcept(false)。如果在destructor中抛出异常,极大的可能会导致此异常没有catch,然后程序挂掉。因为调用destructor的地方,很可能就是在异常处理流程中。因此强烈建议在destructor中将可能的异常全部处理掉。默认的noexcept(true)是OK的。

noexcept(func())

noexcept后面可以带上一个函数接口的调用形式,只是形式,不会真的调用。我理解noexcept是一个编译期间确定值的表达式,就像sizeof或decltype一样。

#include <iostream>
using namespace std;


void foo() noexcept {cout<<"foo\n";}
void bar() noexcept(false) {cout<<"bar\n";}
void foo2() noexcept(noexcept(bar())) {}
void bar2() noexcept(noexcept(foo())) {}

void fp(int a) noexcept {cout<<"fp\n";}


int main(){
    cout << noexcept(foo()) << endl;
    cout << noexcept(bar()) << endl;
    cout << noexcept(foo2()) << endl;
    cout << noexcept(bar2()) << endl;
    // param is necessary,
    // but the actual value is irrelavent.
    cout << noexcept(fp(0)) << endl;
    return 0;
}

输出:

1
0
0
1
1

还没见到过noexcept内带多个函数接口的情况...

noexcept(expression)

noexcept还可以用来在编译器判别一个表达式是否可能throw。

The noexcept keyword has another purpose: You can use it as an operator in an expression, and it evaluates to true if the evaluation of the argument would be considered non-throwing by the compiler. Like sizeof, the argument itself is not evaluated. 把noexcept作为得到bool值的编译期操作符,就像sizeof一样。

例如:

bool example1 = noexcept(1 + 2); // true
bool example2 = noexcept(1 / 0); // true

bool example3 = noexcept(
    std::declval<std::string>().clear()); // true
bool example4 = noexcept(
    std::declval<std::string>().resize(0)); // false

example1很简单,做个加法,不会throw C++ exception。

example2有点tricky,The compiler says that dividing by zero will not raise a C++ exception. Now, dividing by zero is actually undefined behavior, but the compiler isn’t performing any division here. It’s just checking whether operator/(int, int) is potentially-throwing, and it is not. 编译期不会真的执行除以0,只是通过两个int类型的除法来判断。

example3也说明了,clear接口在编译期不会被真的调用执行。

example4说明C++编译器只是使用resize这个接口来判定,不关心接口内的参数是什么值,也不会真的调用执行接口。

example3和example4与前面的noexcept(func())是一样的。

When the noexcept(...) operator is determining whether an expression is potentially-throwing, the compiler looks only at what’s printed on the tin.

noexcept带来的好处

  1. 从语义上理解,noexcept 对于程序员之间的交流是有利的,就像 const 限定符一样。
  2. 显式地指定 noexcept 的函数,编译器会进行优化。因为在调用 noexcept 函数时不需要记录 exception handler,所以编译器可以生成更高效的二进制码(编译器是否优化不一定,但理论上 noexcept 给了编译器更多优化的机会)。另外编译器在编译一个 noexcept(false) 的函数时可能会生成很多冗余的代码,这些代码虽然只在出错的时候执行,但还是会对 Instruction Cache 造成影响,进而影响程序性能。
  3. 如果move constructor带有noexcept修饰,在执行某些操作的时候,会选择move,以实现更好的性能。而如果move constructor没有noexcept,某些操作会选择更安全的copy constructor。(copy constructor 是 Strong Exception Guarantee,发生异常时需要还原,因为原对象的数据并没有被破坏,还原相对容易。但move constructor在执行期间如果异常了,是难以还原的。)
#include <iostream>
#include <vector>
using namespace std;


struct A {
    A(int value):
        value{value} {
        cout << value << " constructor\n";
    }

    A(const A& other) {
        std::cout << other.value << " copy constructor\n";
        value = other.value;
    }

    A(A&& other) noexcept {
        std::cout << other.value << " move constructor\n";
        value = other.value;
    }

    int value;
};


int main(void) {
    std::vector<A> a;
    //cout << a.size() << endl;
    a.emplace_back(1);
    a.emplace_back(2);
    for (auto& x: a)
        cout << x.value << " ";
    cout << endl;
    return 0;
}

上面这段代码,在执行emplace_back的时候,会动态扩展vector的capicity。因为move constructor带有noexcept修饰,在动态扩展capicity的时候,编译器会选择使用move。上面代码执行输出如下:

$ g++ -Wall -Wextra test.cpp -o test
$ ./test
1 constructor
2 constructor
1 move constructor
1 2 

noexcept的使用建议

我们所编写的函数默认都不使用noexcept,只有遇到以下的情况时,再思考是否需要使用:

  1. destructor,必须也应该为 noexcept,默认就是。
  2. 构造函数(普通normal、复制copy、移动move),赋值运算符重载函数。
  3. 尽量让上面的函数都是 noexcept,这可能会给你的代码带来一定的运行期执行效率。
  4. 还有那些你可以 100% 保证不会 throw 的函数。

比如像是 int,pointer 这类的 getter,setter 都可以用 noexcept 修饰,因为不可能出错。但请一定要注意,不能100%保证的地方请一定不要用,否则会害人害己!切记!

destructor与异常

参照《Effective C++》中条款08:别让异常逃离析构函数

总结如下:

  1. 不要在desctructor中抛出异常!虽然C++并不禁止析构函数抛出异常,但这样可能会导致程序直接被终止或出现不明确的行为。(destructor一般是自动调用,程序员也没法catch)
  2. 如果某个操作可能会抛出异常,class应提供一个普通函数(而非析构函数),来执行该操作,目的是给用户一个处理错误的机会。
  3. 如果析构函数中异常非抛不可,那就用try catch来将异常吞下,但这样方法并不好,我们提倡有错早些报出来。
  4. 如果在constructor中抛出异常,destructor就不会执行!

constructor与异常

总结如下:

  1. 如果在constructor中抛出异常(构造函数没有返回值),会导致destructor不能被调用,但对象成员本身的内存资源会被系统释放(已申请到资源的内部成员变量会被系统依次逆序调用其析构函数)。
  2. 因为destructor不会被调用,所以必须保证在构造函数抛出异常之前,把申请的资源释放掉,防止泄露。
  3. 也许在构造函数中抛出异常并不是一个好设计。

Rethrowing Exception

In a catch block, you can use the throw keyword to resume searching for an appropriate exception handler. This is called rethrowing an exception.

#include <iostream>
#include <stdexcept>
using namespace std;


int main(void) {
    try {
        // new in loop ...
        throw bad_alloc{};
    } catch (exception &e) {
        cout << e.what() << endl;
        // delete ...
        throw;  // throw out again
    }
    return 0;
}

比如,在try中使用了new,它可能抛出bad_alloc,我们可以在catch中delete掉之前new成功的对象,然后直接使用throw,再次向上层抛出。(不抛出也可以,但一定要delete掉之前成功的对象,否则就是memory leak)

C++异常与性能

教材中说:

When you use exceptions correctly and no errors occur, your code is faster than manually error-checked code. If an error does occur, exception handling can sometimes be slower, but you make huge gains in robustness and maintainability over the alternative. Kurt Guntheroth, the author of Optimized C++, puts it well: “use of exception handling leads to programs that are faster when they execute normally, and better behaved when they fail.” When a C++ program executes normally (without exceptions being thrown), there is no runtime overhead associated with checking exceptions. It’s only when an exception is thrown that you pay overhead. (没异常发生时,执行速度更快,有异常发生时,代码更好处理,这与下面摘录的另一个观点一致,但似乎有异常时的overhead非常高,因此下面有个观点是,建议不要在关键流程中使用异常)

*

著名的 Google C++ Style Guide 中,关于异常的一节,明确不使用 C++ 异常:We do not use C++ exceptions. Google不用异常的主要原因是历史包袱太重了,代码库中有很多旧风格的 C++ 代码对异常不友好,它们根本没考虑异常,做不到异常安全。

On their face, the benefits of using exceptions outweigh the costs, especially in new projects. However, for existing code, the introduction of exceptions has implications on all dependent code. If exceptions can be propagated beyond a new project, it also becomes problematic to integrate the new project into existing exception-free code. Because most existing C++ code at Google is not prepared to deal with exceptions, it is comparatively difficult to adopt new code that generates exceptions. (比如有异常的C++库,被没有异常处理的老项目使用)

教材中有一段:

Another example is with some legacy code. Exceptions are elegant because of how they fit in with RAII objects. When destructors are responsible for cleaning up resources, stack unwinding is a direct and effective way to guarantee against resource leakages. In legacy code, you might find manual resource management and error handling instead of RAII objects. This makes using exceptions very dangerous, because stack unwinding is safe only with RAII objects. Without them, you could easily leak resources.

*

LLVM Coding Standards 中也不使用异常:In an effort to reduce code and executable size, LLVM does not use exceptions or RTTI...

LLVM 在创立之初并没有历史包袱,禁用C++异常的原因是,异常让最终的二进制文件大小增加了不少,异常产生的位置决定了需要如何做栈展开(stack unwinding),这些数据需要存储在表里,这就是异常导致的二进制文件较大的主要原因。

*

也有一些支持使用 C++ 异常的编码指南,比如 Bjarne Stroustrup(C++ 之父) 和 Herb Sutter 维护的 CppCoreGuidelines 中就旗帜鲜明地支持使用异常。可以参考 CppCoreGuidelines 中关于错误处理的一节。在 isocpp 网站上,也有一个 FAQ 专门解释为什么应该使用异常:Exceptions and Error Handling。

异常是以 bad path 的变慢来换取 happy path 的变快,而错误码 bad path 与 happy path 开销相同,使用异常的 bad path 来与错误码的 normal path 来对比是不公平的。

性能!虽然现在的编译器已经能够做到对异常的 happy-path 几乎无性能影响。但是如果 bad-path 的频率较高,性能开销则不可能忽略不计。(错误率较高的流程,不要使用C++异常)

现在编译器对异常的实现性能真是不让人乐观:gcc目前的异常实现是不可伸缩的,多线程并发抛异常会被一把大锁串行化,这个问题逼得一堆用了异常开源库已经准备自己做stack unwind了。

网友:绝对不要将异常用于控制流,或者高频率事件。

在 happy-path 下,使用异常的代码与使用检查返回错误码的代码性能相近,甚至前者的执行速度还会稍微快一些(因为不做错误码的比较判断)。在 bad-path 下,异常比错误码性能差1000倍(会不会太夸张?)。

C++的Structured Binding特性,也许可以让不使用异常,采用比较错误码的代码,更好看一点点。

错误码保平安!

本文链接:https://cs.pynote.net/sf/c/cpp/202209162/

-- EOF --

-- MORE --