理解C++异常机制:栈展开、异常传播与异常安全

C语言中的处理错误的方式

传统的错误处理机制:

  • 终止程序,如assert,发生内存错误,除零错误的时候就会终止程序。

    #include <assert.h>

    int main() {
    int *p = NULL;
    assert(p != NULL); // 条件不成立,程序直接终止
    }

  • 返回错误码(这是 C 语言中最主流、最标准的错误处理方式)

函数通过返回值告诉调用者是否出错:成功 → 返回正常值(如 0);失败 → 返回错误码(如 -1)

复制代码
int func() {
    if (发生错误)
        return -1;
    return 0;
}
  • errno + 错误信息(系统级错误)

在系统调用或标准库函数中,常用 errno 来记录错误原因。

  1. errno:全局变量

  2. strerror(errno):将错误码转成可读字符串

  3. perror():直接打印错误信息

    #include <stdio.h>
    #include <errno.h>
    #include <string.h>

    int main() {
    FILE *fp = fopen("test.txt", "r");
    if (fp == NULL) {
    printf("error: %s\n", strerror(errno));
    perror("fopen failed");
    }
    }

C++异常的概念

异常是一种处理错误的方式,当一个函数发现存在错误时就会抛出异常,让函数的直接或者间接调用者去处理这个错误,而不是直接终止这个程序。

  • throw:当问题出现的时候,程序就会抛出一个异常,这个时候就是通过throw这个关键字来完成的。
  • catch:catch关键字用来捕获异常,可以有多个catch进行捕获。
  • try:try块中的代码标识将激活特定的异常,他后面通常跟着一个或多个catch块。

如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛 出异常的代码,try 块中的代码被称为保护代码。

异常的使用

如果我们只抛出了异常,并没有进行捕获,一旦程序检查到错误,就会直接报错终止程序。

复制代码
double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	int x, y;
	std::cin >> x >> y;

	std::cout << division(x, y) << std::endl;
}

int main()
{
	func();
	return 0;
}

所以当我们抛出异常的时候,一定要进行捕获。

我们在进行捕获异常的时候,抛出的对象是什么类型的,相应的我们进行捕获时的类型也要相匹配。

复制代码
double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	int x, y;
	std::cin >> x >> y;

	std::cout << division(x, y) << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	return 0;
}

如果捕获时没有与抛出时相匹配的类型,程序就会报错,直接终止程序。

找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。就比如这段程序,在正常情况下应该时main函数->func函数->division函数,最后通过division函数->func函数->main函数,这样的方式进行返回,但是如果时抛出异常之后,程序就会执行main函数catch之后的代码。

我们通过简单的程序进行验证一下。

复制代码
double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	int x, y;
	std::cin >> x >> y;

	std::cout << division(x, y) << std::endl;

	std::cout << "xxxxxxxxxxxxxxxxx" << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}

	std::cout << "yyyyyyyyyyyyyyyyy" << std::endl;
	return 0;
}

可以看到func函数中的("xxxxxxxxxxxxxxxxx")这个打印内容并没有执行,而是直接执行catch之后的代码。

那么现在有一个问题就是,从肉眼角度看,我们的程序直接跳到了catch之后的语句进行执行,那么func函数的栈帧还销毁吗?func函数栈帧中申请的那么多临时变量所占的空间要是不销毁,那不是浪费空间吗?

复制代码
class A
{
public:
	A()
	{
		std::cout << "A()" << std::endl;
	}

	~A()
	{
		std::cout << "~A()" << std::endl;
	}
};

double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	A a;
	int x, y;
	std::cin >> x >> y;

	std::cout << division(x, y) << std::endl;

	std::cout << "xxxxxxxxxxxxxxxxx" << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}

	std::cout << "yyyyyyyyyyyyyyyyy" << std::endl;
	return 0;
}

可以看到,即使我们从直观角度看,好像是直接跳转到了catch之后的语句进行执行,但是其实也是一个栈帧一个栈帧的销毁之后,返回到main函数的catch语句之后继续执行,所以这里func函数的栈帧也是被销毁的,不会存在浪费空间的问题。

在抛出异常之后,如果存在多个与之相匹配的捕获代码,优先适用最近原则,谁离得近优先匹配哪一个。

复制代码
class A
{
public:
	A()
	{
		std::cout << "A()" << std::endl;
	}

	~A()
	{
		std::cout << "~A()" << std::endl;
	}
};

double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	A a;
	int x, y;
	std::cin >> x >> y;
	try
	{
		std::cout << division(x, y) << std::endl;
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	

	std::cout << "xxxxxxxxxxxxxxxxx" << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}

	std::cout << "yyyyyyyyyyyyyyyyy" << std::endl;
	return 0;
}

异常中还有一点就是catch(...)可以捕获任何类型的异常,这个功能就是防止我们在抛出异常的时候,一不小心抛出一个没有与之相匹配的类型时,导致我们的程序直接终止,就比如像我们实现一款游戏,为了避免玩家直接进行国粹交谈,我们可以捕捉用户进行交流时的一些不文明用语,然后进行提示(请规范语言交流)这样功能的时候,如果一旦实现错误,我们就要避免我们的程序直接终止,万一这时候用户这把游戏特别的关键,由于你实现一个功能的失误,导致程序直接挂掉,这个代价就十分的大。所以为了避免这种情况的发生,如果我们抛出的异常没有与之相匹配的话,如果实现了catch(...),我们的程序还可以正常运行,不会直接终止。

复制代码
class A
{
public:
	A()
	{
		std::cout << "A()" << std::endl;
	}

	~A()
	{
		std::cout << "~A()" << std::endl;
	}
};

double division(int x, int y)
{
	if (y == 0)
	{
		throw 33;
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	A a;
	int x, y;
	std::cin >> x >> y;
	try
	{
		std::cout << division(x, y) << std::endl;
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	

	std::cout << "xxxxxxxxxxxxxxxxx" << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	catch (...)
	{
		std::cout << "未知异常" << std::endl;
	}

	std::cout << "yyyyyyyyyyyyyyyyy" << std::endl;
	return 0;
}

异常的抛出和匹配原则

  • 异常是通过抛出对象而引发的,这个抛出对象的类型是什么,相应的就会激活哪一个catch的处理代码。
  • 被选中的处理代码是与抛出对象类型相匹配且离抛出对象最近位置的那一个。
  • 抛出异常对象之后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。
  • catch(...)可以捕获任何类型的异常,问题是不知道异常错误是什么。
  • 实际中抛出和捕获的匹配原则有一个例外,并不是类型完全匹配,可以抛出派生类对象,使用基类进行捕获。

异常的重新抛出

有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用 链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理。

这是什么意思呢?其实我们通过一段代码就可以感受到了。

复制代码
double division(int x, int y)
{
	if (y == 0)
	{
		throw "除0错误";
	}
	else
	{
		return (double)x / (double)y;
	}
}

void func()
{
	int* arr = new int[10];

	int x, y;
	std::cin >> x >> y;
	std::cout << division(x, y) << std::endl;
	
	std::cout << "delete[] : " << arr << std::endl;
	delete[] arr;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	catch (...)
	{
		std::cout << "未知异常" << std::endl;
	}

	std::cout << "yyyyyyyyyyyyyyyyy" << std::endl;
	return 0;
}

从结果来看,我们就可以知道当正常退出,没有异常被抛出的时候,我们在func函数栈帧中申请的堆空间资源就可以很好的得到释放;但是一旦异常被抛出,由于异常独特的机制,这就导致我们释放堆空间资源的代码没有执行,这就会导致内存泄漏,持久下去,一定会由于内存泄漏导致程序挂掉,所以这个时候我们就必须将这个异常重新抛出,将他安全的抛出去,避免内存泄漏的问题。

复制代码
void func()
{
	int* arr = new int[10];

	try
	{
		int x, y;
		std::cin >> x >> y;
		std::cout << division(x, y) << std::endl;
	}
	catch (...)
	{
		std::cout << "delete[] : " << arr << std::endl;
		delete[] arr;
		throw;
	}
	
	std::cout << "delete[] : " << arr << std::endl;
	delete[] arr;
}

通过这样的方式,在func函数中对异常进行捕获,但是不做处理,只是将内存安全释放之后,再将这个异常抛出去,让更上层的函数进行处理。

异常安全

我们将上面的代码中的func进行更改一下。

复制代码
void func()
{
	int* arr1 = new int[10];
	int* arr2 = new int[20];

	try
	{
		int x, y;
		std::cin >> x >> y;
		std::cout << division(x, y) << std::endl;
	}
	catch (...)
	{
		std::cout << "delete[] : " << arr1 << std::endl;
		delete[] arr1;
		std::cout << "delete[] : " << arr2 << std::endl;
		delete[] arr2;
		throw;
	}
	
	std::cout << "delete[] : " << arr1 << std::endl;
	delete[] arr1;

	std::cout << "delete[] : " << arr2 << std::endl;
	delete[] arr2;
}

可以看到我们的代码好像依旧没有什么区别,但是其实这已经有内存泄漏的隐患了,这是因为C++中用new和delete进行内存申请和释放的时候,一旦申请或者释放失败是会抛出异常的,一旦抛出异常,内存没有及时的释放,就会导致内存泄漏。就比如如下的场景:

复制代码
void func()
{
	int* arr1 = new int[10];
	int* arr2 = new int[536870911];

	try
	{
		int x, y;
		std::cin >> x >> y;
		std::cout << division(x, y) << std::endl;
	}
	catch (...)
	{
		std::cout << "delete[] : " << arr1 << std::endl;
		delete[] arr1;
		std::cout << "delete[] : " << arr2 << std::endl;
		delete[] arr2;
		throw;
	}
	
	std::cout << "delete[] : " << arr1 << std::endl;
	delete[] arr1;

	std::cout << "delete[] : " << arr2 << std::endl;
	delete[] arr2;
}

当我们的arr1申请内存成功之后,arr2申请内存时,由于内存不够,导致new抛出了异常,这就会让main函数中的catch对其进行捕获,这就会导致arr1申请的内存资源没有得到释放,所以这里是不安全的,另外如果我们在线程中pthread_mutex_lock 和pthread_mutex_unlock之间如果抛出了异常,也会导致死锁,所以我们应该如何解决这些问题呢?我们在下一篇博客的智能指针中好好了解,这里我们只要了解到异常是会导致不安全的情况。

还有就是构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不 完整或没有完全初始化;同时,析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏。

异常规范

  1. 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的 后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
  2. 函数的后面接throw(),表示函数不抛异常。
  3. 若无异常接口声明,则此函数可以抛掷任何类型的异常。

但规范终究还是规范,只要没有强制,规范还是可以不遵守的,这就好比我们在街道开车的时候,机动车道不能走非机动车道,但还是有人为了赶时间,看到非机动车道没人就开进行去了,所以异常这里的规范就比较随意了。我们可以看看。

可以看到,我明明只写了可能会抛出int类型的异常,但是const char*类型的异常它依旧可以接收。

throw()表示这个函数不抛出异常,但是const char*类型的异常它依旧可以接收。

所以规范也仅仅只是规范,还是可以不遵守的。

自定义异常体系

现在假如现在有10个人要合理开发一个程序,让每一个去实现一部分功能,核心成员完成整体的框架,但是每一个在实现功能的时候,都可能会抛出异常,这让最外面负责接收异常的人就非常的痛苦了,因为每多抛出一个异常,他就要增加一个对应的catch代码进行捕捉,十分的头疼。

为了解决这一问题,可以构建一个统一的异常体系:定义一个异常基类 Exception,让各个模块在抛出异常时都继承该基类并抛出其派生类对象。这样,在顶层只需通过 catch(const Exception& e) 即可捕获所有类型的异常,并借助虚函数实现多态,根据实际对象类型调用对应的 what() 方法获取具体错误信息。这种设计不仅降低了模块之间的耦合,还提升了代码的扩展性与可维护性,当系统新增异常类型时,无需修改原有的捕获逻辑。

复制代码
class Exception
{
public:
	Exception(const std::string& errmsg, int id)
		:_errmsg(errmsg)
		, _id(id)
	{}
	virtual std::string what() const
	{
		return _errmsg;
	}
protected:
	std::string _errmsg;
	int _id;
};
class SqlException : public Exception
{
public:
	SqlException(const std::string& errmsg, int id, const std::string& sql)
		:Exception(errmsg, id)
		, _sql(sql)
	{}
	virtual std::string what() const
	{
		std::string str = "SqlException:";
		str += _errmsg;
		str += "->";
		str += _sql;
		return str;
	}
private:
	const std::string _sql;
};

class HttpServerException : public Exception
{
public:
	HttpServerException(const std::string& errmsg, int id, const std::string& type)
		:Exception(errmsg, id)
		, _type(type)
	{}
	virtual std::string what() const
	{
		std::string str = "HttpServerException:";
		str += _type;
		str += ":";
		str += _errmsg; return str;
	}
private:
	const std::string _type;
};

void SQLMgr()
{
	srand(time(0));
	if (rand() % 7 == 0)
	{
		throw SqlException("权限不足", 100, "select * from name = '张三'");
	}
	//throw "xxxxxx";
}

void HttpServer()
{
	// ...
	srand(time(0));
	if (rand() % 3 == 0)
	{
		throw HttpServerException("请求资源不存在", 100, "get");
	}
	else if (rand() % 4 == 0)
	{
		throw HttpServerException("权限不足", 101, "post");
	}
	SQLMgr();
}
int main()
{
	while (1)
	{
		Sleep(500);
		try {
			HttpServer();
		}
		catch (const Exception& e) // 这里捕获父类对象就可以
		{
			// 多态
			std::cout << e.what() << std::endl;
		}
		catch (...)
		{
			std::cout << "Unkown Exception" << std::endl;
		}
	}
	return 0;
}

C++异常的本质是为了改变程序处理错误的方式 。它让错误不再被埋在一层层返回值里,而是被明确地抛出来、传递出去,直到被真正处理。但异常也不是"万能解药"。

用得好,它是解耦利器;用不好,它就是一颗随时引爆的雷------资源泄漏、逻辑混乱、程序失控,往往都从这里开始。

所以关键不在于"会不会用异常",而在于:你是否真的理解------

异常发生时,程序到底在做什么?资源有没有被正确管理?错误有没有被合理接住?

相关推荐
我头发多我先学2 小时前
C++ AVL 树:平衡原理到完整实现(自平衡二叉搜索树)
开发语言·数据结构·c++·算法
啊我不会诶2 小时前
2025浙江省赛补题
c++·算法
郝学胜-神的一滴2 小时前
epoll 边缘触发 vs 水平触发:从管道到套接字的深度实战
linux·服务器·开发语言·c++·网络协议·unix
cpp_25012 小时前
P1877 [HAOI2012] 音量调节
数据结构·c++·算法·动态规划·题解·洛谷·背包dp
dragen_light2 小时前
1.ROS2-Install
c++·python·ros
不知名的老吴2 小时前
编程初体验之句柄的概念及使用示例
c++
木子墨5162 小时前
LeetCode 热题 100 精讲 | 矩阵与图论进阶篇:矩阵置零 · 螺旋矩阵 · 旋转图像 · 搜索二维矩阵 II · 岛屿数量 · 腐烂的橘子
c++·算法·leetcode·矩阵·力扣·图论
stolentime2 小时前
线段树套?——洛谷P7312 [COCI 2018/2019 #2] Sunčanje题解
c++·算法·图论·洛谷
EverestVIP2 小时前
c++ 的terminate()函数
c++