C++ 异常 奇牛(Gemini润色)
C++ 异常 —— 奇牛
Note: 现代 C++ 对异常的态度是“标准库离不开,工业界不敢用”。官方标准从未彻底抛弃异常,但实际工程(尤其是高性能领域和Google等大厂规范)因性能开销常将其禁用,转而使用 std::expected 等现代替代方案。学习异常,是为了看懂标准库和理解 C++ 底层机制,以及维护老旧项目
1. 异常的核心概念
异常(Exception)是 C++ 引入的一种错误处理和程序控制机制,它与传统的函数返回值机制互补。
- 传统函数机制:基于栈结构展开,依赖上下层函数通过返回值(如返回
-1或NULL)逐层传递错误状态。如果调用链很深,逐层判断返回值会使代码变得极为臃肿。 - 异常机制:一种带“穿透力”的控制结构。当底层函数出现无法处理的“意外”时,可以直接中断当前执行流,并利用
throw将异常对象抛出。程序会自动跨越(跳过)中间隔代的调用者,直到在调用栈中找到能够匹配该异常类型的catch块。
2. 异常处理基本语法与执行流程
异常处理由三个关键字组成:throw(抛出)、try(尝试捕获)、catch(处理异常)。
2.1 基本语法
// 1. 在异常发生的第一现场,抛出异常
void func()
{
// ... 发现错误
throw 表达式; // 抛出一个任意类型的对象或内置数据类型
}
// 2. 在需要关注并处理异常的地方,尝试捕获
void caller()
{
try
{
// 保护段:可能抛出异常的代码
func();
}
catch (异常类型1 [形参])
{
// 匹配异常类型1时的处理代码
}
catch (异常类型2 [形参])
{
// 匹配异常类型2时的处理代码
}
catch (...)
{
// 兜底:捕获所有其他未明确声明的异常类型
}
}
2.2 执行流程与注意事项
-
正常执行:程序顺序执行到达
try语句,进入try块(保护段)。如果执行期间没有发生异常,所有的catch子句将被忽略,程序继续执行最后一个catch块之后的代码。 -
异常触发:一旦
throw被执行,try块内后续的代码将立即中止执行。 -
栈展开(Stack Unwinding):非常重要!在寻找匹配的
catch的过程中,系统会从抛出异常的函数开始,逐层向上退出调用栈。在退栈时,局部对象的析构函数会被自动调用,确保资源被正确释放。 -
严格的类型匹配:
catch子句按其在try块后出现的顺序被检查。一旦找到类型严格匹配的catch块,就会执行其中的代码。 -
未处理的异常:如果异常抛出后,在整个程序的调用栈中都没有找到匹配的
catch,C++ 的缺省行为是调用std::terminate()(内部调用abort())强行终止程序。 -
异常的重新抛出:如果当前的
catch块只能记录日志,或者无法完全处理该异常,可以使用无操作数的throw;将同一个异常继续向外层调用者抛出。
2.3 示例代码:文件拷贝
这里提供一个完整的上下文基础框架,后续的演示都将基于此框架的变体。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <Windows.h>
#define BUFSIZE 1024
// 1. 底层函数:抛出异常的第一现场
int copyfile2(const char* dest, const char* src)
{
FILE* fp1 = nullptr;
FILE* fp2 = nullptr;
// 使用标准的 std::fopen,保证跨平台 (GCC/MSVC均可)
fp1 = std::fopen(src, "rb");
if (fp1 == nullptr)
{
// 演示:抛出指针(极其危险的写法,仅为展示语法匹配)
// 更好的做法应该是:throw std::runtime_error("文件不存在");
throw new std::string("源文件不存在或无法打开");
}
fp2 = std::fopen(dest, "wb");
if (fp2 == nullptr)
{
std::fclose(fp1); // 发生错误前需手动清理资源,推荐使用 RAII 自动管理
throw - 2; // 抛出 int 型异常
}
// ... 文件读写逻辑略 ...
std::fclose(fp1);
std::fclose(fp2);
return 0;
}
// 2. 中间层函数:拦截部分异常,重新抛出
int copyfile1(const char* dest, const char* src)
{
try
{
copyfile2(dest, src);
}
catch (float e)
{
std::cout << "copyfile1 捕获到 float: " << e << ",准备向上层重新抛出...\n";
throw e; // 抛出具体的变量 e
}
catch (...)
{
std::cout << "copyfile1 捕获到未知异常,准备原封不动向上层重新抛出...\n";
// 核心语法:无参 throw,将异常原封不动继续向上抛给 main 函数
throw;
}
return 0;
}
// 3. 顶层调用者:最终捕获兜底
int main()
{
try // 保护段
{
// 故意传入不存在的路径触发异常
copyfile1("C:/test/dest.txt", "C:/test/NonExistent.txt");
std::cout << "执行 copyfile1 完毕,未发生异常\n";
}
// 按照从上到下的顺序匹配 catch
catch (int error)
{
std::cout << "main 捕获到 int 异常!错误码: " << error << "\n";
}
catch (std::string* error)
{
std::cout << "main 捕获到 std::string* 异常: " << *error << "\n";
delete error; // 捕获指针必须手动 delete 释放内存!
}
catch (...)
{
std::cout << "main 触发未知类型异常的兜底捕获 (catch ...)\n";
}
system("pause");
}
//输出
//copyfile1 捕获到未知异常,准备原封不动向上层重新抛出...
//main 捕获到 std::string* 异常: 源文件不存在或无法打开
3. 异常接口声明(动态异常规范)
在早期的 C++ 标准中,可以在函数声明后列出该函数可能抛出的异常类型。
注意:此特性在 C++11 中被弃用,并在 C++17 中被正式移除,现代 C++ 中使用 noexcept 来标记不抛出异常的函数。
// 声明此函数只会抛出 float, std::string*, int 这三种类型的异常
// 如果抛出了其他类型,程序会调用 std::unexpected() 终止
int copyfile2(const char* dest, const char* src) throw (float, std::string*, int)
{
// ...
}
4. 异常类型和生命周期
异常的 throw 可以抛出任何类型的数据。但在 C++ 的底层机制中,异常对象不在普通的函数调用栈上,也不在常规的堆(Heap)上。
-
异常对象在特殊的异常内存区中:
当执行
throw时,当前的函数调用栈即将被“栈展开(Unwinding)”并销毁。为了保证异常对象在退栈后依然存活,并能传递给外层的catch块,编译器会调用底层 API(如 GCC 的__cxa_allocate_exception),在编译器专门维护的、独立于调用栈的“线程局部存储区(TLS)”或专用的异常堆空间中,开辟一块内存来单独存放这个异常对象。
4.1 抛出普通类型
抛出普通数据类型(如 int, float, char 等)时,其语法表现和函数传值一致,但底层生命周期同样依赖这块专门的异常内存区。
#include <iostream>
void testBasicType()
{
int errorCode = 404; // 1. 在当前函数栈上生成局部变量 errorCode
throw errorCode; // 2. 核心:系统将栈上的 404 拷贝到“专用的异常内存区”中。
// 3. 栈展开开始,局部变量 errorCode 被销毁。
}
int main()
{
try
{
testBasicType();
}
catch (int e) // 4. 按值捕获:系统将“异常内存区”中的 404 再次拷贝给局部变量 e。
// (注:对于内置类型,按值捕获开销极小,通常不要求写成引用)
{
std::cout << "捕获到 int 型异常,错误码: " << e << "\n";
} // 5. catch 块结束,局部变量 e 销毁。
// 6. 系统回收并清理“专用异常内存区”里的那个 404 原始数据。
return 0;
}
4.2 抛出 C 风格字符串常量
当抛出字符串字面量时,系统会将其视为指针类型。异常匹配是极其严格的,没有隐式类型转换机制(比如不会自动转为 std::string),因此必须严格匹配 const char*。
#include <iostream>
#include <string>
void testFunc()
{
// 抛出字符串字面量,其类型严格为 const char*
throw "源文件有问题!";
}
// 在 main 中:
int main()
{
try
{
testFunc();
}
catch (std::string error) // ❌ 无法匹配!不会隐式转换为 std::string
{
std::cout << "匹配失败\n";
}
catch (char* error) // ❌ 无法匹配!少了 const 修饰符
{
std::cout << "匹配失败\n";
}
catch (const char* error) // ✅ 严格匹配成功!
{
std::cout << "main 捕获到 const char* 异常: " << error << "\n";
}
return 0;
}
4.3 抛出自定义对象类型(深究拷贝与多态)
当我们抛出自定义对象时,如果不注意写法,会经历多次拷贝构造。这不仅会带来不必要的性能开销,还可能在多态使用中引发对象切片(Object Slicing)问题。
测试类定义:
class ErrorException
{
public:
int _id;
ErrorException() : _id(0)
{
std::cout << "ErrorException 无参构造\n";
}
~ErrorException()
{
std::cout << "_id: " << _id << " 析构\n";
}
ErrorException(const ErrorException& other) : _id(1)
{
std::cout << "ErrorException 拷贝构造\n";
}
};
场景 A:按值抛出,按值捕获(不推荐)
void copyfile4()
{
ErrorException ee; // 1. 在当前函数栈上调用无参构造,生成局部对象 ee (id=0)
throw ee; // 2. 将栈上的 ee 拷贝构造到“专用的异常内存区” (id=1)。
// 3. 栈展开开始,局部变量 ee 被销毁,调用栈上的析构函数 (id=0)
}
// 在 main 中:
catch (ErrorException error) // 4. 按值捕获。系统又将“专用异常内存区”里的对象,
// 拷贝构造给 catch 块内的局部变量 error (id=1 拷贝变 id=1)。
// (注:部分现代编译器会优化掉这一次拷贝,但在理论语义上它是存在的)
{
error._id = 2;
std::cout << "main 捕捉到 ErrorException 类型\n";
} // 5. catch 块结束,销毁 catch 块内的局部对象 error (id=2)
// 6. 异常处理彻底完成,系统回收并销毁“专用异常内存区”里的那个原始异常对象 (id=1)
场景 B:按值抛出匿名对象,按引用捕获(推荐)
为了避免上述场景中多余的拷贝构造与内存折腾,我们通常采用统一的规范:throw by value, catch by reference(按值抛出,按引用捕获)。
void copyfile4()
{
// 1 & 2. 直接抛出匿名对象。
// 编译器会直接在“专用的异常内存区”中调用构造函数生成该对象,省去了一次从局部栈拷贝的开销。
throw ErrorException();
}
// 在 main 中:
catch (const ErrorException& error) // 3. 使用 const 引用捕获!
{
// 直接引用“专用异常内存区”里的那个对象,不再发生任何多余的拷贝构造。
std::cout << "main 通过引用捕捉异常,没有发生额外的拷贝\n";
} // 4. catch 块结束,引用失效。
// 5. 系统清理“专用异常内存区”的对象。
优点:
- 性能最优:没有额外的拷贝开销,直接引用异常专属内存区的数据。
- 多态安全:如果抛出的是派生类,捕获的是基类引用,可以完美支持多态,避免了按值捕获导致的子类对象被“切片”(Slicing)退化为不完整的基类对象。
- 内存安全:不需要像抛出堆指针(
throw new ...)那样要求捕获者去手动delete内存,从根本上防止了内存泄漏。
5. 异常和继承
在 C++ 中,异常本身也是类。我们可以像设计普通类一样设计异常类,在其中使用虚函数、继承、引用传递,并添加数据成员(例如记录出错的具体数值)。
5.1 为什么要结合“继承”与“异常”
- 统一捕获,利用多态: 将多种错误归类到一个“基类”下。在
try-catch时,只需要catch基类的引用,就能自动处理所有派生类的异常,省去写一大堆catch分支的麻烦。 - 必须使用“引用”捕获 (
catch(Exception& e)):- 防切片: 按值捕获会导致派生类被“裁剪”成基类,丢失子类特有的数据和虚函数特性。
- 提性能: 避免异常对象拷贝带来的额外开销。
注意事项:
-
虚析构: 基类只要有虚函数,就务必加上虚析构函数(
virtual ~类名() = default;),防内存泄漏。 -
override: 子类重写虚函数时加上此关键字,让编译器帮忙查错。 -
const: 像打印错误(logError)这种不修改数据的操作,函数尾部应加上const。
5.2 示例代码
设计数组容器 MyArr,重载 [] 操作符。
idx < 0,抛出ErrNegativeExceptionidx > 1000,抛出ErrTooBigException- 父类
ErrSizeException实现有参构造,并定义virtual void logError() const。
MyArr.hpp:
#ifndef MYARR_HPP
#define MYARR_HPP
#include <iostream>
// ==========================================
// 1. 异常类设计 (基类与派生类)
// ==========================================
class ErrSizeException
{
public:
explicit ErrSizeException(int size) : _size(size) {}
virtual ~ErrSizeException() = default; // 规范:添加虚析构
virtual void logError() const { // 规范:添加 const
std::cout << "size or idx: " << _size << ", ErrSizeException\n";
}
protected:
int _size;
};
class ErrNegativeException : public ErrSizeException
{
public:
explicit ErrNegativeException(int size) : ErrSizeException(size) {}
void logError() const override { // 规范:明确 override
std::cout << "size or idx: " << _size << ", ErrNegativeException\n";
}
};
class ErrTooBigException : public ErrSizeException
{
public:
explicit ErrTooBigException(int size) : ErrSizeException(size) {}
void logError() const override {
std::cout << "size or idx: " << _size << ", ErrTooBigException\n";
}
};
// ==========================================
// 2. MyArr 容器类实现
// ==========================================
class MyArr
{
public:
MyArr(int size = 128);
MyArr(const MyArr& other);
~MyArr();
MyArr& operator=(const MyArr& other);
int& operator[](int idx);
int getSize() const { return _size; }
private:
int _size;
int* _arr;
};
MyArr::MyArr(int size) : _size(size)
{
if (size < 0) throw ErrNegativeException{_size};
if (size > 1000) throw ErrTooBigException{_size};
_arr = new int[_size];
}
MyArr::MyArr(const MyArr& other) : _size(other._size)
{
if (_size < 0) throw ErrNegativeException{_size};
if (_size > 1000) throw ErrTooBigException{_size};
_arr = new int[_size];
for (int i = 0; i != _size; i++) {
_arr[i] = other._arr[i];
}
}
MyArr::~MyArr()
{
_size = 0;
delete[] _arr;
_arr = nullptr;
}
MyArr& MyArr::operator=(const MyArr& other)
{
if (this == &other) return *this;
// 强异常安全保证:先 new 再 delete
int* new_arr = new int[other._size];
for (int i = 0; i != other._size; i++) {
new_arr[i] = other._arr[i];
}
delete[] _arr;
_size = other._size;
_arr = new_arr;
return *this;
}
int& MyArr::operator[](int idx)
{
if (idx < 0) throw ErrNegativeException{ idx };
if (idx >= _size) throw ErrTooBigException{ idx };
return _arr[idx];
}
#endif // MYARR_HPP
test.cpp:
#include <iostream>
#include "MyArr.hpp"
int main()
{
try
{
MyArr marr{ 1 };
// 触发异常:数组大小为 1,索引 1 越界
marr[1] = 0;
std::cout << "running normally without any error" << std::endl;
}
// 重点:只需使用基类引用捕获,即可触发多态
catch(const ErrSizeException& error)
{
error.logError(); // 实际调用的是 ErrTooBigException::logError()
}
catch (...)
{
std::cout << "Unknown error" << std::endl;
}
return 0;
}

浙公网安备 33010602011771号