【C++】异常

【C++】异常

1、概念

1.1 C语言处理错误方式

传统的错误处理机制:

  1. 终止程序,如assert,缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。

  2. 返回错误码,缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通 过把错误码放到errno中,表示错误

    实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。

1.2 引入异常

在C++中,异常是程序运行过程中的一种错误或意外情况,它破坏了程序的正常执行流程。
当发生异常时,程序会立即跳转到异常处理代码,以便在异常发生时采取适当的措施,比如恢复错误、清理资源或提供错误信息。

以下是C++中异常的一些关键概念:

  1. 异常类型:异常可以是内置类型(如整数、字符等)或自定义类型。通过抛出不同类型的异常,可以在异常处理代码中针对不同的情况进行不同的处理。

  2. 异常抛出:当程序运行到某个异常情况时,可以使用throw语句抛出一个异常。throw语句后面可以是一个表达式,该表达式的类型决定了抛出的异常类型。

  3. 异常捕获:在C++中,异常的捕获是通过try-catch语句来实现的。try块中的代码用于包含可能引发异常的语句,而catch块则用于捕获并处理特定类型的异常。可以使用多个catch块来捕获不同类型的异常,以便针对每种异常类型执行不同的操作。

  4. 异常处理:一旦异常被抛出,程序的执行将立即跳转到最近的匹配的catch块。在catch块中,可以编写处理异常的代码,比如输出错误信息、进行错误恢复或清理资源。如果没有找到匹配的catch块,程序将终止并显示默认的异常处理机制。

  5. 异常传播:如果异常在catch块中没有被处理,它可以被重新抛出,使其在更高层次的异常处理代码中得到处理。这样可以实现异常在不同层次之间的传播,直到找到匹配的catch块进行处理或直到程序终止。

1.3 异常的意义

异常处理在C++中具有重要的意义,它带来了以下几个方面的好处:

  1. 错误处理:异常提供了一种机制来处理程序执行中的错误。当程序遇到异常情况时,可以通过异常处理代码来采取适当的措施,如错误恢复、资源清理等。这有助于避免程序在发生错误时崩溃或产生无法预料的行为。

  2. 容错能力:通过使用异常处理,程序可以更好地处理意外情况。即使某个函数或方法出现错误,异常处理机制也可以使程序得以继续执行,而不会导致整个程序的崩溃。这提高了程序的容错能力和稳定性。

  3. 代码组织:异常处理机制促使开发人员将错误处理代码与正常的业务逻辑代码分离开来。通过将异常处理代码放置在catch块中,可以更好地组织代码,使得业务逻辑更加清晰和易于理解。这有助于提高代码的可读性和可维护性。

  4. 异常传播:异常处理机制允许异常在不同的函数或方法之间传播。当一个函数抛出异常时,它可以被上层调用函数的异常处理代码捕获并处理。这样的异常传播机制使得错误可以在程序的不同层次之间传递,从而使得错误的处理更加灵活和全面。

  5. 异常信息:异常处理允许开发人员捕获和记录有关错误的信息,以便进行错误分析和调试。通过在catch块中获取异常对象的信息,可以了解发生异常的具体原因,并采取适当的措施进行处理。

总体而言,异常处理机制在C++中的意义在于提供一种结构化和灵活的错误处理机制,增强程序的健壮性和可靠性。它使开发人员能够更好地管理和处理程序中的异常情况,从而提高程序的可维护性和可靠性。

2、用法

2.1 抛出&捕获

异常的抛出和匹配原则:

  1. 异常对象的类型决定匹配:在抛出异常时,抛出的对象的类型决定了应该激活哪个catch块的处理代码。只有与抛出的异常对象类型匹配的catch块会被执行。

  2. 最近匹配原则:如果在调用链中存在多个catch块与抛出的异常对象类型匹配,那么离抛出异常位置最近的catch块会被选中执行。这意味着程序会从当前位置开始,依次向上搜索调用链,直到找到最近匹配的catch块。

  3. 异常对象的拷贝:抛出异常对象后,会生成该异常对象的拷贝。这是因为抛出的异常对象可能是一个临时对象,为了在异常处理期间保持异常对象的有效性,会创建一个拷贝对象。这个拷贝对象在catch块处理完异常后会被销毁。

  4. catch(...)的使用:catch(...)是一种特殊的catch块,可以捕获任意类型的异常。但是,它无法知道具体的异常类型,因此只能用于处理未知的异常错误。应该尽量避免在正常情况下使用catch(...),而是针对特定的异常类型提供明确的处理代码。

  5. 异常的派生类匹配:实际中,可以抛出派生类对象,并在catch块中使用基类类型进行捕获。这种情况下,派生类对象会与基类类型匹配,因为派生类对象也是基类的一种形式。这种技术称为基类捕获,它在实际编程中非常实用,可以处理一组相关的异常类型。

需要注意的是,异常处理的匹配原则并不总是依赖于类型完全匹配,还涉及到派生类和基类之间的关系。这允许开发人员在catch块中使用更通用的异常类型来捕获一组相关的异常,并提供相应的处理代码。这种灵活性使异常处理更具可扩展性和可维护性。

在函数调用链中,异常栈展开的匹配原则是指异常在调用链中传播时如何查找匹配的catch块进行处理。以下是具体的匹配原则:

  1. 检查是否在try块内:首先检查抛出异常的位置是否在try块内部。如果是,在当前函数中查找匹配的catch块。

  2. 向上查找调用栈:如果当前函数中没有匹配的catch块,异常会沿着调用栈向上查找,即返回调用当前函数的函数,继续在其内部查找匹配的catch块。

  3. 递归向上查找:这个过程会一直递归执行,直到找到匹配的catch块或者到达调用栈的最顶层(例如main函数)。如果在整个调用链中都没有找到匹配的catch块,则程序会终止。

  4. 匹配后处理:找到匹配的catch块后,程序会跳转到该catch块并执行相应的处理代码。处理完成后,程序将继续执行位于匹配的catch块后面的代码。

需要注意的是,异常栈展开的匹配原则是按照函数调用链的顺序进行查找,而且只有在try-catch块中的代码才会触发异常栈展开。如果没有找到匹配的catch块,程序会终止。因此,为了确保异常能够被处理,通常会在调用栈的最顶层(例如main函数)添加一个catch(...)块来捕获任意类型的异常,以防止异常没有被处理而导致程序的终止。

当抛出异常时,异常的类型将决定哪个catch块将处理该异常。下面是一个简单的例子,以帮助理解异常的抛出和匹配过程:

#include <iostream>

void foo()
{
    try {
        throw 42;  // 抛出整数异常
    }
    catch (int num) {
        std::cout << "Caught exception: " << num << std::endl;
        throw;  // 重新抛出异常
    }
}

int main()
{
    try {
        foo();  // 调用函数foo
    }
    catch (double) {
        std::cout << "Caught double exception" << std::endl;
    }
    catch (int) {
        std::cout << "Caught int exception" << std::endl;
    }
    catch (...) {
        std::cout << "Caught unknown exception" << std::endl;
    }

    return 0;
}

在上述示例中,函数foo抛出一个整数异常(42)。然后,在main函数中调用foo

foo函数中,我们使用try-catch块捕获整数异常,并打印出相应的消息。然后,我们使用throw语句重新抛出异常,使其在调用栈中向上传播。

main函数中,我们使用多个catch块来匹配异常类型。首先,我们的catch块按顺序尝试匹配double类型的异常,但这并不匹配抛出的整数异常。然后,我们的catch块匹配int类型的异常,成功捕获并打印消息。最后,我们有一个catch块,使用...捕获任意类型的异常,以防止未被其他catch块捕获的异常。在此示例中,我们不会到达这个catch块,因为异常已经被捕获并处理。

运行上述代码将产生以下输出:

Caught exception: 42
Caught int exception

从输出中可以看出,抛出的异常首先在foo函数中被捕获,然后被重新抛出,最终在main函数中被匹配并捕获。

这个例子演示了异常的抛出和匹配过程。抛出的异常对象的类型决定了应该激活哪个catch块,而在调用链中寻找匹配的catch块,并且只有最接近抛出异常位置的匹配的catch块将被执行。

2.2 重新捕获

重新捕获(Re-throw)是指在异常处理代码中重新抛出(re-throw)当前捕获的异常,使得该异常可以在调用栈的更高层次继续传播。重新捕获的目的是将异常传递给更高层的异常处理代码或者使得异常在程序的其他部分进行处理。

在C++中,通过使用 throw; 语句可以重新抛出当前捕获的异常。这个语句不带参数,它会重新抛出当前已捕获的异常对象,使得异常在调用栈中向上传播。

重新捕获的含义在于:

  1. 传播异常:通过重新抛出异常,可以将异常传递给调用当前函数的函数,进而继续在更高层次的异常处理代码中处理该异常。这样可以实现异常在不同层次之间的传播,直到找到合适的异常处理代码。
  2. 选择不同的异常处理策略:在重新捕获异常时,可以在不同的上下文中采用不同的异常处理策略。例如,可以在某个函数中捕获并记录异常,然后在调用该函数的代码中重新捕获并执行特定的错误恢复操作。
  3. 异常过滤和重分类:重新捕获异常还可以用于异常过滤和重分类的目的。通过在不同的 catch 块中重新抛出异常,可以将异常重新分类为不同的异常类型,并由匹配的 catch 块进行处理。

需要注意的是,重新捕获异常不是必需的,如果在当前的异常处理代码中无法处理异常,异常会自动传播到调用栈的更高层次。重新捕获的使用应该是有明确目的的,用于特定的异常处理需求。在进行重新捕获时,需要注意异常类型的匹配,以便在适当的地方捕获和处理异常。

下面是一个简单的例子,演示如何在异常处理代码中重新捕获异常:

#include <iostream>

void foo()
{
    try {
        throw "Exception in foo";  // 抛出字符串异常
    }
    catch (const char* ex) {
        std::cout << "Caught exception in foo: " << ex << std::endl;
        throw;  // 重新抛出异常
    }
}

void bar()
{
    try {
        foo();  // 调用函数 foo
    }
    catch (const char* ex) {
        std::cout << "Caught exception in bar: " << ex << std::endl;
    }
}

int main()
{
    try {
        bar();  // 调用函数 bar
    }
    catch (const char* ex) {
        std::cout << "Caught exception in main: " << ex << std::endl;
    }

    return 0;
}

在上述示例中,函数 foo 抛出一个字符串异常。在 foo 函数的异常处理代码中,我们捕获并打印异常信息,然后使用 throw; 语句重新抛出异常。

bar 函数中,我们调用了 foo 函数。在 bar 函数的异常处理代码中,我们捕获并打印异常信息。

最后,在 main 函数中调用 bar 函数。在 main 函数的异常处理代码中,我们再次捕获并打印异常信息。

运行上述代码将产生以下输出:

Caught exception in foo: Exception in foo
Caught exception in bar: Exception in foo
Caught exception in main: Exception in foo

从输出中可以看出,异常在 foo 函数中首先被捕获和处理,然后在 foo 函数中重新抛出。这个重新抛出的异常被传递到调用栈中的 bar 函数的异常处理代码中,并被捕获和处理。最后,异常再次被传递到 main 函数的异常处理代码中,并被捕获和处理。

这个例子展示了如何在异常处理代码中重新捕获异常。重新捕获异常可以将异常传递给更高层的异常处理代码,实现异常在调用栈中的传播。

2.3 异常安全

在C++中,建议在构造函数和析构函数中尽量避免抛出异常,以避免对象不完整或资源泄漏等问题。

  1. 构造函数中的异常:在构造函数中抛出异常可能导致对象不完整或没有完全初始化。当抛出异常时,已经执行的构造函数代码将被撤销,对象将被销毁,且析构函数不会被调用。这可能导致资源的泄漏或对象状态不一致。为了避免这种情况,应该在构造函数中尽可能避免抛出异常,或者使用初始化列表和异常安全的编程技术来处理异常。

  2. 析构函数中的异常:在析构函数中抛出异常同样会导致问题。当析构函数中抛出异常时,可能会导致其他代码无法正常处理资源的释放或清理操作,从而导致资源泄漏。为了避免这种情况,应该在析构函数中尽量避免抛出异常,或者使用异常安全的编程技术确保资源的正确释放。

  3. 资源泄漏问题:异常在C++中确实可能导致资源泄漏的问题。例如,在使用new进行动态内存分配时,如果在分配后抛出异常而没有正确释放内存,就会导致内存泄漏。类似地,在使用锁进行资源保护时,如果在锁定和解锁之间抛出异常,可能导致死锁或资源未正确释放。为了解决这些问题,可以使用RAII(Resource Acquisition Is Initialization)技术,通过对象的构造函数获取资源,并在析构函数中自动释放资源,以确保资源在异常发生时也能正确释放。

RAII是一种C++的编程范式,利用对象的生命周期来管理资源的获取和释放。智能指针是RAII的一种常见实现方式,它们会在对象析构时自动释放所持有的资源,避免了手动释放资源的繁琐和可能遗漏的问题。

通过遵循良好的异常处理和RAII原则,可以有效地管理资源,避免资源泄漏和对象不完整的问题,提高代码的健壮性和可靠性。

2.4 异常规范

异常规格说明(Exception Specification)的相关内容:

  1. 异常规格说明的目的:异常规格说明用于向函数的使用者提供关于该函数可能抛出的异常类型的信息。通过异常规格说明,函数使用者可以了解到哪些异常类型可能在调用函数时被抛出,从而能够适当地处理这些异常情况。

    在早期的C++标准中,可以使用throw(类型)的语法在函数的后面列出函数可能抛出的所有异常类型。例如:void func() throw(int, char)表示func函数可能抛出intchar类型的异常。但是需要注意的是,这个异常规格说明在C++11标准中已经被废弃,不再推荐使用。

  2. throw()表示函数不抛出异常:在C++标准中,使用throw()(也称为空异常规格说明)表示函数不会抛出任何异常。这意味着在调用这样的函数时,不需要担心异常的处理,可以放心地进行调用。

    然而,需要注意的是,C++11标准中已经废弃了这种写法,并推荐使用更加现代化的异常安全和异常处理机制来处理异常。

  3. 无异常接口声明:如果函数没有异常规格说明,即没有使用throw()throw(类型)语法声明可能抛出的异常类型,那么该函数可以抛出任何类型的异常。这意味着在调用这样的函数时,需要在调用处适当地处理可能抛出的异常。

在现代的C++编程实践中,异常规格说明已经不再被广泛使用,而更多地侧重于使用异常安全的编程技术和异常处理机制来处理异常。这样可以提供更灵活、更可靠的异常处理,同时减少对具体异常类型的依赖。

3、异常体系

3.1 自定义异常体系

在C++中,可以通过继承自std::exception类或其派生类来创建自定义异常体系。自定义异常体系可以根据应用程序的需要,定义特定的异常类型来表示不同的错误或异常情况。以下是创建自定义异常体系的一般步骤:

  1. 创建基类异常:首先,可以创建一个基类异常,它通常继承自std::exception类。基类异常可以提供一些通用的异常属性和方法,如错误消息、异常代码等。
#include <exception>

class MyBaseException : public std::exception
{
public:
    MyBaseException(const char* message) : message_(message) {}
    virtual const char* what() const noexcept override { return message_; }

private:
    const char* message_;
};
  1. 派生自基类异常:接下来,可以派生自基类异常创建具体的异常类型。每个派生类可以表示不同的错误或异常情况,并提供特定的属性和方法。
class MyCustomException : public MyBaseException
{
public:
    MyCustomException(const char* message) : MyBaseException(message) {}
    // 可以添加额外的特定属性和方法
};
  1. 抛出和捕获自定义异常:在代码中,可以通过使用派生类异常来抛出和捕获特定的异常。
void someFunction()
{
    // 抛出自定义异常
    throw MyCustomException("Something went wrong!");
}

int main()
{
    try {
        someFunction();
    }
    catch (const MyCustomException& ex) {
        // 捕获自定义异常并处理
        std::cout << "Caught exception: " << ex.what() << std::endl;
    }

    return 0;
}

在上述示例中,我们创建了一个基类异常MyBaseException,它继承自std::exception。然后,我们派生了一个具体的异常类型MyCustomException,它继承自MyBaseException。在代码中,我们在someFunction函数中抛出了MyCustomException异常,并在main函数中捕获和处理该异常。

通过自定义异常体系,可以更好地组织和表示不同的错误和异常情况。它提供了一种扩展性和可维护性较好的方式,使得异常处理更具有可读性和可靠性。

3.2 C++标准库的异常体系

C++标准库提供了一个异常体系,其中定义了一些常见的异常类型,这些异常类型是继承自std::exception类的。以下是一些C++标准库中常见的异常类型:

  1. std::exception:这是所有标准库异常类的基类,它提供了一个基本的接口,包括what()方法用于获取异常的错误消息。

  2. std::bad_alloc:当内存分配失败时,new操作符会抛出该异常。

  3. std::bad_cast:在类型转换失败时,比如使用dynamic_cast进行类的动态类型转换时,如果转换不成功,就会抛出该异常。

  4. std::logic_error:这是一组逻辑错误的基类异常,派生自std::exception。它的派生类包括:

    • std::invalid_argument:当函数参数无效或不合法时抛出。
    • std::domain_error:当参数在函数的定义域之外时抛出。
    • std::length_error:当容器长度超出其允许的最大限制时抛出。
  5. std::runtime_error:这是一组运行时错误的基类异常,派生自std::exception。它的派生类包括:

    • std::overflow_error:当进行数值溢出运算时抛出。
    • std::underflow_error:当进行数值下溢运算时抛出。
    • std::range_error:当数值超出有效范围时抛出。
  6. std::out_of_range:当访问容器或数组中索引超出范围时抛出。

这些异常类型提供了一种标准的异常体系,用于表示不同的错误和异常情况。它们继承自std::exception,使得异常处理更加一致和可靠。通过捕获这些异常类型,可以更好地处理和恢复错误,以提高程序的可靠性和健壮性。

4、优缺点

4.1 优点

C++异常主要的优点:

  1. 清晰准确的错误信息:异常对象可以提供清晰、准确的错误信息,包括异常类型、错误描述以及可选的堆栈跟踪信息。相比传统的错误码方式,异常能够更直观地展示出错误的各种信息,帮助程序员更好地理解和定位错误。

  2. 异常处理的集中性:使用异常处理机制,可以将错误处理的逻辑集中在一处。当异常发生时,程序可以跳过多层函数调用,直接到达异常处理代码,避免了在函数调用链中逐层传递错误码的复杂性和繁琐性。这简化了错误处理的代码,使代码更易读、易维护。

  3. 提供异常安全保证:异常处理机制提供了异常安全的编程方式。在异常发生时,可以通过异常处理代码来进行资源的正确释放和清理,避免资源泄漏和不一致的状态。这有助于编写更健壮、可靠的代码,提高程序的稳定性。

  4. 异常的传播和捕获:异常能够在调用栈中传播和捕获,使得错误的处理更具灵活性和可扩展性。在调用链中的合适位置捕获异常,可以根据具体的错误类型进行不同的处理,提供针对性的错误恢复策略。

  5. 与其他错误处理机制的结合:异常处理机制可以与其他错误处理机制(如错误码)结合使用。例如,可以在底层的异常处理中将异常转换为错误码,然后在更高层次的代码中统一处理错误码。这种结合使用可以兼顾异常的灵活性和错误码的兼容性。

总体而言,C++异常提供了一种强大的错误处理机制,具有清晰准确的错误信息、集中的错误处理、异常安全保证以及灵活的异常传播和捕获等优点。通过合理地使用异常处理,可以改善代码的可读性、可维护性和稳定性。

4.2 缺点

C++异常缺点的进一步解释:

  1. 程序执行流的混乱:异常的抛出和捕获可能导致程序的执行流在不同的位置之间跳跃,这可能使程序的控制流程变得混乱和难以理解。特别是当异常的使用不当时,可能导致难以追踪和调试的问题。

  2. 性能开销:异常处理机制可能会带来一定的性能开销,尤其是在异常发生时的堆栈展开和异常处理的过程中。虽然在现代硬件和编译器优化的情况下,异常处理的性能开销通常可以忽略不计,但在某些对性能要求非常高的场景中,可能需要考虑使用其他错误处理机制以避免这种开销。

  3. 资源管理和异常安全问题:C++没有垃圾回收机制,程序员需要自己管理资源的释放和清理。异常的使用可能导致异常安全问题,如内存泄漏、死锁等。为了解决这些问题,需要使用RAII(Resource Acquisition Is Initialization)等技术来管理资源,这增加了一定的学习和使用成本。

  4. 异常体系的混乱:C++标准库的异常体系在一定程度上定义不够完善,导致许多开发者倾向于定义自己的异常体系。这可能导致不同的代码库使用不同的异常类型,增加了异常处理的复杂性和代码的不一致性。

  5. 异常规范的重要性:异常的规范使用非常重要,否则可能会导致捕获异常的代码无法正确处理异常,给用户带来困惑和不便。为了规范异常的使用,可以使用异常继承自一个基类,以及使用func() throw()这样的方式来声明函数是否抛出异常以及抛出的异常类型,从而使异常的使用更加规范化。

虽然C++异常具有一些缺点,但在合理使用和规范约定的情况下,可以充分利用其优点,提高代码的可靠性和健壮性。对于异常的使用,需要权衡其优缺点,并在实践中根据具体的情况进行适当的使用和处理。

4.3 总结

异常在工程中利大于弊。同时,面向对象(OO)的语言普遍使用异常处理错误,这也反映了异常处理是一个发展的趋势。

异常处理是一种强大的错误处理机制,可以提供清晰准确的错误信息、集中的错误处理、异常安全保证以及灵活的异常传播和捕获等优点。异常处理使得代码更具可读性、可维护性和稳定性,能够提高程序的健壮性和可靠性。

除了C++,许多面向对象的编程语言,如Java、C#等,也广泛使用异常作为错误处理的机制。异常处理已经成为了一种行业标准和最佳实践,它提供了一种统一、标准化的方式来处理错误,并且能够适应不同规模和复杂度的软件开发项目。

然而,在使用异常时,还需要注意合理的使用方式和遵循一些最佳实践,以避免异常滥用和潜在的问题。这包括避免在构造函数和析构函数中抛出异常、规范异常的使用、避免异常的频繁抛出等。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值