C/C++ 对错误的处理
在项目开发中,非主体逻辑的部分在整体代码量中占得比重往往更大。比如我们做一个加减乘除计算器,需要处理用户输入非数字怎么办,数字太大溢出怎么办,零作为除数了怎么办等等;比如我们要读取配置文件中的用户名、密码,我们得首先处理配置文件不存在,内容格式不正确,用户名过长等等。
机器(具体说就是一门语言,比如 C++)是一板一眼的,你告诉它了它就能做,你没告诉的它就不知道。对机器来说不存在“常识”这个词。
用 C++ 写代码的时候总是避免不了处理错误,一般来说有两种方式:通过函数的返回值 return code;抛出异常 exceptions。
使用返回值的缺点
从 C 语言过渡过来的开发者可能更习惯使用返回值。就我自己的开发经历(给某个风场使用 Labwindows/CVI 开发自动化采集软件)来说,使用返回值有四点很是不爽的地方:
与业务真正的返回值混用,需要规定一个错误代码。比如我们用
int statistics(int id)
统计员工今天检测的样品数目,从业务角度来说返回值是非负数,所以可以使用负数作为错误代码——这就是“混用”。我们也可以强制改为
int statistics(int id, int* count)
,严格限制返回值只能用于记录运行状态,而将统计结果置于参数列表中。这样又会引入新的问题:输入参数、输出参数混用,且调用之前必须提前声明变量,而不能将声明和调用在同一条语句中执行。参考 其他语言中的错误处理 中 GO 语言错误信息简陋。尤其是函数被调用后如果存在多种错误状态,单独使用错误码根据其不同的数值标识不同的状态,然后在调用函数中根据错误代码编辑并打印对应的错误信息,这是很不合理的:一方面调用函数和被调用函数很可能不是同一开发者,存在沟通成本;另一方面,即便是同一开发者,在存在多个调用函数的情况下,也可能出现同一错误代码打印的错误信息内容却不一致的尴尬。
针对这一点不足,我们可以在每个被调用函数中再添一个输出参数,用于保存错误信息。比如
int statistics(int id, int* count, std::string& errinfo)
。这样做的问题,是针对每次调用我们都需要额外多使用一个字符串,而且同前者一样(比前者更甚),将错误处理方式和“执行逻辑”混在了一起:一方面都是使用控制流;另一方面,大多情况下还都是在同一控制流中。exceptions simplify my function return type and parameter types.
exceptions separate the “good path” (or “happy path”) from the “bad path”.
每个可能产生错误的函数在调用后都需要判断是否有错误,一旦发生了不可解决的错误,就要终止当前函数(并释放当前函数申请的资源),然后向上传递错误。而且每个调用层都需要检查错误。代码会变得很丑陋。This “error propagation” often needs to go through dozens of functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27int func(int n) {
int fd = open("path/to/file", O_RDONLY);
if (fd == -1) {
return ERROR_OPEN;
}
int* array = new[n];
int err;
err = do_something(fd, array);
if (err != SUCCESS) {
delete[] array;
return err;
}
err = do_other_thing();
if (err != SUCCESS) {
delete[] array;
return err;
}
err = do_more_thing();
if (err != SUCCESS) {
delete[] array;
return err;
}
delete[] array;
return SUCCESS;
}
// func 的调用者还需要再次检查错误开发过程中很容易忘记处理错误返回值,程序带着错误执行的不确定性。等程序宕掉的时候,可能距离错误源十万八千里远,调试起来老费劲了。
其他传统的错误处理方式
而传统错误处理技术,检查到一个局部无法处理的问题时: 来源
- 终止程序(例如
atol
,atoi
,输入 NULL,会产生段错误,导致程序异常退出,如果没有core文件,找问题的人一定会发疯) - 返回一个表示错误的值(很多系统函数都是这样,例如
malloc
,内存不足,分配失败,返回 NULL 指针) - 返回一个合法值,让程序处于某种非法的状态(最坑爹的东西,有些第三方库真会这样)
- 调用一个预先准备好在出现”错误”的情况下用的函数。
第一种情况是不允许的,无条件终止程序的库无法运用到不能当机的程序里。第二种情况,比较常用,但是有时不合适,例如返回错误码是 int,每个调用都要检查错误值,极不方便,也容易让程序规模加倍(但是要精确控制逻辑,我觉得这种方式不错)。第三种情况,很容易误导调用者,万一调用者没有去检查全局变量 errno 或者通过其他方式检查错误,那是一个灾难,而且这种方式在并发的情况下不能很好工作。至于第四种情况,本人觉得比较少用,而且回调的代码不该多出现。
推荐使用异常
到目前为止,除了学习异常时写过几个小的测试程序,对于异常并没有更多的认识。主要源于 google 编码规范中“禁止使用 C++ 异常”,以及有些大牛也不推荐使用异常(参考),所以下意识地对 exception
有些回避。
相比使用返回值的缺点,异常的优点有:
- 程序逻辑和错误处理分离。
- 错误信息丰富,便于获得错误现场。我们可以自定义异常对象。
- 代码相对简短,不需要判断每个函数的返回值
- 开发者如果忘记处理异常,程序会直接宕掉。
想获得上述优点,肯定要付出点代价。使用 exception
的开销相对较大。
写到这里,我自己突然很倾向使用异常了。但也不能盲目地将所有使用返回值的地方全部替换成异常,很可能使得控制流变得复杂,难以追踪。
我的观点是,用异常来表示真正的、而且不太可能发生的错误。所谓不太可能发生的错误,指的是真正难以预料,但发生了却又不得不单独处理的,譬如内存耗尽、读文件发生故障。
一句话来概况就是不要用异常代替正常的控制流。只有当程序真的「不正常」的时候,才使用异常;反过来,当程序真正发生错误了,一定要使用异常而不是返回一个错误代码,因为错误代码总是倾向于被忽略。引用来源
C++ 标准的中的 Exceptions and Error Handling
怎么用好 exception
上述也就是在说 exception 的优缺点以及 exception 的必要性。可怎么样才是用好 exception,发挥了其优点,而非滥用、误用,放大了其缺点?仅仅知道其语法没甚意义。
我自己梳理标准库里的 异常 std::exception
构造函数中的错误
接下来说说处理构造函数中的错误,通常有三种常见的处理方法:
使用一个额外的
initialize()
函数来初始化;标记错误状态;或者直接抛出异常。
- 第一种方式,违背了对象产生和初始化要在一起的原则,强迫用户记住调用一个额外的初始化函数,一旦没有调用直接使用了其他函数,其行为很可能是未定义的。
- 标记状态的方法在实践中相当丑陋,因为在使用前总是需要判断它是否「真的创建成功了」。
- 最直接的方法还是在构造函数中抛出异常。构造函数抛出异常以后析构函数是不会被调用的,所以如果你在构造函数里面申请了内存或者打开了资源,需要在异常产生时关闭。
如何在构造函数中捕获异常,语法方面具体怎么写可以参考原文,也请自行学习。
ps 整篇笔记主要参考自 如何处理C++构造函数中的错误,作者的专业知识值得肯定,但文字功底就不敢恭维了:有些描述起承转合很是僵硬,逻辑上毫无关系的两句话竟然用的逗号衔接,有些段落在文字顺序、排版上强加因果,误导读者。总之,文章很不错,如果读到某个地方觉得别扭,考虑重组语句,甚至重组段落。
std::expected
错误处理的新方式。但其作为一项提案,截止到 2019 年 3 月尚未成为标准。
Error Handling in C++ or: Why You Should Use Eithers in Favor of Exceptions and Error-codes
- The return-codes mindset
- The Java mindset
- Organizing the exception classes around the physical thrower rather than the logical reason for the throw
- Using the bits / data within an exception object to differentiate different categories of errors
- Designing exception classes on a subsystem by subsystem basis
- Use of raw (as opposed to smart) pointers
- Confusing logical errors with runtime situations