在实际编写程序时,我们并不需要每遇到一种错误都自己从头定义一个新的异常类。C++ 标准库已经提前准备好了一套现成的 “标准异常” 类,涵盖了日常开发中大多数常见的错误场景。
在这套标准异常体系中,“异常类型” 用来区分这是哪一类问题,而异常对象里保存的说明文字则用来告诉我们具体出了什么错。
C++ 标准异常体系
在 C++ 中,标准库的异常类通过继承构成了一个清晰的家族体系。最顶层的总基类(父类)是定义在 <exception> 头文件中的 std::exception,而日常开发用到的具体异常子类大多定义在 <stdexcept> 中。其中,stdexcept 是 “standard exception(标准异常)” 的缩写。
在 std::exception 基类之下,<stdexcept> 中的标准异常主要分为两大分支:一种是以 std::logic_error 为代表的 “逻辑错误”,说白了就是在调用函数前检查一下参数就能避免的错误;另一种是以 std::runtime_error 为代表的 “运行时错误”,也就是运行期间受外部环境影响才出现的错误。
此外,<new> 头文件中还定义了使用 new 申请动态内存失败时自动抛出的 std::bad_alloc 异常。常见的标准异常类如下表所示。
| 异常类型 | 所属头文件 | 说明 |
|---|---|---|
| std::exception | <exception> | 所有标准异常的总基类,提供统一的 what() 函数 |
| std::logic_error | <stdexcept> | 逻辑错误基类,表示调用前提或内部逻辑不成立 |
| std::invalid_argument | <stdexcept> | 无效参数(logic_error 子类),表示实参值不符合要求 |
| std::out_of_range | <stdexcept> | 越界错误(logic_error 子类),表示下标或位置超出范围 |
| std::length_error | <stdexcept> | 长度错误(logic_error 子类),表示超出容器最大长度限制 |
| std::runtime_error | <stdexcept> | 运行时错误基类,表示运行期间发生的一般性失败 |
| std::bad_alloc | <new> | 内存分配失败,使用 new 申请内存失败时自动抛出 |
上表列举这些异常类只是方便小伙伴们平时查询,并不是叫大家死记硬背。对于初学的小伙伴来说,实际开发中最常用的是 std::runtime_error、std::invalid_argument 和 std::out_of_range 这 3 种,先把它们掌握好就足够应对大多数场景了。
作为所有标准异常的总基类,std::exception 提供了一个统一的成员函数 what()。在使用 try...catch 捕获异常时,只要我们用 const std::exception& e 接收对象,调用 e.what() 就能拿到抛出异常时填入的错误说明文字。
catch (const std::exception& e) {
std::cout << e.what() << '\n'; // 输出错误说明文字
}提示 平时写代码时,如果同时用到了 std::runtime_error 等具体子类和基类 std::exception,建议把 <stdexcept> 和 <exception> 这两个头文件都显式引入。这样可以避免更换编译器时因头文件包含关系不同而报错。
3 种常用的标准异常
定义在 <stdexcept> 中的具体标准异常类,创建和抛出的写法完全一致。
语法:
throw 异常类名(错误说明);说明:
类名后面的小括号里用于传入一段描述错误原因的文字,既可以直接传入字符串字面量(如 "参数错误"),也可以传入 std::string 字符串变量。下面我们就来看看最常用的 3 种标准异常的具体用法。
1. std::runtime_error
std::runtime_error 是 “运行时错误” 的意思,适合用来表示程序在运行过程中才暴露出来、且当前函数无法继续往下执行的一般性失败。比如配置文件读取失败、网络连接中断或外部服务未就绪等场景,我们都可以抛出该异常。
示例 1:抛出 runtime_error
#include <iostream>
#include <stdexcept>
#include <string>
void loadConfig(const std::string& filename, bool exists) {
if (!exists) {
throw std::runtime_error("读取文件失败:" + filename);
}
std::cout << "配置文件加载成功\n";
}
int main() {
try {
loadConfig("app.conf", false);
} catch (const std::runtime_error& e) {
std::cout << "运行时错误:" << e.what() << '\n';
}
return 0;
}运行结果如下。
运行时错误:读取文件失败:app.conf分析:
在这个例子中,loadConfig("app.conf", false) 模拟了运行期间找不到配置文件的情况。因为形参 filename 是 std::string 类型,抛出异常时我们可以直接通过字符串拼接把具体文件名附带进去,方便在外层通过 e.what() 看清是哪个文件出了问题。
需要注意的是,小伙伴们不要把 std::runtime_error 当成偷懒的 “万能异常”。只有当错误确实属于运行期间的执行故障,且没有更贴切的参数或越界异常类可用时,我们才选择它。
2. std::invalid_argument
std::invalid_argument 中的 “invalid argument” 是 “无效参数” 的意思,专门用来表示调用者传入的参数值违反了函数的要求。比如设置年龄的函数规定年龄必须在 0 到 150 之间,一旦传入负数,我们就可以抛出 std::invalid_argument。
示例 2:抛出 invalid_argument
#include <iostream>
#include <stdexcept>
void setAge(int age) {
if (age < 0 || age > 150) {
throw std::invalid_argument("年龄必须在 0 到 150 之间");
}
std::cout << "年龄设置成功:" << age << '\n';
}
int main() {
try {
setAge(20);
setAge(-5);
} catch (const std::invalid_argument& e) {
std::cout << "参数错误:" << e.what() << '\n';
}
return 0;
}运行结果如下。
年龄设置成功:20
参数错误:年龄必须在 0 到 150 之间分析:
先看第 1 次调用 setAge(20),传入的年龄落在合法范围内,函数正常打印成功提示。第 2 次调用 setAge(-5) 时触发了 age < 0 条件,函数直接抛出 std::invalid_argument 异常,明确告知调用者实参不符合要求。
3. std::out_of_range
std::out_of_range 中的 “out of range” 是 “超出范围” 的意思,专门用来表示下标或位置超出了允许的有效区间。比如容器里只有 3 个元素,有效下标是 0 到 2,如果尝试访问下标 3 或更大的位置,抛出 std::out_of_range 最贴切。
此外,标准异常并不只是在我们手动写 throw 时才会出现。C++ 标准库本身的许多组件,比如 vector 容器或 std::string 的 at() 成员函数,在检测到下标越界时也会自动抛出 std::out_of_range 异常。下面我们来看一个例子。
示例 3:at() 自动抛出越界异常
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
int main() {
std::vector<std::string> langs = {"C++", "Python", "Go"};
try {
std::cout << langs.at(3) << '\n'; // 访问下标 3 触发越界异常
} catch (const std::out_of_range& e) {
std::cout << "捕获到越界异常:" << e.what() << '\n';
}
return 0;
}运行结果如下。
捕获到越界异常:vector::_M_range_check: __n (which is 3) >= this->size() (which is 3)分析:
langs 容器里一共有 3 个元素,有效下标是 0 到 2。执行 langs.at(3) 访问下标 3 时,at() 检测到越界,就会自动抛出 std::out_of_range 异常并交由下方的 catch 分支处理。
运行结果中打印的那串英文是标准库自带的越界提示,意思是访问的下标 3 大于或等于当前容器长度 3。这里顺带提醒一句:如果你使用方括号 langs[3] 越界访问,容器不会做边界检查,也不会抛出异常。只有调用 at() 成员函数,越界时才会自动抛出 std::out_of_range。
标准异常的选择与统一捕获
认识了上面 3 种常用的标准异常后,平时写代码遇到错误时我们应该选哪一种呢?可以按以下 3 条标准快速判断:
- 参数值本身不合规则:比如年龄传了负数、除数不能为 0 却传了 0,优先选择
std::invalid_argument。 - 下标或位置超出有效范围:比如只有 3 个元素却去读取下标 5,优先选择
std::out_of_range。 - 运行中途任务无法继续:比如参数本身合法,但文件打不开或外部服务未就绪导致任务中断,选择
std::runtime_error。
下面我们通过一个完整的例子,看看如何在函数内部根据不同失败原因抛出具体的标准异常,并在外层使用总基类 std::exception 统一接住它们。
示例 4:抛出多种异常并统一捕获
#include <exception>
#include <iostream>
#include <stdexcept>
// 根据编号读取成绩
int getScore(int index, bool isReady) {
if (index < 0) {
throw std::invalid_argument("编号不能为负数");
}
const int scores[3] = {90, 85, 96};
if (index >= 3) {
throw std::out_of_range("编号超出有效范围");
}
if (!isReady) {
throw std::runtime_error("成绩数据尚未就绪");
}
return scores[index];
}
void printScore(int index, bool isReady) {
try {
int score = getScore(index, isReady);
std::cout << "读取成功,成绩:" << score << '\n';
} catch (const std::exception& e) {
std::cout << "读取失败:" << e.what() << '\n';
}
}
int main() {
printScore(-1, true);
printScore(5, true);
printScore(1, false);
printScore(1, true);
return 0;
}运行结果如下。
读取失败:编号不能为负数
读取失败:编号超出有效范围
读取失败:成绩数据尚未就绪
读取成功,成绩:85分析:
在这个例子中,getScore() 针对 3 种不同的失败原因,在前 3 次调用中分别抛出了 std::invalid_argument、std::out_of_range 和 std::runtime_error。直到第 4 次调用传入合法参数,函数才正常返回下标 1 对应的成绩 85。
从上面的代码可以看出,尽管 getScore() 抛出了 3 种不同的异常类型,外层的 printScore() 却只写了一个 catch (const std::exception& e) 分支就把它们全部接住了。这也是实际开发中常用的配合方式:抛出异常时尽量使用具体的子类把错误类别表达清楚,而捕获时如果只需要统一打印提示,直接使用基类 const std::exception& 接收即可。
当然了,如果外层确实需要针对某一种特定错误(比如越界错误)做单独补救,我们也可以编写多个 catch 分支。此时只需要遵循 “子类在前,基类在后” 的顺序,把 std::out_of_range 排在 std::exception 前面就可以了。
try {
int score = getScore(index, isReady);
std::cout << "读取成功,成绩:" << score << '\n';
} catch (const std::out_of_range& e) {
std::cout << "请检查编号范围:" << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "其他错误:" << e.what() << '\n';
}注意 在实际开发中,不要通过判断 e.what() 返回的字符串内容来决定后续业务逻辑。“异常类型” 才是专门用来给 catch 做分支匹配的,而 what() 返回的文字主要是给开发者看日志排错用的。
