C/C++ 对错误的处理

在项目开发中,非主体逻辑的部分在整体代码量中占得比重往往更大。比如我们做一个加减乘除计算器,需要处理用户输入非数字怎么办,数字太大溢出怎么办,零作为除数了怎么办等等;比如我们要读取配置文件中的用户名、密码,我们得首先处理配置文件不存在,内容格式不正确,用户名过长等等。

机器(具体说就是一门语言,比如 C++)是一板一眼的,你告诉它了它就能做,你没告诉的它就不知道。对机器来说不存在“常识”这个词。

用 C++ 写代码的时候总是避免不了处理错误,一般来说有两种方式:通过函数的返回值 return code;抛出异常 exceptions。

使用返回值的缺点

从 C 语言过渡过来的开发者可能更习惯使用返回值。就我自己的开发经历(给某个风场使用 Labwindows/CVI 开发自动化采集软件)来说,使用返回值有四点很是不爽的地方:

  1. 与业务真正的返回值混用,需要规定一个错误代码。比如我们用 int statistics(int id) 统计员工今天检测的样品数目,从业务角度来说返回值是非负数,所以可以使用负数作为错误代码——这就是“混用”。

    我们也可以强制改为 int statistics(int id, int* count),严格限制返回值只能用于记录运行状态,而将统计结果置于参数列表中。这样又会引入新的问题:输入参数、输出参数混用,且 调用之前必须提前声明变量,而不能将声明和调用在同一条语句中执行。参考 其他语言中的错误处理 中 GO 语言

  2. 错误信息简陋。尤其是函数被调用后如果存在多种错误状态,单独使用错误码根据其不同的数值标识不同的状态,然后在调用函数中根据错误代码编辑并打印对应的错误信息,这是很不合理的:一方面调用函数和被调用函数很可能不是同一开发者,存在沟通成本;另一方面,即便是同一开发者,在存在多个调用函数的情况下,也可能出现同一错误代码打印的错误信息内容却不一致的尴尬。

    针对这一点不足,我们可以在每个被调用函数中再添一个输出参数,用于保存错误信息。比如 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”.

  3. 每个可能产生错误的函数在调用后都需要判断是否有错误,一旦发生了不可解决的错误,就要终止当前函数(并释放当前函数申请的资源),然后向上传递错误。而且每个调用层都需要检查错误。代码会变得很丑陋。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
    27
    int 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 的调用者还需要再次检查错误
  4. 开发过程中很容易忘记处理错误返回值,程序带着错误执行的不确定性。等程序宕掉的时候,可能距离错误源十万八千里远,调试起来老费劲了。

其他传统的错误处理方式

而传统错误处理技术,检查到一个局部无法处理的问题时: 来源

  1. 终止程序(例如 atol,atoi,输入 NULL,会产生段错误,导致程序异常退出,如果没有core文件,找问题的人一定会发疯)
  2. 返回一个表示错误的值(很多系统函数都是这样,例如 malloc,内存不足,分配失败,返回 NULL 指针)
  3. 返回一个合法值,让程序处于某种非法的状态(最坑爹的东西,有些第三方库真会这样)
  4. 调用一个预先准备好在出现”错误”的情况下用的函数。

第一种情况是不允许的,无条件终止程序的库无法运用到不能当机的程序里。第二种情况,比较常用,但是有时不合适,例如返回错误码是 int,每个调用都要检查错误值,极不方便,也容易让程序规模加倍(但是要精确控制逻辑,我觉得这种方式不错)。第三种情况,很容易误导调用者,万一调用者没有去检查全局变量 errno 或者通过其他方式检查错误,那是一个灾难,而且这种方式在并发的情况下不能很好工作。至于第四种情况,本人觉得比较少用,而且回调的代码不该多出现。

推荐使用异常

到目前为止,除了学习异常时写过几个小的测试程序,对于异常并没有更多的认识。主要源于 google 编码规范中“禁止使用 C++ 异常”,以及有些大牛也不推荐使用异常(参考),所以下意识地对 exception 有些回避。

相比使用返回值的缺点,异常的优点有:

  1. 程序逻辑和错误处理分离。
  2. 错误信息丰富,便于获得错误现场。我们可以自定义异常对象。
  3. 代码相对简短,不需要判断每个函数的返回值
  4. 开发者如果忘记处理异常,程序会直接宕掉。

想获得上述优点,肯定要付出点代价。使用 exception 的开销相对较大。

写到这里,我自己突然很倾向使用异常了。但也不能盲目地将所有使用返回值的地方全部替换成异常,很可能使得控制流变得复杂,难以追踪

我的观点是,用异常来表示真正的、而且不太可能发生的错误。所谓不太可能发生的错误,指的是真正难以预料,但发生了却又不得不单独处理的,譬如内存耗尽、读文件发生故障。

一句话来概况就是不要用异常代替正常的控制流。只有当程序真的「不正常」的时候,才使用异常;反过来,当程序真正发生错误了,一定要使用异常而不是返回一个错误代码,因为错误代码总是倾向于被忽略。引用来源

C++ 标准的中的 Exceptions and Error Handling

怎么用好 exception

上述也就是在说 exception 的优缺点以及 exception 的必要性。可怎么样才是用好 exception,发挥了其优点,而非滥用、误用,放大了其缺点?仅仅知道其语法没甚意义。

避免以下 mindset(观念模式/思维倾向):

我自己梳理标准库里的 异常 std::exception

构造函数中的错误

接下来说说处理构造函数中的错误,通常有三种常见的处理方法:

使用一个额外的 initialize() 函数来初始化;标记错误状态;或者直接抛出异常。

  1. 第一种方式,违背了对象产生和初始化要在一起的原则,强迫用户记住调用一个额外的初始化函数,一旦没有调用直接使用了其他函数,其行为很可能是未定义的。
  2. 标记状态的方法在实践中相当丑陋,因为在使用前总是需要判断它是否「真的创建成功了」。
  3. 最直接的方法还是在构造函数中抛出异常。构造函数抛出异常以后析构函数是不会被调用的,所以如果你在构造函数里面申请了内存或者打开了资源,需要在异常产生时关闭。

如何在构造函数中捕获异常,语法方面具体怎么写可以参考原文,也请自行学习。

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