💬 :如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区畅所欲言!我们一起学习、共同成长~!
👍 :如果你觉得这篇文章还不错,不妨顺手点个赞、加入收藏,并分享给更多的朋友噢~!
1. C 语言传统的错误处理方式
C 语言在实际应用中,基本采用返回错误码的方式处理错误。在部分非常严重错误的情形下,使用终止程序的方式。
1.1 返回错误码
- 实现方式:系统中许多库的接口函数通过将错误码存储在
errno中来表示错误。
示例:
#include <stdio.h> #include <errno.h> #include <string.h> int main() { FILE* file = fopen("nonexistent_file.txt", "r"); if (file == NULL) { // 打印错误信息 fprintf(stderr, "打开文件时出错: %s\n", strerror(errno)); return errno; } fclose(file); return 0; }- 缺陷:程序员需自行查找错误码对应的错误信息。
1.2 终止程序
- 实现方式:借助
assert等手段实现。
示例:
#include <stdio.h> #include <assert.h> double divide(double a, double b) { // 传入的表达式为假时,程序会终止并输出错误信息 assert(b != 0); return a / b; } int main() { double result = divide(10.0, 0.0); printf("结果: %f\n", result); return 0; }- 缺陷:用户体验差,当出现内存错误、除零错误等情况时,程序会直接终止运行。
2.C++ 异常
2.1 异常的概念
异常是 C++ 中处理错误的机制。当函数遇到自身无法处理的错误时,会抛出异常给直接调用者。若直接调用者也无法处理,异常便会沿着调用链向上层传递,直至被能处理该错误的调用者捕获并处理。
2.2 相关关键字
| 关键字 | 作用 | 使用场景 | 特点 |
|---|---|---|---|
| throw | 抛出异常 | 当程序中出现问题,函数无法处理时 | 无,直接抛出指定类型的异常对象,异常对象类型可为内置 / 自定义类型 |
| try | 界定可能抛出异常的代码范围 | 将可能会抛出异常的代码放在 try 块中 | 后面通常跟随一个或多个 catch 块,用于捕获 try 块中抛出的异常 |
| catch | 捕获异常 | 在需要对异常进行处理的位置 | 可设置多个 catch 块,每个块可捕获特定类型的异常 |
throw / try / catch 基本语法
throw 异常对象;try { // 可能抛出异常的代码 }catch (异常类型 异常对象名) { // 处理异常的代码 }2.4 异常的使用
2.4.1 异常的抛出和捕获
2.4.1.1 异常的抛出和匹配原则
- (1)对象类型决定处理代码:异常通过抛出对象引发,异常对象的类型决定了应该激活哪个
catch的处理代码。 - (2)就近匹配原则:在调用链中,被选中的
catch处理代码是与异常对象类型匹配且离抛出异常位置最近的那一个。 - (3)异常对象拷贝机制:抛出异常对象后,会生成一个该异常对象的拷贝。这个拷贝的临时对象在被
catch处理后销毁(其机制类似于函数的传值返回)。 - (4)通用捕获情况:
catch能够捕获任意类型的异常,不过使用该方式无法明确具体的错误信息。 - (5)派生类对象捕获例外:实际应用中,抛出和捕获的匹配原则存在例外情况——可以抛出派生类对象,使用基类的
catch块进行捕获,这种方式在实际编程中非常实用。
2.4.1.2 函数调用链中异常栈展开的匹配原则
沿着调用链查找匹配catch子句的过程被称作栈展开。
- (1) 首先检查
throw操作是否处于try块内部。若处于try块内,则开始查找与之匹配的catch语句。若存在匹配的catch语句,程序将跳转到该catch块进行异常处理。 - (2) 若在当前
try块对应的所有catch语句中,没有找到与抛出异常相匹配的catch块,程序会退出当前函数栈。然后,在调用当前函数的函数栈中继续查找匹配的catch语句。 - (3) 若沿着调用链不断查找,直至到达
main函数栈,依然没有找到匹配的catch语句,程序将终止运行。 - (4) 为了防止因存在未捕获的异常而导致程序直接终止,在实际编程中,通常需要在异常处理的最后添加
catch(...){} 语句,用以捕获任意类型的异常。 - (5) 当程序找到匹配的
catch子句并完成异常处理后,会继续执行该catch子句后面的代码。
2.4.1.3 示例
#include <iostream> double divide(double dividend, double divisor) { if (divisor == 0) { // 若除数为 0,抛出一个字符串类型的异常 throw "Error: Division by zero!"; } // 若除数不为 0,进行除法运算并返回结果 return dividend / divisor; } int main() { double num1, num2; std::cout << "请输入被除数: "; std::cin >> num1; std::cout << "请输入除数: "; std::cin >> num2; try { double result = divide(num1, num2); std::cout << "结果: " << result << std::endl; } catch (const char* error) { // 捕获抛出的字符串类型异常,并输出错误信息 std::cerr << error << std::endl; } return 0; }2.4.2 异常的重新抛出
单个catch块可能无法彻底处理异常。鉴于此,在catch块捕获到异常后,可先进行校正处理,再
重新抛出异常传递给更上层函数处理。
校正处理可能涵盖
- 资源清理(如释放动态分配的内存、关闭文件句柄或网络连接等)、
- 数据修复(将数据恢复到某个已知的正确状态)、
- 日志记录(记录异常的详细信息,以便后续分析)等操作。
示例:
#include <iostream> double Division(int a, int b) { if (b == 0) { throw "Division by zero condition!"; } return (double)a / (double)b; } // 中间函数,捕获异常并进行校正处理后重新抛出 void Func() { int* array = new int[10]; try { int len, time; std::cout << "请输入两个整数(用空格分隔): "; std::cin >> len >> time; std::cout << "除法结果: " << Division(len, time) << std::endl; } catch (...) { // 资源清理 std::cout << " Func 函数中捕获到异常,释放数组内存: delete [] " << array << std::endl; delete[] array; throw; } std::cout << "正常情况释放数组内存: delete [] " << array << std::endl; delete[] array; } // 更上层函数,调用 Func 函数 void Upper() { try { Func(); } catch (...) { // 日志记录 std::cout << " Upper 函数中重新捕获到异常,输出提示信息后,再次重新抛出" << std::endl; throw; } } int main() { try { Upper(); } catch (const char* errmsg) { std::cout << " main 函数中最终捕获到异常: " << errmsg << std::endl; } return 0; }Division函数中,"Division by zero condition!"是一个字符串字面量,C++ 中字符串字面量的类型就是const char* 。
const char* errmsg声明了一个名为errmsg的参数,errmsg会被初始化为抛出的异常对象,即指向"Division by zero condition!"字符串的指针。虽然errmsg是一个指针,但从功能上它可以看作是对抛出的异常对象的一种引用。通过该指针,我们可以访问和操作异常对象所代表的字符串内容。
2.4.3 异常安全
2.4.3.1 构造函数
构造函数用于对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或未完全初始化。
2.4.3.2 析构函数
析构函数主要用于资源清理,最好不要在析构函数中抛出异常,否则可能导致资源泄漏(如内存泄漏、句柄未关闭等)。
2.4.3.3 RAII
在 C++ 编程里,异常的出现容易引发资源泄漏问题。比如在执行new和delete操作时抛出异常,会造成内存泄漏;在lock和unlock之间抛出异常,会导致死锁。
为有效解决这些问题,C++ 广泛采用 RAII(资源获取即初始化)技术。
关于 RAII 的详细内容,将在智能指针部分深入讲解。
2.4.4 异常规范
2.4.4.1 异常类型继承
抛出的异常类型应继承自同一个基类,这样能通过捕获基类异常来处理所有派生类异常。
2.4.4.2函数异常声明
函数需明确声明是否抛出异常以及可能抛出的异常类型。
- 不抛出异常声明
throw()跟在函数后面,表示该函数不会抛出异常。
返回类型 函数名(参数列表) throw() { // 函数体 }C++11 引入noexcept关键字,置于函数后同样表明函数不会抛出异常。
返回类型 函数名(参数列表) noexcept { // 函数体 }- 无异常声明情况:若函数没有异常接口声明,意味着该函数可以抛出任何类型的异常。
- 指定异常类型:在函数后面接
throw (类型),列出该函数可能抛出的所有异常类型。
返回类型 函数名(参数列表) throw(异常类型1, 异常类型2, ...) { // 函数体 } // 如 void fun() throw(A,B,C,D) // 该函数会抛出A/B/C/D中的某种类型的异常3. C++ 标准库的异常体系
C++ 提供了一系列标准的异常,定义在<exception>中,可在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的。
| 异常 | 描述 |
|---|---|
| std::exception | 该异常是所有标准 C++ 异常的父类。 |
| std::bad_alloc | 该异常可以通过 new 抛出。 |
| std::bad_cast | 该异常可以通过 dynamic_cast 抛出。 |
| std::bad_exception | 这在处理 C++ 程序中无法预期的异常时非常有用。 |
| std::bad_typeid | 该异常可以通过 typeid 抛出。 |
| std::logic_error | 理论上可以通过读取代码来检测到的异常。 |
| std::domain_error | 当使用了一个无效的数学域时,会抛出该异常。 |
| std::invalid_argument | 当使用了无效的参数时,会抛出该异常。 |
| std::length_error | 当创建了太长的 std::string 时,会抛出该异常。 |
| std::out_of_range | 该异常可以通过方法抛出,例如 std::vector 和 std::bitset<>::operator。 |
| std::runtime_error | 理论上不可以通过读取代码来检测到的异常。 |
| std::overflow_error | 当发生数学上溢时,会抛出该异常。 |
| std::range_error | 当尝试存储超出范围的值时,会抛出该异常。 |
| std::underflow_error | 当发生数学下溢时,会抛出该异常。 |
4. 自定义异常体系
4.1 C++标准库/自定义异常体系的使用总结
- 实际开发
- 标准库异常体系:一些小型项目或对异常处理要求不是特别定制化的场景中较为常用。如,使用标准库容器(如
std::vector)、智能指针等时,它们抛出的标准库异常(如std::out_of_range)能直接处理常见错误,方便快捷,且无需额外定义异常类。同时,在一些跨团队协作项目中,如果大家遵循统一规范,使用标准库异常体系可以让代码具有更好的通用性和可维护性。 - 自定义异常体系:在大型项目、企业级开发中更为常用。很多公司会自己定义一套异常继承体系以规范异常管理。大型项目业务逻辑复杂,标准库异常难以精准描述业务层面错误。此外,在一些对代码架构和异常管理要求严格的项目中,自定义异常体系有助于规范异常抛出和捕获 。
- 标准库异常体系:一些小型项目或对异常处理要求不是特别定制化的场景中较为常用。如,使用标准库容器(如
- 个人练习:前期多使用标准库异常体系,随着能力提升和练习项目复杂度增加,自定义异常体系使用会增多。
4.2 示例
服务器开发中常用的异常继承体系:
#include <iostream> #include <string> #include <thread> #include <chrono> #include <cstdlib> #include <ctime> 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 CacheException : public Exception { public: CacheException(const std::string& errmsg, int id) : Exception(errmsg, id) {} virtual std::string what() const { std::string str = "CacheException:"; str += _errmsg; return str; } }; 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() { std::srand(std::time(0)); if (std::rand() % 7 == 0) { throw SqlException("权限不足", 100, "select * from name = '张三'"); } //throw "xxxxxx"; } void CacheMgr() { std::srand(std::time(0)); if (std::rand() % 5 == 0) { throw CacheException("权限不足", 100); } else if (std::rand() % 6 == 0) { throw CacheException("数据不存在", 101); } SQLMgr(); } void HttpServer() { // ... std::srand(std::time(0)); if (std::rand() % 3 == 0) { throw HttpServerException("请求资源不存在", 100, "get"); } else if (std::rand() % 4 == 0) { throw HttpServerException("权限不足", 101, "post"); } CacheMgr(); } int main() { while (1) { std::this_thread::sleep_for(std::chrono::seconds(1)); try { HttpServer(); } catch (const Exception& e) // 这里捕获父类对象就可以 { // 多态 std::cout << e.what() << std::endl; } catch (...) { std::cout << "Unkown Exception" << std::endl; } } return 0; }5. 异常的优劣
5.1 异常的优点
- 相比返回错误码方式,异常对象定义好后能清晰准确地展示错误信息,甚至包含堆栈调用信息,利于更好地定位程序的 bug。
- 返回错误码方式在函数调用链中,深层函数返回错误时,需层层返回错误,最外层才能拿到错误。而异常体系中,不管是哪个调用函数出错,抛出的异常会直接跳到
main函数中catch捕获的地方,main函数可直接处理错误。
示例:
// 传统错误码方式 int ConnnectSql() { // 用户名密码错误 if (...) return 1; // 权限不足 if (...) return 2; return 0; } int ServerStart() { if (int ret = ConnnectSql() < 0) return ret; int fd = socket(); if (fd < 0) return errno; return 0; } int main() { if (ServerStart() < 0) // 处理错误 ; return 0; }- 很多第三方库都包含异常,如
boost、gtest、gmock等常用库,使用它们需要使用异常。 - 部分函数使用异常更好处理,例如构造函数没有返回值,不方便使用错误码方式处理;像
T& operator[]这样的函数,若pos越界,只能使用异常或者终止程序,无法通过返回值表示错误。
5.2 异常的缺点
- 异常会使程序的执行流乱跳,且非常混乱,运行时出错抛异常就会乱跳,这会增加跟踪调试和分析程序的难度。
- 异常会有一些性能开销,但在现代硬件速度较快的情况下,该影响基本可忽略不计。
- C++ 没有垃圾回收机制,资源需自己管理,有了异常容易导致内存泄漏、死锁等异常安全问题,需要使用 RAII 处理资源管理问题,学习成本较高。
- C++ 标准库的异常体系定义得不好,导致大家各自定义异常体系,比较混乱。
- 异常需规范使用,否则后果严重,随意抛异常会让外层捕获的用户难以处理。
5.3 总结
异常总体而言利大于弊,所以在工程中鼓励使用异常。
另外,面向对象的语言基本都用异常处理错误,这是大势所趋。