C++初学者指南-3.自定义类型(第一部分)-异常
文章目录
- C++初学者指南-3.自定义类型(第一部分)-异常
- 简介
- 什么是异常?
- 第一个示例
- 用途:报告违反规则的行为
- 异常的替代方案
- 标准库异常
- 处理
- 问题和保证
- 资源泄露
- 使用 RAII 避免内存泄漏!
- 析构函数:不要让异常逃脱!
- 异常保证
- 无抛出异常保证:noexcept (C++11)
简介
什么是异常?
可以在调用层次结构中向上抛出的对象。
- 抛出将控制权转移回当前函数的调用方
- 它们可以通过try…catch块捕获/处理
- 如果不处理,异常会向上传播,直到它们到达 main
- 如果main中没有处理异常,将会调用std::terminate
- std::terminate 的默认行为是中止程序
第一个示例
异常的最初动机是报告构造函数未能正确初始化对象,即未能建立所需的类不变量(构造函数没有可用于错误报告的返回类型)。
#include <stdexcept> // standard exception types
class Fraction {
int numer_;
int denom_;
public:
explicit constexpr
Fraction (int numerator, int denominator):
numer_{numerator}, denom_{denominator}
{
if (denom_ == 0)
throw std::invalid_argument{"denominator must not be zero"};
}
…
};
int main () {
try {
int d = 1;
std::cin >> d;
Fraction f {1,d};
…
}
catch (std::invalid_argument const& e) {
// deal with / report error here
std::cerr << "error: " << e.what() << '\n';
}
…
}
运行上面代码
用途:报告违反规则的行为
- 前提条件违规
- 前提条件 = 关于输入的期望(有效函数参数)
- 违规示例: 越界容器索引/平方根为负数
- 宽契约函数在使用其输入值之前执行前置条件检查
在性能关键的代码中,如果传入的参数已经知道是有效的,那么人们不想支付输入有效性检查的成本,因此通常不会使用这些方法。
- 未能建立/保持不变量
- 公共成员函数无法设置有效的成员值
- 内存不足,向量vector增长失败
- 后置条件违规
- 后置条件 = 关于输出的期望值(返回值)
- 违规=函数未能产生有效的返回值或损坏全局状态
- 例子:
- 构造函数失败
- 无法返回除以零的结果
异常的优点和缺点
优点1:将错误处理代码与业务逻辑分离
优点2:错误处理集中化(在调用链的更高层)
优点3:现在,当没有抛出异常时,性能影响可以忽略不计
缺点1:但是,抛出异常 时通常会影响性能
缺点2:由于额外的有效性检查而影响性能
缺点3:容易产生资源/内存泄漏(更多见下文)
异常的替代方案
输入值无效(违反前提条件)
- 窄契约函数:在传递参数之前确保参数有效
- 使用可以排除无效值的参数类型
- 如今这是首选以获得更好的性能
未能建立/保留不变量
- 错误状态/标志
- 将对象设置为特殊的无效值/状态
无法返回有效值(违反后置条件)
- 通过单独的输出参数(引用或指针)返回错误代码
- 返回特殊的无效值
- 使用特殊的词汇类型,可以包含有效结果,也可以什么都不包含,就像C++17的std::optional或Haskell的Maybe
标准库异常
异常是 C++ 标准库使用继承的少数地方之一:
所有标准异常类型都是std::exception的子类型。
std::exception
↑ logic_error
| ↑ invalid_argument
| ↑ domain_error
| ↑ length_error
| ↑ out_of_range
| …
↑ runtime_error
↑ range_error
↑ overflow_error
↑ underflow_error
…
try {
throw std::domain_error{
"Error Text"};
}
catch (std::invalid_argument const& e) {
// 仅仅处理 'invalid_argument'异常
…
}
// 捕捉其它所有异常
catch (std::exception const& e) {
std::cout << e.what()
// prints "Error Text"
}
一些标准库容器提供了宽契约函数,通过抛出异常来报告无效的输入值:
std::vector<int> v {0,1,2,3,4};
// 窄契约:不检查以获取最大性能
int a = v[6]; // 未定义行为
// 宽契约:检查是否超范围
int b = v.at(6); // throws std::out_of_range
处理
重新抛出异常
try {
// potentially throwing code
}
catch (std::exception const&) {
throw; // re-throw exception(s)
}
捕获所有异常
try {
// potentially throwing code
}
catch (...) {
// handle failure
}
集中异常处理!
- 如果同样的异常类型在许多不同的地方被抛出,可以避免代码重复。
- 对于将异常转换为错误代码很有用
void handle_init_errors () {
try { throw; // re-throw! }
catch (err::device_unreachable const& e) { … }
catch (err::bad_connection const& e) { … }
catch (err::bad_protocol const& e) { … }
}
void initialize_server (…) {
try {
…
} catch (...) { handle_init_errors(); }
}
void initialize_clients (…) {
try {
…
} catch (...) { handle_init_errors(); }
}
问题和保证
资源泄露
几乎任何一段代码都可能抛出异常导致对 C++ 类型和库的设计产生重大影响。
如果与以下内容一起使用,则可能是资源/内存泄漏的潜在来源
- 进行自己的内存管理的外部 C 库
- (设计不佳)不使用 RAII 进行自动资源管理的 C++ 库
- (设计不佳)在销毁时不清理资源的类型
示例:由于 C 风格的资源处理而导致的泄漏
即,两个单独的函数用于资源初始化(连接)和资源终止(断开连接)。
void add_to_database (database const& db, std::string_view filename) {
DBHandle h = open_dabase_conncection(db);
auto f = open_file(filename);
// 如果 "open_file"抛出异常,则链接不会断开!
// do work…
close_database_connection(h);
// ↑ 如果"open_file"抛出了异常不会执行上面代码
}
使用 RAII 避免内存泄漏!
RAII 又是什么?
- 构造函数:资源获取
- 析构函数:资源释放/终结
如果抛出异常:
- 局部作用域中的对象被销毁:被调用的析构函数
- 使用 RAII:正确释放资源
class DBConnector {
DBHandle handle_;
public:
explicit
DBConnector (Database& db):
handle_{make_database_connection(db)} {}
~DBConnector () { close_database_connection(handle_); }
// 使connector不能复制:
DBConnector (DBConnector const&) = delete;
DBConnector& operator = (DBConnector const&) = delete;
};
void add_to_database (database const& db, std::string_view filename) {
DBConnector(db);
auto f = open_file(filename);
// 如果 'open_file' 抛出异常 ⇒ 连接关闭!
// do work normally…
} // 连接关闭了!
如果你需要使用一个(比如来自C语言的)库,这个库采用独立的初始化和资源释放函数,那么就编写一个RAII包装器。
通常,如果无法控制引用的外部资源,将包装器设为不可复制(删除复制构造函数和复制赋值运算符)也是有意义的。
析构函数:不要让异常逃脱!
… 否则资源可能会泄露!
class E {
public:
~E () {
// throwing code ⇒ BAD!
}
…
};
class A {
// some members:
G g; F f; E e; D d; C c; B b;
…
};
如果对象e析构时抛出异常的话会导致 f 和 g 对象的析构函数没有被调用。
在析构函数中: 捕获可能引发异常的代码!
class MyType {
public:
~MyType () { …
try {
// y throwing code…
} catch ( /* … */ ) {
// handle exceptions…
} …
}
};
异常保证
如果引发异常:
不能保证
任何 C++ 代码都应该默认做出这个假设,除非它的文档另有说明:
- 操作可能会失败
- 资源可能泄露
- 可能会破坏不变性(= 成员可能包含无效值)
- 部分执行失败的操作可能会导致副作用(例如输出)
- 异常可能会向外传播
基本保正
- 不变量被保留,没有资源泄漏
- 所有成员都将包含有效值
- 执行失败操作的部分可能会导致一些副作用(例如,值可能已写入文件)
这是你最起码的目标!
强保证(提交或回滚语义)
- 操作可能会失败,但不会产生明显的副作用
- 所有成员都保留其原值
内存分配容器应该提供这一保证,即,如果在增长过程中内存分配失败,容器应保持有效和不变。
无抛出异常保证(最强)
- 保证操作成功
- 外部看不到任何异常(要么没有抛出异常,要么在内部被捕获了)
- 使用 noexcept 关键字进行记录和强制执行
在高性能代码和资源受限的设备上,首选此功能。
无抛出异常保证:noexcept (C++11)
void foo () noexcept { … }
- ‘foo’ 承诺永远不会抛出异常或让任何异常逃逸
- 如果一个异常从一个 noexcept 函数中逃逸了,程序会被终止
好好想想,你能不能遵守不抛出异常的承诺!
- noexcept是函数的接口的一部分(甚至是自C++17函数类型的一部分)
- 稍后将不抛出异常的函数更改为抛出异常的函数可能会破坏那些依赖不必处理异常的调用代码
有条件noexcept
A noexcept( expression ) | 如果表示式为真则声明A不抛出异常 |
A noexcept( noexcept( B ) ) | 如果B为不抛出异常则声明A也不抛出异常 |
默认情况下为 noexcept(true)
都是隐式声明的特殊成员
- 默认构造函数
- 析构函数
- 复制构造函数, 移动构造函数
- 复制赋值运算符、移动赋值运算符
- 继承的构造函数
- 用户定义的析构函数
以上这些都是默认noexcept(true)
除非
- 他们需要调用 noexcept(false) 的函数
- 明确的声明另有说明
附上原文地址
如果文章对您有用,请随手点个赞,谢谢!^_^