【C++修炼】异常

1. 异常的概念及使用

1.1 概念

异常是C++处理错误的一种方式。

C语⾔对错误进行编号,通过错误码的方式进行处理。例如调用某些C语言库的函数接口,比如fopen打开文件,如果出错了并不是通过函数返回值处理错误,而是将错误码放到了一个全局变量errno中。错误码本质就是对错误信息进⾏分类编号,拿到错误码以后还要去查询对应的错误信息,⽐较⿇烦。

异常时则抛出⼀个对象,而不仅仅是错误码这样的单一的值,还可以包含更全⾯的各种信息,比如说一个字符串,对错误进行更具体的描述。

  • 异常处理机制允许程序中独⽴开发的部分能够在运⾏时就出现的问题进⾏通信并做出相应的处理。异常使得我们能够将问题的检测与解决问题的过程分开,程序的⼀部分负责检测问题的出现,然后解决问题的任务传递给程序的另⼀部分,检测环节⽆须知道问题的处理模块的所有细节。

例如C++的new/delete,和malloc/free最大的区别就是失败了会抛异常,就可以在外层捕捉异常进行错误处理。

1.2 异常的抛出与捕获

  • 程序出现问题时,我们通过抛出 throw ⼀个异常对象 (可能是任意类型),该对象的类型以及当前的调⽤链,决定了应该由哪个 catch 的处理代码来处理该异常。

代码示例 1.2.1

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
#include <exception>
using namespace std;

// 0不能做除数,a/0是非法的,所以抛异常
double Divide(int a, int b)
{
	// 当 b == 0 时抛异常
	if (b == 0)
	{
		string s("Divide by zero condition!");
        // 抛异常可以是任意类型的对象
		throw s;
	}
	else
	{
		return ((double)a / (double)b);
	}
	return 0;
}

void Func()
{
	int len, time;
	cin >> len >> time;
	cout << Divide(len, time) << endl;
}

int main()
{
	while (1)
	{
		Func();
	}
	return 0;
}
  • 错误可以层层传递,func1发生的错误,抛出的异常,可以传到func2、func3、main,即在这条调用链中,不管哪一层都可以进行捕获处理。不像C语言发生错误就必须立马在当前层完成处理。

代码示例 1.2.2

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
#include <exception>
using namespace std;

double Divide(int a, int b)
{
	try
	{
		// 当 b == 0 时抛异常
		if (b == 0)
		{
			string s("Divide by zero condition!");
			throw s;
		}
		else
		{
			return ((double)a / (double)b);
		}
	}
	// throw s后可以直接在当前层catch处理
    // 当然,抛异常,catch捕获时要求类型是匹配的,抛string对象就要用string对象捕获
    // 这里catch捕获的类型是int,是不匹配的
	catch (int errid)
	{
		cout << errid << endl;
	}
	return 0;
}

void Func()
{
	int len, time;
	cin >> len >> time;
	try
	{
		cout << Divide(len, time) << endl;
	}
	// 也可以根据调用链,在Func层catch处理,错误和异常可以传递
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}

int main()
{
	while (1)
	{
		try
		{
			Func();
		}
		// 也可以根据调用链回到main函数层catch处理,错误和异常可以传递
		catch (const string& errmsg)
		{
			cout << errmsg << endl;
		}
	}
	return 0;
}
  • 调用链中可能有多个 try-catch,每一层都做了捕捉处理。此时选择调⽤链中与异常对象类型匹配且离抛出异常位置最近的那⼀个。根据抛出对象的类型和内容,程序的抛出异常部分告知异常处理部分到底发⽣了什么错误。

例如,上面代码的结果如下图,抛出的异常对象是 string 类型,不管是 int errid,还是 const char* errmsg,都不如 const string& errmsg 更匹配。如果调用链还能往下,再有匹配的,也不会向下了,因为 const string& errmsg 已经是最近的了。

注:异常处理通常会放在较外层,接近main函数,方便统一捕获各种异常然后记录日志,进行分类,特殊异常再特殊处理。

  • 当我们 throw 了一个异常对象,后⾯语句不再执⾏,程序会直接跳转到调用链中与异常对象类型匹配的最近的 catch 模块。可以说,程序控制权从 throw 的位置转移到了 catch 中。

这⾥还有两个重要的含义:1、throw 位置到 catch 是直接跳转的,即中间的逻辑都会被跳过,比如 throw 所在函数到 catch 所在函数,中间的其他函数都会提早退出,销毁释放栈帧等等。2、除了函数,中间这些函数内部的对象什么的,也会随栈帧释放正常析构销毁。

  • 抛出异常对象后,会⽣成异常对象的拷⻉。

因为抛出的异常对象可能是当前函数的⼀个局部对象,但需要沿着函数调用链传递,例如 string s("Divide by zero condition!"),这就类似传值返回,所以必须⽣成⼀个拷⻉对象,避免函数栈帧销毁影响局部对象跟着销毁。该拷⻉对象会在 catch ⼦句,即 catch 中的错误处理代码完成后销毁。

当然也未必就是走拷贝构造,有了右值引用和移动语义以后,也可能是移动构造,效率高很多。

1.3 栈展开

  • 栈展开 :抛出异常后,程序暂停当前函数的执⾏,开始寻找与之匹配的 catch ⼦句的过程。

⾸先检查 throw 异常是否在 try 块内部,如果在则查找匹配的 catch 语句,如果有匹配的则跳转到 catch 中。如果当前函数中没有 try/catch,或者有 try/catch 但捕获类型不匹配,则退出当前函数,沿调用链去外层查找。

  • 如果到达main函数,依旧没有找到匹配的 catch ⼦句,程序会自动调⽤标准库的 std::terminate 直接终止程序。如果找到匹配的 catch ⼦句则正常执⾏。

所以异常没有被捕获,程序就会挂掉。程序终止在实践中是很严重的问题,可能导致严重事故。

1.4 查找匹配的处理代码

异常有一个保障机制。如果到main函数,异常仍旧没有被匹配就会终⽌程序,不是发⽣严重错误的情况下,我们并不期望程序终⽌,因此必须使用 catch(...)。

  • ⼀般,main函数中最后都会使⽤ catch(...),三个点 ... 代表它可以捕获任意类型的异常,虽然不知道具体异常错误是什么,但可以避免事故。

代码示例 1.4.1

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
#include <exception>
using namespace std;

double Divide(int a, int b)
{
	try
	{
		// 当 b == 0 时抛异常
		if (b == 0)
		{
			string s("Divide by zero condition!");
			throw s;
		}
		else
		{
			return ((double)a / (double)b);
		}
	}
	catch (int errid)
	{
		cout << errid << endl;
	}
	return 0;
}

void Func()
{
	int len, time;
	cin >> len >> time;
	try
	{
		cout << Divide(len, time) << endl;
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}

int main()
{
	while (1)
	{
		try
		{
			Func();
		}
		catch (const string& errmsg)
		{
			cout << errmsg << endl;
		}
		// 同一层可以同时有多个catch
		catch (...) // 可以捕捉任意类型的异常,相当于整个程序的兜底保障
		{
            // 虽然不知道具体错误,但可以提示出错了,而且程序不会挂
            // 当然也可以尝试将异常记录进项目日志 (某个文件/数据库)
            // 还可以写详细点,描述时间、位置、行数等各种原因,说明是谁抛的异常
			cout << "未知异常" << endl;
		}
	}
	return 0;
}

虽然异常在调用链各层都可以捕获,但通常在最外层统一捕获进行日志记录与分类。这样就会导致异常对象可能同时有各种类型。⼀般情况下,抛出对象和 catch 是类型完全匹配的。如果有多个匹配的,就选择最近的。过于严格虽然更安全更细致,但在实践层面,反而可能不太友好,过于繁琐冗余。

因此,异常的类型匹配有一些例外:

  1. 允许**⾮常量类型** 到常量类型 的转换,即权限缩小。比如 throw 了一个 int 对象,允许用 const int 匹配。
  2. 允许数组元素指针类型 的转换。比如 throw 了一个 int 数组,允许用 int* 匹配 (不常用)。
  3. 允许函数函数指针 的转换。比如 throw 了一个函数对象,允许用指向该函数对象的函数指针匹配 (不常用)。
  4. 允许子类父类 的转换,即子类可以通过切割/切片得到父类。这个点⾮常实⽤,实践中继承体系基本都是⽤这个⽅式设计的。比如 throw 了一个子类对象,我们可以在外层统一用父类进行捕捉。

⼀般⼤型项⽬程序才会使⽤异常。例如,通常封装实现一个基础的异常类如 Exception 作为父类,可能提供一个string字符串或者一个整型变量错误码之类的。程序中,或者说main函数中,只会 catch 捕获两个类型,一个是任意类型 catch(...),一个就是 catch(const Exception& e)。整个项目团队,只允许抛 Exception 类型的异常对象。

当然,也可以根据实际情况,团队成员自由实现一些专门的子类去继承 Exception,例如Sql语句相关错误 SqlException、内存相关错误 CacheException、Http连接相关错误 HttpException 等。在对应的服务模块执行过程中,如果出现错误,就要抛异常,就可以专门 throw 对应的子类对象,这样异常对象包含的信息就更具体更全面。并且,main函数中虽然只有一个 catch(const Exception& e),但可以同时匹配父类对象和多个子类对象。

代码示例 1.4.2

cpp 复制代码
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <cstdlib>
#include <ctime>

using namespace std;

/**
 * @brief 全局异常基类
 * 
 * 【大型项目异常设计核心 1】:
 * 定义统一的异常基类。所有业务模块的异常都继承自此类。
 * 这样在最外层捕获时,只需要 catch(基类&) 即可统一处理所有已知异常。
 * 注意:what() 必须声明为 virtual,以支持多态,
 *      子类1调子类1的what,子类2调子类2的what,父类调父类的what。
 */
class Exception
{
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id)
    {}

    // 虚函数:允许派生类重写,实现多态。
    // 捕获基类引用时,调用 what() 会实际执行派生类的逻辑。
    virtual string what() const
    {
        return _errmsg;
    }

    int getid() const
    {
        return _id;
    }

protected:
    string _errmsg; // 错误描述信息
    int _id;        // 错误码,便于前端或日志系统进行结构化处理
};

/**
 * @brief 数据库模块异常
 * 
 * 【大型项目异常设计核心 2】:
 * 每个模块可以定义自己的派生异常类,并携带该模块特有的上下文数据。
 * 例如:SqlException 额外携带了引发错误的 SQL 语句,方便排查问题。
 */
class SqlException : public Exception
{
public:
    SqlException(const string& errmsg, int id, const string& sql)
        : Exception(errmsg, id), _sql(sql)
    {}

    // 重写 what(),拼接模块特有的信息
    virtual string what() const override
    {
        string str = "SqlException: ";
        str += _errmsg;
        str += " -> SQL: ";
        str += _sql;
        return str;
    }

private:
    const string _sql; // 模块特有数据:出错的 SQL 语句
};

/**
 * @brief 缓存模块异常
 */
class CacheException : public Exception
{
public:
    CacheException(const string& errmsg, int id)
        : Exception(errmsg, id)
    {}

    virtual string what() const override
    {
        string str = "CacheException: ";
        str += _errmsg;
        return str;
    }
};

/**
 * @brief 网络/HTTP模块异常
 */
class HttpException : public Exception
{
public:
    HttpException(const string& errmsg, int id, const string& type)
        : Exception(errmsg, id), _type(type)
    {}

    virtual string what() const override
    {
        string str = "HttpException [";
        str += _type;
        str += "]: ";
        str += _errmsg;
        return str;
    }

private:
    const string _type; // 模块特有数据:HTTP 请求方法 (GET/POST等)
};

// ==================== 业务模块模拟 ====================

void SQLMgr()
{
    if (rand() % 7 == 0)
    {
        // 【抛出异常】:底层模块不需要处理异常,直接抛出派生类对象。
        // 抛出对象而非指针,避免内存泄漏和生命周期管理问题。
        throw SqlException("权限不足", 100, "select * from name = '张三'");
    }
    else
    {
        cout << "SQLMgr 调用成功" << endl;
    }
}

void CacheMgr()
{
    if (rand() % 5 == 0)
    {
        throw CacheException("权限不足", 100);
    }
    else if (rand() % 6 == 0)
    {
        throw CacheException("数据不存在", 101);
    }
    else
    {
        cout << "CacheMgr 调用成功" << endl;
    }
    // 缓存检查通过后,继续调用数据库
    SQLMgr(); 
}

void HttpServer()
{
    if (rand() % 3 == 0)
    {
        throw HttpException("请求资源不存在", 100, "GET");
    }
    else if (rand() % 4 == 0)
    {
        throw HttpException("权限不足", 101, "POST");
    }
    else
    {
        cout << "HttpServer 调用成功" << endl;
    }
    // 路由解析通过后,进入业务逻辑
    CacheMgr(); 
}

// ==================== 主程序入口 ====================

int main()
{
    srand((unsigned int)time(0));

    while (1)
    {
        this_thread::sleep_for(chrono::seconds(1));

        try 
        {
            // 顶层业务入口,调用底层一系列服务
            HttpServer(); 
        }
        catch (const Exception& e) 
        {
            // 【大型项目异常设计核心 3】:
            // 1. 必须使用【引用】(const Exception&) 捕获,防止对象切片(Slicing),确保多态生效。
            // 2. 统一捕获基类,无论是 Sql、Cache 还是 Http 异常,都会走到这里。
            // 3. 调用 e.what() 时,由于是虚函数,会自动调用对应派生类的 what() 打印详细信息。
            cout << e.what() << endl;
        }
        catch (...)
        {
            // 兜底捕获:防止未预期的异常(如 std::exception 或基础类型)导致程序崩溃
            cout << "Unknown Exception" << endl;
        }
    }

    return 0;
}

1.5 异常重新抛出

catch 到⼀个异常对象后,可能需要对错误进⾏分类。

  • 在一条调用链上,可能需要暂时拦截某个异常对象。如果是特殊异常错误,则进⾏特殊处理;如果是其他异常错误,就需要重新抛出 throw 异常,给外层调⽤链处理,此处只需要 throw; 即可,不需要跟其他异常对象,重新抛出的是当前还在传递的异常对象。

比如,我们用手机发送消息,现实中需要通过信号基站,通过各种路由发送到对端设备。但可能信号不好,手机上就可能显式"转圈圈"等,消息持续等待发送。这实际上就是当前消息发送失败了,但在安全时间内,所以会帮用户持续尝试重新发送。

代码示例 1.5.1

cpp 复制代码
// 下面程序模拟展示了聊天时发送消息,发送失败捕获异常,但是可能在电梯地下室等场景手机信号不好
// ,则需要多次尝试,如果多次尝试都发送不出去,则就需要捕获异常再重新抛出,其次如果不是网络差
// 导致的错误,捕获后也要重新抛出。
#include <iostream>
#include <string>
#include <exception>
#include <cstdlib>
#include <ctime>

// Exception 和 HttpException 是自定义的异常基类和异常子类

using namespace std;

// 【底层发送函数】:模拟消息发送,有一定概率抛出异常
void _SendMsg(const string& s) {
    // 模拟网络不稳定(概率较高)
    if (rand() % 2 == 0) {
        // 抛出 102 号异常:网络不稳定
        throw HttpException("网络不稳定,发送失败", 102, "put");
    } 
    // 模拟权限错误(概率较低)
    else if (rand() % 7 == 0) {
        // 抛出 103 号异常:非好友
        throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
    } 
    // 发送成功
    else {
        cout << "发送成功: " << s << endl;
    }
}

// 【上层业务函数】:处理业务逻辑,包含重试机制
void SendMsg(const string& s) {
    // 最多尝试发送 4 次(初始1次 + 重试3次)
    for (size_t i = 0; i < 4; i++) {
        try {
            // 尝试调用底层发送函数
            _SendMsg(s);
            
            // 如果发送成功,没有抛出异常,直接跳出循环,结束函数
            break; 
        }
        catch (const Exception& e) {
            // 【异常拦截与分类处理】
            // 捕获到了异常,现在需要判断这个异常的类型/错误码,决定是重试还是直接抛出
            
            if (e.getid() == 102) {
                // 1. 如果是 102 号错误(网络不稳定),允许重试
                
                if (i == 3) {
                    // 【关键点:重新抛出】
                    // 如果已经重试了3次(即第4次依然失败),说明网络确实太差,
                    // 不再重试。此时使用 `throw;` 将当前的异常原样重新抛出,
                    // 交给调用该函数(SendMsg)的更外层代码(如 main 函数)去处理。
                    throw; 
                }
                
                // 没到最大重试次数,打印日志,准备下一次循环重试
                cout << "网络波动,开始第 " << i + 1 << " 次重试..." << endl;
            }
            else {
                // 2. 如果是其他错误(比如 103 非好友错误),重试没有意义
                // 立即使用 `throw;` 重新抛出,不再进行后续重试。
                // 交给上层调用者处理这个特定的业务错误。
                throw; 
            }
        }
    }
}

int main() {
    srand(time(0));
    string str;
    
    // 模拟用户输入消息
    while (cin >> str) {
        try {
            // 调用业务发送函数
            SendMsg(str);
        }
        catch (const Exception& e) {
            // 【异常捕获与善后】
            // 捕获从 SendMsg 函数中经过 `throw;` 重新抛出后向上传播的异常。
            // 这里可以进行最终的日志打印、用户提示或者资源释放。
            cout << "发送彻底失败,错误信息: " << e.what() << endl << endl;
        }
        catch (...) {
            // 【异常安全兜底】
            // 捕获所有未知类型的异常(包括标准库异常 std::exception 以外的异常)
            // 确保程序不会因为未知的异常而导致进程崩溃 (Abort)
            cout << "发生了未知异常 (Unkown Exception)" << endl;
        }
    }
    return 0;
}

1.6 异常安全问题

异常抛出后,直接跳转到匹配的 catch 子句,后⾯的代码就得不到执⾏。如果有资源申请 (内存、锁等) ,抛异常就可能会导致资源得不到释放。因为异常引发资源泄漏,产⽣安全性问题。

  • 因此,如果还有资源未释放,我们就需要暂时拦截异常对象,释放资源后再重新抛出。当然,这种方式其实不够好,后⾯智能指针章节的RAII⽅式解决更好。
  • 析构函数中,如果抛出异常也要谨慎处理。避免资源释放的中途抛出异常,如果抛出也需要暂时拦截,继续将资源全部释放。《EffctiveC++》第8个条款就专⻔讲了这个问题,不要让异常逃离析构函数。

毕竟析构函数本身就是处理资源的,如果析构函数走到一半就跳转了,显然不合理,我们必须保证析构函数逻辑正常走完。

1.7 异常规范

对于用户和编译器⽽⾔,能够预先或者说标识程序中会不会抛异常是大有裨益的。我们期望知道函数是否会抛出异常,以帮助简化调⽤函数的代码。

C++98中:

  • 可以在函数的形参列表后加 throw(),表⽰函数不抛异常;
  • 在形参列表后加 throw(类型1, 类型2...),表⽰可能会抛出多种类型的异常。可能抛出的异常对象类型⽤逗号分割。

C++98这种⽅式过于复杂,实践中并不好⽤。因为函数可能又调用其他函数,一层层套外层的函数可能抛出很多异常,就要写一堆。

C++11进⾏了简化:

  • 函数形参列表后加 noexcept,表⽰不会抛出异常。啥都不加表⽰可能会抛出异常。

编译器并不会在编译时检查 noexcept。即函数⽤ noexcept 修饰了,但是同时⼜ throw 了,或者内部调⽤的其他函数 throw 了,编译器还是会顺利编译通过,顶多报个警告。

  • 声明了 noexcept 的函数,如果在运行时确实抛出了异常,程序就会调⽤ std::terminate 终⽌程序。
  • noexcept(expression) 还可以作为⼀个运算符,去检测⼀个表达式是不是不会抛异常,确实不会抛则返回 true,可能会抛则返回 false。

noexcept 的效果其实也不太好,这种检测其实更应该放在编译时。不过作为运算符还是挺不错的,比如我们需要使用某些库函数时,我们需要判断可不可能抛异常,就可以用 noexcept,确实不可能抛就是true,可能抛就是false。

2. 标准库的异常

C++程序严格意义上来说还需要捕获一个标准库的异常 exception。程序中,首先需要 catch(...) 捕获任意类型作为保障兜底,其次是自己写的部分要定义自己的异常体系。我们的程序还可能引用了各种库,可能是C++标准库,也可能是一些外部库,不同的库异常体系可能是不一样的。

  • C++标准库也定义了⾃⼰的⼀套异常继承体系库 <exception>,基类是 exception。

exception 类主要实现了构造、拷贝构造、拷贝赋值、析构等,还有一个 what 的虚函数接口。比较大的缺陷就是异常信息不够全面,例如 what 返回的是 const char*,返回字符串,我们前面都是直接返回 string 对象。一个自定义类型可以尝试包含的信息和东西是很多的,而且通过移动构造效率也是特别高的,拷贝字符串这种

还有一些分散在不同库文件中的子类,抛异常抛的都是这些子类对象,甚至是子类的子类。

因此,我们⽇常写程序,一般在主函数用一个 const exception& 类型捕获标准库异常即可,获取异常信息,调⽤ what 函数,what 是⼀个虚函数,子类会重写。

由于C++标准库的异常并不是很好用,各个公司或者项目团队通常都会定义一套自己的异常体系,因此异常这里还是比较乱的,但也还好,只是多几个分类而已。通常就是C++标准一套异常,项目引用的某个库一套异常,当前公司一套异常。多一套异常,无非是多一个父类,多一套继承体系,实践再根据需要自己补自己的子类。

相关推荐
GHL2842710901 小时前
豆包换脸学习
学习·ai
玖玥拾1 小时前
Lua 基础语法(六)xLua Lua 调用 C# 交互
开发语言·unity·c#·lua
youm20031 小时前
【学习笔记】reids的数据类型
redis·笔记·学习
Joolun商城源码_Java1 小时前
Java 商城技术栈怎么选:单体、微服务还是前后端分离多端?
java·开发语言·微服务
charlie1145141911 小时前
浅谈C++的列表初始化std::initializer_list
开发语言·数据结构·c++·list·开源项目
老猿讲编程1 小时前
【Eclipse OpenSOVD学习之五】拓扑引擎(Topology)
学习·rust·eclipse·sovd
拒绝内耗。1 小时前
一个请求进入 Java 应用后,怎么找到我们写的方法?
java·开发语言
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】RS_Line创建之后发生了什么?从图形容器到屏幕渲染
开发语言·c++·qt·学习·系统架构
再吃一根胡萝卜1 小时前
05 · Agent 编排:LangGraph 状态图与降级内核
面试