跳转至

7 Exceptions

文本统计:约 2044 个字 • 236 行代码

静态类型语言会在编译阶段对代码进行类型检查,确保变量、函数等使用正确。编译器会检查代码是否符合语法和语义规则,如果代码存在明显错误(如拼写错误、类型不匹配等),编译器会报错

但是即使代码通过了编译时检查,仍然可能在运行时遇到问题。这是因为编译时检查无法覆盖所有潜在的运行时情况。于是我们引入异常的处理机制以检测这种在运行时发生的错误。

比如说读取一个文件,需要打开文件并确定文件大小,然后分配相应内存,将文件读入内存最后关闭文件。加入异常处理的部分就如下所示

try {
 // -----------------------
 // main logic here
 open the file;
 determine its size;
 allocate that much memory;
 read the file into memory;
 close the file;
 // -----------------------
} catch ( fileOpenFailed ) {
 doSomething;
} catch ( sizeDeterminationFailed ) {
 doSomething;
} catch ( memoryAllocationFailed ) {
 doSomething;
} catch ( readFailed ) {
 doSomething;
} catch ( fileCloseFailed ) {
 doSomething;
}

为什么使用异常处理?一个主要的优势是:它能够将错误处理代码与正常运行情况下的代码分开。下面以向量为例举一个例子

template <typename T>
class Vector {
private:
 T* m_elements;
 int m_size;
public:
 Vector(int size = 0) : m_size(size) { /* ... */ }
 ~Vector() { delete[] m_elements; }
 /* ... */
 int length() { return m_size; }
 T& operator[](int); // How to implement?
};

对于运算符 [],可能会出现越界的情况

  • 如果不做处理,直接返回 return m_elements[idx] 这显然是存在隐患的

  • 可以选择返回一个特殊的值来代表错误。

    if (idx < 0 || idx >= m_size) { 
     T error_marker("some magic value");
     return error_marker;
    }
    return m_elements[idx];
    

    但是这也造成一个问题,我们将错误码和正常的返回值混在了一起【然后相应的返回值也要去掉错误值这个值】,可能会需要大量的错误检测,说明这也不太是一个很好的解决方式。

  • 可以选择直接退出程序

    if (idx < 0 || idx >= m_size){ 
     exit(22);
    }
    return m_elements[idx];
    
  • 可以通过 assert 机制来退出,也可以打印一些信息

    assert(idx >= 0 && idx < m_size);
    return m_elements[idx];
    

什么时候需要用异常?当遇到无法确定如何处理的错误时,应该抛出异常,将问题交给调用者或更上层的代码来处理,而不是在当前层级强行解决,这样可以避免做出错误的操作,并让更适合处理该问题的地方来进行适当的应对。

template <class T>
T& Vector<T>::operator[](int idx) {
 if (idx < 0 || idx >= m_size) {
 // throw is a *keyword*
 // exception is raised at this point
 throw <<something>>;
 }
 return m_elements[idx];
}

比如说下面这个错误类,就是用来扔出相应的错误信息的

class VectorIndexError {
public:
     VectorIndexError(int v) : m_badValue(v) { }
     ~VectorIndexError() { }
     void diagnostic() {
        cerr << "index " << m_badValue<< "out of range!";
     }
private:
    int m_badValue;
};
template <class T>
T& Vector<T>::operator[](int idx){ 
     if (idx < 0 || idx >= m_size) {
        throw VectorIndexError(idx); // the data object
     }
     return m_elements[idx];
}

对于调用者,如何处理抛出来的异常?

  • 直接无视,相应的语句并未执行
int func() {
     Vector<int> v(12);
     v[3] = 5;
     int i = v[42]; // out of range
     // control never gets here!
     return i * 5;
}
  • 如果我们需要关系,我们将可能抛出异常的语句放到 try 块里面去,catch 用来接住抛出的异常,解决相应的异常,参数的抛出异常的类型
void outer() {
     try {
         func();
         func2();
     } catch (VectorIndexError& e) {
       e.diagnostic();
       // This exception does not propagate
     }
     cout << "Control is here after exception";
}
  • Mildly interested 只是看一下,但是不处理
void outer2() {
     string err_msg("exception caught");
     try {
         func();
     } catch (VectorIndexError&) {
         cout << err_msg;
         throw; // propagate the exception
     }
}
  • Doesn’t care about the details. 不考虑异常类型,... 的意思是匹配任意异常类型
void outer3() {
     try {
      outer2();
     } catch (...) {
        // ... catches ALL exceptions!
        cout << "The exception stops here!";
     }
}

上述这个异常抛出链如下

throw 语句触发异常,控制沿调用链回溯至第一个匹配的异常处理器,同时确保栈上的对象被正确销毁。

throw exp; 用于主动抛出一个新的异常,而 throw; 则是在处理异常的过程中,如果需要,可以将当前异常再次抛出,以便进一步处理【只在 handler 中有效】。

一个 try 后面可以加很多的 catch 以捕获不同的异常

try { ... }
catch { ... }
catch { ... }

对于异常处理的 handlers,可以

  • Select exception by type 通过类型选择异常

  • Can re-raise,可以再次抛出,throw

  • Two forms

catch (SomeType v) { // handler code
}
catch (...) { // handler code
}
  • 总是只接受一个参数

对于异常处理程序 (handler) 可以有任意数量的处理程序,那么是如何选择相应的异常处理程序呢?

  • 精确匹配:当抛出异常时,系统会首先尝试找到与异常类型完全匹配的处理程序。例如,如果抛出了 SomeException 类型的异常,那么系统会优先查找 catch (SomeException e) 这样的处理程序。但是【要注意将子类的 catch 写在父类的前面,否则会向上造型】。
  • 应用基类转换:如果找不到精确匹配的处理程序,系统会尝试将异常对象转换为其基类类型,并寻找相应的处理程序。这种转换仅适用于引用类型和指针类型。例如,如果 SomeException 继承自 BaseException,而没有找到 catch (SomeException e),系统会继续查找 catch (BaseException e)
  • 通用捕获处理程序:如果前两步都没有找到合适的处理程序,系统会使用通用的 catch (...) 处理程序来捕获所有未被其他处理程序捕获的异常。相应的 catch(...) 也要写在最后,否则也会拦截掉【甚至会直接报错】。

所以写 catch 类型的顺序的时候,按照子类往父类写,如果存在 catch(...) 要放在最后

继承可用于结构化异常:通过定义异常类的继承关系,可以更好地组织和管理异常。例如,可以创建一个基异常类 MathErr,然后根据不同的错误类型派生出具体的异常类,如 OverflowErrUnderflowErr 等。这样,在编写异常处理程序时,可以根据需要选择捕获特定类型的异常或其基类异常。

class MathErr {
 ...
 virtual void diagnostic();
};
class OverflowErr : public MathErr { ... }
class UnderflowErr : public MathErr { ... }
class ZeroDivideErr : public MathErr { ... }
try {
 // code to exercise math options
 throw UnderFlowErr();
} catch (ZeroDivideErr& e) {
 // handle zero divide case
} catch (UnderFlowErr& e) {
 // handle underflow errors
} catch (MathErr& e) {
 // handle other math errors
} catch (...) {
 // any other exceptions
}

标准库里面也给我们定义了一些异常类型

Exceptions and new: new 出现错误的时候并不会返回 0,而是会抛出一个异常 bad_alloc()

noexcept 标识符,用于标识一个函数不会抛出异常

void abc(int a) noexcept { ... }

以便编译器可以更有效地运行代码,但是如果这个函数真抛出了异常,那么 std::terminate 就会被执行。有些函数比如说 destructors 或者 move constructor 等等函数就要求不抛出异常


Design considerations

异常机制还是要用来检测错误的,设计的时候最好不要利用这个机制去处理一些不是错误的内容,比如说

try {
 for (;;) {
 p = list.next()
 // ... 
 }
} catch (List::end_of_list) {
 // handle end of list here
}

把链表的末尾当作一个异常来处理,这就不太好。

还有一些不必要的异常,比如说

void func() {
 File f;
 if (f.open("somefile")) {
     try {
        // work with f
     } catch (...) {
        f.close()
     }
 }
}

完全可以选择将 f.close() 这个函数放到析构函数里面去完成,抛出异常时会退栈,而退栈的时候就会调用相应的析构函数。


构造函数中发生的错误,因为没有返回值,所以不能用错误码【但是可以在这个类中加一个字段以标识是否正常完成构造函数】,然后将相应的工作在构造函数外面再加上一层函数,以实现相应的构造函数。但是这个方法总归来说不太好,多一个字段,多一层调用函数。于是我们依然使用抛出异常的方式来完成。

但是如果 constructor 抛出了异常,那么会不会出现内存泄漏的问题呢?

int main(){
    X x;//不会发生内存泄漏,因为栈上的动态内存会被收回的,这里的内存指的是 x 的内存

    Y* y = new Y()//new执行两个阶段,一个阶段是分配内存,另一个阶段是初始化,如果初始化出现异常,那么栈上的内存会被收回,同时由于new,并没有做完,所以指针y也不会发生内存泄漏
}
class A
{
private:
    int* vdata;
public:
    A():vdata(new int[10]){
        std::cout<<"A()"<<std::endl;
        if (true){
            throw 2;
        }
    }
    ~A(){
        std::cout<<"~A()"<<std::endl;
        delete[] vdata;
        std::cout<<"deleting vdata......"<<std::endl;
    }
}

int main(){
    try{
        A a;
    } catch (...){
        std::cout<<"caught"<<std::endl;
    }
}

运行之后发现输出为

A()
caught

说明我并没有执行语句 delete[] vdata;,那么 vdata 就会出现内存泄漏。可以通过一个两段式的方式来解决这个问题

在构造函数中初始化所有成员对象,并将所有指针设置为 nullptr,然后通过一个 init() 函数来分配资源,这样构造相应的对象就需要两步

A a;
a.init();

如果 A a 发生错误,相应的成员对象都在栈上被正常销毁,然后如果 a.init() 发生错误,仍然会调用相应的析构函数(因为 a 需要被销毁),相应的指针也会被释放。但是这样两段式的构造方式显然不够好。

我们可以在抛出异常前释放所有分配的函数。

A():vdata(new int[10]){
    std::cout<<"A()"<<std::endl;
    if (true){
        delete[] vdata;
        throw 2;
    }
}

但是这样相应的释放部分要写两份,同时释放的时候也许也会产生错误。更好的方式是将数据进行打包,打包到一个类中

class Wrapper{
private:
    int* vdata;
public:
    Wrapper(int* data):vdata(data){
        cout<<"W::W()"<<endl;
    }
    Wrapper(){
        delete[] vdata;
        cout<<"W::~W()"<<endl;
    }
}

class A
{
private:
    Wrapper w;
public:
    A():w(new int[10]){
        std::cout<<"A()"<<std::endl;
        if (true){
            throw 2;
        }
    }
    ~A(){
        std::cout<<"~A()"<<std::endl;
    }
}

int main(){
    try{
        A a;
    } catch (...){
        std::cout<<"caught"<<std::endl;
    }
}

由于这样的情况实在是太常见了,所有库中给我们提供了智能指针。std::unique_ptr, std::shared_ptr,将上述代码替换为

class A
{
private:
    std::unique_ptr up;
public:
    A():up(new int[10]){
        std::cout<<"A()"<<std::endl;
        if (true){
            throw 2;
        }
    }
    ~A(){
        std::cout<<"~A()"<<std::endl;
    }
}

析构函数在两种情况下会被调用,一种是正常的生命周期结束的时候调用,另一种就是在异常抛出的时候,销毁栈上的变量的时候调用。如果析构函数进行过程中发生的异常,程序就会终止

评论区

对你有帮助的话请给我个赞和 star => GitHub stars
欢迎跟我探讨!!!