C++ 异常处理

C++异常处理

1. throw / try / catch 基本语法

1. 头文件

1
2
3
4
5
// 写异常最少要这两个:
#include <iostream>
#include <exception> // 用于标准异常类

#include <stdexcept> // 如果用具体的错误类型(比如运行时错误),加这个标准异常类(runtime_error / invalid_argument / out_of_range)

2. 三个关键字格式

1
2
3
4
5
6
7
8
9
10
11
12
13
try
{
// 可能抛异常的代码
throw std::runtime_error("出错了");
}
catch (const std::exception& e)
{
// 处理
}
catch (...)
{
// 捕获所有(尽量别用)
}

1. throw(扔错误)

格式:throw 错误内容;,例子:

1
2
3
throw "出错了";                         // 扔C风格字符串
throw std::runtime_error("文件打开失败"); // 扔标准异常对象
throw 42; // 扔整数(不推荐)

常用标准异常类在 <stdexcept> 头文件里:

  • std::logic_error:逻辑错,比如参数传错。
  • std::runtime_error:运行时错,比如文件打不开。
  • std::out_of_range:越界,比如数组下标超标。
  • std::invalid_argument:无效参数。

throw 发生时:

  1. 当前函数立刻停止执行(后面的代码都不跑了)
  2. 系统开始沿着调用栈 向外一层一层找 最近的 catch
  3. 每退出一层,就把这一层的所有局部对象(栈上对象)按逆序调用析构函数
  4. 找到匹配的 catch → 跳进去执行 catch 块
  5. 一直找到 main() 外面都没人 catch → 调用 std:: terminate() → 程序崩溃

所以:throw 不是立刻死,而是“带着析构一路向外跑”,没人接住才死,这机制叫“栈展开”。最大好处:不用手动删内存,局部对象自动清理。即局部变量不用担心泄露,但 new 出来的堆内存,没智能指针还是会漏。

2. try(一定范围的异常捕获)

try 后面跟花括号 {},把可能出错的代码放里面。只要括号里抛出异常,后面剩下的代码就不执行了,直接跳去 catch。

1
2
3
4
5
6
try
{
// 放可能出错的代码
divide(10, 0); // 这一行抛异常
cout << "这行不会执行" << endl; // 跳过
}

3. catch(接错误)

catch 有点像 if-else,匹配顺序是 从前往后,第一个匹配成功的 catch 会执行,后面的忽略。

1
2
3
4
5
6
7
8
9
10
11
12
catch (const runtime_error& e)
{
cout << "捕获到运行时错误:" << e.what() << endl;
}
catch (const exception& e)
{
cout << "捕获到标准错误:" << e.what() << endl;
}
catch (...) // 兜底,必须放最后
{
cout << "捕获到未知错误" << endl;
}

小技巧

  • const 引用 接收:catch (const exception& e),避免拷贝,效率高。
  • 具体异常放上面,通用异常放下面。

4. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <stdexcept> // 用标准异常要用这个
using namespace std;

// 一个会抛异常的函数
void divide(int a, int b)
{
if (b == 0)
{
// 扔出一个运行时错误
throw runtime_error("除数不能为 0");
}
cout << "结果:" << a / b << endl;
}

int main()
{
try
{
// 试着调用可能出错的函数
divide(10, 0);
cout << "这行不会执行" << endl;
}
catch (const runtime_error& e)
{
cout << "捕获到运行时错误:" << e.what() << endl; // 专门接 runtime_error 类型的错误
}
catch (const exception& e)
{
cout << "捕获到标准错误:" << e.what() << endl; // 接其他标准错误
}
catch (...) // 接所有其他未知错误
{
cout << "捕获到未知错误" << endl;
}

cout << "程序继续运行" << endl;
return 0;
}

3. 注意事项小结

  • catch 用引用:能写 catch (const exception& e),就别写 catch (exception e)。避免拷贝,效率高。
  • 顺序很重要:具体的异常放上面,通用的放下面。比如 runtime_error 要在 exception 前面。
  • throw 啥都行:可以扔 int,扔字符串,最好扔异常对象。
  • catch (…) ):这是兜底的,放最后。啥错误都能接,但不知道具体是啥错。
  • 头文件别漏:不用 <stdexcept> 编译器可能不认识 runtime_error
  • 标准异常类std::exception 是基类。常用的有 runtime_error(运行时错)、logic_error(逻辑错)、out_of_range(越界)。

2. 异常传播规则(栈展开、析构函数调用)

1. 什么是异常传播

当 throw 发生时,程序会 沿着调用链往上找 catch,这个过程叫 栈展开(Stack Unwinding)。简单说就是:从当前函数一层一层往回跑,看看谁能接住这个异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void f3()
{
throw std::runtime_error("f3 失败");
}

void f2()
{
f3(); // 不处理,继续向上传播
}

void f1()
{
f2(); // 也不处理
}

int main()
{
try
{
f1();
}
catch (const std::exception& e)
{
std::cout << e.what() << '\n';
}
}

执行顺序:

  1. f3() 抛出异常
  2. f2() 没 catch,继续往上
  3. f1() 没 catch,继续往上
  4. main() 有 catch,捕获成功

2. 栈展开时发生了什么?

每跳出一层函数,那一层的 局部对象会被自动销毁(调用析构函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Widget
{
~Widget() { cout << "Widget 析构\n"; }
};

void level3()
{
Widget w; // 局部对象
throw std::runtime_error("错!");
}

void level2()
{
Widget w; // 这个也会被销毁
level3();
}

int main()
{
try
{
level2();
}
catch (...)
{
cout << "捕获\n";
}
}

输出:

1
2
3
Widget 析构   // level3 的 w
Widget 析构 // level2 的 w
捕获

发生异常后,程序会从抛出点一路“回退”调用栈。

  • 当前作用域里已经构造成功的局部对象,会按逆序调用析构函数。
  • 这就是 RAII 能防泄漏的根本原因。
  • 还没构造成功的对象,不会析构。

这就是 RAII 的基础:利用析构函数自动释放资源。

3. 析构函数与异常

重要规则:析构函数绝对不能抛异常!如果析构函数在栈展开期间再抛异常,程序会直接 std::terminate()

为什么?

  • 栈展开时已经在处理一个异常了
  • 如果析构函数又抛异常,程序不知道该处理哪个
  • 结果:直接调用 std::terminate() 崩溃
1
2
3
4
5
6
7
8
class Bad
{
public:
~Bad() noexcept
{
// 千万不能 throw!
}
};

正确做法:析构函数里如果可能出错,捕获并吞掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Good
{
public:
~Good() noexcept
{
try
{
// 可能失败的操作
}
catch (...)
{
// 吞掉,别让它抛出去
}
}
};

4. 构造函数中的异常

构造函数抛异常时,已构造的部分会被自动销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget
{
public:
Widget(int size)
{
if (size < 0)
throw std::invalid_argument("size 不能为负");
data = new int[size]; // 如果后面抛异常,这里会自动 delete
}

~Widget() { delete[] data; }

private:
int* data;
};

实用建议:用 RAII 管理资源,别直接 new/delete。

3. noexcept

1. 基本语法

noexcept 表示 “这个函数 承诺不抛异常”。

1
2
3
4
5
6
7
void func() noexcept;           // 声明:不抛异常
void func() noexcept(true); // 显式写法,效果一样
void func() noexcept(false); // 可能抛(默认行为)

// 用于模板
template<typename T>
void swap(T& a, T& b) noexcept(std::is_nothrow_swappable_v<T>);

2. 违反 noexcept 会怎样

noexcept 函数里如果真的抛异常,程序不会继续找 catch,而是直接 std::terminate()

1
2
3
4
void bad() noexcept
{
throw std::runtime_error("Oops!"); // ❌ 完蛋!
}

如果调用 bad() 并抛出异常,程序会直接调用 std::terminate()二话不说直接崩溃

教训:只有 100% 确定不会抛异常,才加 noexcept

3. noexcept 有什么用的?

1. 性能优化

编译器知道函数不抛异常后:

  • 省略栈展开代码
  • 生成更小更快的二进制
1
2
3
4
5
std::vector<std::string> v;
// string 的 move 构造函数是 noexcept → 高效移动

std::vector<MyClass> w;
// 如果 MyClass 的 move 不是 noexcept → vector 可能用拷贝!

2. STL 容器依赖 noexcept 做决策

std::vector 扩容时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 伪代码示意
void reallocate()
{
if (std::is_nothrow_move_constructible<T>::value)
{
// 用移动,更快
newData = static_cast<T*>(::operator new(n));
new (newData + i) T(std::move(oldData[i])); // noexcept
}
else
{
// 退化为拷贝,更安全
newData[i] = oldData[i]; // 可能抛异常,但不会丢数据
}
}

3. 接口契约

告诉调用者:“这个函数安全,不会因异常中断”。对于析构函数、移动构造/赋值、swap,强烈建议加 noexcept

4. 常见使用场景

1. 移动构造函数 / 移动赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyClass
{
public:
MyClass(MyClass&& other) noexcept
: ptr(other.ptr)
{
other.ptr = nullptr;
}

MyClass& operator=(MyClass&& other) noexcept
{
if (this != &other)
{
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
}
return *this;
}

private:
int* ptr;
};

2. swap 函数

1
2
3
4
5
6
7
8
9
10
11
class MyStack
{
public:
void swap(MyStack& other) noexcept
{
data.swap(other.data);
}

private:
std::deque<int> data;
};

3. 析构函数

1
2
3
4
5
6
7
8
class Resource
{
public:
~Resource() noexcept = default; // 显式强调(其实默认就是)

// 不要这样:
// ~Resource() { /* 这里不能抛异常 */ }
};

5. 为什么移动构造要尽量 noexcept

标准容器(尤其 std::vector)扩容时会优先选择“更安全”的路径。

  • 元素移动构造是 noexcept:容器更敢用移动,性能通常更好。
  • 不是 noexcept:容器可能退回拷贝策略来保异常安全。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Buffer
{
public:
Buffer(Buffer&& other) noexcept
{
data_ = other.data_;
other.data_ = nullptr;
}

~Buffer() noexcept
{
delete[] data_;
}

private:
int* data_ = nullptr;
};

6. 实用建议

  • 析构函数:默认就应当是 noexcept
  • 移动构造、移动赋值、swap:能保证不抛就加 noexcept
  • 只有 100% 确定不抛,才写 noexcept

4. std:: exception 继承体系(常用标准异常)

1. 常见继承链

1
2
3
4
5
6
7
8
9
10
std::exception
├── std::logic_error
│ ├── std::invalid_argument // 参数不合法
│ ├── std::domain_error // 参数超出有效域
│ ├── std::length_error // 长度超限
│ └── std::out_of_range // 越界
└── std::runtime_error
├── std::range_error // 值超出表示范围
├── std::overflow_error // 算术溢出
└── std::underflow_error // 算术下溢

2. 常用异常类

异常类什么时候用
std::invalid_argument参数值不合法
std::out_of_range下标越界访问
std::runtime_error运行时出错,文件打不开、网络断连等
std::overflow_error整数溢出、加法溢出
std::length_error比如 string 太长,超过 max_size()
std::logic_error代码逻辑前提被破坏

3. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdexcept>
#include <vector>
#include <iostream>
using namespace std;

void process(vector<int>& v, int index)
{
if (index < 0)
throw invalid_argument("索引不能为负");

if (index >= v.size())
throw out_of_range("索引超出范围");

v[index] *= 2;
}

int main()
{
vector<int> v = {1, 2, 3};

try
{
process(v, 100);
}
catch (const out_of_range& e)
{
cout << "越界:" << e.what() << endl;
}
catch (const invalid_argument& e)
{
cout << "参数错:" << e.what() << endl;
}

return 0;
}

5. 自定义异常类

1. 最简单写法

继承 std::runtime_errorstd::logic_error

1
2
3
4
5
6
7
8
9
#include <stdexcept>
#include <string>

class MyError : public std::runtime_error
{
public:
explicit MyError(const std::string& msg)
: std::runtime_error(msg) {}
};

使用:

1
throw MyError("我的错误信息");

2. 更完整的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdexcept>
#include <string>

class NetworkError : public std::runtime_error
{
public:
NetworkError(const std::string& msg, int code)
: std::runtime_error(msg), errorCode(code) {}

int code() const { return errorCode; }

private:
int errorCode;
};

class ConfigError : public std::logic_error
{
public:
explicit ConfigError(const std::string& msg)
: std::logic_error(msg) {}
};

3. 直接继承 std:: exception

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <exception>
#include <string>

class MyException : public std::exception
{
public:
explicit MyException(const std::string& msg) noexcept
: message(msg) {}

const char* what() const noexcept override
{
return message.c_str();
}

private:
std::string message;
};

4. 实用建议

  • 继承 std::runtime_errorstd::logic_error(推荐)
  • 提供有意义的错误信息
  • 如果需要额外数据,加成员变量和 getter
  • what() 要覆盖,返回错误描述
  • 析构函数要 noexcept

6. catch(…) 的危险性与替代方案

1. catch(…) 是什么?

catch(...) 能捕获 所有 异常:

1
2
3
4
5
6
7
8
try
{
// 可能抛任何异常
}
catch (...)
{
// 什么异常都能接住
}

2. 危险在哪?

不知道具体是什么异常,没法正确处理:

1
2
3
4
5
catch (...)
{
// ❌ 盲目吞掉所有异常
// 不知道是内存不足?还是文件不存在?还是逻辑错误?
}

常见错误:

1
2
3
4
5
6
7
8
9
10
11
try
{
doSomething();
}
catch (...)
{
// 错误:悄悄吞掉,不告诉任何人
}

// 程序员以为成功了,实际上失败了
saveToDatabase(); // 基于错误的数据做后续操作

3. 正确用法

1. 重新抛出

1
2
3
4
5
6
7
8
9
10
try
{
riskyOperation();
}
catch (...)
{
// 做一些清理
logError();
throw; // 重新抛出原来的异常
}

2. 转换为已知类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try
{
process();
}
catch (const std::exception& e)
{
// 已知类型,直接处理
cerr << "错误:" << e.what() << endl;
}
catch (...)
{
// 未知类型,转换为通用错误
throw std::runtime_error("未知错误");
}

3. 资源清理(配合 RAII)

1
2
3
4
5
6
7
8
9
10
try
{
// 关键操作
}
catch (...)
{
// 这里其实不需要做太多
// RAII 会自动清理
throw; // 通常是重新抛出
}

4. 替代方案

现代 C++ 推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 用 std::optional(简单情况)
std::optional<int> divide(int a, int b)
{
if (b == 0) return std::nullopt;
return a / b;
}

// 2. 用 std::expected(C++23,更复杂情况)
std::expected<int, std::string> divide(int a, int b)
{
if (b == 0) return std::unexpected("除数不能为0");
return a / b;
}

// 3. 用 error_code(传统风格)
std::error_code divide(int a, int b, int& result);

7. RAII 与异常安全保证

1. 什么是 RAII?

RAII = Resource Acquisition Is Initialization,翻译:资源获取即初始化。

  • 构造时拿资源。
  • 析构时放资源。
  • 异常发生时,栈展开会自动调用析构。

核心思想:用对象的构造函数获取资源,析构函数释放资源(对象生命周期绑定资源生命周期)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class FileHandle
{
public:
FileHandle(const string& filename)
{
file = fopen(filename.c_str(), "r");
if (!file) throw std::runtime_error("打不开");
}

~FileHandle()
{
if (file) fclose(file); // 自动关闭
}

private:
FILE* file;
};

使用:

1
2
3
4
5
6
7
void readFile()
{
FileHandle fh("data.txt"); // 构造函数打开文件
// 读写文件...
} // 函数结束自动调用析构,文件关闭

// 即使上面抛异常,析构也会调用,文件不会泄漏!

2. 异常安全三级保证

  • Basic:异常后资源不泄漏,对象仍然有效,但值可能变化。
  • Strong:要么成功,要么像没执行过(无副作用)。
  • Nothrow:承诺绝不抛异常。

1. 基本保证(Basic Guarantee)

资源不泄漏,对象处于有效状态(即使不清楚是什么状态)。

1
2
3
4
5
6
7
void doSomething()
{
auto p = std::make_unique<Widget>(); // 智能指针,异常下自动释放

// 如果后面抛异常,p 会自动 delete
// 不会泄漏资源
}

3. 强保证(Strong Guarantee)

操作要么 完全成功,要么 完全没发生

1
2
3
4
5
6
7
8
9
10
11
12
void addCustomer(Customer& c)
{
auto transaction = db.beginTransaction(); // 开启事务

db.insert(c); // 可能抛异常
log.write(c); // 可能抛异常

transaction.commit(); // 成功提交

// 如果中间任何一步失败,全部回滚
// 就像什么都没发生过
}

3. Nothrow 保证

绝不抛异常。用于析构函数、swap、移动操作。

1
2
3
4
5
6
7
8
9
class Widget
{
public:
~Widget() noexcept {} // 绝不抛
void swap(Widget& other) noexcept {} // 绝不抛

Widget(Widget&&) noexcept {} // 绝不抛
Widget& operator=(Widget&&) noexcept {} // 绝不抛
};

3. 如何实现强保证?

复制-交换(Copy-and-Swap) 模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget
{
public:
void update(const Data& newData)
{
Widget temp(*this); // 拷贝一份
temp.apply(newData); // 修改副本(可能抛异常)

// 如果上面成功,交换
swap(temp); // noexcept

// 失败,原对象不变;成功,新数据生效
}

void swap(Widget& other) noexcept
{
data.swap(other.data);
}

private:
Data data;
};

8. 异常下的资源管理

1. 智能指针(必用)

智能指针是现代 C++ 的 标配

1
2
3
4
5
6
7
// C++14+
auto p1 = std::make_unique<Widget>(); // 独占所有权
auto p2 = std::make_shared<Widget>(); // 共享所有权

// C++11
std::unique_ptr<Widget> p1(new Widget);
std::shared_ptr<Widget> p2(new Widget);

为什么用智能指针?

  • 异常发生时,自动释放内存
  • 不用手动 delete
  • 不会泄漏

2. Scope Guard(作用域守卫)

std::scope_exit(C++17)是 RAII 版 try-finally

1
2
3
4
5
6
7
8
9
10
11
#include <scope>

void process()
{
auto guard = std::scope_exit([&] {
cout << "无论成功还是失败,这行都会执行\n";
});

// 业务代码...
// 即使抛异常,lambda 也会执行
}

类似的还有:

  • std::scope_success(仅成功时执行)
  • std::scope_failure(仅失败时执行)

3. 传统写法(了解即可)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void oldStyle()
{
FILE* f = fopen("data.txt", "w");
if (!f) throw runtime_error("打不开");

try
{
// 写文件...
}
catch (...)
{
fclose(f); // 手动清理
throw; // 重新抛出
}

fclose(f);
}

现在不推荐:用智能指针或 scope guard 更简洁、更安全。

4. 智能指针补充(点到为止)

关于智能指针的详细内容,会在其他文章中讲解。这里记住:能用 make_unique / make_shared 就用,别直接 new

9. terminate() / set_terminate / unexpected()

1. std:: terminate()

std::terminate()异常处理系统的紧急出口,即“程序无法继续安全运行”时的最后处理。

调用时机:

  • 析构函数抛异常
  • noexcept 函数内部抛异常
  • 异常传播中找不到匹配的 catch
  • 析构函数在栈展开过程中再抛异常
1
2
3
4
5
6
7
8
9
10
void bad() noexcept
{
throw std::runtime_error("boom"); // 程序直接 terminate
}

int main()
{
bad(); // 程序崩溃
cout << "不会到这里" << endl;
}

2. std:: set_terminate()

可以自定义 terminate 的行为,用于打日志、上报、快速失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <exception>
#include <iostream>
using namespace std;

void myTerminate()
{
cout << "程序要崩溃了,做最后的清理...\n";
exit(1); // 强制退出
}

int main()
{
set_terminate(myTerminate); // 注册自己的 terminate 处理函数
// ...
throw 1; // 没 catch,直接触发 terminate
}

用途

  • 记录崩溃日志
  • 保存现场
  • 强制退出

3. std:: unexpected()(了解即可)

unexpected() 会在 动态异常规范 违规时调用(C++98 遗留特性,现代代码基本不用),这套机制在 C++11 开始被弃用,在 C++17 已移除

1
2
3
4
5
// 古老写法,了解即可
void oldStyle() throw(std::runtime_error); // 只允许抛 runtime_error

// 现代代码用 noexcept 代替:
void modernStyle() noexcept;

现代 C++ 不推荐动态异常规范,noexcept 已经完全够用。

10. 现代 C++ 异常处理小结

1. 推荐做法

  1. 析构函数、move 构造/赋值、swap → 必须 noexcept
  2. 优先用 make_unique / make_shared → 别直接 new
  3. 简单情况用 std:: optional → 避免异常
  4. 复杂情况用 std:: expected(C++23)→ 错误码风格
  5. 库边界用异常,内部模块倾向 error code
  6. 永远不要在 catch 里吞掉异常不处理

2. 不推荐做法

  • catch (...) 盲目吞掉所有异常
  • 在析构函数里抛异常
  • 不用智能指针,手动 new/delete
  • 用异常做流程控制(if-else 更合适)

异常是工具,不是洪水猛兽。用好 RAII,遵循规范,程序稳如泰山。