C++异常:基本语法
目录
程序运行错误处理
C++异常处理机制
try(代码监控)
throw(抛出异常)
catch(异常捕获)
标准异常类
std::exception
std::logic_error
std::runtime_error
std::bad_alloc
std::bad_cast
catch(...)
附录:
C++异常规范
主页🌏:R6bandito_
所属专栏📰:《C++异常》
程序运行错误处理
在我们编写程序时,有时会遇到运行阶段的一些错误从而导致程序无法按流程正常进行下去。为了尽量去规避此种导致程序意外跑飞的情况,需要针对此建立一套处理流程,以便于在程序运行时出现意外情况能够着手进行处理,转危为安。
先来看下面一个C语言例子:
#include <stdio.h>
double divided(double aVal, double bVal) {
return aVal / bVal;
}
int main() {
double result = divided(14.2,3.4);
printf("%.2f\n",result);
return 0;
}
--Output:
4.18
此C程序实现了一个最为简单的除法运算,也是能够正常跑通的。但是很明显这个简单实现是有致命漏洞的:当用户传入的bVal
为0时,也就是说被除数为0,这显然是不符合逻辑的!对于此种除以0的情况,许多编译器通过生成一个无穷大的特殊浮点数来进行处理(打印输出显示:INF
)。
针对这种情况,该怎么去改善?通过写一段提示语,提醒用户不允许向bVal
传入0?这或许是个办法,但也仅仅只是起到提醒作用,还是会有种种理由将其误传入从而导致程序偏离正常运行轨迹。
一种实现是首先对传入的值进行检测,符合条件再进行运算,否则将退出执行。
double divided(double aVal, double bVal) {
if (bVal == 0) {
printf("The bVal can't be Zero\n");
return 0;
}
return aVal / bVal;
}
这当然可行,但是类似于这种检测特征值以及状态码的错误处理方式往往会导致下面的一系列问题:
错误处理的繁琐性:传统的错误处理需要检查每个返回值,并在错误发生时返回给调用者。这种处理方式导致大量的重复代码,且容易遗漏检查步骤。
错误传播困难:当错误需要沿调用栈逐层向上传递时,每一层都要额外编写代码来传递状态,代码量和复杂度大大增加。
缺乏统一性:每个开发者都采用不同的错误处理方式,难以在项目中保持一致的错误处理风格。
C++异常处理机制
在此,我们引入c++中的异常处理机制。C++ 中的异常是一种错误处理机制,用于在程序运行时捕获和处理不可预见的错误(如内存分配失败、文件打开错误等)。
当错误发生时,程序会抛出一个相关异常,该异常会沿调用栈向上传递从而被合适的处理程序将其捕获,对其进行处理,避免程序崩溃。
接下来让我们来看看异常处理的一些基本语法:
try(代码监控)
try {
/*Function Body*/
}
try
形似于一个局部函数,try`块中将放置可能抛出异常的代码。
try
块不可以单独使用,其后必须跟有一个或多个catch
块,否则无法通过编译。当
try
块中抛出异常后,若无对应的catch
块对其进行处理,则std::terminate()
将会被调用并中止掉进程。
throw(抛出异常)
throw [异常类型]
throw
可以抛出多种类型的异常,例如所有的类类型(包括用户自定义类),指针类型,引用类型,及基本的数据类型。
void error_handle() {
//do_something
}
throw 404; //抛出一个整数
throw "Error Occurs"; //抛出一个C风格字符串
throw std::exception(); //抛出一个标准异常类对象
void (*ptr_err_handle)() = error_handle;
throw ptr_err_handle; //抛出一个函数指针
当throw
被调用并抛出异常后,函数会立即跳出作用域,并寻找可供处理的catch
块。被抛出的异常若无对应catch
块处理,则终止进程。
catch(异常捕获)
catch (Exception_Type) {
/*...*/
}
catch
关键字用于捕获抛出的异常,括号中Exception_Type
表示所要捕获的异常的类型,花括号中放置对于异常的处理语句。示例如下:
try {
/*some_other_function */
throw std::runtime_error("Oops!Something went wrong.");
}
catch (std::exception &err) {
std::cout << err.what() << std::endl;
}
使用(...)
可以捕获所有类型的异常。可用于对不可预见的异常进行捕获处理。
try {
/*some_other_function */
}
catch (...) {
std::cout<<"Oops!Unknown exception occured."<<std::endl;
}
因此,上述C代码可以用C++风格修改如下:
auto divided(double aVal, double bVal) {
if (bVal == 0) throw std::logic_error("bVal can't be zero.");
return aVal / bVal;
}
int main() {
try {
auto result = divided(4.2,0.0);
std::cout<<result<<std::endl;
}
catch (const std::logic_error &err) {
std::cout<<err.what()<<std::endl;
}
return 0;
}
将可能引发异常的调用函数用try
进行监控,若参数传递出现了错误,则抛出logic_error
异常,而后在处理程序中打印出错误信息并结束运行。
catch
块中可以再次抛出异常,且再次抛出的异常不用给出其类型。在 catch
块中被抛出异常,它将继承之前捕获的异常的类型和状态。也就是说,再次被抛出的异常类型与第一次被捕获的异常类型是一样的(当然,如果你显示指定了一个类型则将抛出你所指定的类型,而不会发生继承)。
void myfunc2() {
try {
/*Function*/
throw std::runtime_error("Runtime Error");
}
catch (const std::runtime_error &err) {
/*Do some work to handle error*/
throw; /*Re-throw*/
}
}
void myfunc1() {
try {
myfunc2();
}
catch (const std::runtime_error &err) {
std::cout<<err.what()<<std::endl;
}
}
如上代码,myfunc2
中catch
捕获异常后,对其进行了一些初始处理,而后将其再次抛出将其上交给更高一级的catch处理块进行处理。myfunc1
中的catch
将捕获再次抛出的该异常,对其进行相关处理。运行后输出下列结果:
Runtime Error
若将myfunc2中catch块抛出的异常指定为一个int对象:
throw 404;
这个操作将导致myfunc2
中再次抛出一个int类型异常,而myfunc1
与main
(此处代码未给出,但是main
中没有任何catch
处理操作)中均无相应的处理对策,因此std::terminate()
被调用,程序异常终止。
说完基本的异常语法规范,再来聊聊c++中标准的异常类:
标准异常类
标准异常类是 C++ 标准库中定义的一组异常类,用于表示不同类型的异常情况。标准异常类的实现提供了一个统一的接口用于捕获和处理异常,这使得我们用户可以通过编写一个通用的异常处理代码来捕获和处理程序中抛出的各类异常。
std::exception
std::exception
类是C++标准库中用于异常处理的基类。它为异常处理提供了一种结构化的方法,通过捕获、抛出和传递异常,帮助开发者处理程序在运行时可能出现的各种意外情况。
exception
类是所有标准异常类的抽象基类(Base),其下派生出了所有的标准异常类型。
以下为exception
的实现源码:
class exception
{
public:
exception() _GLIBCXX_USE_NOEXCEPT { }
virtual ~exception() _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_USE_NOEXCEPT;
/** Returns a C-style character string describing the general cause
* of the current error. */
virtual const char*
what() const _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_USE_NOEXCEPT;
};
exception() _GLIBCXX_USE_NOEXCEPT { }
:exception类的无参构造函数,被宏_GLIBCXX_USE_NOEXCEPT
标记,意味着不会抛出任何异常。
virtual ~exception()
:虚析构,可被派生类重写。同样被宏_GLIBCXX_TXN_SAFE_DYN
_GLIBCXX_USE_NOEXCEPT
修饰,意味着是线程安全的,且不会抛出任何异常。
virtual const char* what() const
:该类的一个虚方法,可被派生类重写。返回一个C风格的字符串,用于描述异常的原因。用户可派生一个自定义异常处理类,并且重写该方法。
可以看到std::exception
只有默认构造,一般用作一个通用的接口进行异常处理,以及派生更多的异常类,而不是将该类实例化抛出该类的异常。
try {
throw std::exception();
/*一个示例,一般不这么用*/
}
catch (const std::exception &err) {
std::cout<<"exception caught"<<std::endl;
}
下面来看看C++标准库中,exception
的派生类。
std::logic_error
std::logic_error
表示在逻辑上不合理的操作,一般是程序设计上的错误。例如,非法的参数值、除以零等。通常,这些错误可以在编译时或设计时避免。
以下是一些常见的可能会抛出std::logic_error
的操作:
std::vector::resize()
:如果指定的大小超过了容器的最大大小,或者指定的大小小于容器的当前大小,会抛出std::logic_error
异常。
std::map::insert()
:如果插入的元素违反了键的唯一性要求,会抛出std::logic_error
异常。
使用时,一般不会直接抛出logic_error
,而是将其派生出更具体地异常类型来表示不同地错误情况。以下是logci_error
类的一些派生类:
std::invalid_argument
:用于表示函数接收到的参数不符合要求。例如,传递负数到要求正数的函数中。
std::domain_error
:表示输入参数超出了函数定义域,比如对负数调用sqrt
函数。
std::length_error
:表示操作超出了允许的长度,比如在字符串或容器超过最大长度时抛出。
std::out_of_range
:表示索引或其他参数超出了允许的范围,比如访问数组越界。
int main() {
std::vector<int> vec = {1, 2, 3, 4};
try {
std::cout<<vec.at(5)<<std::endl;
}
catch (std::out_of_range &err) {
std::cout<<err.what()<<std::endl;
exit(EXIT_FAILURE);
}
return 0;
}
如上代码,我们在try
块中,使用库函数对动态数组的访问越界,因此将抛出一个std::out_of_range
类异常,在catch
块中进行捕获并处理该异常。
得到如下结果:
invalid vector subscript
std::runtime_error
std::runtime_error
表示运行时错误,这类错误在运行之前通常无法预料,例如文件未找到、硬件故障等。
其派生类如下:
std::range_error
:表示运算结果超出允许的范围。例如,整数溢出或除法结果超过定义的范围。
std::overflow_error
:表示算术运算结果上溢,例如浮点数上溢出。
std::underflow_error
:表示算术运算结果下溢,例如浮点数下溢出。
一般而言,logic_error
说明了该异常可以通过编程进行修复,而runtime_error
则是表明无法避免的问题。
std::bad_alloc
与logic_error
,runtime_error
一样,std::bad_alloc
是exception
的直接派生类,是专门用于内存分配失败的异常类。当new
操作符无法分配请求的内存时,会抛出此异常。
在内存分配密集的场景中,捕获std::bad_alloc
可以帮助程序在内存不足时执行特定的处理逻辑,例如释放不必要的资源或输出错误信息。
其源码如下:
class bad_alloc : public exception
{
public:
bad_alloc() throw() { }
virtual ~bad_alloc() throw();
virtual const char* what() const throw();
};
采用了异常规范的形式(见附录),保证了其构造与析构函数均不会抛出异常,同时重写了what
虚方法,用于反馈错误信息。
示例如下:
struct Big_Data {
double block[250000];
};
int main() {
Big_Data *ptr_b;
try {
ptr_b = new Big_Data[15000]; //bad_alloc
std::cout<<"Memory Allocated Successfully"<<std::endl;
}
catch (std::bad_alloc &err) {
std::cout<<"Exception Occured:"<< err.what() << std::endl;
exit(EXIT_FAILURE);
}
}
--Output:
Exception Occured:bad allocation
上述代码中,我们试图在堆区请求一块很大的内存空间,但是我们的操作系统无法分配请求的内存量,于是便抛出一个std::bad_alloc
异常。
注:在使用new分配失败之后,不要在catch
处理程序块中画蛇添足的再次释放空间。
catch (std::bad_alloc &err) {
delete[] ptr_b; //不要这么做
std::cout<<"Exception Occured:"<< err.what() << std::endl;
exit(EXIT_FAILURE);
}
new本来就没有分配成功,所以才会抛出异常。尝试对这块没有分配成功的空间进行内存释放会导致未定义行为。
对于内存分配失败再说一点:c++中提供了一个开关供用户使用,也就是当内存分配失败时,用户可以选择让其抛出异常,或者不抛出异常,返回一个nullptr,
即:
ptr_b = new (std::nothrow) Big_Data[20000];
此种写法可使其在内存分配失败时,不抛出异常并返回一个nullptr
给ptr_b
。见下列例子:
int main() {
Big_Data *ptr_b;
try {
ptr_b = new (std::nothrow) Big_Data[20000]; //not throw bad_alloc
std::cout<<"FLAG STRING."<<std::endl;
}
catch (std::bad_alloc &err) {
std::cout<<"Exception Occured:"<< err.what() << std::endl;
exit(EXIT_FAILURE);
}
if (ptr_b == nullptr) {
std::cout<<"ptr_b is nullptr.Memory Allocated Failed."<<std::endl;
}
return 0;
}
--Output:
FLAG STRING.
ptr_b is nullptr.Memory Allocated Failed.
上述代码中,分配失败不会抛出异常,因此catch
的处理块也不会被执行,try块的流程也不会在new分配失败后被终止,而是向下走完整个try块。所以有如上的输出结果。
std::bad_cast
当使用dynamic_cast
进行类型转换,且转换失败时会抛出std::bad_cast
异常。主要在RTTI(运行时类型识别)中使用,特别是当多态类型转换失败时,bad_cast
可以捕获此类错误。
对于c++中类型转换运算,详见《C++:类型转换运算》。
见如下示例:
class Base {
public:
virtual ~Base() {}
};
class Derived : public Base {
public:
virtual ~Derived() {}
};
int main() {
Base base;
try {
Derived &ptr_dev = dynamic_cast<Derived &>(base); //throw bad_cast
std::cout<<"Transfer Successfully"<<std::endl;
}
catch (std::bad_cast &err) {
std::cout<<"Exception Occured:"<< err.what() << std::endl;
exit(EXIT_FAILURE);
}
return 0;
}
--Output:
Exception Occured:Bad dynamic_cast!
上述代码,dynamic_cast
转型失败,抛出bad_cast
,随后被catch
块捕获并被处理。
catch(...)
catch(...)
是一个特殊的 catch 块,称为"捕获所有异常"(catch-all)。它可以捕获任何类型的异常,不论是继承自 std::exception
还是其他类型。
其中一个用法是可以在析构函数中吞掉可能抛出的异常,防止异常逃离出析构函数导致未定义行为。
不过catch(...)
虽然可以防止程序崩溃,但由于无法获知异常的类型或信息,不建议在处理细致的异常时使用,可将其作为兜底处理。
class Entity {
public:
Entity() {}
~Entity() {
try {
/*Something work that might be
throwing an exception*/
}
catch (...) {
/*Prevents the exception escaping from
the destructor*/
std::abort();
}
}
};
上述示例,Entity的析构函数中进行了一些可能抛出异常的处理操作,而一旦让异常逃出析构函数,则会导致程序意外终止等未定义行为。因此其中一种办法是使用catch(...)
吞下所有异常。
附录:
C++异常规范
异常规范是指对函数是否会抛出异常的声明,目的是帮助开发者更好地控制异常的行为。其是在c++98中被加入的一项新功能,然而在c++11中却几乎被废弃了。它允许开发者明确地声明某个函数可以抛出哪些类型的异常,甚至可以声明函数不抛出任何异常。
void Function() throw(double) { //表示Function函数只会抛出double类型的异常
/*Function*/
}
以上代码中,
throw
部分便是异常规范,其后可包含类型列表,也可不包含。通过这种方式,函数明确声明了可以抛出的异常类型,如果函数抛出其他类型的异常,程序将调用
std::unexpected()
终止运行。
但由于这种异常规范的实现较复杂,并且限制较多,极少被使用。此外,编译器在优化过程中对异常规范的检查和保证比较困难。因此在c++11后引入了noexcept
关键字。
目前来看,早期的异常规范throw
已经被弃用,在vscode
中使用也被报出错误。
不过仍然是能够通过编译并运行的。
早期的规范大家就做个简单了解吧。
C++11引入了noexcept
关键字,以替代早期的异常规范。noexcept
比throw
的写法更简单,并且适用于更多场景:
void func() noexcept; // 表示 func 不会抛出异常
noexcept
关键字的出现,使得C++异常处理更加简洁,同时带来了一定的性能提升。通过合理使用noexcept
,开发者可以创建更高效、更稳定的代码。
🌹🌹(●'◡'●)