错误处理是程序里绕不开的话题。C语言用错误码,函数返回一个int,调用方去查表,麻烦不说,还容易把业务逻辑和错误处理搅在一起。C++的异常机制提供了一种不同的思路:把发现错误 和处理错误的代码分开。出错的模块只负责抛出异常,至于怎么处理,由调用链上合适的捕获点决定,两边互不侵入。
这篇文章把异常的抛出、捕获、栈展开过程以及类型匹配规则梳理一遍,最后用一组自定义异常体系展示实际项目中怎么用。
目录
[1. 抛出与捕获的基本姿势](#1. 抛出与捕获的基本姿势)
[1.1 throw干了什么](#1.1 throw干了什么)
[1.2 try/catch怎么接](#1.2 try/catch怎么接)
[2. 栈展开:异常的传播路径](#2. 栈展开:异常的传播路径)
[3. 自定义异常体系:利用派生类到基类的转换](#3. 自定义异常体系:利用派生类到基类的转换)
1. 抛出与捕获的基本姿势
1.1 throw干了什么
当程序运行到throw语句时,会构造一个异常对象,然后函数的剩余代码不再执行,控制权开始沿着调用链向上转移,寻找匹配的catch。这个过程有三个关键点:
-
throw后面的语句被跳过。
-
当前函数可能提前退出。
-
局部对象会按顺序析构(栈展开时完成)。
throw的异常对象如果是局部的,会生成一份拷贝交给catch,原对象在栈展开过程中销毁。这份拷贝会在catch处理完毕后销毁。
1.2 try/catch怎么接
cpp
void Func() {
int len, time;
cin >> len >> time;
try {
cout << Divide(len, time) << endl;
} catch (const char* errmsg) {
cout << errmsg << endl;
}
cout << "Func continuing..." << endl;
}
double Divide(int a, int b) {
if (b == 0) {
string s("Divide by zero condition!");
throw s;
}
return (double)a / (double)b;
}
Divide里抛出的是string对象,而Func里捕获的类型是const char*,不匹配。所以Func的catch会被跳过,继续向外层调用者(这里是main)寻找匹配的catch(string)。如果main也找不到,程序就调用terminate终止。
catch的匹配规则:
-
一般情况下要求类型完全匹配。
-
允许从非常量向常量的转换(权限缩小方向),但不允许其他隐式类型转换(比如int转double)。
-
数组会被转换为指向元素类型的指针,函数转换为函数指针。
-
支持派生类向基类的转换。这个在实际项目中非常实用,后面会展开。
如果外层没有匹配的catch,main函数最后通常会写一个catch(...)兜底,防止程序崩溃,但它只能捕获,没法知道具体错误信息。
2. 栈展开:异常的传播路径
栈展开(stack unwinding)是理解异常行为的关键。从throw开始,编译器会依次查找:
-
检查throw本身是否在try块内。如果在,看当前try块的catch有没有匹配的。
-
有匹配就跳到catch执行,之后从这个catch结束后的第一条语句继续运行。
-
没有匹配,则退出当前函数,析构所有局部对象,回到调用方函数的调用点,重复第一步。
-
到达main还没匹配,调用
terminate终止程序。
假设调用链是:main() → func3() → func2() → func1(),func1抛出异常,catch在main里。它会依次退出func1、func2、func3的栈帧,直到在main里找到匹配的catch。这里"退出"不是简单的跳转,而是沿着调用链,层层析构局部对象,这保证了RAII资源能被正确释放。
3. 自定义异常体系:利用派生类到基类的转换
在实际项目中,通常不会到处抛string或基本类型,而是构建一个异常类体系,基类可以是std::exception或者自己写的类,各模块派生自己的异常类型。捕获时只捕获基类引用,就能统一处理所有异常。
cpp
// 基类
class Exception {
public:
Exception(const string& errmsg, int id)
: _errmsg(errmsg), _id(id) {}
virtual string what() const { return _errmsg; }
int getid() const { return _id; }
protected:
string _errmsg;
int _id;
};
// 各模块派生自己的异常
class SqlException : public Exception {
public:
SqlException(const string& errmsg, int id, const string& sql)
: Exception(errmsg, id), _sql(sql) {}
string what() const override {
return "SqlException:" + _errmsg + "->" + _sql;
}
private:
string _sql;
};
class CacheException : public Exception {
public:
CacheException(const string& errmsg, int id)
: Exception(errmsg, id) {}
string what() const override {
return "CacheException:" + _errmsg;
}
};
class HttpException : public Exception {
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id), _type(type) {}
string what() const override {
return "HttpException:" + _type + ":" + _errmsg;
}
private:
string _type;
};
业务函数模拟抛出各类异常:
cpp
void SQLMgr() {
if (rand() % 7 == 0)
throw SqlException("权限不足", 100, "select * from name = '张三'");
cout << "SQLMgr 调用成功" << endl;
}
void CacheMgr() {
if (rand() % 5 == 0)
throw CacheException("权限不足", 100);
else if (rand() % 6 == 0)
throw CacheException("数据不存在", 101);
cout << "CacheMgr 调用成功" << endl;
SQLMgr();
}
void HttpServer() {
if (rand() % 3 == 0)
throw HttpException("请求资源不存在", 100, "get");
else if (rand() % 4 == 0)
throw HttpException("权限不足", 101, "post");
cout << "HttpServer调用成功" << endl;
CacheMgr();
}
主函数里只需要捕获基类引用:
cpp
int main() {
srand(time(0));
while (1) {
this_thread::sleep_for(chrono::seconds(1));
try {
HttpServer();
} catch (const Exception& e) {
cout << e.what() << endl;
} catch (...) {
cout << "Unknown Exception" << endl;
}
}
}
捕获基类引用配合虚函数what(),既能拿到具体类型的信息,又不需要写一堆分支类型的catch。新增一个派生类异常,只要继承Exception并重写what(),上层代码一行不改。这就是开放-封闭原则在错误处理中的体现。
这里有一个细节:catch的参数应该用引用,否则会发生拷贝切片,派生类的额外信息就丢了。
小结
异常机制的本质是把错误检测和处理解耦,代价是引入了栈展开带来的控制流复杂性和资源管理风险。下一篇文章会顺着这个思路往下走:异常重新抛出、异常安全问题、以及C++11引入的noexcept规范------它们试图解决"异常本身带来的问题"。