目录

 

一、异常的概念

二、异常的抛出与捕获

  三、栈展开

四、捕捉(catch)中的类型匹配

五、异常安全问题

六、异常规范


 

一、异常的概念

   异常(错误/bug)在现实项目中并不少见,出现了异常我们程序员也必须去解决。解决代码错误的前提是知晓错误的发生位置与产生原因,C/C++也会在异常发生时给我们相关查看手段。

   在C语言中程序的错误通常以错误码的形式体现,但这种方式并不方便观察具体错误信息(因为要自己去查错误码的具体信息)。在C++中,我们可以通过捕获一个错误类来呈现具体的错误信息。

二、异常的抛出与捕获

double Divide(int x, int y)
{
	try
	{
		if (y == 0)
		{
			string s = "除零错误";
			throw s;
		}
		else
		{
			return (double)x / y;
		}
	}
	catch (string x)
	{
		cout << "error:" << x << endl;
	}

}

   以上面代码为例:

1、程序出现问题时,我们通过抛出(throw) 一个异常对象。C++标准规定这个被throw抛出的异常对象(无论原对象是否为const)具有强制的const属性

2、异常的接收通过捕捉(catch) 实现。

3、抛出(throw)异常后,throw 后面的代码不会执行,程序会跳转到最近的、符合异常类型要求的 catch 语句中执行。catch 语句可能在当前函数内,也可能在调用链的其他函数中。这会导致两个结果:沿着调用链的函数可能提前退出;一旦执行异常处理程序,调用链上创建的所有局部对象都会被销毁(即 “栈展开” 过程)。

4、由于捕捉异常后,调用链上的相关对象会被销毁(如上面代码的局部变量s),因此 throw 抛出的是原对象的一份拷贝。这份拷贝对象会被传递给 catch 语句,在 catch 处理结束后才会被销毁。

static int h = 10;
double Divide(int x, int y)
{
	try
	{
		if (y == 0)
		{
			string s = "除零错误";
			throw s;

			h = 20;
			return -1;//随便写的
		}
		else
		{
			return (double)x / y;
		}
	}
	catch (double x)
	{
		cout << "error:" << x << endl;
	}

	catch(int x)
	{
		cout << "error:" << x << endl;
	}
}

int main()
{
	int a, b; cin >> a >> b;
	try
	{
		Divide(a, b);
	}
	catch (string x)
	{
		cout << x << endl;
	}
	cout << "h:" << h << endl;

	return 0;
}

   上面很清楚看到throw后面的代码语句并没有执行下去。并且还不是在原函数catch而是跳出原函数到main函数中捕捉。在跳出的时候调用链上的原函数栈帧会被销毁。

   当没有合适的catch捕捉时程序同样会报错终止

  三、栈展开

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

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

   3、如果到达main函数,依旧没有找到匹配的catch子句,程序会调用标准库的 terminate 函数终止程序。(上面有示例)

   4、如果找到匹配的catch子句处理后,catch子句代码(包括后面代码)会继续执行。(异常捕获这种处理方式与报错直接粗暴中断程序很不同)

   

   上方图例中,f3中throw出异常,而这异常的捕获(catch)是在外部f1中实现。上图右侧从throw到catch的过程是栈展开的形象描述,在栈展开的过程里f3、f2都会被销毁。

   栈展开避免因程序的某一环节出错使整个程序中断崩溃的极端情况,大大优化了使用舒适度(不会因为小毛病使得程序不可用)、开发效率、程序的稳定可维护性。

四、捕捉(catch)中的类型匹配

   一般情况下我们设计的异常捕获都会有类型匹配的catch捕获(不然就报错了),但凡是都有例外:

   catch捕获允许从非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针;允许从派生类向基类类型的转换。尤其是最后一个转化十分实用。

class Person
{
public:

	Person(int x = 0)
		:_a(x)
	{ }

	virtual void Fun()const
	{
		cout << "Person" << endl;
	}

private:
	int _a;
};

class Student :public Person
{
public:

	Student(const string& s, int x)
		:_name(s)
		, Person(x)
	{ }
	virtual void Fun()const
	{
		cout << "Student" << endl;
	}
private:
	string _name;
};

class Staff :public Person
{
public:

	Staff(double x, int y)
		:_year(x)
		, Person(y)
	{	}
	virtual void Fun()const
	{
		cout << "Staff" << endl;
	}
private:
	double _year;
};

int main()
{
	Person a(1);
	Student b("zhangsan", 2);
	Staff c(18.5, 3);

	for (int i = 1; i <= 3; i++)
	{
		try
		{
			if (i == 1) throw a;
			else if ((i == 2)) throw b;
			else if (i == 3) throw c;
		}
		catch (const Person& x)
		{
			x.Fun();
		}
	}
	return 0;
}

   

   上面代码不仅在捕获(catch)阶段体现派生类向基类的转化,还体现了异常抛出捕获后仍然会继续执行catch后面的代码(注意栈展开)。

   下面补充一个小知识,我们分别用linux的g++和vs2022运行下面代码:

class Person
{
public:

	Person(int x = 0)
		:_a(x)
	{
	}

	virtual void Fun()//const
	{
		cout << "Person" << endl;
	}

private:
	int _a;
};

class Student :public Person
{
public:

	Student(const string& s, int x)
		:_name(s)
		, Person(x)
	{
	}
	virtual void Fun()//const
	{
		cout << "Student" << endl;
	}
private:
	string _name;
};

class Staff :public Person
{
public:

	Staff(double x, int y)
		:_year(x)
		, Person(y)
	{
	}
	virtual void Fun()//const
	{
		cout << "Staff" << endl;
	}
private:
	double _year;
};

int main()
{
	Person a(1);
	Student b("zhangsan", 2);
	Staff c(18.5, 3);

	for (int i = 1; i <= 3; i++)
	{
		try
		{
			if (i == 1) throw a;
			else if ((i == 2)) throw b;
			else if (i == 3) throw c;
		}
		catch ( Person& x)
		{
			x.Fun();
		}
	}
	return 0;
}

   不是说C++标准throw抛出的是const类型错误对象吗,怎么上面的代码还能在g++、vs上编译?

   这是编译器的 “非标准扩展”:允许非const引用捕获const异常对象.

   根据 C++ 标准,throw抛出的异常对象强制为const类型(目的是防止catch块修改异常状态)。此时,catch(Person&x)用非const引用捕获const异常对象,理论上属于 “权限放大”,标准不允许。

   但 GCC、VS 等编译器存在 “非标准扩展”:为了兼容旧代码或简化使用,会放宽对 “异常对象const属性” 的检查,允许非const引用直接绑定const异常对象。

   虽然编译器会在这放宽要求,但依旧建议使用const写法!

   既然谈到了编译器,我们还需要知道目前主流的编译器都会对throw与catch步骤做出优化:比如throw、catch的过程中不创建临时对象而是直接构造以减少拷贝。我们一般不需要关心其中的效率问题。

   好了,假如我们抛出异常后到了main函数中依旧查找不到合适的捕捉语句,我们可以在main函数中最后都会使用catch(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。

nt main()
{
	int a = 1;
	try
	{
		if (a == 1)
			throw int(10);//例子随便举的
	}
	catch(const string&s)
	{
		cout << s << endl;
	}
	catch (...)
	{
		cout << "error" << endl;
	}

	return 0;
}

五、异常安全问题

   异常抛出后,后面的代码就不再行(指throw后面那部分),前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常就会导致资源没有释放,这里由于异常就引发了资源泄漏,产生安全性的问题。

   中间我们需要捕获异常,释放资源后面再重新抛出。

   下面就是捕获到无法解析的异常(catch(…)捕获),就需要往上层(这里是main函数)重新抛出异常捕获具体的错误信息。

void func() 
{
	int* p = new int(10); // 1. 分配堆内存(资源)
	try 
	{
		throw runtime_error("出错了"); // 2. 抛异常(模拟错误)
	}
	catch (...) 
	{
		delete p; // 3. 捕获后,先释放资源(避免泄漏)
		cout << "已释放内存,再抛异常给上层" << endl;
		throw; // 4. 重新抛出异常
	}
}

int main() 
{
	try 
	{
		func();
	}
	catch (const exception& e)
	{
		cout << "上层捕获到:" << e.what() << endl;
	}
	return 0;
}

                 

   当然使用RAII思想来解决这个问题更好。(智能指针那里介绍)

  其次析构函数中,如果抛出异常也要谨慎处理,比如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,也资源泄漏了。所以别让异常逃离析构函数

class ResourceManager 
{
private:
    int* resources[10];
public:

    ResourceManager()
    {
        for (int i = 0; i < 10; ++i)
        {
            resources[i] = new int(i); // 资源编号0~9
            cout << "创建资源 " << i << "(地址:" << resources[i] << ")" << endl;
        }
    }

    // 析构函数(重点关注)
    ~ResourceManager()
    {
        cout << "\n开始释放资源:" << endl;

        // 方式1:不处理异常(错误示范)
        // for (int i = 0; i < 10; ++i) 
        //     {
        //     if (i == 5) { // 模拟释放第5个资源时抛异常
        //         throw runtime_error("释放资源5时失败!");
        //     }
        //     delete resources[i];
        //     cout << "已释放资源 " << i << endl;
        // }

        // 方式2:捕获异常并处理(正确示范)
        for (int i = 0; i < 10; ++i) 
        {
            try
            {
                if (i == 5)
                { // 模拟释放第5个资源时抛异常
                    throw runtime_error("释放资源5时失败!");
                }
                delete resources[i];
                cout << "已释放资源 " << i << endl;
            }
            catch (const exception& e)
            {
                // 捕获异常后,记录错误但继续释放后续资源
                cerr << "处理资源" << i << "时出错:" 
                     << e.what() << "(继续释放后续资源)" << endl;
                // 注意:此处不重新抛出,避免异常逃离析构函数
            }
        }

        cout << "所有资源释放流程结束" << endl;
    }
};

int main()
{
    try
    {
        ResourceManager rm; // 创建对象,自动初始化10个资源
        // 假设业务逻辑正常执行,对象销毁时调用析构函数
    }
    catch (const exception& e)
    {
        cout << "\n上层捕获到异常:" << e.what() << endl;
    }
    return 0;
}

六、异常规范

   noexcept  修饰函数的核心作用是保证“异常不会逃离该函数”,而非“禁止函数体内部产生或捕获异常”。

标准库中的异常:https://legacy.cplusplus.com/reference/exception/exception/

#include <iostream>
#include <stdexcept> // 包含标准异常
using namespace std;

// 计算正数的平方(参数必须为正数)
int square(int n) 
{
    if (n < 0) 
    {
        // 抛出“无效参数”异常,附带错误信息
        throw invalid_argument("参数必须为正数(square函数)");
    }
    return n * n;
}

int main()
{
    try 
    {
        cout << square(-5) << endl; // 传入无效参数
    }
    catch (const exception& e)
    { 
        // 捕获所有标准异常
        cout << "错误:" << e.what() << endl; // 输出错误信息
    }
    return 0;
}

  抛出异常时用专门的类,接受时用exception就可以了。

   当使用万能捕获(catch(...))捕获不确定异常时可用throw;(万能捕获)再次抛出,外部用const exception&二次接收并结合what接口分析具体错误信息。

 

 

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐