C++ 异常 - 类和异常
错误处理是程序开发的一个重点和难点,一个安全的程序中错误处理的代码可能和其他逻辑代码一样多;错误又大致分为逻辑和系统两个来源:前者是由于用户输入错误,后者来源于外部环境;错误的代码本身也是一种错误;错误的处理方式又有许多派系和方式:C 风格的 if 条件判断,goto
和断言,Ada 风格的异常,Go 风格的 err,Rust 风格的 Result。最致命的是 C++ 支持上述所有风格(虽然在语法上可能有些不便),并且错误处理这个话题下的内容可能足够写一本书,导致作为系统编程语言而广泛使用的 C++ 在错误处理方面有着高度的复杂性和争议。本文是本人集思广益总结的介绍 C++ 错误处理相关概念和如何编写异常安全代码的文章。
本文是《C++ 异常》系列第一篇文章。
《C++ 异常》目录:
类和异常
C++ 的 class
可以用 C 的 struct
和普通函数实现 1,但是 C++ 的 class
在代码实现的基础上提出了一种更高级的抽象概念:类。
类具有不变性(invariant) 2 3:类的成员互相依赖(或者通过指针持有某个资源),如果擅自修改某个成员,会导致对象(或者内存泄漏)失效,产生错误。
C++ 构造函数具有以下概念,性质和意义4:
- 类需要对成员的访问权限进行控制,即某些成员不应该被暴露
- 因为 1,所以必须使用成员函数才能控制这些成员,因此构造函数是成员函数
- C/C++ 中,对象(变量)必须被初始化为确定的值才有意义,读取未被初始化的变量的值是未定义行为
- C++ 主张对象尽可能在声明的同时被初始化 5 6,用于简化代码和避免错误
- 为了让类也支持声明时初始化,C++ 产生了构造函数
- 由于构造函数在对象声明的时候调用,所以构造函数不能有传统的返回值
- 一个对象只有使用构造函数才完成构造 7
- 构造过程可能发生系统错误(文件被占用,内存不足等)8
- 因为 6 和 8,所以构造过程中如果出错,无法通过返回值来判断构造是否成功
- 因此 C++ 使用异常来表达构造失败的错误
- 构造函数如果抛出异常,则不会执行该对象的析构
- 为了保证按声明顺序构造子对象,C++ 在构造函数上使用初始化器列表构造子对象
- 如果初始化器列表已经构造了 n 个子对象对象,而在构造第 n + 1 个对象时失败引发异常,则 C++ 通过编译器生成的代码保证前 n 个对象能够被按声明的逆序析构
- 析构函数不能抛异常 9
- 派生类构造过程中若发生异常,则基类可以被正常析构
因此,异常在 C++ 中广泛应用。
一个典型的例子是矩阵相乘:矩阵相乘需要进行内存分配,有可能内存分配失败;矩阵相乘要求行数和列数匹配,而该过程可能在构造过程中被发现:matrix c = a * b
这种写法代表使用异常。
而在 C 中,通常使用返回值来表示输入错误:
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;
}
也有一些情况是不能或难以在构造函数的初始化列表中进行的,例如线程的创建 10,此时必须在构造函数的函数体内创建,或者使用延迟创建,因为线程会积极的运行,从而在初始化其他对象之前在其他线程访问这些对象。
异常起码有以下两点好处:
- 异常是非侵入式的,使用返回值会导致内层代码增加后,需要修改外层代码,有时甚至需要逐层修改
- 异常不会修改函数签名,使用返回值会占用返回值位置,对于 C 来说连 Optional 都没有,造成写法上的浪费
注意,异常本身并不是设计用于 debug,虽然不否认异常有 debug 的能力,但一个成熟的,正式的软件系统,不应该在正式交付的软件中使用异常侦测和处理 bug 11,因为 bug 通常难以恢复,并不能有意义的通过捕获异常解决。
错误和异常
经典的错误实际只存在 IO(广义)的过程 11,例如:
- 用户输入字符串与预期不符(用户 IO)
- 文件由于不存在或者被占用的打开失败(文件 IO)
- 内存分配失败(系统 IO)
此外,对于用户输入,应该尽早验证输入的正确性,一旦用户输入的信息以安全的形式进入程序内部,则不应该担心输入有错而进行冗余的检查。
有一些错误不仅仅发生在构造过程,例如内存分配失败可以发生在任何内存分配的过程中,但是,大部分内存分配失败是不需要检测的:系统可能在内存严重不足时提前把程序终止,并且此时就算程序继续运行下去也并无太大意义。
对于暴露在外的类,我们有充分的理由使用任何运行期检查手段来保证输入的正确性,但是对于内部使用的类,在运行期进行检查通常是冗余而不必要的。同时,某些错误的代码可能破坏类的不变性,为了检查这类错误,C++ 使用继承自 C 的 assert
。
RAII 和异常
C++ 异常的难点在于异常安全:
- 发生异常时,没有资源泄露
- 发生异常后,程序状态正确
实际上第二点不光是异常的难点,使用返回值也有此类问题,不过由于异常是非侵入式的,所以异常并不一定是在抛出后立刻被捕获(即就地解决),某些情况下确实不是很清晰(毕竟你不一定知道函数内部抛出了何种异常,也就无从捕获),但这不是本文的重点。
发生异常时,没有资源泄露主要依赖于 RAII 技术:
- C++ 有两种特殊的类型,值类和句柄(handle)类 12,这两种类型用于表达对象的概念
- C++ 认为
new
的使用应该只存在于成员函数,尤其是构造函数(某些友元函数是特例) - C++ 通过句柄类来储存一个自由储存区(即堆)对象,或者引用系统提供的资源
- 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;
}
虽然上述代码可能看起来很蠢,但是实际上这种内存泄漏广泛的存在初学者的手中,尤其是当诱因被隐藏在函数调用中时。
而使用句柄类 12 则能解决此问题:
void foo(){
Handle a{};
Handle b{};
}
Handle 的构造过程中可以向自由储存区中申请空间,在析构函数中清理该空间,或者在构造函数中申请资源,以及在析构函数中释放资源。
此时若 b 的构造函数抛出 std::bad_alloc
,则 C++ 确保 a 能够被析构,也就没有内存泄漏。
所以我主张:
- 尽量简化句柄类的构造,让句柄类只构造自己该有的东西,例如,请让文件对象和数据库链接对象独立构造(并且支持移动),在有些资料中这被叫做单一责任原则。
- 如果非要跨二进制传递对象,请使用
std::unique_ptr
的类似物进行管理,std::unique_ptr
同时具有句柄类和指针的性质,可以被移动并且使用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