引言
在C++学习初期,或者在C语言中,我们都是使用返回值处理错误--比如用-1表示函数执行失败,用nullptr表示资源获取失败。这种方式直观简单,在简单代码中完全够用。但当项目变大、函数调用层级变深(比如 “接口层 -> 服务层 -> 数据层” 的多层调用)时,返回值的短板会暴露无遗,代码会被大量 “错误码传递” 逻辑淹没。而 C++ 异常机制,正是为解决复杂场景下的错误处理痛点而生。
本文将从新手最熟悉的返回值入手,通过真实的深层调用场景暴露其局限性,再对比异常的解决方案,逐步拆解 C++ 异常体系、基础用法和关键注意事项,帮助你建立 “场景化选择错误处理方式” 的思维。
返回值 vs 异常:深层调用场景下的鲜明对比
很多新手觉得 “返回值够用”,是因为没遇到复杂的多层调用场景。下面通过一个 “文件数据处理” 的真实案例,直观感受返回值的局限性,以及异常如何解决这些问题。
场景说明
假设我们需要实现一个 “读取配置文件 -> 解析数据 -> 计算结果” 的流程,分为三层函数调用:
- 底层:
readConfigFile(读取文件,返回数据字符串); - 中层:
parseConfigData(解析数据字符串,返回配置对象); - 上层:
calculateResult(使用配置对象计算,返回结果); - 入口:
main函数调用上层函数,处理结果或错误。
用返回值(错误码)实现:冗余的 “错误传递链”
为了表示错误,我们先定义错误码枚举,再让每个函数返回 “错误码 + 结果”(用结构体封装,避免返回值语义污染):
#include <string>
#include <iostream>
using namespace std;
// 定义错误码
enum class ErrorCode {
SUCCESS = 0,
FILE_OPEN_FAILED, // 文件打开失败
FILE_READ_FAILED, // 文件读取失败
DATA_PARSE_FAILED, // 数据解析失败
CONFIG_INVALID // 配置无效
};
// 配置数据结构
struct Config {
int threshold;
double factor;
};
// 底层:读取配置文件,返回“错误码+数据字符串”
struct ReadResult {
ErrorCode err;
string data;
};
ReadResult readConfigFile(const string& path) {
FILE* fp = fopen(path.c_str(), "r");
// 错误1:文件打开失败
if (!fp) {
return {ErrorCode::FILE_OPEN_FAILED, ""};
}
char buf[1024] = {0};
size_t readLen = fread(buf, 1, sizeof(buf)-1, fp);
// 错误2:文件读取失败
if (readLen == 0) {
fclose(fp);
return {ErrorCode::FILE_READ_FAILED, ""};
}
fclose(fp);
return {ErrorCode::SUCCESS, string(buf)};
}
// 中层:解析数据字符串,返回“错误码+配置对象”
struct ParseResult {
ErrorCode err;
Config config;
};
ParseResult parseConfigData(const string& data) {
// 模拟解析逻辑:假设数据格式是“threshold:factor”
size_t colonPos = data.find(':');
// 错误3:数据格式错误,解析失败
if (colonPos == string::npos) {
return {ErrorCode::DATA_PARSE_FAILED, {}};
}
Config cfg;
try {
cfg.threshold = stoi(data.substr(0, colonPos));
cfg.factor = stod(data.substr(colonPos+1));
} catch (...) {
return {ErrorCode::DATA_PARSE_FAILED, {}};
}
// 错误4:配置值无效(阈值必须大于0)
if (cfg.threshold <= 0) {
return {ErrorCode::CONFIG_INVALID, {}};
}
return {ErrorCode::SUCCESS, cfg};
}
// 上层:使用配置计算结果,返回“错误码+结果”
struct CalculateResult {
ErrorCode err;
double result;
};
CalculateResult calculateResult(const string& configPath) {
// 调用底层:读取文件
ReadResult readRes = readConfigFile(configPath);
// 必须检查错误,传递给上层
if (readRes.err != ErrorCode::SUCCESS) {
return {readRes.err, 0.0};
}
// 调用中层:解析数据
ParseResult parseRes = parseConfigData(readRes.data);
// 必须检查错误,传递给上层
if (parseRes.err != ErrorCode::SUCCESS) {
return {parseRes.err, 0.0};
}
// 核心计算逻辑
Config cfg = parseRes.config;
double result = cfg.threshold * cfg.factor * 100;
return {ErrorCode::SUCCESS, result};
}
// 入口函数
int main() {
CalculateResult calcRes = calculateResult("config.txt");
// 最终检查错误,处理异常
if (calcRes.err != ErrorCode::SUCCESS) {
switch (calcRes.err) {
case ErrorCode::FILE_OPEN_FAILED:
cerr << "错误:文件打开失败" << endl;
break;
case ErrorCode::FILE_READ_FAILED:
cerr << "错误:文件读取失败" << endl;
break;
case ErrorCode::DATA_PARSE_FAILED:
cerr << "错误:数据解析失败" << endl;
break;
case ErrorCode::CONFIG_INVALID:
cerr << "错误:配置无效" << endl;
break;
default:
cerr << "错误:未知错误" << endl;
}
return 1;
}
cout << "计算结果:" << calcRes.result << endl;
return 0;
}
返回值的致命局限性(深层调用场景下)
看完上面的代码,你会发现 3 个无法回避的问题:
- 业务逻辑被错误处理淹没:
calculateResult函数的核心逻辑只有 1 行(double result = ...),但其余代码全是 “检查错误 -> 传递错误” 的冗余逻辑(if (err != SUCCESS) return err)。随着调用层级增加(比如再加 “网络层”“缓存层”),这种冗余会呈指数级增长; - 返回值语义污染:为了同时返回 “结果 + 错误码”,我们不得不定义
ReadResult、ParseResult等结构体,增加了代码复杂度。如果函数本身返回值类型复杂(比如std::vector<std::map<string, int>>),结构体的定义会更繁琐; - 构造函数无法处理错误:如果
Config类的构造函数需要依赖文件数据初始化(比如Config(const string& path)),构造函数没有返回值,无法通过返回值表示初始化失败 —— 这是返回值的 “天生缺陷”; - 错误处理分散:错误处理逻辑分散在每一层函数中,一旦需要修改错误处理方式(比如新增错误类型),所有层级的函数都要修改,维护成本极高。
用异常实现:剥离错误处理,专注业务逻辑
#include <string>
#include <iostream>
#include <stdexcept> // 标准异常头文件
#include <fstream>
using namespace std;
// 配置数据结构
struct Config {
int threshold;
double factor;
// 构造函数:直接接收文件路径,初始化失败则抛异常(解决返回值的天生缺陷)
Config(const string& path) {
// 读取文件(用std::ifstream,RAII自动管理资源)
ifstream file(path);
if (!file.is_open()) {
throw runtime_error("文件打开失败"); // 抛出异常,无需返回值
}
string data;
if (!getline(file, data)) {
throw runtime_error("文件读取失败");
}
// 解析数据
size_t colonPos = data.find(':');
if (colonPos == string::npos) {
throw invalid_argument("数据解析失败:格式错误");
}
try {
threshold = stoi(data.substr(0, colonPos));
factor = stod(data.substr(colonPos+1));
} catch (...) {
throw invalid_argument("数据解析失败:数值无效");
}
// 校验配置
if (threshold <= 0) {
throw logic_error("配置无效:阈值必须大于0");
}
}
};
// 上层:专注核心计算,无需处理错误传递
double calculateResult(const string& configPath) {
Config cfg(configPath); // 构造失败会抛异常,直接跳过后续代码
return cfg.threshold * cfg.factor * 100; // 核心业务逻辑
}
// 入口函数:集中处理所有异常
int main() {
try {
double result = calculateResult("config.txt");
cout << "计算结果:" << result << endl;
}
// 集中捕获不同类型的异常,统一处理
catch (const runtime_error& e) {
cerr << "运行时错误:" << e.what() << endl;
}
catch (const invalid_argument& e) {
cerr << "参数错误:" << e.what() << endl;
}
catch (const logic_error& e) {
cerr << "逻辑错误:" << e.what() << endl;
}
catch (const exception& e) {
cerr << "未知异常:" << e.what() << endl;
}
return 0;
}
异常的核心优势(对比返回值)
对比两段代码,异常的优势一目了然:
- 剥离错误处理与业务逻辑:中间层(
calculateResult)无需关心错误传递,只专注核心业务逻辑,代码量减少 60% 以上; - 支持无返回值场景:构造函数、运算符等无返回值的场景,可通过异常表示错误(如
Config构造函数),弥补了返回值的天生缺陷; - 错误处理集中化:所有错误都在入口函数的
catch块集中处理,修改错误类型或处理逻辑时,只需修改一处,维护成本低; - 错误信息更丰富:异常对象可携带详细错误信息(如
what()返回的描述),比单纯的错误码更易调试。
场景化选择:什么时候用返回值,什么时候用异常?
通过上面的对比,我们可以明确二者的适用场景 —— 它们是互补关系,而非替代关系:
| 场景类型 | 推荐方案 | 核心原因 |
|---|---|---|
| 简单参数校验、查询无结果 | 返回值(优先用std::optional) |
错误可预期、非致命,无需中断流程 |
| 非致命、可忽略的错误 | 返回值(错误码 /std::expected) |
如日志写入失败、缓存未命中,不影响主流程 |
| 致命错误、必须处理的错误 | 异常 | 如文件打开失败、内存分配失败,需中断当前流程 |
| 构造函数 / 运算符错误 | 异常(唯一选择) | 无返回值,无法用返回值表示错误 |
| 深层调用场景(多层函数调用) | 异常 | 避免冗余的错误码传递,简化代码 |
小贴士:C++17 的std::optional(表示 “可能无结果”)和 C++23 的std::expected(表示 “结果或错误”)是返回值处理的 “升级方案”,比传统错误码更优雅,推荐在简单场景下使用。
异常基本写法
C++ 异常依赖三个关键字,构成 “抛出 - 监控 - 捕获” 的闭环:
- 抛出(
throw):当程序检测到错误时,用throw语句抛出一个 “异常对象”(可以是标准异常、自定义异常),直接中断当前代码流; - 监控(
try):用try块包裹 “可能抛出异常的代码”,告诉编译器 “这里需要监控异常”; - 捕获(
catch):try块后紧跟一个或多个catch块,每个catch指定要捕获的异常类型,匹配成功则执行对应的错误处理逻辑。
try
{
// 保护的标识代码
}
catch( ExceptionName &e1 )
{
// catch 块
}
catch( ExceptionName &e2 )
{
// catch 块
}
catch( ExceptionName &eN )
{
// catch 块
}
catch( ... ) //捕获任意类型异常,防止某个异常直到程序结束都没被捕获
{
// catch 块
cout << "Unkown Exception" << endl;
}
异常类型
异常对象可以是内置类型(如throw -1、throw "error"),但可读性差、无法携带详细信息,不推荐。实际开发中,异常通常是 “类对象”,分为标准异常体系和自定义异常。
标准异常体系
C++ 标准库提供了一套现成的异常基类std::exception(定义在<exception>头文件中),所有标准异常都是它的派生类,核心成员是what()方法(返回 C 风格字符串,描述异常信息)。
@startuml C++ 标准异常体系(规整版)
' 布局方向:自上而下,垂直分层
top to bottom direction
' 第1层:顶层根类(居中置顶)
class std::exception #lightblue
' 第2层:核心分支(居中)+ 独立异常(左右分组,缩减宽度)
' 左组独立异常
class std::bad_alloc #lightyellow
class std::bad_cast #lightyellow
' 中组核心分支
class std::logic_error #lightgreen
class std::runtime_error #lightgreen
' 右组独立异常
class std::bad_exception #lightyellow
class std::bad_typeid #lightyellow
class std::system_error #lightyellow
' 第3层:补充独立异常(下一层,避免同层拥挤)
class std::bad_function_call #lightyellow
class std::bad_weak_ptr #lightyellow
' 第4层:logic_error 子类(垂直对齐父类)
class std::invalid_argument #e6f7ff
class std::out_of_range #e6f7ff
class std::length_error #e6f7ff
class std::domain_error #e6f7ff
' 第5层:runtime_error 子类(垂直对齐父类)
class std::overflow_error #e6f7ee
class std::underflow_error #e6f7ee
class std::range_error #e6f7ee
' 继承关系(箭头无交叉,层级清晰)
std::exception <|-- std::bad_alloc
std::exception <|-- std::bad_cast
std::exception <|-- std::logic_error
std::exception <|-- std::runtime_error
std::exception <|-- std::bad_exception
std::exception <|-- std::bad_typeid
std::exception <|-- std::system_error
std::exception <|-- std::bad_function_call
std::exception <|-- std::bad_weak_ptr
std::logic_error <|-- std::domain_error
std::logic_error <|-- std::invalid_argument
std::logic_error <|-- std::length_error
std::logic_error <|-- std::out_of_range
std::runtime_error <|-- std::range_error
std::runtime_error <|-- std::overflow_error
std::runtime_error <|-- std::underflow_error
' 隐藏冗余信息,聚焦类名+继承关系
hide members
hide methods
@enduml

| 异常类名 | 父类 | 核心描述(新手易懂) |
|---|---|---|
std::exception |
-(顶层基类) | 所有标准异常的根类,提供 what() 方法返回异常描述 |
std::logic_error |
std::exception |
逻辑错误(编码阶段可规避),如参数无效、越界 |
std::runtime_error |
std::exception |
运行时错误(运行阶段触发,不可提前预知) |
std::bad_alloc |
std::exception |
内存分配失败(new/new[] 申请内存时抛出) |
std::bad_cast |
std::exception |
类型转换失败(如 dynamic_cast 向下转换无效) |
std::bad_exception |
std::exception |
异常处理链错误(用于异常规范兼容) |
std::bad_typeid |
std::exception |
类型识别失败(如 typeid 作用于空指针) |
std::bad_function_call |
std::exception |
空函数对象调用(如 std::function 未绑定函数) |
std::bad_weak_ptr |
std::exception |
std::weak_ptr 锁失败(如指向的对象已销毁) |
std::system_error |
std::exception |
系统调用错误(如文件打开失败、网络连接错误) |
std::invalid_argument |
std::logic_error |
无效参数(如 std::stoi("abc") 字符串转整数) |
std::out_of_range |
std::logic_error |
超出有效范围(如 vector::at(100) 索引越界) |
std::length_error |
std::logic_error |
长度错误(如 std::string 构造时长度溢出) |
std::domain_error |
std::logic_error |
定义域错误(如不支持的数学运算,如负数开根号) |
std::overflow_error |
std::runtime_error |
数值溢出(如 int 运算结果超出最大值) |
std::underflow_error |
std::runtime_error |
数值下溢(如浮点数运算精度丢失,结果接近 0) |
std::range_error |
std::runtime_error |
范围错误(如计算结果超出有效取值范围) |
自定义异常
当标准异常无法满足业务需求时(如需要携带错误码、业务标识),可自定义异常类 ——必须继承std::exception或其派生类,并重写what()方法。
#include <stdexcept>
#include <string>
// 自定义业务异常:继承std::runtime_error
class BusinessError : public std::runtime_error {
private:
int err_code_; // 业务错误码
public:
// 构造函数:接收错误码和错误描述
BusinessError(int err_code, const std::string& msg)
: std::runtime_error(msg), err_code_(err_code) {}
// 获取业务错误码
int getErrorCode() const { return err_code_; }
// 重写what(),返回更详细的错误信息(可选)
const char* what() const noexcept override {
static std::string full_msg = "业务错误[" + std::to_string(err_code_) + "]: " + std::runtime_error::what();
return full_msg.c_str();
}
};
// 使用自定义异常
void createOrder(int userId) {
if (userId <= 0) {
throw BusinessError(1001, "用户ID无效"); // 抛出自定义异常
}
}
万能异常捕获
在 C++ 异常处理中,catch(...) 是一种 “万能异常捕获” 语法,也叫 “捕获所有异常” 或 “省略号捕获”—— 它能捕获任何类型的异常—— 它能捕获任何类型的异常(包括标准异常、自定义异常、甚至内置类型如 int/const char* 抛出的异常),是异常捕获体系中的 “终极兜底方案”。
语法定义
try {
// 可能抛出任意类型异常的代码
}
// 特定异常捕获(先匹配具体类型)
catch (const std::runtime_error& e) {
// 处理运行时异常
}
catch (const std::logic_error& e) {
// 处理逻辑异常
}
// 万能捕获(最后匹配,捕获所有未被前面捕获的异常)
catch (...) {
// 兜底处理所有其他异常
}
核心特点
- 匹配所有异常:无论
throw抛出的是std::exception派生类、自定义类、int、const char*等任何类型,catch(...)都能捕获; - 必须放在最后:
catch块的匹配顺序是 “自上而下”,catch(...)会拦截所有异常,因此必须放在所有特定类型 catch 块之后(否则会导致前面的特定 catch 永远无法匹配); - 无法直接获取异常信息:
catch(...)没有参数,无法访问异常对象的细节(比如what()方法返回的描述),只能知道 “有异常发生”。
核心作用:兜底防崩溃
catch(...) 的核心价值是 “防止程序因未捕获的异常直接终止”——C++ 中如果异常未被任何 catch 块捕获,会调用 std::terminate() 终止程序(类似崩溃)。而 catch(...) 能作为 “最后一道防线”,捕获所有遗漏的异常,保证程序能优雅处理(如记录日志、清理资源)后退出,而非直接崩溃。
典型用法示例
#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;
// 自定义异常
class MyException : public exception {
public:
const char* what() const noexcept override { return "自定义异常"; }
};
int main() {
try {
// 可能抛出多种异常
throw MyException(); // 自定义异常
// throw 100; // 内置类型 int 异常
// throw "未知错误"; // 内置类型 const char* 异常
// throw runtime_error("运行时错误"); // 标准异常
}
// 特定异常捕获(精准处理已知异常)
catch (const MyException& e) {
cout << "捕获到自定义异常:" << e.what() << endl;
}
catch (const exception& e) {
cout << "捕获到标准异常:" << e.what() << endl;
}
// 万能捕获(兜底处理所有未匹配的异常)
catch (...) {
cout << "捕获到未知类型异常!" << endl;
// 关键操作:记录日志、清理资源(如释放内存、关闭文件)
// ... 资源清理逻辑 ...
}
return 0;
}
运行结果(抛出 MyException 时):
捕获到自定义异常:自定义异常
若抛出 throw 100(无特定 catch 匹配),运行结果:
捕获到未知类型异常!
注意事项
- 必须放在所有 catch 块的最后
错误示例(catch(...) 在前,导致特定 catch 失效):
try {
throw runtime_error("运行时错误");
}
catch (...) { // 放在前面,直接捕获所有异常
cout << "未知异常" << endl;
}
catch (const exception& e) { // 永远不会执行!
cout << e.what() << endl;
}
正确示例(特定 catch 在前,catch(...) 兜底):
try {
throw runtime_error("运行时错误");
}
catch (const exception& e) { // 先匹配特定异常
cout << e.what() << endl;
}
catch (...) { // 最后兜底
cout << "未知异常" << endl;
}
- 无法获取异常详情,需配合 “重新抛出”
catch(...) 不能访问异常对象,因此无法获取 what() 描述、错误码等信息。如果需要上层处理异常详情,可在 catch(...) 中使用 “重新抛出”(rethrow) 语法 throw;,将异常原样抛给上层:
void func() {
try {
throw MyException();
}
catch (...) {
cout << "func 中捕获到异常,记录日志后向上抛出" << endl;
throw; // 重新抛出异常,不改变异常类型和信息
}
}
int main() {
try {
func();
}
catch (const MyException& e) {
// 上层能获取完整异常信息
cout << "main 中捕获到异常:" << e.what() << endl;
}
return 0;
}
运行结果:
func 中捕获到异常,记录日志后向上抛出
main 中捕获到异常:自定义异常
注意:重新抛出时用 throw;(无参数),而非 throw 异常对象;—— 后者会创建新的异常对象,丢失原始异常信息。
- 不可滥用:优先特定捕获,再兜底
catch(...) 是 “兜底方案”,而非首选。原因:
- 无法精准处理异常(比如不同异常需要不同修复逻辑时,
catch(...)做不到); - 可能掩盖潜在 bug(比如捕获了本应暴露的逻辑错误)。
使用原则:
- 先写所有能预见的 特定类型 catch(如
catch(const std::runtime_error&)、catch(const MyException&)),精准处理已知异常; - 最后用
catch(...)兜底,仅用于:- 记录异常日志(便于调试);
- 清理资源(如释放 RAII 未覆盖的资源);
- 避免程序直接崩溃,优雅退出或重新抛出异常。
基础用法
结合前面的案例,我们再通过 3 个核心场景,巩固异常的基础用法。
基础用法:抛出 + 捕获标准异常
#include <iostream>
#include <stdexcept>
using namespace std;
// 除法函数:除数为0时抛异常
double divide(double a, double b) {
if (b == 0) {
// 抛出std::invalid_argument异常,携带错误信息
throw invalid_argument("除数不能为0");
}
return a / b;
}
int main() {
try {
cout << divide(10, 2) << endl; // 正常执行,输出5
cout << divide(10, 0) << endl; // 抛出异常,中断执行
}
// 捕获特定类型的异常
catch (const invalid_argument& e) {
cerr << "捕获到参数异常:" << e.what() << endl; // 输出:捕获到参数异常:除数不能为0
}
// 捕获所有std::exception派生的异常(兜底)
catch (const exception& e) {
cerr << "捕获到异常:" << e.what() << endl;
}
return 0;
}
异常传播:多层调用下的自动传递
如果异常在函数中抛出,但该函数没有catch块,异常会自动 “向上传播” 到调用者,直到找到匹配的catch块:
// 底层函数:抛异常
void funcC() {
throw runtime_error("funcC中发生运行时错误");
}
// 中层函数:调用funcC,不捕获异常
void funcB() {
funcC(); // 异常传播到funcB的调用者
}
// 上层函数:调用funcB,不捕获异常
void funcA() {
funcB(); // 异常传播到funcA的调用者
}
int main() {
try {
funcA(); // 异常传播到这里
}
catch (const runtime_error& e) {
cerr << "捕获到传播的异常:" << e.what() << endl; // 输出对应错误信息
}
return 0;
}
自定义异常的捕获顺序
catch块的匹配顺序是 “自上而下”,子类异常必须放在父类异常前面,否则子类异常会被父类异常 “拦截”:
int main() {
try {
throw BusinessError(1001, "用户ID无效"); // 抛出自定义异常(子类)
}
// 正确:先捕获子类异常
catch (const BusinessError& e) {
cerr << "业务异常:" << e.what() << ",错误码:" << e.getErrorCode() << endl;
}
// 后捕获父类异常(兜底)
catch (const runtime_error& e) {
cerr << "运行时异常:" << e.what() << endl;
}
catch (const exception& e) {
cerr << "未知异常:" << e.what() << endl;
}
return 0;
}
// 错误示例:父类异常在前,子类异常无法被捕获
try {
throw BusinessError(1001, "用户ID无效");
}
catch (const runtime_error& e) { // 父类异常在前,直接捕获子类异常
cerr << "运行时异常:" << e.what() << endl;
}
catch (const BusinessError& e) { // 永远不会执行
cerr << "业务异常:" << e.what() << endl;
}
使用注意事项
异常虽强大,但使用不当会导致代码崩溃、资源泄漏等严重问题。以下 6 个注意事项,是新手避坑的核心。
异常安全:用 RAII 避免资源泄漏
异常会中断正常代码流,如果手动管理资源(如new/delete、文件句柄、锁),容易导致资源泄漏:
// 反例:异常导致内存泄漏
void badExample() {
int* arr = new int[10]; // 分配内存
divide(10, 0); // 抛出异常,后续delete不会执行
delete[] arr; // 内存泄漏!
}
解决方案:RAII(资源获取即初始化)—— 用对象的生命周期管理资源,析构函数会自动释放资源(即使发生异常)。C++ 标准库的std::unique_ptr(智能指针)、std::lock_guard(锁)、std::ifstream(文件流)都是 RAII 的典型应用:
// 正例:用RAII保证异常安全
#include <memory> // std::unique_ptr
void goodExample() {
// 智能指针管理内存,异常时自动释放
unique_ptr<int[]> arr = make_unique<int[]>(10);
divide(10, 0); // 抛出异常,arr的析构函数自动调用delete[]
}
禁止在析构函数中抛出异常
析构函数的职责是释放资源,若析构函数中抛出异常,会导致严重问题:
- 如果当前正处于异常传播过程中(已有一个未处理的异常),再抛出新异常,程序会直接调用
std::terminate()终止; - 析构函数是自动调用的,其异常无法被上层捕获。
规则:析构函数必须是noexcept(C++11 + 默认是noexcept),内部不抛出异常(若有错误,内部处理):
// 正确示例:析构函数不抛出异常
class ResourceHolder {
private:
FILE* fp_;
public:
explicit ResourceHolder(const string& path) {
fp_ = fopen(path.c_str(), "r");
}
~ResourceHolder() noexcept { // 明确noexcept
if (fp_) {
try {
fclose(fp_); // 可能失败的操作
}
catch (...) {
// 内部处理错误,不向外抛出
cerr << "文件关闭失败" << endl;
}
}
}
};
合理使用noexcept
noexcept用于声明函数 “不会抛出异常”,有两个核心作用:
- 编译器可优化代码(减少异常处理开销);
- 若函数违反声明(实际抛出异常),程序会直接调用
std::terminate(),避免不可预期的行为。
使用场景:析构函数、移动构造 / 赋值函数、明确无异常的工具函数:
// 声明函数不会抛出异常
int add(int a, int b) noexcept {
return a + b; // 无异常风险
}
// 移动构造函数声明为noexcept(推荐)
class MyString {
public:
MyString(MyString&& other) noexcept {
// 移动资源,无异常
}
};
不抛出裸指针异常
抛出new分配的异常对象指针(如throw new BusinessError(1001, "错误"))是危险的:
- 捕获方需要手动
delete,容易遗漏导致内存泄漏; - 若未被捕获,异常终止时,指针指向的内存永远无法释放。
规则:抛出异常对象(值类型),捕获时用const&(避免拷贝开销):
// 错误:抛出裸指针
throw new BusinessError(1001, "用户ID无效");
// 正确:抛出异常对象
throw BusinessError(1001, "用户ID无效");
// 捕获时用const&
catch (const BusinessError& e) { ... }
避免过度使用异常
异常适合处理 “严重、不可预期” 的错误,不适合处理 “频繁发生的预期错误”(如循环中的参数校验):
// 反例:用异常处理频繁的参数校验
double calculateScore(int score) {
if (score < 0 || score > 100) {
throw invalid_argument("分数必须在0-100之间"); // 不推荐,预期内的错误
}
return score * 0.8;
}
// 正例:用返回值处理预期错误
std::optional<double> calculateScore(int score) {
if (score < 0 || score > 100) {
return std::nullopt; // 预期内的错误,用std::optional表示
}
return score * 0.8;
}
异常信息要具体
抛出异常时,携带的错误信息要足够详细(如错误原因、发生位置、相关参数),便于调试:
// 不好:错误信息模糊
throw runtime_error("文件读取失败");
// 好:携带具体信息
throw runtime_error("文件读取失败:路径=" + path + ",错误码=" + to_string(errno));
捕获异常对象时使用引用类型(防止切片问题)
当抛出子类异常对象,但用父类值类型捕获时,会发生 “切片”—— 子类对象中 “父类没有的成员(属性、方法)” 会被 “截断”,最终得到的是一个纯父类对象,丢失子类特有的信息。
这是异常处理中的隐蔽问题,会导致无法获取子类异常的完整错误信息(如自定义异常的业务错误码、详细描述)。
错误示例:
#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;
// 子类异常:携带业务错误码(父类std::runtime_error没有该成员)
class BusinessError : public runtime_error {
private:
int err_code_; // 子类特有成员:业务错误码
public:
BusinessError(int code, const string& msg)
: runtime_error(msg), err_code_(code) {}
// 子类特有方法:获取错误码
int getErrorCode() const { return err_code_; }
// 重写what(),拼接错误码和描述
const char* what() const noexcept override {
static string full_msg = "错误码[" + to_string(err_code_) + "]: " + runtime_error::what();
return full_msg.c_str();
}
};
int main() {
try {
// 抛出子类异常对象(含错误码1001)
throw BusinessError(1001, "用户ID无效");
}
// 错误:用父类值类型(runtime_error)捕获
catch (runtime_error e) { // 这里发生切片!
// 此时e是纯runtime_error对象,子类的err_code_已被截断
cout << "捕获到异常:" << e.what() << endl;
// 无法调用getErrorCode():e的编译时类型是父类,且子类成员已丢失
// cout << "错误码:" << e.getErrorCode() << endl; // 编译报错
}
return 0;
}
运行结果(切片导致的问题):
捕获到异常:错误码[0]: 用户ID无效 // 子类的err_code_被截断,变成默认值0
C++ 的解决方案:用「父类 const 引用」捕获
C++ 中避免切片的核心是:捕获异常时,永远用「const 引用」类型(而非值类型)。引用传递不会拷贝整个对象,仅绑定到抛出的异常对象,且能完整保留子类的所有成员。
修正后的代码(正确用法):
int main() {
try {
throw BusinessError(1001, "用户ID无效");
}
// 正确:用父类const引用捕获
catch (const runtime_error& e) { // 无切片,绑定子类对象
cout << "捕获到异常:" << e.what() << endl; // 能获取完整子类信息
// 若需调用子类特有方法,可动态转型(可选)
if (const BusinessError* be = dynamic_cast<const BusinessError*>(&e)) {
cout << "错误码:" << be->getErrorCode() << endl;
}
}
return 0;
}
运行结果(无切片):
捕获到异常:错误码[1001]: 用户ID无效
错误码:1001
总结与实践建议
通过本文的案例对比和知识点梳理,你应该明白:C++ 异常不是返回值的替代品,而是复杂场景下的 “优雅解”—— 它解决了深层调用中错误码传递的冗余、无返回值场景的错误处理等痛点。
核心要点回顾
- 异常机制:
throw抛出异常,try监控代码,catch捕获处理,异常会自动向上传播; - 异常体系:优先使用标准异常,自定义异常继承
std::exception,重写what()方法; - 场景选择:简单错误用
std::optional/std::expected,严重错误 / 深层调用 / 构造函数错误用异常; - 异常安全:依赖 RAII(智能指针、标准库容器),避免手动管理资源;
- 关键禁忌:析构函数不抛异常、捕获顺序子类在前、不抛裸指针异常。