一篇文章教会你什么是C++异常
- C语言传统的处理错误的方式
- C++异常概念
- 异常的使用
- 自定义异常体系
- C++标准库的异常体系
-
- [1. std::exception](#1. std::exception)
- [2. std::bad_alloc](#2. std::bad_alloc)
- [3. std::bad_cast](#3. std::bad_cast)
- [4. std::bad_typeid](#4. std::bad_typeid)
- [5. std::bad_exception](#5. std::bad_exception)
- [6. std::logic_error](#6. std::logic_error)
-
- [6.1 std::invalid_argument](#6.1 std::invalid_argument)
- [6.2 std::domain_error](#6.2 std::domain_error)
- [6.3 std::length_error](#6.3 std::length_error)
- [6.4 std::out_of_range](#6.4 std::out_of_range)
- [7. std::runtime_error](#7. std::runtime_error)
-
- [7.1 std::range_error](#7.1 std::range_error)
- [7.2 std::overflow_error](#7.2 std::overflow_error)
- [7.3 std::underflow_error](#7.3 std::underflow_error)
- 异常的优缺点
C语言传统的处理错误的方式
在传统的C语言中,错误处理通常采用以下方法:
断言检查
c
#include <stdio.h>
#include <assert.h>
int main() {
int x = 5;
int y = 7;
// 检查条件,如果条件不满足,程序会终止并输出错误信息
assert(x == y); // 这个例子会触发断言失败,因为x不等于y
printf("After assert\n");
return 0;
}
在这个例子中,assert(x == y)
会检查条件 x == y
是否为真。如果条件为假,程序会停止执行,并输出一条错误信息,显示失败的条件,以及在代码中的位置。
assert
在调试时非常有用,但在发布产品版本时,默认情况下通常会被禁用。这是因为在生产环境中,终止程序并输出错误信息可能不是一个良好的做法。- 在开发阶段,
assert
可以帮助开发者快速定位代码中的问题,但不应该被用于处理预期可能发生的错误,如文件打开失败、内存分配失败等情况。对于这些情况,通常应该使用其他错误处理机制。
返回值检查
在C语言中,函数通常返回一个表示操作成功与否的值。例如,标准库函数fopen()
用于打开文件,如果成功打开文件,它将返回一个指向文件的指针,否则返回NULL
。所以,调用这个函数后需要检查返回的指针是否为NULL
,以确定文件是否成功打开。
c
FILE *file = fopen("example.txt", "r");
if (file == NULL) {
// 处理文件打开失败的情况
} else {
// 文件成功打开,可以进行读取或写入操作
// 记得在结束后关闭文件:fclose(file);
}
全局错误码
在C中,有时候会使用全局变量来存储错误码。例如,标准库中的errno
是一个表示发生错误类型的全局变量。函数会将错误码写入errno
,然后调用方可以根据这个值判断是否发生了错误。
c
#include <stdio.h>
#include <errno.h>
int main() {
FILE *file = fopen("example.txt", "r");
if (file == NULL) {
printf("Error number: %d\n", errno);
// 可以使用 perror("fopen") 打印具体错误信息
} else {
// 文件成功打开,可以进行读取或写入操作
// 记得在结束后关闭文件:fclose(file);
}
return 0;
}
设置全局错误处理函数
C语言中还允许你设置一个全局的错误处理函数,通过signal
函数可以设置程序在遇到某些错误信号时调用特定的函数进行处理。
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void error_handler(int signal) {
// 处理特定信号的代码
printf("Error signal %d occurred\n", signal);
exit(signal);
}
int main() {
signal(SIGSEGV, error_handler); // 捕获段错误信号
// 其他代码
return 0;
}
C++异常概念
在 C++ 中,异常是一种用于处理程序运行时错误和异常情况的机制。当发生异常时,程序可以选择性地捕获和处理这些异常,避免导致程序崩溃或出现未定义行为。异常提供了一种更结构化的方式来处理错误,与传统的错误码或返回值不同。
基本概念
-
抛出异常(Throwing an exception) :当程序执行过程中遇到错误或异常情况时,可以使用
throw
关键字抛出异常。异常通常是某种特定类型的对象,用于传递关于错误的信息。cppthrow MyException("Something went wrong");
-
捕获异常(Catching an exception) :使用
try-catch
块来捕获并处理异常。try
块包含可能抛出异常的代码,而catch
块用于捕获并处理特定类型的异常。cpptry { // 可能抛出异常的代码 } catch (MyException& e) { // 处理 MyException 类型的异常 std::cerr << "Caught an exception: " << e.what() << std::endl; }
-
异常传递(Exception propagation) :如果在
try
块内抛出异常,程序将尝试匹配对应的catch
块来处理异常。如果没有匹配到相应的catch
块,异常会被传递到调用栈的上一层,直到找到匹配的处理器或者导致程序终止。
注意事项
- 异常类型 :通常,异常是某个特定类型的对象。C++允许使用任何类型(包括内置类型和自定义类型)作为异常,但最佳实践是使用继承自
std::exception
的自定义异常类。 - 资源管理:异常可以破坏程序正常的控制流程。在使用动态分配的资源时(如内存或文件句柄),确保使用资源管理技术(比如 RAII)来避免资源泄露。
- 异常成本:抛出和捕获异常会带来一定的性能开销。因此,在性能敏感的代码路径上过度使用异常可能不是最佳选择。
- 异常安全性:在设计和编写代码时,要考虑异常对程序状态的影响。确保当发生异常时,程序状态不会出现不一致或资源泄露。
异常处理是C++中强大而灵活的特性,使得代码更加健壮和易于维护。然而,它需要小心谨慎地使用,特别是在设计高性能或对性能敏感的系统时。
异常的使用
异常的抛出和捕获
异常的抛出和匹配原则
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
- 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)。
- catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。
- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,
使用基类捕获,在实际中非常实用
在函数调用链中异常栈展开匹配原则
- 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。
- 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
- 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
- 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。
cpp
#include <iostream>
using namespace std;
double Division(int a, int b) {
if (b == 0)
throw "Division by zero condition!";
else
return static_cast<double>(a) / static_cast<double>(b);
}
void Func() {
try {
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
catch (const char* errmsg) {
cout << errmsg << endl;
throw; // 重新抛出异常,以便在主函数中捕获
}
}
int main() {
while (true) {
try {
Func();
}
catch (const char* errmsg) {
cout << errmsg << endl;
}
catch (...) {
cout << "未知异常" << endl;
}
}
return 0;
}
在上面的示例中,展现了一个除法函数在进行除0操作时的异常触发抛出和捕获。
异常的重新捕获
有时候单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理
cpp
double Division(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}
void Func()
{
int* array = new int[10];
try {
int len, time;
cin >> len >> time;
cout << Division(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;
}
return 0;
}
这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。所以这里捕获异常后并不处理异常,异常还是交给外面处理,这里捕获了再重新抛出去,但是这样的处理方法还是有很大问题的,后面接触智能指针才能更好的解决这个问题
cpp
#include <iostream>
#include <memory>
double Division(int a, int b) {
// 当b等于0时抛出异常
if (b == 0) {
throw "Division by zero condition!";
}
return static_cast<double>(a) / static_cast<double>(b);
}
void Func() {
// 使用智能指针unique_ptr管理动态分配的数组内存
std::unique_ptr<int[]> array(new int[10]);
try {
int len, time;
std::cin >> len >> time;
std::cout << Division(len, time) << std::endl;
} catch (...) {
std::cout << "发生异常" << std::endl;
// 无需手动管理内存,unique_ptr会处理
throw; // 重新抛出异常以在主函数中捕获
}
// 无需显式删除,unique_ptr会处理
}
int main() {
try {
Func();
} catch (const char* errmsg) {
std::cout << errmsg << std::endl;
}
return 0;
}
在这个改进版本中,使用了 std::unique_ptr
来管理动态分配的数组内存,避免了手动的内存管理操作,确保异常发生时资源能够被正确释放。
异常安全
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用智能指针中的RAII来解决以上问题,后面的文章中我们会详细讲解智能指针。
异常规范
异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数 可能抛掷的所有异常类型。
函数的后面接throw(),表示函数不抛异常。
若无异常接口声明,则此函数可以抛掷任何类型的异常
cpp
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
在现代 C++ 中,也就是C++11之后的标准中,throw
声明已经被视为过时的特性,而且在 C++11 标准之后,它的使用变得越来越不推荐。C++11 引入了 noexcept
关键字,用于指示函数是否可能抛出异常
cpp
// C++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;
自定义异常体系
自定义异常体系在企业中的运用程度取决于具体的项目需求、规模和开发团队的实践。以下是一些关于自定义异常体系在企业中的运用的考虑因素:
- 异常分类和处理: 在大型项目中,通常会有多个模块和子系统,每个子系统可能面临不同的异常情况。自定义异常体系可以帮助将异常细化为不同的类型,使得在处理异常时更具体、更有针对性。
- 错误信息传递: 自定义异常体系可以包含更多有关错误的信息,例如错误代码、错误描述、触发异常的上下文等。这有助于更好地诊断和解决问题。
- 业务逻辑的清晰性: 在大型项目中,有可能涉及到复杂的业务逻辑。通过使用自定义异常,可以使代码更具可读性和可维护性,使得开发人员更容易理解和调试代码。
- 统一的错误处理策略: 自定义异常体系可以促使团队建立统一的错误处理策略,提高代码一致性。这对于维护和协同开发非常有帮助。
- 与第三方库和框架的集成: 在与第三方库和框架集成时,可能需要处理它们可能抛出的异常。自定义异常可以作为中介,使得整个系统的异常处理更加一致。
- 团队经验和偏好: 不同的开发团队可能有不同的经验和偏好,有些团队更喜欢使用标准库的异常,而有些团队则更愿意使用自定义异常体系。这取决于团队的技术文化和项目的特定需求。
总体而言,自定义异常体系在企业中的运用可以提高代码的质量、可维护性和可读性,特别是在大型项目中。然而,这并不意味着在所有情况下都必须使用自定义异常体系。在某些情况下,标准库提供的异常可能已经足够满足需求。最终,选择使用何种异常体系应该基于项目的具体要求和团队的实际情况。
下面是一个简易的服务器开发中通常使用的异常继承体系
cpp
#include<iostream>
#include<Windows.h>
using namespace std;
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg)
, _id(id)
{}
virtual string what() const
{
return _errmsg;
}
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 HttpServerException : public Exception
{
public:
HttpServerException(const string& errmsg, int id, const string& type)
:Exception(errmsg, id)
, _type(type)
{}
virtual string what() const
{
string str = "HttpServerException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
private:
const string _type;
};
void SQLMgr()
{
srand(time(0));
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '鱼佬'");
}
}
void CacheMgr()
{
srand(time(0));
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
SQLMgr();
}
void HttpServer()
{
srand(time(0));
if (rand() % 3 == 0)
{
throw HttpServerException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
int main()
{
while (1)
{
Sleep(100);
try {
HttpServer();
}
catch (const Exception& e)
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
可以看到上面的自定义异常例子中是继承的派生类对象,捕获一个基类
在C++中,通过捕获基类异常对象而不是派生类异常对象的好处主要在于代码的灵活性和简洁性。这种做法被称为基类异常捕获,它有以下几个优势:
- 多态的利用: 当派生类的异常对象被捕获时,基类异常指针或引用可以指向派生类的对象。这允许你使用多态的特性,即便捕获的是基类异常,你仍然可以访问派生类特有的信息。这对于在一个统一的异常处理中处理多种异常类型非常有用。
- 异常处理的一致性: 使用基类异常捕获可以提供更一致的异常处理方式,因为不需要为每个派生类都编写相应的
catch
块。这对于维护和修改代码是更为方便的。 - 简化异常处理逻辑: 使用基类异常捕获可以减少异常处理代码的数量,使得代码更简洁易读。这尤其在大型项目中,有助于降低维护的复杂性。
虽然基类异常捕获有其优势,但在一些情况下,特别是对于特定的异常处理需求,直接捕获派生类异常也是合理的选择。最佳实践取决于具体的应用场景和代码设计目标。
C++标准库的异常体系
C++ 标准库定义了一组异常类,这些异常类被组织成层次结构,形成了一个异常体系。这个体系的根是 std::exception
类,而其他具体的异常类都直接或间接地继承自它。让我们来详细了解一下 C++ 标准库的异常体系:
1. std::exception
std::exception
是 C++ 标准库中异常体系的根类。它定义了一个虚函数 what()
,用于返回异常的描述信息。
cpp
class exception {
public:
exception() noexcept;
virtual ~exception() noexcept;
virtual const char* what() const noexcept;
};
what()
: 返回一个 C 风格的字符串,提供关于异常的描述信息。通常,自定义的异常类应该继承自std::exception
并覆盖这个函数。
2. std::bad_alloc
std::bad_alloc
是用于内存分配失败的异常类,它继承自 std::exception
。
cpp
class bad_alloc : public exception {
public:
bad_alloc() noexcept;
bad_alloc(const bad_alloc& other) noexcept;
virtual const char* what() const noexcept override;
};
what()
: 提供有关内存分配失败的信息。
3. std::bad_cast
std::bad_cast
是用于类型转换失败的异常类,它继承自 std::bad_alloc
。
cpp
class bad_cast : public bad_alloc {
public:
bad_cast() noexcept;
bad_cast(const bad_cast& other) noexcept;
virtual const char* what() const noexcept override;
};
what()
: 提供有关类型转换失败的信息。
4. std::bad_typeid
std::bad_typeid
是用于 typeid
运算符无效的异常类,它继承自 std::bad_alloc
。
cpp
class bad_typeid : public bad_alloc {
public:
bad_typeid() noexcept;
bad_typeid(const bad_typeid& other) noexcept;
virtual const char* what() const noexcept override;
};
what()
: 提供有关typeid
运算符无效的信息。
5. std::bad_exception
std::bad_exception
是一个通用的异常类,用于表示异常规范未匹配的异常,它继承自 std::exception
。
cpp
class bad_exception : public exception {
public:
bad_exception() noexcept;
bad_exception(const bad_exception& other) noexcept;
virtual const char* what() const noexcept override;
};
what()
: 提供有关异常规范未匹配的信息。
6. std::logic_error
std::logic_error
是用于表示逻辑错误的基类,它继承自 std::exception
。
cpp
class logic_error : public exception {
public:
explicit logic_error(const std::string& what_arg);
explicit logic_error(const char* what_arg);
};
what()
: 通常由具体的派生类实现,提供有关逻辑错误的信息。
std::logic_error
是用于表示逻辑错误的基类,它有四个派生类,每个派生类都对应于一种特定的逻辑错误。这四个派生类分别是:
6.1 std::invalid_argument
cpp
class invalid_argument : public logic_error {
public:
explicit invalid_argument(const std::string& what_arg);
explicit invalid_argument(const char* what_arg);
};
invalid_argument
类表示函数参数无效的错误。例如,当一个函数接收到一个无效的参数时,可以抛出这个异常。
6.2 std::domain_error
cpp
class domain_error : public logic_error {
public:
explicit domain_error(const std::string& what_arg);
explicit domain_error(const char* what_arg);
};
domain_error
类表示在数学领域上的错误。例如,当一个函数接收到一个超出其定义域的参数时,可以抛出这个异常。
6.3 std::length_error
cpp
class length_error : public logic_error {
public:
explicit length_error(const std::string& what_arg);
explicit length_error(const char* what_arg);
};
length_error
类表示由于长度错误导致的异常。例如,当试图超过某个容器或字符串的最大长度时,可以抛出这个异常。
6.4 std::out_of_range
cpp
class out_of_range : public logic_error {
public:
explicit out_of_range(const std::string& what_arg);
explicit out_of_range(const char* what_arg);
};
out_of_range
类表示由于超出有效范围导致的异常。例如,当试图访问数组、容器或字符串中的不存在的元素时,可以抛出这个异常。
每个派生类都有两个构造函数,一个接受一个 std::string
类型的参数,另一个接受一个 const char*
类型的参数,用于提供关于异常的描述信息。在实际使用中,可以选择使用适当的派生类来更具体地表示不同的逻辑错误。
7. std::runtime_error
std::runtime_error
是用于表示运行时错误的基类,它继承自 std::exception
。
cpp
class runtime_error : public exception {
public:
explicit runtime_error(const std::string& what_arg);
explicit runtime_error(const char* what_arg);
};
what()
: 通常由具体的派生类实现,提供有关运行时错误的信息。
std::runtime_error
是用于表示运行时错误的基类,它有三个派生类,每个派生类对应于一种特定的运行时错误。这三个派生类分别是:
7.1 std::range_error
cpp
class range_error : public runtime_error {
public:
explicit range_error(const std::string& what_arg);
explicit range_error(const char* what_arg);
};
range_error
类表示由于超出范围导致的异常。例如,当试图使用一个超出有效范围的索引访问数组、容器或字符串时,可以抛出这个异常。
7.2 std::overflow_error
cpp
class overflow_error : public runtime_error {
public:
explicit overflow_error(const std::string& what_arg);
explicit overflow_error(const char* what_arg);
};
overflow_error
类表示由于溢出导致的异常。例如,当进行整数运算导致结果超出了类型的表示范围时,可以抛出这个异常。
7.3 std::underflow_error
cpp
class underflow_error : public runtime_error {
public:
explicit underflow_error(const std::string& what_arg);
explicit underflow_error(const char* what_arg);
};
underflow_error
类表示由于下溢导致的异常。例如,当进行浮点数运算导致结果小于类型能够表示的最小值时,可以抛出这个异常。
这些异常类提供了一个通用的、层次化的异常体系,允许程序员根据需要选择适当的异常类来表示和处理不同类型的错误。在编写自己的异常类时,通常建议继承自 std::exception
或其派生类,以保持与标准库一致的异常体系结构
异常是一种用于处理程序运行时错误的机制,它有一些优点和缺点,具体取决于应用的上下文和设计选择。
异常的优缺点
异常的优点
- 分离错误处理逻辑: 异常允许将错误处理逻辑与正常业务逻辑分离开来。这有助于使代码更清晰,减少错误处理代码与业务逻辑的耦合。
- 集中错误处理: 异常提供了一种集中处理错误的机制。在函数调用链中的任何一层发生错误时,异常可以传递到最合适的地方进行处理,而不需要在每一层都显式检查错误。
- 代码简洁性: 使用异常可以使代码更简洁,因为你不需要在每个函数调用后都检查返回值或错误码。
- 更容易维护: 异常可以提高代码的可维护性,因为错误处理的逻辑不会分散在各个函数中,而是集中在异常处理的地方。
- 适用于异常情况: 异常通常用于处理那些在正常情况下不太可能发生的错误,例如内存分配失败、数组越界等。
异常的缺点
- 性能开销: 异常处理可能引入一些性能开销,尤其是在异常被抛出和捕获的时候。如果异常被滥用,可能会影响程序的性能。
- 可预测性差: 异常使得程序的控制流变得不太可预测。在一些情况下,异常可能会导致代码更难理解和调试。
- 不适合一些应用: 在一些对性能和可预测性要求极高的应用中,例如实时系统或嵌入式系统,异常处理可能不太适用。
- 可能导致资源泄漏: 如果异常发生时,未能适当地释放资源,可能导致资源泄漏,例如未关闭的文件、未释放的内存等。
- 滥用可能导致问题: 异常应该用于处理异常情况,而不应该被滥用用作常规的控制流。滥用异常可能导致代码难以理解和维护。
总的来说,异常是一种强大的错误处理机制,但在使用时需要谨慎。在一些特定的情况下,例如对性能和可预测性要求极高的系统中,开发者可能更倾向于使用传统的错误处理机制。在其他情况下,合理使用异常可以提高代码的清晰度和可维护性。