深入剖析 C++ 异常机制:异常对象、栈展开与 noexcept

目录

[1.1 异常的概念](#1.1 异常的概念)

[1.2 异常处理的基本语法](#1.2 异常处理的基本语法)

[1.2.1 throw ------ 抛出异常](#1.2.1 throw —— 抛出异常)

[1.2.2 try ------ 监控异常](#1.2.2 try —— 监控异常)

[1.2.3 catch ------ 捕获异常](#1.2.3 catch —— 捕获异常)

[1.3 异常对象与异常匹配规则](#1.3 异常对象与异常匹配规则)

[1.3.1 什么是异常对象](#1.3.1 什么是异常对象)

[1.3.2 异常对象的传递过程](#1.3.2 异常对象的传递过程)

[1.3.3 异常匹配原则](#1.3.3 异常匹配原则)

[1.3.4 万能捕获 catch(...)](#1.3.4 万能捕获 catch(...))

[1.3.5 为什么推荐引用捕获](#1.3.5 为什么推荐引用捕获)

[1.4 异常栈展开](#1.4 异常栈展开)

[1.4.1 什么是栈展开](#1.4.1 什么是栈展开)

[1.4.2 栈展开过程中对象析构](#1.4.2 栈展开过程中对象析构)

[1.5 异常重新抛出](#1.5 异常重新抛出)

[1.6 异常安全问题](#1.6 异常安全问题)

[1.7 异常规范](#1.7 异常规范)

[1.8 标准库的异常](#1.8 标准库的异常)


1.1 异常的概念

异常处理机制允许程序在运行过程中,将"正常逻辑"和"错误处理逻辑"分离开来。当程序运行出现问题时,可以通过抛出异常的方式,将错误信息从当前执行流中传递出去,由更高层统一处理,而无需在每一层函数中手动检查错误。
这种机制使得程序的结构更加清晰:正常代码专注于业务逻辑,而异常处理代码专门负责处理运行时错误,从而提高代码的可读性和可维护性。
在传统的 C 语言中,通常通过返回错误码的方式来表示函数执行是否成功,调用者需要逐层检查返回值才能判断是否发生错误。这种方式虽然简单,但容易导致错误遗漏,并且使正常逻辑被大量错误判断代码干扰。
相比之下,C++ 引入异常机制。当程序发生错误时,会抛出一个异常对象,该对象携带更完整的错误信息,并自动跳转到匹配的处理代码(catch 块)。这种方式避免了层层返回错误码的繁琐过程,使错误处理与正常逻辑解耦。
因此,从本质上看,异常机制是对传统错误处理方式的一种升级,它将"手动检查错误状态"转变为"自动化的控制流转移机制"

补充:异常并不是用来替代所有错误处理的,例如逻辑错误仍然应该用返回值处理。

1.2 异常处理的基本语法

在C++中,这三个关键字(try、throw、catch)共同构成了 C++ 异常处理体系。

1.2.1 throw ------ 抛出异常

当程序检测到无法继续正常执行的错误时,可以通过throw关键字抛出一个异常对象。

例如:

cpp 复制代码
int Divide(int a, int b)
{
    if (b == 0)
    {
        throw "除数不能为0";
    }

    return a / b;
}

当执行到throw语句时,当前函数将立即终止后续代码的执行,并将异常对象向外抛出,等待异常处理器进行捕获。

需要注意的是,throw 后面可以抛出任意类型的对象

例如:

cpp 复制代码
throw 10;                  // int
throw 3.14;                // double
throw "error";             // 字符串常量
throw std::string("error");// string对象

不过在实际开发中,更推荐抛出自定义异常对象或标准库异常对象,以变携带更丰富的错误信息

1.2.2 try ------ 监控异常

可能发生异常的代码需要放在 try 代码块中。

cpp 复制代码
try
{
    Divide(10, 0);
}

当 try 代码块中的代码正常执行时,程序不会受到任何影响。如果某条语句抛出了异常,则程序会立即停止当前执行流程,并开始寻找与之匹配的异常处理器。

1.2.3 catch ------ 捕获异常

异常处理器通过 catch 关键字定义。

cpp 复制代码
catch(const char* msg)
{
    cout << msg << endl;
}

异常处理的完整示例

cpp 复制代码
int Divide(int a, int b)
{
    if (b == 0)
    {
        throw "除数不能为0";
    }

    return a / b;
}

int main()
{
    try
    {
        cout << Divide(10, 0) << endl;
    }
    catch(const char* msg)
    {
        cout << msg << endl;
    }

    return 0;
}

运行结果:

除数不能为0

1.3 异常对象与异常匹配规则

1.3.1 什么是异常对象

在 C++ 中,异常并不是一个特殊的语法结构,而是一个普通对象

当程序执行到 throw 语句时,会创建一个异常对象,并将该对象抛出,随后匹配的 catch 语句进行接受和处理。

例如:

cpp 复制代码
throw 10;
throw 3.14;
throw "error";

上述代码分别抛出了: int对象 double对象 const char*对象。因此,从本质上来说:throw抛出的不是错误,而是一个对象。

1.3.2 异常对象的传递过程

例如:

cpp 复制代码
try
{
    throw 10;
}
catch(int x)
{
    cout << x << endl;
}

执行流程: throw 10 -> 创建异常对象 -> 存储到异常缓冲区 -> 查找匹配catch -> catch接收异常对象

可以简单理解为:普通对象 -> throw -> 异常对象 -> catch接收

1.3.3 异常匹配原则

异常处理时,系统会寻找参数匹配的 catch。

cpp 复制代码
try
{
    throw 10;
}
catch(int)
{
    cout << "int" << endl;
}

输出:int

cpp 复制代码
try
{
    throw 10;
}
catch(double)
{
    cout << "double" << endl;
}

不会输出double,原因:throw 的是int,catch接收的是double,类型不匹配。

在异常处理机制中,异常匹配要求严格匹配,不会进行隐式类型转换。

但是也有一些例外,允许从非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针;允许从派生类向基类类型的转换,这个点非常实用,实际中继承体系基本都是用这个方式设计的。

多个catch时,按照代码执行顺序依次匹配。如果有多个类型匹配的,就选择离他位置更近的
那个。

cpp 复制代码
try
{
    throw 10;
}
catch(double s)
{
}
catch(char s)
{
}
catch(int s)
{
    cout << "int 1" << endl;
}
catch(int t)
{
    cout << "int 2" << endl;
}

输出:int 1

系统找到第一个匹配项后立即停止查找,跳转到匹配的catch代码块中执行代码。

1.3.4 万能捕获 catch(...)

如果到main函数,异常仍旧没有被匹配,程序就会调用标准库的terminate函数终止程序。所以不是发生严重错误的情况下,我们是不期望程序终止的,所以一般main函数中最后都会使catch(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。

cpp 复制代码
catch(...)
{
    cout << "未知异常" << endl;
}

catch(...) 可以捕获任意类型的异常。因此它通常放在最后作为兜底处理。如果写在其他catch的前面,后面的catch将永远无法执行。

1.3.5 为什么推荐引用捕获

很多初学者会这样写:

cpp 复制代码
catch(string s)
{}

实际上更推荐:

cpp 复制代码
catch(const string& s)
{}

原因有两个:

1. 避免额外拷贝,提高效率

异常对象在抛出时已经构造完成,如果使用值接收的方式捕获异常,会额外产生一次对象拷贝;而使用引用接收则不会发生拷贝,因此效率更高。

2. 保留多态特性

在实际开发中,异常体系通常采用继承结构设计。不同模块可能对应不同类型的异常,因此往往会定义一个异常基类,并派生出多个具体异常类,通过重写统一的错误信息接口来描述不同错误。
如果采用值接收:

复制代码
catch(Exception e)

会发生对象切片(Object Slicing),派生类对象中的派生部分被截断,异常对象退化为基类对象,导致动态绑定失效,只能调用基类版本的虚函数,而无法调用派生类重写后的虚函数。
如果采用引用接收:

复制代码
catch(const Exception& e)

则不会发生对象切片,能够保留异常对象的真实动态类型,从而正确地发挥多态机制的作用。

因此,在实际开发中,异常对象通常都采用 const 引用 的方式进行捕获。

1.4 异常栈展开

1.4.1 什么是栈展开

当程序执行过程中发生异常,并且当前作用域内没有找到匹配的 catch 时,异常会沿着函数调用链逐层向上传播。在向上传播的过程中,系统会自动销毁当前作用域内已经构造完成的局部对象,并调用它们的析构函数。这个过程称为:栈展开

栈展开过程:

异常发生

退出当前函数

析构局部对象

返回上一层函数

继续寻找catch

直到找到匹配处理器

示例:

cpp 复制代码
#include <iostream>
using namespace std;

void Func3()
{
    throw "发生异常";
}

void Func2()
{
    Func3();
}

void Func1()
{
    Func2();
}

int main()
{
    try
    {
        Func1();
    }
    catch(const char* msg)
    {
        cout << msg << endl;
    }

    return 0;
}

调用关系:main -> Func1 -> Func2 -> Func3

当执行到 throw "发生异常";程序会首先检查Func3中是否存在类型匹配的 catch 处理器,没有找到,Func3 栈帧销毁,继续检查Func2中是否存在类型匹配的 catch 处理器,没有找到,Func2 栈帧销毁,依次类推,到main函数内部找到类型匹配的catch,此时代码的执行流到达类型匹配的catch处。其中,在回退过程中会析构局部对象。

如果到main函数中依旧没有找到类型匹配的catch,那么程序会调用 std::terminate() 函数,程序直接终止。

1.4.2 栈展开过程中对象析构

栈展开最重要的特点就是:栈销毁时,已经构造完成的局部对象一定会被自动析构

为什么会自动析构?

因为局部对象存储在函数栈帧中,函数退出时,局部对象生命周期结束,自动调用析构函数,然后栈帧销毁。

异常导致的函数退出本质上仍然属于:局部对象生命周期结束,因此析构函数依然会执行。
对于栈上一些局部对象管理着堆内存、文件句柄、网络连接、互斥锁等资源,由于异常直接退出,还没有来得及释放它们拥有的资源,那么就会导致资源丢失、内存泄漏等问题。

解决这一问题的核心思想就是将它们封装成一个对象,通过栈展开自动调用析构函数这一特性来完成资源的管理。这一章节我们将在智能指针中详细理解。

1.5 异常重新抛出

有些情况下,catch捕获到一个异常对象后,需要对错误进行分类,其中的某种异常错误需要进行特殊处理,其他错误则重新抛出异常对象给外层调用链处理。这种异常处理机制就称为异常重新抛出。

典型场景:当前层知道发生了异常,但不知道如何处理,于是记录日志、清理资源后,继续把异常交给上层处理。

cpp 复制代码
#include <iostream>
using namespace std;

void Func()
{
    try
    {
        throw "数据库连接失败";
    }
    catch(const char* err)
    {
        cout << "Func记录日志:" << err << endl;

        throw; // 重新抛出当前异常
    }
}

int main()
{
    try
    {
        Func();
    }
    catch(const char* err)
    {
        cout << "main处理异常:" << err << endl;
    }
}

throw; 和 throw err; 的区别

正确做法 throw; 错误做法 throw err;

对于throw err; 这里会重新创建一个新的异常对象,如果存在继承关系,创建的这个新对象由于发生对象切片会使得异常对象变成基类,多态信息丢失。
一句话总结:

当当前层无法真正处理异常时,可以在 catch 中执行必要的清理或日志记录工作,然后通过 、throw;将异常继续向上传播。需要注意,应使用 throw; 而不是 throw err; ,前者会保留原始异常对象,而后者可能导致对象切片和类型信息丢失。
对于很多初学者,可能会存在一下这个问题

假如是三层调用关系,第一层抛出一个异常对象,第二层用引用接收能理解,因为异常对象被创建在第二层函数栈帧内部,但第二层捕获后重新抛出,这个异常对象依旧在第二层函数栈帧内部,如果throw; 不创建一个新的异常对象,这不就导致野引用了吗。

真相:异常对象不在函数栈帧里,异常对象会被放到一个运行时维护的异常缓冲区中,因此throw;不会创建一个新的对象,而是重新抛出异常对象,所以不会出现野引用的问题,该异常对象的生命周期会持续到异常处理结束。

1.6 异常安全问题

异常抛出后,后面的代码就不再执行,产生的问题:前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常就会导致资源没有释放,这里由于异常就引发了资源泄漏,产生安全性的问题。对于没有智能指针的情况,就需要我们捕获异常,释放资源后再重新抛出,这种处理方式会导致代码冗余、可读性差。于是C++11智能指针的RAII思想就是解决这种问题的最佳方式。
对于析构函数,如果需要抛出异常也要谨慎处理,比如析构需要释放10个资源,释放到第5个资源时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,就会导致资源泄漏。

1.7 异常规范

对于用户和编译器而言,预先知道某个程序会不会抛出异常意义很大,知道某个函数是否抛出异常有助于简化调用函数的代码。

C++98中参数列表的后面接throw(),表示函数不抛异常,函数参数列表的后面throw(类型 1,类型 2 ...) 表示可能抛出多种类型的异常,可能会抛出异常对象的类型用逗号分隔。

cpp 复制代码
// C++98
// 这里表示这个函数只会抛出bad_alloc的异常​
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常​
void* operator delete (std::size_t size, void* ptr) throw();

C++98中的这种方式非常复杂,在实践中并不好用,于是C++11对其进行了简化,函数参数列表后面加noexcept表示不会抛出异常,不加 noexcept 表示可能抛出异常。

cpp 复制代码
// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;

编译器并不会在编译时检查noexcept,也就是说如果一个函数被noexcept修饰,但是又包含了throw语句或者调用时可能会抛出异常,编译器在编译时会通过(有些编译器会报个警告)。但是一个被noexcept修饰的函数如果抛出了异常,程序会调用 std::terminate() 终止程序。

cpp 复制代码
double Divide(int a, int b) noexcept
{
	// 当b == 0时抛出异常​
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}

noexcept(expression)还可以作为一个运算符去检测一个表达式是否会抛出异常,可能会则返回false,不会就返回true。对于被noexcept 修饰的函数,无论函数内部是否抛异常,都返回true。

cpp 复制代码
double Divide(int a, int b)
{
	// 当b == 0时抛出异常​
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
int main()
{
	try
	{
		int len = 2, time = 3;
		Divide(len, time);
	}
	catch (const char *errmsg)
	{
		cout << errmsg << endl;
	}
	catch (...)
	{
		cout << "Unkown Exception" << endl;
	}
	int i = 0;
	cout << noexcept(Divide(1, 2)) << endl;
	cout << noexcept(Divide(1, 0)) << endl;
	cout << noexcept(++i) << endl;
	return 0;
}

运行结果:

0

0

1
对于noexpect作为运算符时,它只判断表达式函数内部是否可能抛异常,不去运行,所以 noexcept(Divide(1, 2)) 返回值也为false。

1.8 标准库的异常

exception - C++ Referencehttps://legacy.cplusplus.com/reference/exception/exception/

C++标准库也定义了一套自己的一套异常继承体系库,基类是exception,所以我们日常写程序,需要在主函数捕获exception即可,要获取异常信息,调用what函数,what是一个虚函数,派生类可以重写。