在大多数日常开发中,C++ 标准库提供的 std::runtime_error、std::invalid_argument 等异常已经够用了。但在规模稍大的项目里,有些错误带有明确的业务模块归属,比如 “配置文件错误”、“网络请求超时” 或 “数据库读写失败”。
如果所有业务错误都统一抛出 std::runtime_error,外层代码在捕获时就很难区分到底是配置出错还是网络出错。此外,标准异常只能保存一段说明文字,无法附带错误码、非法数值等额外数据。遇到这类情况时,我们就可以编写专属的 “自定义异常” 类。
C++ 自定义异常的定义
在 C++ 中,自定义异常本质上就是我们自己定义的一个类。平时开发时不需要从零开始写,直接通过类的继承在 std::runtime_error 的基础上扩展即可。
语法:
class 异常类名 : public std::runtime_error {
public:
异常类名(const std::string& message)
: std::runtime_error(message) {}
};说明:
为什么构造函数的花括号 {} 里面是空的,连 what() 函数也不用自己写呢?
其实关键就在于构造函数后面的初始化列表 : std::runtime_error(message)。创建自定义异常对象时,它会直接把传入的错误说明 message 转交给父类 std::runtime_error 的构造函数。
也就是说,父类内部已经帮我们把这段文字存好了,并且自带了现成的 what() 成员函数。我们只需要换上一个专属的类名,就可以直接拿来使用了。
示例 1:定义并捕获自定义异常
#include <iostream>
#include <stdexcept>
#include <string>
// 定义配置错误异常类
class ConfigError : public std::runtime_error {
public:
ConfigError(const std::string& message)
: std::runtime_error(message) {}
};
void checkConfig(bool exists) {
if (!exists) {
throw ConfigError("找不到配置文件");
}
}
int main() {
try {
checkConfig(false);
} catch (const ConfigError& e) {
std::cout << "捕获配置异常:" << e.what() << '\n';
}
return 0;
}运行结果如下。
捕获配置异常:找不到配置文件分析:
在这个例子中,我们定义了继承自 std::runtime_error 的 ConfigError 类。当 checkConfig(false) 抛出 ConfigError 对象后,外层 main() 中的 catch (const ConfigError& e) 将它接住,并通过继承来的 e.what() 正常输出了错误信息。
提示 因为 std::runtime_error 继承自顶层基类 std::exception,所以 ConfigError 同样属于 std::exception 家族。外层既可以专门捕获 ConfigError,也可以使用 const std::exception& 把它统一接住。
C++ 自定义异常的应用
相比直接使用现成的标准异常,自定义异常主要有 2 个实际用途:① 按专属类型区分错误;② 随异常携带额外数据。
1. 按异常类型区分不同错误
当一个项目包含多个业务模块时,我们可以为重点模块定义专属的异常类。这样在外层捕获时,就能通过不同的 catch 分支把该模块的错误与其他普通运行错误区分开。
示例 2:区分自定义异常与标准异常
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
// 自定义配置异常类
class ConfigError : public std::runtime_error {
public:
ConfigError(const std::string& message)
: std::runtime_error(message) {}
};
void connectDb(const std::string& host, bool networkOk) {
if (host.empty()) {
throw ConfigError("数据库地址不能为空");
}
if (!networkOk) {
throw std::runtime_error("网络连接中断");
}
std::cout << "数据库连接成功:" << host << '\n';
}
void startApp(const std::string& host, bool networkOk) {
try {
connectDb(host, networkOk);
} catch (const ConfigError& e) {
std::cout << "[配置错误] " << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "[其他错误] " << e.what() << '\n';
}
}
int main() {
startApp("", true);
startApp("127.0.0.1", false);
startApp("127.0.0.1", true);
return 0;
}运行结果如下。
[配置错误] 数据库地址不能为空
[其他错误] 网络连接中断
数据库连接成功:127.0.0.1分析:
在这个例子中,startApp() 设置了两个 catch 分支来分别处理不同错误。先看第 1 次调用 startApp("", true),传入的数据库地址为空,connectDb() 抛出的 ConfigError 直接命中第 1 个分支。
第 2 次调用 startApp("127.0.0.1", false) 时,网络未就绪抛出了 std::runtime_error。由于它不属于 ConfigError,程序继续往下匹配并进入第 2 个 std::exception 分支。第 3 次传入正常参数时未发生异常,顺利输出连接成功提示。
这里小伙伴们要留意两个 catch 分支的排列顺序:ConfigError 是具体子类,而 std::exception 是通用基类。这与 try...catch 多分支匹配的规则一致,必须遵循 “子类在前,基类在后” 的顺序。
2. 在自定义异常中携带额外数据
如果只是为了打印一句错误提示,标准异常其实也能保存字符串。自定义异常真正不可替代的地方在于:我们可以在类里面添加成员变量,把出错时的 “错误码” 或 “具体非法数值” 一起打包带出来,方便外层排错。
下面我们来看一个检查服务器端口号的例子。
示例 3:自定义异常携带额外数据
#include <iostream>
#include <stdexcept>
#include <string>
// 保存出错端口号的异常类
class PortError : public std::runtime_error {
public:
PortError(const std::string& message, int port)
: std::runtime_error(message), badPort(port) {}
int getBadPort() const {
return badPort;
}
private:
int badPort;
};
// 检查端口号是否合法
void checkPort(int port) {
if (port < 1 || port > 65535) {
throw PortError("端口号超出范围 (1~65535)", port);
}
std::cout << "端口 " << port << " 校验通过\n";
}
int main() {
try {
checkPort(8080);
checkPort(-80);
} catch (const PortError& e) {
std::cout << "配置异常:" << e.what() << '\n';
std::cout << "出错端口号:" << e.getBadPort() << '\n';
}
return 0;
}运行结果如下。
端口 8080 校验通过
配置异常:端口号超出范围 (1~65535)
出错端口号:-80分析:
在这个例子中,PortError 类在继承 std::runtime_error 的基础上,新增了私有成员变量 badPort 和读取函数 getBadPort()。当 checkPort(-80) 检测到端口不合法时,同时把错误说明和非法数值 -80 存进了异常对象里。
这样一来,外层 catch 块接住异常后,既能调用 e.what() 查看报错原因,又能直接调用 e.getBadPort() 拿到出错数值 -80。你就不需要再费劲去错误字符串里拆分数字了。
同时大家还要留意 int getBadPort() const 末尾的 const 关键字。因为 catch 小括号里的 e 带有 const 只读限制,编译器只允许在它上面调用不修改成员变量的 const 成员函数。如果漏写了这个 const,编译时就会直接报错。
什么时候需要自定义异常?
在实际开发中,并不是每一种错误都需要单独建一个新类。通常满足以下 3 种场景之一时,我们才会编写自定义异常:
- 外层需要单独识别并处理该错误:比如项目包含配置、网络和文件 3 个模块,上层代码确实需要专门捕获
ConfigError做特殊补救,而不是把所有错误混在一起处理。 - 需要随异常携带额外的业务数据:当报错发生时,单靠
what()返回一段字符串还不够,还需要把错误码、状态码或非法参数值一起打包传给catch块。 - 开发独立的业务模块或工具库:比如编写一个供团队其他人调用的数据解析模块,定义一个专属的
ParseError异常类,能让调用者清楚看出异常来自哪个模块。
反过来说,如果只是为了换一句报错提示文字,或者现成的 std::invalid_argument、std::out_of_range 等标准异常已经够用,直接复用它们就可以了。这种情况下不需要额外定义新类。
直接继承 std::exception 的写法
在阅读一些早期的 C++ 代码时,小伙伴们有时会看到直接继承顶层基类 std::exception 来编写自定义异常的写法。
不过顶层基类 std::exception 默认只提供了无参构造函数,内部并没有现成保存错误字符串的地方。如果直接继承它,不仅要自己定义 std::string 成员变量来存文字,还得手动重写返回 const char* 的 what() 虚函数。相比之下,std::runtime_error 已经把字符串存储和 what() 函数全部封装好了,日常开发中直接继承它会省事得多。
