Skip to content

Chapter 13 Exceptions

1 Why Exceptions

C++ 的理念是格式错误的代码不应运行,但运行时仍可能出现各种异常情况,程序必须设计好错误处理机制。

以读文件为例,若用错误码处理,每一步都要手动嵌套判断,结果是正常逻辑被大量错误处理代码淹没。

错误码风格
errorCodeType readFile {
    initialize errorCode = 0;
    open the file;
    if ( theFilesOpen ) {
        determine its size;
        if ( gotTheFileLength ) {
            allocate that much memory;
            if ( gotEnoughMemory ) {
                read the file into memory;
                if ( readFailed ) {
                    errorCode = -1;
                }
            } else {
                errorCode = -2;
            }
        } else {
            errorCode = -3;
        }
        close the file;
        if ( theFILEDidntClose && errorCode == 0 ) {
            errorCode = -4;
        }
    } else {
        errorCode = -5;
    }
    return errorCode;
}

异常(Exceptions)的价值在于分离了正常业务逻辑和错误处理逻辑。

异常风格
try {
    open_the_file();
    determine_size();
    allocate_memory();
    read_file();
    close_file();
} catch (fileOpenFailed) {
    doSomething();
} catch (sizeDeterminationFailed) {
    doSomething();
} catch (memoryAllocationFailed) {
    doSomething();
} catch (readFailed) {
    doSomething();
} catch (fileCloseFailed) {
    doSomething();
}

2 Exceptions Throwing & Propagation

Vector Index Error
template <class T>
class Vector {
private:
    T* m_elements;
    int m_size;
public:
    Vector(int size = 0) : m_size(size) { /* ... */ }
    ~Vector() { delete[] m_elements; }
    void length(int);
    int length() { return m_size; }
    T& operator[](int);
};

operator[] 越界怎么办?

  • 返回随机内存:显然不行
  • 返回魔法错误值:污染正常语义
  • 直接 exit():库代码替调用方做决定
  • assert():只适合调试阶段
  • 更合理的方式:抛出异常,让调用者决定

抛出异常用 throw 关键字,典型做法是定义一个异常类,携带错误数据。

示例
异常类型
class VectorIndexError {
public:
    VectorIndexError(int v) : m_badValue(v) {}
    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);
    }
    return m_elements[idx];
}

调用者的 4 种态度

Case 1:代码完全没有预判到会出问题
int func() {
    Vector<int> v(12);
    v[3] = 5;
    int i = v[42]; // out of range
    // control never gets here!
    return i * 5;
}
Case 2:捕获并处理
void outer() {
    try {
        func();
        func2();
    } catch (VectorIndexError& e) {
        e.diagnostic();
        // This exception does not propagate
    }
    cout << "Control is here after exception";
}
Case 3:重新抛出
void outer2() {
    String err("exception caught");
    try {
        func();
    } catch (VectorIndexError) {
        cout << err;
        throw; // propagate the exception
    }
}
Case 4:捕获任意异常
void outer3() {
    try {
        outer2();
    } catch (...) {
        // ... catches ALL exceptions!
        cout << "The exception stops here!";
    }
}

控制流沿调用链向外回退,直到找到第一个匹配的处理器,栈上对象会在回退过程中正常析构(stack unwinding)。

What Happens During Propagation?
  • operator[]() 抛出异常后,不会“返回错误码”,而是直接中断当前正常流程
  • func() 没有 handler,控制继续回退到 outer2()
  • outer2() 若只是记录信息后执行 throw;,异常会继续传播到 outer3()
  • outer3() 中的 catch (...) 可以把这次传播真正截住
  • throw exp;:抛出一个新异常对象
  • throw;:重新抛出当前正在处理的异常,仅在异常处理器内部有效

3 Exception Hierarchy

Try Block
try {
    // ...
} catch (...) {
    // ...
}

一个 try 后面可跟多个 catch,如果根本不打算处理,也可以不写 try,建立 handler 有运行时成本。

handler 选择时,先找精确类型匹配,再考虑基类转换(仅引用/指针),最后考虑 catch (...)。因此,使用 handler 时捕获顺序很重要,派生类 handler 应放在基类 handler 前面

示例:using inheritance
class MathErr {
public:
    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 (MathErr& e) {
    // handle other math errors
} catch (UnderFlowErr& e) {
    // handle underflow errors
} catch (...) {
    // any other exceptions
}

  • new:分配失败时不会返回 0,而是抛出 std::bad_alloc
示例
try {
    while (1) {
        char* p = new char[10000];
    }
} catch (std::bad_alloc& e) {
    // ...
}
  • 异常说明(Exception specifications):用于指定函数是否可能抛出异常,是函数类型的一部分,但不属于函数签名。
示例:noexcept
void abc(int a) noexcept {
    // ...
}

编译器不一定会在编译期检查它,但会利用它来进行特定优化。如果该函数抛出了异常,程序会调用 std::terminate 直接终止运行。

4 Design Considerations

异常应用于表示错误,不应用于替代正常流程控制。

不合适的用法
try {
    for (;;) {
        p = list.next();
    }
} catch (List::end_of_list) {
    // 用异常表示普通迭代结束,不推荐
}

不能用异常代替良好设计,资源释放应交给对象析构(RAII),不要手工写伪 finally 式清理。

更好的资源管理
void func() {
    File f("some file");
    // 假设析构函数自动关闭文件
    if (f.ok()) {
        // ...
    }
}

5 Constructors, Destructors and Exceptions

构造函数不能返回错误码,更好的方式是直接抛异常。

如果构造函数抛异常,该对象自身的析构函数不会被调用。若在抛出前已手动申请资源,必须先清理,否则会泄漏。

两阶段构造(Two-stage construction)
  • 第一阶段:构造函数只做基础初始化
    • 初始化成员对象
    • 初始化内置类型成员
    • 把指针设为 0 / nullptr
  • 第二阶段:把额外可能失败的工作放进 Init()
    • 打开文件
    • 网络连接
    • 动态申请资源

Smart Pointers

智能指针在析构时会自动释放所管理的原生指针。

  • std::unique_ptr
  • std::shared_ptr

析构函数会在正常离开作用域或异常传播导致的栈展开时会被调用。其应遵循的规则是不能让异常从析构函数逃出去,如果析构函数在异常展开期间再次抛异常,程序会 std::terminate()

6 Catch by Reference

为什么最好按引用捕获?

  • 按值捕获会切片(slicing)
  • 按指针捕获则把释放责任带进异常处理代码,容易泄漏
切片问题
struct X {};
struct Y : public X {};

try {
    throw Y();
} catch (X x) {
    // 已切片,分不清原来是 X 还是 Y
}

推荐方式

struct B {
    virtual void print() { /* ... */ }
};
struct D : public B { /* ... */ };

try {
    throw D();
} catch (B& b) {
    b.print();
}

若异常最终无人捕获,会调用 std::terminate()

示例
void my_terminate() {
    /* ... */
}

set_terminate(my_terminate);

7 Exception-safe Code

代码在异常发生后仍应保持对象状态一致。

错误顺序

void withdrawMoney(int amount) {
    reduceBalance(amount);
    prepareCash();  // 这里抛异常
    releaseCash();
}
  • 如果 prepareCash() 抛异常,余额已经被减少,系统状态不一致。
  • 设计时应优先保证不变量不被破坏、资源有对象托管、失败后系统仍处于合理状态。

总原则

  • 早期就制定错误处理策略
  • 不要滥用 try/catch,应通过对象来管理资源的获取与释放
  • 本地控制结构(如 if/else)能处理的,不要强行上异常
  • 不是每个函数都能处理每种错误
  • 为核心接口添加异常说明(如 noexcept
  • 库代码不应擅自终止程序,应抛给调用者决定