目录

1.1 异常的概念

1.2 异常处理的基本语法

1.2.1 throw —— 抛出异常

1.2.2 try —— 监控异常

1.2.3 catch —— 捕获异常

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

1.3.1 什么是异常对象

1.3.2 异常对象的传递过程

1.3.3 异常匹配原则

1.3.4 万能捕获 catch(...)

1.3.5 为什么推荐引用捕获

1.4 异常栈展开

1.4.1 什么是栈展开

1.4.2 栈展开过程中对象析构

1.5 异常重新抛出

1.6 异常安全问题

1.7 异常规范

1.8 标准库的异常


1.1 异常的概念

异常处理机制允许程序在运行过程中,将“正常逻辑”和“错误处理逻辑”分离开来。当程序运行出现问题时,可以通过抛出异常的方式,将错误信息从当前执行流中传递出去,由更高层统一处理,而无需在每一层函数中手动检查错误。

这种机制使得程序的结构更加清晰:正常代码专注于业务逻辑,而异常处理代码专门负责处理运行时错误,从而提高代码的可读性和可维护性。

在传统的 C 语言中,通常通过返回错误码的方式来表示函数执行是否成功,调用者需要逐层检查返回值才能判断是否发生错误。这种方式虽然简单,但容易导致错误遗漏,并且使正常逻辑被大量错误判断代码干扰。

相比之下,C++ 引入异常机制。当程序发生错误时,会抛出一个异常对象,该对象携带更完整的错误信息,并自动跳转到匹配的处理代码(catch 块)。这种方式避免了层层返回错误码的繁琐过程,使错误处理与正常逻辑解耦。

因此,从本质上看,异常机制是对传统错误处理方式的一种升级,它将“手动检查错误状态”转变为“自动化的控制流转移机制”

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

1.2 异常处理的基本语法

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

1.2.1 throw —— 抛出异常

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

例如:

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

    return a / b;
}

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

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

例如:

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

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

1.2.2 try —— 监控异常

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

try
{
    Divide(10, 0);
}

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

1.2.3 catch —— 捕获异常

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

catch(const char* msg)
{
    cout << msg << endl;
}

异常处理的完整示例

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 语句进行接受和处理。

例如:

throw 10;
throw 3.14;
throw "error";

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

1.3.2 异常对象的传递过程

例如:

try
{
    throw 10;
}
catch(int x)
{
    cout << x << endl;
}

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

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

1.3.3 异常匹配原则

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

try
{
    throw 10;
}
catch(int)
{
    cout << "int" << endl;
}

输出:int

try
{
    throw 10;
}
catch(double)
{
    cout << "double" << endl;
}

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

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

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

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

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(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。

catch(...)
{
    cout << "未知异常" << endl;
}

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

1.3.5 为什么推荐引用捕获

很多初学者会这样写:

catch(string s)
{}

实际上更推荐:

catch(const string& s)
{}

原因有两个:

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

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

2. 保留多态特性

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

如果采用值接收:

catch(Exception e)

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

如果采用引用接收:

catch(const Exception& e)

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

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

1.4 异常栈展开

1.4.1 什么是栈展开

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

栈展开过程:

异常发生
    ↓
退出当前函数
    ↓
析构局部对象
    ↓
返回上一层函数
    ↓
继续寻找catch
    ↓
直到找到匹配处理器

示例:

#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捕获到一个异常对象后,需要对错误进行分类,其中的某种异常错误需要进行特殊处理,其他错误则重新抛出异常对象给外层调用链处理。这种异常处理机制就称为异常重新抛出。

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

#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 ...) 表示可能抛出多种类型的异常,可能会抛出异常对象的类型用逗号分隔。

// 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 表示可能抛出异常。

// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;

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

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。

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是一个虚函数,派生类可以重写。

更多推荐