C++ 异常 - 类和异常
错误处理是程序开发的一个重点和难点,一个安全的程序中错误处理的代码可能和其他逻辑代码一样多;错误又大致分为逻辑和系统两个来源:前者是由于用户输入错误,后者来源于外部环境;错误的代码本身也是一种错误;错误的处理方式又有许多派系和方式:C 风格的 if 条件判断,goto
和断言,Ada 风格的异常,Go 风格的 error 接口,Rust 风格的 Result。最致命的是 C++ 支持上述所有风格(虽然在语法上可能有些不便),并且错误处理这个话题下的内容可能足够写一本书,导致作为系统编程语言而广泛使用的 C++ 在错误处理方面有着高度的复杂性和争议。本文是本人集思广益总结的介绍 C++ 错误处理相关概念和如何编写异常安全代码的文章。
目录:
类和异常
C++ 的 class
可以用 C 的 struct
和普通函数实现 1,但是 C++ 的 class
在代码实现的基础上提出了一种更高级的抽象概念:类。
类具有不变性(invariant) 2 3:类的成员互相依赖(或者通过指针持有某个资源),如果擅自修改某个成员,会导致对象(或者内存泄漏)失效,产生错误。
C++ 构造函数具有以下概念,性质和意义4:
- 类需要对成员的访问权限进行控制,即某些成员不应该被暴露
- 因为 1,所以必须使用成员函数才能控制这些成员,因此构造函数是成员函数
- 传统 C/C++ 中,一个对象(变量)必须被赋值才有意义,读取未被赋值的变量是未定义行为
- 由于 C++ 主张对象尽可能在声明的同时被赋值 5 6 ,所以形如
int i = 1;
这种语句被称为声明和初始化语句,而不是声明和赋值 - 为了让类也支持声明时赋值,C++ 产生了构造函数
- 由于构造函数返回一个对象,所以构造函数的返回值被严格限定
- 一个类对象只有使用构造函数才完成构造 7
- 构造过程可能发生系统错误(文件被占用,内存不足等) 8
- 因为 6 和 8,所以构造过程中如果出错,无法通过返回值来判断构造是否成功
- 构造函数如果抛出异常,则不会执行该对象的析构
- 若构造函数内部存在 n 个其他对象的构造,而构造第 n + 1 个对象时失败引发异常,则 C++ 保证 n 个对象能够被析构
- 析构函数不能抛异常 9
- 派生类构造过程中若发生异常,则基类可以被正常析构(需要基类构造和基类析构正确)
由于以上原因,C++ 必须使用异常来处理构造函数的失败。
一个有趣的例子是矩阵相乘:矩阵相乘需要进行内存分配,有可能内存分配失败;矩阵相乘要求行数和列数匹配,而该过程可能在构造过程中被发现:matrix c = a * b
这种写法代表一定会使用异常,不用异常的写法如下:
matrix c;
err_code = multipl(a, b, c);
if (error_code) {
goto error_hand;
}
当然,也可以选择工厂函数(又称二段式构造):
matrix c; // c 的构造函数什么也不干
err_code = c.multipl(a, b); //c 的构造完全交给 multipl
if (error_code) {
goto error_hand;
}
但是并没有什么用。
C++ 也可使用 Pair 式返回:
auto [c, error_code] = multipl(a, b);
if (error_code) {
goto error_hand;
}
似乎比上两种方法好点,但是错误仍然需要自己处理。
其次,C++ RTTI 的 dynamic_cast
需要使用异常来捕获构造失败,而 dynamic_cast
连工厂函数都不能用。
也有一些情况是不能使用异常的,例如线程 10,此时必须使用工厂函数。
异常起码有以下两点好处:
- 异常是非侵入式的,使用返回值会导致内层代码增加后,需要修改外层代码,有时甚至需要逐层修改
- 异常不会修改函数签名,使用返回值会占用返回值位置,对于 C 来说连 Optional 都没有,造成写法上的浪费
注意,异常本身并不是设计用于 debug,虽然不否认异常有 debug 的能力,但一个成熟的,正式的软件系统,不应该在正式交付的软件中使用异常侦测和处理 bug 11 (此时实际上不应该使用任何运行时手段侦测 bug)。
错误和异常
经典的错误实际只存在 IO(广义)的过程 11:
- 用户输入字符串与预期不符(用户 IO)
- 构造一个持有文件对象的类对象时,文件打开失败(文件 IO)
- 内存分配失败(系统 IO)
此外,对于用户输入,应该将错误处理用于最外层,一旦用户输入的信息以安全的形式进入程序内部,则不应该担心输入有错而进行冗余的检查,实际上是将用户输入的错误拦截(通常是使用普通函数,或者在构造函数中进行此行为)。
有一些错误不仅仅发生在构造过程,例如内存分配失败可以发生在任何内存分配的过程中,但是,大部分内存失败是不需要检测的:系统可能在内存严重不足时提前把程序杀掉,并且此时就算程序继续运行下去也并无太大意义。只有少数例外:vector
的扩容等预先分配比当前需求大得多的内存的操作,此时可以选择减小扩容量。
对于暴露在外的类,我们有充分的理由使用任何运行期检查手段来保证输入的正确性,但是对于内部使用的类,在运行期进行检查通常是冗余而不必要的。同时,某些错误的代码可能破坏类的不变性,为了检查这类错误,C++ 使用继承自 C 的 assert
。
RAII 和异常
C++ 异常的难点在于异常安全:
- 发生异常时,没有资源泄露
- 发生异常后,程序状态正确
实际上第二点不光是异常的难点,使用返回值也有此类问题,不过由于异常是非侵入式的,所以异常并不一定是在抛出后立刻被捕获(即就地解决),某些情况下确实不是很清晰(毕竟你不一定知道函数内部抛出了何种异常,也就无从捕获),但这不是本文的重点。
发生异常时,没有资源泄露主要依赖于 RAII 技术:
- C++ 认为对象只应存在两种类别:值类和 Handle 类 12
- C++ 认为
new
的使用应该只存在于成员函数,尤其是构造函数(某些友元函数是特例) - C++ 通过 Handle 类来储存一个自由储存区(即堆)对象
- C++ 通过构造函数创造一个如此的对象,并且通过析构函数释放该对象的资源
- C++ 确保异常发生时,已经构造的对象能够被正确析构
- C++ 确保构造函数中异常发生时,在异常发生之前构造的对象能够被正确析构
- C++ 主张对象尽可能在声明的同时被赋值,并且在栈退出时析构该对象
- C++ 把使用构造函数和析构函数管理资源的方式称为 RAII
有些内置 GC 的语言能通过 GC 机制自动回收内存,但是并不是所有的资源都是内存资源,系统资源也是资源,而 GC 不能解决该问题。
基于 RAII 技术,C++ 认为可以消除非成员函数中 new
的使用,并且能确保异常安全:
void foo(){
int* a = new int{}; // 此对象在诱因发生时出现内存泄漏
int* b = new int{};// 内存泄漏诱因
delete a; // 若b的内存分配失败,则此语句不会被执行
delete b;
}
虽然上述代码可能看起来很蠢,但是实际上这种内存泄漏广泛的存在初学者的手中,尤其是当诱因被隐藏在函数调用中时。
而使用 Handle 类 12 则能解决此问题:
void foo(){
Handle a{};
Handle b{};
}
Handle 的构造过程中可以向自由储存区中申请空间,在析构函数中清理该空间。
此时若 b 的构造函数抛出 std::bad_alloc
,则 C++ 确保 a 能够被析构,也就没有内存泄漏。
所以我主张:
- 尽量简化 Handle 类的构造,让 Handle 类只构造自己该有的东西,例如,请让文件对象和数据库连结对象独立构造(并且支持移动)。
- 如果非要跨二进制传递对象,请使用
std::unique_ptr
,std::unique_ptr
同时具有 Handle 类和指针的性质,可以被移动并且使用std::unique_ptr::get
进行跨二进制传递。
此外,异常安全的问题不仅仅发生于结构化异常处理,没有异常的系统中仍有异常安全问题:
void foo()
{
int *a = new(nothrow) int{};
if (!a) {
return MEMORY_ALLOCATE_FAILED;
}
int *b = new(nothrow) int{};
if (!b) {
return MEMORY_ALLOCATE_FAILED; // 过早的返回会造成内存泄漏
}
}
void foo1()
{
int *a = new(nothrow) int{};
if (!a) {
return MEMORY_ALLOCATE_FAILED;
}
else // 使用嵌套的结构使得平行的资源申请关系变为依赖先后顺序,结构复杂
{
int *b = new(nothrow) int{};
if (!b) {
return MEMORY_ALLOCATE_FAILED;
}
}
}
void foo2()
{
int *a = new(nothrow) int{};
if (!a) {
return MEMORY_ALLOCATE_FAILED;
}
int *b = new(nothrow) int{};
if (!b) {
delete a; // 看似解决了内存泄漏的问题,但是仍然使得a的内存释放嵌入到了b中
return MEMORY_ALLOCATE_FAILED;
}
}
函数体 try
块
函数体可以是一个 try 块,例如 13:
int main()
try
{
/* */
}
catch (...)
{
/* */
}
对于大多数函数来说,使用函数 try
块仅仅是为了方便。然而,try
块允许我们在构造函数中处理基类或者成员或者成员初始化器抛出的异常。默认情况下如果基类或者成员初始化器抛出了一个异常,则该异常将传递到调用了该成员的类的构造函数的地方。不过,我们也可以把构造函数的函数体(包括成员初始化器列表在内)放在一个 try
块中,这样 该构造函数就能自己捕获异常了。例如:
class X {
vector<int> vi;
vector<string> vs;
/* */
public:
X(int, int);
/* */
};
X::X(int sz1, int sz2)
try
:vi(sz1), vs(sz2)
{
/* */
}
catch (std::exception& err)
{
/* */
}
由于在构造函数中捕获异常不能解决构造失败的事实,所以必须将异常重新抛出使得失败状态可以被外部捕获,这也是编译器的默认行为。
终止
在有些情况下最好不要使用异常,基本的原则是 13:
- 处理异常的时候不要抛出异常
- 不要抛出一个无法捕获的异常
如果异常处理发现你违反了上述规则,程序就会终止。
std::terminate()
的触发条件是:
- 当没有合适的
catch
块 - 当
noexcept
函数结束时存在throw
- 当析构函数的异常没有被自身捕获
异常使用
-
C++中函数只有两种可能,一种是潜在抛出异常,一种是不抛出异常。不抛出异常有两种情况,第一种是有些函数按标准不允许抛出异常,第二种是手动添加
noexcept
说明符的函数不抛出异常。由于无法保证函数不抛出异常,所以对于所有不抛出异常的函数,在函数内部如果抛出了异常,则栈展开停止于不抛出异常的函数内的抛出点,并且调用
std::terminate
。 -
上一点说过,因为无法保证函数不抛出异常,所以
noexcept
运算按照上述所说的规则进行判断,和函数体内写的任何东西都没关系。最后,不抛出异常的函数就一个有用的效果:不抛出异常的函数可以不用在该函数上,以及该函数的调用点添加栈展开的代码,使得编译器更容易进行优化,并减小二进制体积。
如果一个类的移动是不抛异常的,则储存该类的容器可以使用移动代替拷贝,能少一次拷贝的开销。不过我认为这算是一种漏洞,因为没有任何理由使得移动抛异常。
-
《C++ 语言的设计和演化》中提到过 C with Class 和 C++ 早期的编译器 Cfront 是将 C++ 翻译为 C,再将对 C 进行编译。 ↩
-
不变性是对类的成员的一种约束,有别于不需要约束或者约束无意义的结构,例如 pair。 ↩
-
之前的文章 构造函数和 C++ 的哲学 中提到过一部分。 ↩
-
C 的 const 关键字是从 C++ 中取得的(参考《C++ 语言的设计和演化》),函数内的 static 变量应该也如此(我忘记了),对于 const 变量和函数/类内的静态变量来说,初始化和赋值是完全不同的东西。 ↩
-
C++ 主张变量应该在被使用时才声明(而 K&R C 及 ANSI C 要求一定要先声明),所以 C++ 变量可以在任何合理位置声明并初始化。 ↩
-
之前的文章 严格别名规则和指针的安全性 中提到了一些小小的例外。 ↩
-
参数错误通常是程序 bug,属于特殊情况,当前不讨论,除了通过解析参数 string 进行构造的。 ↩
-
参考《C++ 程序设计语言》,实际上是因为析构函数的异常无法被确定性捕获,并且析构函数的异常会阻止其他对象的析构。 ↩
-
参考陈硕的《Linux 多线程服务端编程》 ↩
-
Zero-overhead deterministic exceptions: Throwing values ↩ ↩2