Re:从零开始的 C++ 进阶篇(四)工业级 C++ 编程:如何构建异常安全的健壮系统?(含案例分析)


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️C++系列个人专栏: 主题曲:C++程序设计
⭐️ 踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰


文章目录


0.1概要&序論

这里是此方,好久不见 。 异常不仅是 C++ 的错误处理机制,更是对 RAII 和 代码健壮性 的终极考验。它上接语法层面的抛出捕获,下连底层的栈展开与异常安全 。如果不理解其背后逻辑,异常便会从 "救命稻草"变成"资源泄露" 的深坑。本文将从底层原理出发,结合工程实战,带你彻底攻克异常处理。让我们现在开始吧!

一,异常的概念

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

C语言主要通过错误码的形式处理错误,错误码本质就是对错误信息进行分类编号 ,拿到错误码以后还要去查询错误信息,比较麻烦。异常时抛出一个对象,这个对象可以比函数更全面的各种信息。

二,异常的抛出和捕获

2.1抛出的异常应当由对应的捕获类型捕获

程序出现问题时,我们通过抛出 (throw) 一个对象 来引发一个异常,该对象的类型以及当前的调用链决定了应该由哪个 catch 的处理代码来处理该异常。(捕获异常实际上也可以走移动构造 )

我们举个例子就明白了:

cpp 复制代码
#include<iostream>
using namespace std;
int ExceptionofDivision(int x,int y)
{
	//如果除数为0则抛出异常
	if (y == 0)
	{
		//抛出一个string类型的异常
		const string error_div = "Divisor cannot be zero";
		throw error_div;
	}
	return x / y;
}
void Test()
{
	//no error
	int x = 10;
	int y = 20;
	cout<<ExceptionofDivision(10, 20)<<endl;
	//error
	int z = 0;
	cout << ExceptionofDivision(x, 0) << endl;
}
int main()
{
	try
	{
		//什么地方抛出来的
		Test();
	}
	//捕获抛出的异常 捕获类型:string
	catch (const string& errormessage)
	{
		//打印捕获的异常
		cout << errormessage << endl;
	}
	//类型不同不会捕获
	catch (const int& errormessage)
	{
		cout << errormessage << endl;
	}
	//类型不同不会捕获
	catch (const char* errormessage)
	{
		cout << errormessage << endl;
	}
	return 0;
}

2.2异常抛出根据就近原则

被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个 。根据抛出对象的类型和内容,程序的抛出异常部分告知异常处理部分到底发生了什么错误。

当然,如果找到匹配的 catch 子句处理后,catch 子句代码会继续执行。

cpp 复制代码
#include<iostream>
using namespace std;
int ExceptionofDivision(int x,int y)
{
	try
	{
		if (y == 0)
		{
			const string error_div = "Divisor cannot be zero";
			throw error_div;
		}
	}
	//就近捕获
	catch (const string& errormessage)
	{
		cout << errormessage << endl;
	}
	return x / y;
}
void Test()
{
	//no error
	int x = 10;
	int y = 20;
	cout<<ExceptionofDivision(10, 20)<<endl;
	//error
	int z = 0;
	cout << ExceptionofDivision(x, 0) << endl;
}
int main()
{
	try
	{
		Test();
	}
	//不会捕获
	catch (string& errormessage)
	{
		cout << errormessage << endl;
	}
	return 0;
}

2.3异常抛出后到被捕获前的代码全部失效

当 throw 执行时,throw 后面的语句将不再被执行。程序的执行从 throw 位置跳转到与之匹配的 catch 模块,catch 可能是同一函数中的一个局部的 catch,也可能是调用链中另一个函数中的 catch,控制权从 throw 位置转移到了 catch 位置。这里还有两个重要的含义:

  • 沿着调用链的函数可能提早退出。
  • 一旦程序开始执行异常处理程序,沿着调用链创建的对象都将销毁。
  • 栈帧正常销毁,对象正常析构。
cpp 复制代码
#include<iostream>
using namespace std;
int ExceptionofDivision(int x,int y)
{
	string str =new string("1232456);//抛出异常后堆上的内存发生内存泄漏,这里简单一提,后面讲RAII的时候再给出解决方案
	int tmp01 =1;//抛出异常后栈上的内存释放
	//try
	//{
		if (y == 0)
		{
			const string error_div = "Divisor cannot be zero";
			throw error_div;
		}
	//}
	/*catch (const string& errormessage)
	{
		cout << errormessage << endl;
	}*/
	//抛出异常后不会执行
	cout << "common runing" << endl;
	return x / y;
}
void Test()
{
	//no error
	int x = 10;
	int y = 20;
	cout<<ExceptionofDivision(10, 20)<<endl;
	//error
	int z = 0;
	cout << ExceptionofDivision(x, 0) << endl;
}
int main()
{
	try
	{
		Test();
	}
	//抛出异常后直接跳转到该位置
	catch (string& errormessage)
	{
		cout << errormessage << endl;
	}
	return 0;
}

一个有趣的代码:永远无法执行的cout:执行它就必须引发异常,而抛出异常后必然不能执行它。

2.4异常抛出会生成异常拷贝对象

抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个局部对象,所以会生成一个拷贝对象,这个拷贝的对象会在 catch 子句后销毁。(这里的处理类似于函数的传值返回)

2.5栈展开------异常捕捉的原理

抛出异常后,程序暂停当前函数的执行,开始寻找与之匹配的 catch 子句 。首先检查 throw 本身是否在 try 块内部,如果有则查找匹配的 catch 语句,如果有匹配的,则跳到 catch 的地方进行处理。

如果当前函数中没有 try/catch 子句,或者有 try/catch 子句但是类型不匹配,则退出当前函数,继续在外层调用函数链中查找,上述查找的 catch 过程被称为栈展开。

如果到达 main 函数,依旧没有找到匹配的 catch 子句,程序会调用标准库的 terminate 函数终止程序。

调用标准库的 terminate 函数终止程序是很严重的,说明你的程序挂掉了。 在公司中,因为这种低级错误而让整个产品挂掉是重大事故。

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

就是说,你发一个信息没发出去直接闪退(异常未捕获时闪退的一种结果)。这样显然不行的。而应该是:返回消息:当前网络不好等等。(异常捕获)

cpp 复制代码
int main()
{
	try
	{
		Test();
	}
	catch (int& errormessage)
	{
		cout << errormessage << endl;
	}
	catch (...)//未知异常//兜底
	{
		cout << "Unknown Exception" << endl;
	}
	return 0;
}

三,查找匹配的处理代码

一般情况下抛出对象和 catch 是类型完全匹配的,如果有多个类型匹配的,就选择离它位置更近的那个。

但是也有一些例外,

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

3.1基于派生类向基类类型的转换的异常捕捉机制

设想一个场景,张三李四王二麻组成一个项目组,张三负责数据库,李四负责缓存,王二麻负责网络。张三想要抛这样的异常,李四想要抛那样的异常,王二麻抛出的异常和前两者都不相同。 那么异常捕获的时候就必须写各种各样的捕获。非常麻烦,而且不利于代码的维护。于是三人想出来一种设计方式:基于派生类向基类类型的转换的异常捕捉机制

一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块 ,每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据,最后捕获时,我们捕获基类就可以。

代码着实有些长,以下是一张图来辅助理解,然后再看代码

cpp 复制代码
#include <thread>
//设计一种Exception类。
//如果想要抛出一种个性化的异常类型,
//又想要被 exception catch 捕获,
//就可以写一个 xxxExceptin 类来继承,
//然后抛出的时候就抛出这个 xxxExceptin
class Exception
{
public:
    Exception(const string& errmsg, int id)//构造父类异常
        :_errmsg(errmsg)
        , _id(id)
    {}

    virtual string what() const//获取错误信息
    {
        return _errmsg;
    }

    int getid() const//获取错误码
    {
        return _id;
    }

protected:
    string _errmsg;//错误信息
    int _id;//错误码
};
//数据库相关异常
class SQLException : public Exception
{
public:
    SQLException(const string& errmsg, int id, const string& sql)
        :Exception(errmsg, id)
        , _sql(sql)
    {}

    virtual string what() const
    {
        string str = "SQLException:";
        str += _errmsg;
        str += "->";
        str += _sql;
        return str;//获取异常信息:返回抛异常,抛了什么异常
    }

private:
    const string _sql;//数据库异常特殊信息
};
//缓存相关的异常
class CacheException : public Exception
{
public:
    CacheException(const string& errmsg, int id)
        :Exception(errmsg, id)
    {}

    virtual string what() const
    {
        string str = "CacheException:";
        str += _errmsg;
        return str;
    }
};
//网络相关异常
class HttpException : public Exception
{
public:
    HttpException(const string& errmsg, int id, const string& type)
        :Exception(errmsg, id)
        , _type(type)
    {}

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

private:
    const string _type;
};

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(time(0));

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

        try//我不可能满足所有人的异常抛出需求,所以我只写这两个
        {
            HttpServer();
        }
        catch (const Exception& e)  // 这里捕获基类,基类对象和派生类对象都可以被捕获
        {
            cout << e.what() << endl;
        }
        catch (...)
        {
            cout << "Unknown Exception" << endl;
        }
    }

    return 0;
}

这里讲的就是一个实践的规范,虽然你可以去抛出任何一种类型的异常,但是实践当中还是要遵行这种规范

四,异常重新抛出

生活中的场景:发送一个消息,消息发不出去会在那里转圈------这不是正在发送消息,而是正在反复尝试。

有时 catch 到一个异常对象后,需要对错误进行分类其中的某种异常错误需要进行特殊的处理 ,其他错误则重新抛出异常给外层调用链处理。捕获异常后需要重新抛出,直接 throw; 就可以把捕获的对象直接抛出。

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

// 模拟一次"发送消息"的底层行为(可能成功,也可能失败并抛异常)
void _SeedMsg(const string& s)
{
    // 50%概率:模拟网络不稳定(错误码102)
    if (rand() % 2 == 0)
    {
        throw HttpException("网络不稳定,发送失败", 102, "put");
    }
    // 约1/7概率:模拟对方已不是好友(错误码103)
    else if (rand() % 7 == 0)
    {
        throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
    }
    else
    {
        // 正常发送成功
        cout << "发送成功" << endl;
    }
}

// 对外提供的发送接口:带"失败重试机制"
void SendMsg(const string& s)
{
    // 发送消息失败,则再重试3次(总共最多尝试4次:0,1,2,3)
    for (size_t i = 0; i < 4; i++)
    {
        try
        {
            // 尝试发送一次(可能抛异常)
            _SeedMsg(s);

            // 如果没有抛异常,说明发送成功,直接跳出循环
            break;
        }
        catch (const Exception& e)
        {
            // 捕获异常,if中是102号错误,网络不稳定,则重新发送
            // 捕获异常,else中不是102号错误,则将异常重新抛出

            // 判断是否为"可重试错误"(网络问题)
            if (e.getid() == 102)
            {
                // 如果已经是最后一次(第4次尝试仍失败)
                // 说明网络持续异常,不再处理,直接向上抛出
                if (i == 3)
                    throw;

                // 否则提示当前是第几次重试
                cout << "开始第" << i + 1 << "重试" << endl;
            }
            else
            {
                // 非网络问题(如:不是好友),属于逻辑性错误
                // 无法通过重试解决,直接向上抛出
                throw;
            }
        }
    }
}

int main()
{
    // 初始化随机种子(保证每次运行结果不同)
    srand(time(0));

    string str;

    // 持续读取输入(模拟不断发送消息)
    while (cin >> str)
    {
        try
        {
            // 调用带重试机制的发送接口
            SendMsg(str);
        }
        catch (const Exception& e)
        {
            // 捕获已知类型异常(业务异常)
            // 输出错误信息(如网络失败/非好友等)
            cout << e.what() << endl << endl;
        }
        catch (...)
        {
            // 捕获未知异常(兜底保护,防止程序崩溃)
            cout << "Unknown Exception" << endl;
        }
    }

    return 0;
}

五,异常安全问题

异常抛出后,后面的代码就不再执行 ,前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常就会导致资源没有释放,这里由于异常就引发了资源泄漏,产生安全性的问题 。中间我们需要捕获异常,释放资源后面再重新抛出,当然后面智能指针章节讲的 RAII 方式解决这种问题是更好的。

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

void Func()
{
    // 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
    // 所以这里捕获异常后并不处理异常,异常还是交给外层处理,这里捕获了再
    // 重新抛出去。
    int* array = new int[10];

    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...)
    {
        // 捕获异常释放内存
        cout << "delete []" << array << endl;
        delete[] array;

        throw; // 异常重新抛出,捕获到什么抛出什么
    }

    cout << "delete []" << array << endl;
    delete[] array;
}

int main()
{
    try
    {
        Func();
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (const exception& e)//细心的小伙伴一定会发现:这里实际上是一个多态调用,
    //父类和子类的what()函数构成重写,
    //这里调用的时候如果捕捉到基类就调用基类,捕捉到子类就调用子类。
    //库里面的异常也有相似的设计
    {
        cout << e.what() << endl;
    }
    catch (...)
    {
        cout << "Unknown Exception" << endl;
    }

    return 0;
}

其次析构函数中,如果抛出异常也要谨慎处理,比如析构函数要释放 10 个资源,释放到第 5 个时抛出异常,则也需要捕获处理,否则后面的 5 个资源就没释放,也资源泄漏了。《Effective C++》第 8 个条款也专门讲了这个问题,别让异常逃离析构函数。

六,异常规范

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

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

有人说,C++98这麻烦在哪里呀?试想一下,你的函数内部调用了10个函数,这十个函数内部又调用了几十个函数,他们都会抛异常。那么这个throw()将会变得无比巨大。

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 表示不会抛出异常,啥都不加表示可能会抛出异常。

cpp 复制代码
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;

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

cpp 复制代码
double Divide(int a, int b) noexcept
{
	// 向编译器保证noexcept,但同时又抛出异常,编译器:"我才不会照顾你"
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
		return (double)a / (double)b;
}

noexcept(expression) 还可以作为一个运算符去检测一个表达式是否会抛出异常,可能就会则返回 false,不会就返回 true。

noexcept不会去检查这个函数里面是怎么设计的,而是直接看这个表达式是否会抛出异常。不会则返回true

七,标准库的异常

C++异常标准库

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

但是库里面的这个体系不够用,一般公司里面会制作自己的异常体系。

这些异常类,都是继承自基类生成的。


好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!

相关推荐
yyuuuzz1 小时前
独立站运维:常见坑与实操优化技巧
运维
电商API_180079052471 小时前
如何实现批量化自动化获取淘宝商品详情数据?爬虫orAPI?
大数据·c++·爬虫·自动化
爱学习的小囧1 小时前
VMware ESXi 双管理网口配置全教程:新增 vmk1 端口 + 主备冗余 / 负载均衡双模式实操
运维·服务器·网络·windows·负载均衡·虚拟化
❆VE❆1 小时前
python基础篇(一):使用vscode搭建python相关环境
开发语言·vscode·python
傻啦嘿哟2 小时前
本地部署 vs 云服务器部署:IP环境对采集成功率的影响有多大
运维·服务器·tcp/ip
TechWayfarer2 小时前
IP归属地API接入实战指南:3天内安全上线的评估与落地方法
网络·tcp/ip·安全
星幻元宇VR2 小时前
VR消防安全学习机,数字化消防培训新选择
科技·学习·安全·vr
被java抛弃的网工2 小时前
Linux基础--挣点元子(1)
linux·运维·服务器
t***5442 小时前
如何确认 Clang 是否在 Dev-C++ 中成功应用
java·开发语言·c++