C++ 异常处理 - 第19次课
第一部分: 引言 - 为什么需要异常处理?
1.1 程序中的错误和异常
- 任何程序在运行时都可能遇到错误或意外情况,我们称之为异常 (Exception)。
- 这些异常可能是由程序逻辑错误引起的(如除零错误),也可能是由外部因素导致的(如文件不存在、内存不足)。
- 区别于编译时错误,异常发生在程序运行时。
1.2 传统的错误处理方式及其局限性
- 返回值判断: 函数通过返回特定的值来表示错误状态。
- 优点: 简单直观。
- 缺点:
- 代码冗余:需要在每个可能出错的地方检查返回值。
- 容易忽略错误:程序员可能忘记检查返回值。
- 返回值歧义:返回值本身可能就是合法的结果,难以区分错误和正常结果。
- 函数调用链深时,错误信息难以向上层传递。
- 返回值判断: 函数通过返回特定的值来表示错误状态。
1.3 异常处理的优势
- 分离错误处理代码和正常逻辑代码: 使代码结构更清晰,可读性更高。
- 更灵活的错误处理机制: 可以跨越多个函数调用栈来传递和处理错误。
- 提高程序健壮性: 防止程序因未处理的错误而崩溃。
- 强制处理异常: 必须在某个地方捕获并处理抛出的异常,避免忽略错误。
- 提供更丰富的错误信息: 异常对象可以携带关于错误的详细信息。
代码示例 (传统的错误处理方式的局限性):
c++
#include <iostream>
#include <fstream>
int readFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
return -1; // 文件打开失败
}
// ... 读取文件内容 ...
return 0; // 成功
}
void processFile(const std::string& filename) {
if (readFile(filename) == -1) {
std::cerr << "Error: Failed to open file " << filename << std::endl;
// 这里可能还需要进行其他错误处理,例如通知用户
} else {
// ... 处理文件内容 ...
std::cout << "File processed successfully." << std::endl;
}
}
int main() {
processFile("non_existent_file.txt");
return 0;
}
思考: 在 processFile
函数中,如果 readFile
返回 -1,我们只简单地输出了错误信息。如果 readFile
在更深的调用栈中,错误信息可能丢失或处理不当。
第二部分: C++ 异常处理机制详解
2.1 try, catch, throw 关键字
try
块: 用于包含可能抛出异常的代码。程序会尝试执行try
块中的代码。catch
块: 用于捕获并处理特定类型的异常。一个try
块可以跟随多个catch
块,每个catch
块处理不同类型的异常。throw
关键字: 用于抛出一个异常对象,表示发生了某种错误。
2.2 异常类型
- 内置异常类型: C++ 标准库提供了一组预定义的异常类,它们都继承自
std::exception
类。std::runtime_error
: 运行时错误,例如逻辑错误、超出范围等。std::logic_error
: 程序逻辑错误,例如违反前提条件。std::bad_alloc
: 内存分配失败。std::out_of_range
: 超出有效范围。std::domain_error
: 数学函数定义域错误。- ... 等等。
- 自定义异常类型: 可以通过继承
std::exception
类或其子类来创建自己的异常类型,以便更精确地表达程序中的错误。
- 内置异常类型: C++ 标准库提供了一组预定义的异常类,它们都继承自
2.3 异常处理流程 (栈展开 - Stack Unwinding)
- 当在
try
块中的代码抛出一个异常时,程序会立即停止执行try
块中剩余的代码。 - 系统会沿着函数调用栈向上查找,寻找与抛出的异常类型匹配的
catch
块。 - 匹配规则:
catch
块参数的类型必须与抛出的异常类型相同,或者是抛出异常类型的基类。catch(...)
可以捕获任何类型的异常。
- 如果找到匹配的
catch
块,则执行该catch
块中的代码,异常被处理。 - 如果沿着调用栈一直找不到匹配的
catch
块,程序会调用std::terminate()
函数并异常终止。这个过程称为栈展开 (Stack Unwinding),在栈展开的过程中,局部对象的析构函数会被调用,以确保资源得到释放。
- 当在
代码示例 (使用异常处理机制):
c++
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
if (b == 0) {
throw std::runtime_error("Error: Division by zero!"); // 抛出异常对象
}
return a / b;
}
int main() {
try {
int result = divide(10, 0); // 可能抛出异常
std::cout << "Result: " << result << std::endl; // 如果没有抛出异常,则执行这里
} catch (const std::runtime_error& e) { // 捕获 std::runtime_error 类型的异常
std::cerr << "Caught an exception: " << e.what() << std::endl; // 使用 what() 方法获取异常信息
} catch (...) { // 捕获其他所有类型的异常 (不推荐过度使用)
std::cerr << "Caught an unknown exception!" << std::endl;
}
std::cout << "Program continues after exception handling." << std::endl;
return 0;
}
思考: 当 divide
函数抛出异常时,try
块中的剩余代码被跳过,程序查找匹配的 catch
块。如果注释掉 catch (const std::runtime_error& e)
,程序会发生什么?
第三部分: 自定义异常类型
3.1 继承
std::exception
类- 自定义异常类型通常通过公有继承
std::exception
类或其子类来实现。 - 这样做的好处是可以使用标准的异常处理框架,并且可以重写
what()
方法来提供自定义的错误信息。
- 自定义异常类型通常通过公有继承
3.2 重写
what()
函数std::exception
类提供了一个虚函数what()
,用于返回一个描述异常的 C 风格字符串。- 在自定义异常类中,通常需要重写
what()
函数,以便返回更具体、更有意义的错误信息。
代码示例 (自定义异常类型):
c++
#include <iostream>
#include <exception>
#include <string>
class FileOpenError : public std::runtime_error {
public:
FileOpenError(const std::string& filename) : std::runtime_error("Failed to open file: " + filename), filename_(filename) {}
const std::string& getFilename() const {
return filename_;
}
private:
std::string filename_;
};
int main() {
try {
std::string filename = "important_data.txt";
// 模拟文件打开失败的情况
if (true) {
throw FileOpenError(filename);
}
// ... 其他文件操作 ...
} catch (const FileOpenError& e) {
std::cerr << "File error: " << e.what() << std::endl;
std::cerr << "Filename: " << e.getFilename() << std::endl;
} catch (const std::runtime_error& e) {
std::cerr << "Runtime error: " << e.what() << std::endl;
}
return 0;
}
思考: 自定义异常类型的好处是什么?在上面的代码中,我们如何获取导致异常的文件名?
第四部分: 异常处理的最佳实践
4.1 仅在必要时使用异常
- 异常应该用于处理真正异常的情况,即程序无法正常继续执行的错误。
- 对于可以预见的错误,例如用户输入错误,可以考虑使用其他方式(如返回值、错误码)处理。
4.2 抛出和捕获特定类型的异常
- 尽量抛出和捕获具体类型的异常,而不是仅仅使用
catch (...)
。 - 这样做可以提供更精确的错误处理,避免捕获不应该捕获的异常。
- 尽量抛出和捕获具体类型的异常,而不是仅仅使用
4.3 不要在析构函数中抛出异常 非常重要
- 在栈展开的过程中,可能会有多个析构函数被调用。如果在析构函数中抛出异常且未被捕获,程序会调用
std::terminate()
终止。这可能会导致资源泄漏或其他问题。 - 如果析构函数中可能发生错误,应该在析构函数内部处理,例如记录错误日志。
- 在栈展开的过程中,可能会有多个析构函数被调用。如果在析构函数中抛出异常且未被捕获,程序会调用
4.4 使用
noexcept
声明不抛出异常的函数- 使用
noexcept
关键字声明函数不会抛出异常。 - 这可以帮助编译器进行优化,并且在某些情况下可以提高代码的可靠性。
- 如果
noexcept
函数内部确实抛出了异常且未被捕获,程序会调用std::terminate()
。
- 使用
4.5 在适当的层次捕获异常
- 在能够合理处理异常的地方捕获异常。
- 不要过早地捕获异常,导致上层调用者无法获得足够的错误信息。
- 也不要忽略异常,导致程序在未知的状态下继续运行。
4.6 提供清晰的异常信息
- 异常对象应该携带足够的错误信息,方便定位和解决问题。
- 在自定义异常类中,
what()
方法应该返回清晰易懂的错误描述。
4.7 考虑资源管理(RAII)
- 结合 RAII (Resource Acquisition Is Initialization) 原则,可以使用智能指针等技术来自动管理资源,即使在发生异常的情况下也能确保资源被正确释放。
第五部分: 总结
- C++ 的异常处理机制是一种强大的错误处理工具,可以提高程序的健壮性和可维护性。
- 掌握
try
、catch
、throw
关键字以及异常处理流程是使用异常处理的基础。 - 理解内置异常类型和自定义异常类型,并学会根据需要创建自定义异常。
- 遵循异常处理的最佳实践,编写更可靠、更易于维护的代码。
课后作业:
编写一个程序,模拟银行账户操作,包括存款、取款等功能。使用异常处理机制来处理以下情况:
- 取款金额超过余额.
- 存款金额为负数.
作业提示:
- 定义一个
BankAccount
类,包含balance
成员变量. - 定义
deposit
和withdraw
成员函数,分别用于存款和取款. - 在
withdraw
函数中,如果取款金额超过余额,则抛出一个自定义异常InsufficientFundsException
. - 在
deposit
函数中,如果存款金额为负数,则抛出一个自定义异常InvalidDepositAmountException
. - 在
main
函数中,使用try-catch
块来处理这些异常.