C++ 自定义异常

在大多数日常开发中,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() 函数全部封装好了,日常开发中直接继承它会省事得多。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号