17 异常处理
问题引出
程序错误分类
- 编译 / 静态分析错误:语法错误(无法编译)、逻辑错误与不良习惯(new-delete 不配对、使用前未初始化等)
- 运行错误
- 运行异常:可以检测(内存分配失败、文件读写错误、除零、指针越界);难以预测(段错误、栈溢出)
- 逻辑错误:程序正常运行,但未得到期望结果
C语言错误处理
C语言一般通过返回值和全局变量表示错误:
- 函数返回
int或指针时,0/ 非空表示成功,非零 / 空指针表示失败 - 使用全局变量与函数:
errno(<error.h>):错误码char *strerror(int errnum)(<string.h>):将错误码转为描述字符串void perror(const char *s)(<stdio.h>):打印错误描述
FILE *pf = fopen("unexist.txt", "rb");
if (pf == NULL) {
int errnum = errno; // 防止被其他错误覆盖
fprintf(stderr, "error num: %d\n", errno);
perror("open(unexist.txt)");
fprintf(stderr, "open file error: %s\n", strerror(errnum));
} else {
fclose(pf);
}
C语言错误处理的优势:简单、高效。缺点:
- 应检必检,必须检测各种错误可能,对编程能力与经验要求极高
- 正常代码与错误处理代码混在一起,难以阅读与维护
健壮性
健壮性(Robustness) 是指程序在执行过程中处理错误,以及算法在遭遇输入、运算等异常时保持正常运行的能力。异常处理机制的目的就是提升程序的健壮性。
异常处理概述
异常处理(Exception Handling)采用结构化方法对程序的运行时错误进行显式管理:
- 将异常种类定义为树状结构
- 异常检测与异常处理分离:检测到异常时抛出异常对象,由特定位置集中捕获并处理
- 将正常处理代码与异常处理代码显式区别开来,提高可读性
异常用于给出错误信号,不必立即处理。抛出异常的部分与处理异常的部分可以独立开发,通过异常对象进行通信。
异常定义与抛出
throw 关键字
throw 表达式; // 抛出异常对象
throw; // 重抛当前异常(仅用于 catch 块内)
throw 表达式; 的基本行为:
- 复制被抛出的对象为临时对象,作为异常对象
- 如果在当前函数 try 块中,中止 try 执行,执行 catch 语句捕获
- 如异常对象未在当前函数被捕获,则中止函数执行,异常传播给调用者
表达式可以是任意类型的对象,既可以是基本类型(如 int),也可以是类对象。
标准库异常基类 exception
定义在头文件 <exception> 中,是所有标准异常的基类:
class exception {
public:
exception() noexcept;
exception(const exception&) noexcept;
exception& operator=(const exception&) noexcept;
virtual ~exception();
virtual const char* what() const noexcept; // 返回异常描述
};
what() 是虚函数,子类可重写以提供具体错误信息。noexcept 表示该函数不会抛出异常。
标准库异常树
| 头文件 | 异常类 | 说明 |
|---|---|---|
<exception> |
exception |
所有异常的基类 |
<stdexcept> |
logic_error |
逻辑类异常的基类 |
<stdexcept> |
runtime_error |
运行时异常的基类 |
<new> |
bad_alloc |
内存分配失败(new 失败时抛出) |
<typeinfo> |
bad_cast |
dynamic_cast 引用转换失败 |
<typeinfo> |
bad_typeid |
typeid 对空指针解引用 |
<stdexcept> |
out_of_range |
下标越界(如 vector::at) |
<stdexcept> |
invalid_argument |
无效参数 |
<stdexcept> |
length_error |
试图创建超过最大长度的对象 |
<stdexcept> |
domain_error |
定义域错误 |
<stdexcept> |
range_error |
范围错误 |
<stdexcept> |
overflow_error / underflow_error |
算术溢出 |
logic_error及其子类通常可通过修改程序逻辑避免;runtime_error及其子类表示运行时才能发现的错误。二者构造函数都用explicit修饰,防止隐式转换。
使用标准异常
bad_alloc 示例:
#include <iostream>
#include <new>
int main() {
try {
int* myarray = new int[10000];
} catch (std::bad_alloc& ba) {
std::cerr << "bad_alloc caught: " << ba.what() << '\n';
}
return 0;
}
out_of_range 示例(vector::at 越界时自动抛出):
std::vector<int> myvector(10);
try {
myvector.at(20) = 100; // at 会进行越界检查
} catch (const std::out_of_range& oor) {
std::cerr << "Out of Range error: " << oor.what() << '\n';
}
invalid_argument 示例(主动抛出):
double mySqrt(double dnum) {
if (dnum < 0)
throw std::invalid_argument("argument dnum must be great than zero");
return std::sqrt(dnum);
}
int main() {
try {
mySqrt(-1);
} catch (std::exception& e) { // 用基类引用捕获
std::cout << e.what() << '\n';
}
return 0;
}
异常的捕获及处理
try-catch 语法
try {
// 程序的正常处理逻辑
throw exception-object;
} catch (exception-declaration) {
// 异常处理代码
} catch (exception-declaration) {
// 异常处理代码
} catch (...) {
// 捕获其他所有类型的异常(必须放在最后)
}
执行机制:
- 若保护段执行期间没有引发异常,跳过所有 catch 子句,继续执行最后一个 catch 后的语句
- 若有异常,catch 子句按出现顺序被检查,类型匹配的 catch 捕获并处理
- 如果找不到匹配的处理代码,则自动调用
std::terminate,默认调用abort()终止程序
捕获匹配规则
- 按 catch 顺序与异常定义匹配,异常对象类型是定义类型或其子类即匹配
- 匹配成功后:若接收变量是值,则复制异常对象;若是引用或指针,则创建引用或指针
- 子类必须优先于基类 catch,
catch(...)必须放在最后 - 用基类引用类型捕获异常,且虚函数才能产生多态
捕获顺序与多态
class EBase {
public:
void what() { cout << "base" << endl; } // 非虚函数
};
class EDrived : public EBase {
public:
void what() { cout << "EDrived" << endl; } // 隐藏基类
};
int main() {
try {
throw EDrived();
}
// 注意:捕获次序,子类必须在前面
catch (EBase& e) { e.what(); } // 先匹配基类 → 输出 "base"
catch (EDrived& e) { e.what(); } // 永远不会执行到
return 0;
}
若
what()声明为virtual,则用基类引用捕获时仍会调用派生类版本,输出"EDrived"。这就是异常多态的基础。
异常传播
从异常抛出到控制转移给合适的异常处理语句的过程叫异常传播(propagation):
- 由里层向外层执行每一个包围抛出点的 try 语句
- 当前函数未捕获的异常,传播给调用者
- 若调用链中均无处理,则调用
std::terminate终止程序
void f3(int x) {
switch (x) {
case 1: throw 3.4; // double
case 2: throw 2.5f; // float
case 3: throw 1; // int
case 5: throw exception(); // 标准异常
}
}
void f2(int x) {
try { f3(x); }
catch (int) { cout << "int --from f2" << endl; }
catch (float) { cout << "float --from f2" << endl; }
// double 和 exception 未捕获,传播给 f1
}
void f1(int x) {
try { f2(x); }
catch (int) { cout << "int --from f1" << endl; }
catch (float) { cout << "float --from f1" << endl; }
catch (double) { cout << "double --from f1" << endl; }
// exception 未捕获 → terminate
}
异常说明与 noexcept
C++11 之前可用异常说明限定函数可抛出的异常类型(C++11 起废弃,C++17 移除):
void f3(int x) throw(double, float); // 只允许抛出 double、float
C++11 引入 noexcept 关键字,说明函数不会抛出异常:
void f2(int x) noexcept; // 承诺不抛出异常
若
noexcept函数内部有异常未处理,则直接调用std::abort()终止程序。noexcept主要用于优化与接口契约。
异常重抛出
在 catch 块内使用 throw;(无表达式)可以重抛当前异常,交由上层函数处理:
catch (MyException& e) {
e.setMessage("new message");
throw; // 重抛,上层能见到修改后的 message
}
用引用捕获时,修改会传递到上层;用值捕获时,修改的只是局部副本,重抛的对象不发生改变。
异常编程与实践
用户自定义异常
强烈建议从 <stdexcept> 中的异常类继承,常用基类有 logic_error、runtime_error。优势:
- 代码更简洁,易于阅读
- 更利于分类处理异常
- 减少自定义虚函数(多数情况标准库都做好了)
class Divided_By_Zero : public runtime_error {
public:
explicit Divided_By_Zero(const string& s = "Divide By Zero")
: runtime_error(s) {}
explicit Divided_By_Zero(const char* s)
: runtime_error(string(s)) {}
};
int main() {
double a = 10, b = 0, res;
try {
if (b == 0) throw Divided_By_Zero();
res = a / b;
} catch (const exception& e) {
cout << e.what() << endl; // 输出 "Divide By Zero"
}
return 0;
}
对象构造异常
构造函数中抛出异常时:
- 构造失败的对象不会分配空间,也不会析构
- 但 try 块中在该对象之前已正常构造的局部对象会正常析构
class Dummy {
public:
~Dummy() { std::cout << "Dummy destructed\n"; }
};
class Triangle {
public:
Triangle(int a = 3, int b = 4, int c = 5) {
if (a < 0) { throw a; } // 构造中抛出异常
}
~Triangle() { std::cout << "Triangle destructed\n"; }
};
int main() {
try {
Dummy dum;
Triangle tri(-1); // 抛出异常,tri 未完成构造
std::cout << "Triangle created\n";
} catch (...) {
std::cout << "Exception catched\n";
}
}
// 输出:
// Dummy destructed
// Exception catched
这说明异常发生时,已构造的自动对象会被正确析构,但构造未完成的对象不会析构。若构造过程中已
new了资源,这些资源会泄漏——这正是 RAII 要解决的问题。
RAII 技术
RAII(Resource Acquisition Is Initialization),资源获取即初始化,即使用自动对象的生命周期来管理资源(内存、文件等)。
RAII 技术的四个部分:
- 设计一个类封装资源
- 在构造函数中申请资源
- 在析构函数中释放资源
- 使用时声明该类的局部对象
// 传统方法:需手动关闭
void old_fct(const char* s) {
FILE* f = fopen(s, "r");
// use f
fclose(f); // 必须手动关闭,异常时易泄漏
}
// RAII 方法:析构自动关闭
void fct(string s) {
File_handle f(s, "r"); // 构造时打开
// use f
} // 离开作用域,析构自动关闭
文件管理类的 RAII 实现:
class FileManager {
public:
explicit FileManager(const std::string& filename, std::ios_base::openmode mode)
: file_(filename, mode) {
if (!file_.is_open())
throw std::runtime_error("Unable to open file.");
}
~FileManager() { file_.close(); } // 析构自动关闭
void Write(const std::string& data) { file_ << data << std::endl; }
private:
std::ofstream file_;
};
智能指针
智能指针是 RAII 在指针管理上的应用,用对象包装指向资源的指针,析构时自动释放资源。
| 智能指针 | 说明 |
|---|---|
auto_ptr |
C++98 已废弃,C++17 移除。存在拷贝时所有权转移的缺陷 |
unique_ptr |
独占所有权,不允许拷贝,只能移动。一个原始指针只能有一个所有者 |
shared_ptr |
共享所有权,采用引用计数。多个 shared_ptr 可指向同一资源,最后一个所有者销毁时释放 |
weak_ptr |
配合 shared_ptr 使用的弱引用,不增加引用计数,用于打破循环引用 |
#include <memory>
class MyResource {
public:
MyResource(int size) : data_(std::make_unique<int[]>(size)) {
std::cout << "MyResource created with size " << size << std::endl;
}
// 禁用拷贝
MyResource(const MyResource&) = delete;
MyResource& operator=(const MyResource&) = delete;
// 支持移动
MyResource(MyResource&& other) noexcept : data_(std::move(other.data_)) {}
private:
std::unique_ptr<int[]> data_;
};
int main() {
std::vector<std::unique_ptr<MyResource>> resources;
for (int i = 0; i < 5; ++i) {
resources.emplace_back(std::make_unique<MyResource>(i + 1));
}
// vector 销毁时,unique_ptr 自动释放所指向对象
return 0;
}