MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ 异常深入解析

2022-03-121.6k 阅读

C++ 异常基础

异常的概念

在程序运行过程中,可能会出现各种错误情况,例如除零操作、内存分配失败、文件读取错误等。传统的错误处理方式通常是通过返回值来表示错误,调用者需要检查这些返回值并采取相应的措施。然而,这种方式使得代码变得复杂,难以阅读和维护,尤其是在多层函数调用的情况下。

C++ 引入了异常机制,它提供了一种更加结构化和统一的错误处理方式。异常允许程序在发生错误时抛出一个对象,这个对象可以携带关于错误的详细信息。程序中的其他部分可以捕获这个异常,并根据异常类型进行相应的处理。这样可以将错误处理代码与正常的业务逻辑代码分离,提高代码的可读性和可维护性。

异常的抛出

在 C++ 中,使用 throw 关键字来抛出异常。throw 表达式可以是任何类型的对象,通常是自定义的异常类对象。例如,下面的代码演示了如何抛出一个整数类型的异常:

#include <iostream>

void divide(int a, int b) {
    if (b == 0) {
        throw 100; // 抛出整数类型异常,表示除零错误
    }
    std::cout << "Result: " << a / b << std::endl;
}

int main() {
    try {
        divide(10, 0);
    } catch (int e) {
        std::cerr << "Caught exception: " << e << ". Division by zero." << std::endl;
    }
    return 0;
}

在上述代码中,divide 函数检查除数 b 是否为零,如果是,则抛出一个整数 100。在 main 函数中,使用 try - catch 块来捕获这个异常。如果没有捕获到异常,程序将终止并显示错误信息。

异常的捕获

使用 try - catch 块来捕获异常。try 块包含可能抛出异常的代码,catch 块用于处理捕获到的异常。catch 块的参数类型指定了它能够捕获的异常类型。例如:

#include <iostream>

class MyException {
public:
    const char* what() const {
        return "This is a custom exception";
    }
};

void testException() {
    throw MyException();
}

int main() {
    try {
        testException();
    } catch (MyException& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    } catch (...) {
        std::cerr << "Caught an unknown exception" << std::endl;
    }
    return 0;
}

在这个例子中,testException 函数抛出一个 MyException 类型的异常。main 函数中的 try - catch 块首先尝试捕获 MyException 类型的异常,如果捕获到,则输出异常信息。catch(...) 是一个通用的异常捕获块,用于捕获任何类型的异常,但通常应尽量避免使用,因为它会捕获所有异常,可能导致难以调试和处理特定的错误。

异常的类型匹配与传播

类型匹配规则

当异常被抛出时,会按照 catch 块的顺序进行类型匹配。只有与异常对象类型完全匹配(或派生类对象与基类 catch 块匹配)的 catch 块才会被执行。例如:

#include <iostream>

class BaseException {
public:
    const char* what() const {
        return "Base exception";
    }
};

class DerivedException : public BaseException {
public:
    const char* what() const {
        return "Derived exception";
    }
};

void throwException() {
    throw DerivedException();
}

int main() {
    try {
        throwException();
    } catch (DerivedException& e) {
        std::cerr << "Caught DerivedException: " << e.what() << std::endl;
    } catch (BaseException& e) {
        std::cerr << "Caught BaseException: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,throwException 函数抛出一个 DerivedException 对象。try - catch 块中首先尝试匹配 DerivedException 类型的 catch 块,因为 DerivedExceptionBaseException 的派生类,所以它会优先匹配到 catch (DerivedException& e) 块,并输出相应的信息。如果将两个 catch 块的顺序颠倒,结果仍然会先匹配到 catch (DerivedException& e) 块,因为类型匹配是严格按照 catch 块的顺序进行的。

异常的传播

如果在 try 块中的函数调用抛出了异常,而当前的 try - catch 块没有匹配的 catch 块来捕获它,异常将向上传播到调用该函数的上层 try - catch 块。如果一直没有找到匹配的 catch 块,异常将最终传播到 main 函数。如果在 main 函数中仍然没有捕获到异常,程序将调用 std::terminate 函数,导致程序异常终止。

例如:

#include <iostream>

void innerFunction() {
    throw 42;
}

void middleFunction() {
    innerFunction();
}

int main() {
    try {
        middleFunction();
    } catch (int e) {
        std::cerr << "Caught exception: " << e << std::endl;
    }
    return 0;
}

在这个例子中,innerFunction 抛出一个整数异常,middleFunction 调用 innerFunction 但没有捕获异常,异常继续传播到 main 函数。main 函数中的 try - catch 块捕获到了这个异常并进行处理。

自定义异常类

设计原则

在实际应用中,通常需要定义自己的异常类,以便更好地表示特定的错误情况。自定义异常类的设计应遵循以下原则:

  1. 继承关系:通常从标准库中的异常类(如 std::exception)或其他已有的异常类派生,这样可以利用已有的异常处理机制和接口。
  2. 信息提供:异常类应提供足够的信息来描述错误,例如通过成员函数返回错误信息字符串。
  3. 简洁性:异常类的设计应尽量简洁,避免复杂的状态和行为,主要专注于错误信息的传递。

示例

下面是一个自定义异常类的示例,它从 std::exception 派生:

#include <iostream>
#include <stdexcept>

class FileOpenException : public std::runtime_error {
public:
    FileOpenException(const std::string& message) : std::runtime_error(message) {}
};

void openFile(const std::string& filename) {
    // 模拟文件打开失败
    if (filename.empty()) {
        throw FileOpenException("Failed to open file: filename is empty");
    }
    std::cout << "File " << filename << " opened successfully" << std::endl;
}

int main() {
    try {
        openFile("");
    } catch (const FileOpenException& e) {
        std::cerr << "Caught FileOpenException: " << e.what() << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Caught other exception: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,FileOpenException 类继承自 std::runtime_error,它是标准库中用于表示运行时错误的异常类。FileOpenException 类的构造函数接受一个错误信息字符串,并将其传递给基类的构造函数。openFile 函数在文件名为空时抛出 FileOpenException 异常。main 函数中的 try - catch 块首先捕获 FileOpenException 异常,如果捕获到,则输出错误信息。如果捕获到其他类型的异常,也会输出相应的信息。

异常与资源管理

RAII 概念

资源管理是编程中的一个重要问题,例如内存分配、文件打开、锁获取等资源需要在使用完毕后正确释放,否则会导致资源泄漏。C++ 中常用的资源管理技术是 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

RAII 的核心思想是将资源的获取和释放与对象的生命周期绑定。当对象构造时获取资源,当对象析构时释放资源。这样,即使在程序中发生异常,对象的析构函数也会自动被调用,从而确保资源的正确释放。

示例

以动态内存分配为例,传统的内存管理方式容易导致内存泄漏:

#include <iostream>

void badMemoryManagement() {
    int* ptr = new int[10];
    // 假设这里发生异常
    throw std::runtime_error("Some error occurred");
    delete[] ptr; // 这行代码不会被执行,导致内存泄漏
}

int main() {
    try {
        badMemoryManagement();
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

使用 RAII 技术,通过智能指针(如 std::unique_ptr)来管理内存,可以避免这种情况:

#include <iostream>
#include <memory>

void goodMemoryManagement() {
    std::unique_ptr<int[]> ptr(new int[10]);
    // 假设这里发生异常
    throw std::runtime_error("Some error occurred");
    // 这里不需要手动释放内存,std::unique_ptr 的析构函数会自动释放
}

int main() {
    try {
        goodMemoryManagement();
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,std::unique_ptr<int[]> 会在其作用域结束时自动调用 delete[] 来释放分配的内存,无论是否发生异常。同样,对于文件资源,可以使用 std::ifstreamstd::ofstream,它们在析构时会自动关闭文件。

异常安全性

异常安全级别

异常安全性是指在异常发生时,程序能够保持在一个合理的状态,不会导致资源泄漏或数据损坏。C++ 中的异常安全级别通常分为以下几种:

  1. 基本异常安全:如果函数抛出异常,程序的状态不会被破坏,所有资源都能正确释放,但程序的具体状态可能无法预测。例如,一个函数可能部分完成了操作,但由于异常而没有完全完成,但不会导致资源泄漏。
  2. 强异常安全:如果函数抛出异常,程序的状态保持不变,就像函数从未被调用过一样。所有资源都被正确管理,并且不会有数据损坏。
  3. 不抛出异常:函数承诺不会抛出任何异常,通常通过 noexcept 关键字来声明。

实现异常安全

要实现异常安全,需要遵循一些原则:

  1. 使用 RAII:如前所述,使用 RAII 技术来管理资源,确保资源在异常发生时能正确释放。
  2. 事务性语义:对于涉及多个操作的函数,确保这些操作要么全部成功,要么全部回滚。例如,在数据库事务中,如果一个操作失败,需要回滚之前的所有操作。
  3. 谨慎使用第三方库:某些第三方库可能不遵循异常安全原则,在使用这些库时需要特别小心,可能需要进行额外的封装或错误处理。

下面是一个实现强异常安全的示例:

#include <iostream>
#include <vector>

class MyClass {
private:
    std::vector<int> data;
public:
    MyClass(const std::vector<int>& newData) {
        std::vector<int> temp(newData); // 临时向量,用于存储新数据
        try {
            data.swap(temp); // 交换数据,这是一个无异常操作
        } catch (...) {
            // 如果交换失败,不需要做任何事情,因为 temp 会在析构时释放资源
        }
    }
};

int main() {
    try {
        std::vector<int> values = {1, 2, 3};
        MyClass obj(values);
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,MyClass 的构造函数首先创建一个临时向量 temp 来存储传入的数据。然后使用 swap 函数将 tempdata 交换,swap 函数通常是无异常的。如果在交换过程中发生异常,temp 会在析构时自动释放资源,确保了强异常安全。

异常与性能

异常对性能的影响

异常机制在提供强大的错误处理能力的同时,也会对程序性能产生一定的影响。主要体现在以下几个方面:

  1. 空间开销:异常对象需要占用额外的内存空间,用于存储错误信息等数据。
  2. 时间开销:抛出和捕获异常涉及到栈展开等操作,这些操作相对复杂,会增加程序的执行时间。栈展开需要从抛出异常的位置回溯到最近的匹配 catch 块,期间需要销毁局部对象并释放资源。

性能优化策略

为了减少异常对性能的影响,可以采取以下策略:

  1. 避免不必要的异常:在性能关键的代码路径中,尽量使用传统的错误处理方式(如返回值),只有在真正发生异常情况时才使用异常机制。
  2. 优化异常对象:自定义异常类应尽量轻量级,减少不必要的数据成员,以降低空间和时间开销。
  3. 使用 noexcept:对于不会抛出异常的函数,使用 noexcept 关键字进行声明。编译器可以对这些函数进行优化,例如在调用 noexcept 函数时可以省略栈展开的准备工作,从而提高性能。

例如:

#include <iostream>

// noexcept 声明函数不会抛出异常
void noexceptFunction() noexcept {
    std::cout << "This function does not throw exceptions" << std::endl;
}

int main() {
    noexceptFunction();
    return 0;
}

在上述代码中,noexceptFunction 函数使用 noexcept 声明,编译器可以对其进行相应的优化,提高程序性能。

异常规范(旧标准)

异常规范的概念

在 C++ 旧标准(C++98)中,提供了异常规范来声明函数可能抛出的异常类型。异常规范的目的是让调用者知道函数可能抛出哪些异常,从而提前做好相应的处理。

异常规范通过在函数声明的参数列表后加上 throw(类型列表) 来指定,其中 类型列表 列出了函数可能抛出的异常类型。例如:

void function() throw(int, std::runtime_error);

上述声明表示 function 函数可能抛出 int 类型或 std::runtime_error 类型的异常。

异常规范的问题

然而,C++ 旧标准的异常规范存在一些问题:

  1. 违反开闭原则:如果在后续开发中需要让函数抛出新的异常类型,就需要修改函数的声明以及所有调用该函数的代码,这违反了开闭原则(对扩展开放,对修改关闭)。
  2. 运行时检查开销:异常规范需要在运行时进行检查,如果函数抛出了不在规范中的异常,会调用 std::unexpected 函数,这增加了运行时的开销。

由于这些问题,C++11 引入了 noexcept 来替代旧的异常规范,noexcept 更加简洁且高效。

noexcept 说明符

noexcept 的用法

C++11 引入的 noexcept 说明符用于声明函数是否可能抛出异常。noexcept 有两种形式:

  1. 简单形式noexcept,表示函数不会抛出异常。例如:
void myFunction() noexcept {
    // 函数实现
}
  1. 带条件形式noexcept(表达式),表达式的值为 true 时表示函数不会抛出异常,为 false 时表示函数可能抛出异常。例如:
template <typename T>
void swap(T& a, T& b) noexcept(std::is_nothrow_swappable<T>::value) {
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

在上述代码中,swap 函数的 noexcept 说明符使用了带条件形式,根据 std::is_nothrow_swappable<T>::value 的值来确定函数是否抛出异常。如果 T 类型的对象在交换时不会抛出异常,那么 swap 函数也不会抛出异常。

noexcept 的优点

与旧的异常规范相比,noexcept 具有以下优点:

  1. 简洁性noexcept 语法简单,只需要在函数声明中添加 noexcept 关键字即可,不需要列出具体的异常类型。
  2. 编译时检查noexcept 是在编译时进行检查,而不是运行时,这减少了运行时的开销。如果一个 noexcept 函数内部抛出了异常,编译器会报错。
  3. 优化机会:编译器可以对 noexcept 函数进行更多的优化,例如省略栈展开的准备工作,从而提高程序性能。

异常与多线程

多线程中的异常处理挑战

在多线程编程中,异常处理面临一些额外的挑战:

  1. 线程安全性:不同线程之间可能同时抛出异常,这可能导致数据竞争和未定义行为。例如,多个线程同时访问和修改共享资源时,如果其中一个线程抛出异常,可能会使共享资源处于不一致的状态。
  2. 异常传播:异常在多线程环境中的传播比较复杂。一个线程抛出的异常不能直接被另一个线程捕获,需要特殊的机制来处理跨线程的异常。

跨线程异常处理

为了处理跨线程的异常,可以使用一些技术:

  1. 线程局部存储(TLS):可以使用线程局部存储来存储每个线程的异常信息。当一个线程抛出异常时,将异常信息存储在 TLS 中,其他线程可以通过某种方式获取该异常信息并进行处理。
  2. Future 和 Promise:C++11 引入的 std::futurestd::promise 可以用于处理跨线程的异常。std::promise 可以存储一个值或一个异常,std::future 可以获取这个值或异常。例如:
#include <iostream>
#include <thread>
#include <future>

void threadFunction(std::promise<int>& p) {
    try {
        // 模拟一些操作
        if (true) {
            throw std::runtime_error("Thread exception");
        }
        p.set_value(42);
    } catch (...) {
        p.set_exception(std::current_exception());
    }
}

int main() {
    std::promise<int> p;
    std::future<int> f = p.get_future();
    std::thread t(threadFunction, std::ref(p));

    try {
        std::cout << "Result: " << f.get() << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Caught exception from thread: " << e.what() << std::endl;
    }

    t.join();
    return 0;
}

在上述代码中,threadFunction 函数在发生异常时,使用 p.set_exception 将异常存储在 std::promise 中。main 函数通过 f.get() 获取 std::future 中的值或异常,如果获取到异常,则进行相应的处理。

通过这些方式,可以在多线程环境中有效地处理异常,确保程序的稳定性和可靠性。