电脑故障问答网

 找回密码
 立即注册
查看: 92|回复: 1

程序的错误处理:错误码及异常捕捉

[复制链接]

1

主题

2

帖子

5

积分

新手上路

Rank: 1

积分
5
发表于 2022-12-30 16:40:19 | 显示全部楼层 |阅读模式
挺婷 Tina
读完需要
11
分钟
速读仅需 4 分钟
在软件研发项目里,程序的错误处理代码非常常见,但,要把错误处理得很好,并不容易。而每一个稳定的系统,都存在着大量处理错误的代码,所以说,处理错误实践是一个比较重要的事情。今天就来聊聊项目中常见的错误处理的方式。
1

传统的错误检查方式
挺婷刚开始做开发时,用 C 语言开发项目,往往会通过函数返回值告诉调用方的调用是否出错,然后在函数的某个出参告诉你出错的原因。
为什么这么设计?其实很多业务函数中,他们会返回有业务逻辑的值,比如 open 函数,成功则返回打开文件的句柄指针 File*,错误则返回 NULL,这样就会导致调用者并不知道什么原因出错了,需要额外的信息来了解错误原因。
一般情况下,这种错误处理方式是没有什么问题的,直到下面这种情况:
int atoi(const char *str)问题来了,如果一个传入的字符是非法的(不是数字格式),比如传了个字母或整型溢出了,那么这个函数应该返回什么呢?出错返回,其实返回什么都不合理,因为这样会和正常的结果混淆在一起,比如返回 0,这样会和内容为"0"的字符串混在一起,这样就无法检查出错的情况。
你可能会说,是不是要检查一下系统的 errno,按理来说是要检查,但当你查看了 C99 的说明文档后,你会发现:
The functions atof, atoi, atol, and atoll need not affect the value of the integer expression errno on an error. If the value of the result cannot be represented, the behavior is undefined.
像 atoi(), atof(), atol() 或是 atoll() 这样的函数是不会设置 errno 的,而且,还说了,如果结果无法计算的话,行为是 undefined,后来,libc 又给出了一个新的函数 strtol(),这个函数在出错的时会设置全局变量 errno:

long strtol(const char *restrict str, char **restrict endptr, int base);于是,我们就可以这样使用:
long val = strtol(in_str, &endptr, 10);  //10的意思是10进制//如果无法转换if (endptr == str) {    fprintf(stderr, "No digits were found\n");    exit(EXIT_FAILURE);}//如果整型溢出了if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {    fprintf(stderr, "ERROR: number out of range for LONG\n");    exit(EXIT_FAILURE); }//如果是其它错误if (errno != 0 && val == 0) {    perror("strtol");    exit(EXIT_FAILURE);}虽然 strtol()函数解决了 atoi()函数的问题,但这种返回值 + errno 的错误检查方式会有一些问题:

  • 程序员一不小心就会忘记返回值的检查,进而造成 bug
  • 函数接口定义不纯粹,正常返回值和异常值混在一起
后来,有一些类库就开始区分这样的事情,比如,Windows 的系统调用开始使用 HRESULT 的返回来统一错误的返回值,这样可以明确函数调用时的返回值是成功还是错误。但这样一来,函数的输入和输出都只能放到函数参数中,也就是对应的入参和出参。
但这样设计又让函数定义变得更复杂了,而且,依旧没有解决函数调用失败可能被人为忽略的问题。
2

多返回值
于是,有一些语言,就开始解决这个事了, 比如 Go 语言,Go 语言的很多函数都会返回 result,err 两个值,于是函数定义变得清晰了,因为:

  • 函数参数就是入参,而返回把结果和错误分离,使得接口语义清晰了;
  • 如果 Go 语言中的错误参数要忽略,则要用 _ 变量,来显式地忽略;
另外,因为返回的 error 是个接口(其中有一个方法 Error(),返回一个 string),所以你可扩展自定义的错误处理。
比如,这是一个 Json 解析语法错误的示例:
if err := dec.Decode(&val); err != nil {    if serr, ok := err.(*json.SyntaxError); ok {        line, col := findLine(f, serr.Offset)        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)    }    return err}当然,如果有不同的错误类型要返回,还可以使用下面的方式:
if err != nil {  switch err.(type) {    case *json.SyntaxError:      ...    case *ZeroDivisionError:      ...    case *NullPointerError:      ...    default:      ...  }}虽然像 Go 这样的语言,已经能让语义变得清晰,但实际编写时,还是需要写大量的 if err != nil,写起来还是比较烦躁的,而且,正常的逻辑代码会被大量的错误处理打得比较凌乱。
3

资源清理
程序出错时,需要对已分配的一些资源做清理,在传统的玩法下,每一步的错误都要清理已经分配的资源,于是就出现了 goto if fail 这样的错误处理方式,如图:
#define FREE(p) if(p) { \                    free(p); \                    p = NULL; \                 }main(){  char *fname=NULL, *lname=NULL, *mname=NULL;  fname = ( char* ) calloc ( 20, sizeof(char) );  if ( fname == NULL ){      goto fail;  }  lname = ( char* ) calloc ( 20, sizeof(char) );  if ( lname == NULL ){      goto fail;  }  mname = ( char* ) calloc ( 20, sizeof(char) );  if ( mname == NULL ){      goto fail;  }  ......fail:  FREE(fname);  FREE(lname);  FREE(mname);  ReportError(ERR_NO_MEMORY);}这种处理方式带来的问题是,你不能直接 return,因为你需要清理资源。而在维护代码时,你需要特别小心,因为一不注意就会导致代码有资源泄漏的问题。
于是,C++的 RAII 机制使用面向对象的特性可以容易地处理这件事。RAII 利用 C++类的机制,在构造函数中分配资源,在析构函数中释放资源。下面,来看下 RAII 是怎么解决这个问题的:
//首先,先声明一个RAII类,注意其中的构造函数和析构函数class LockGuard {public:  LockGuard(std::mutex &m):_m(m) { m.lock(); }  ~LockGuard() { m. unlock(); }private:  std::mutex& _m;}//然后,我们来看一下,怎样使用的void good(){  LockGuard lg(m);           // RAII类:构造时,互斥量请求加锁  f();                             // 若f()抛异常,则释放互斥  if(!everything_ok()) return;     // 提早返回,LockGuard析构时,互斥量被释放}                                    // 若good()正常返回,则释放互斥
4

异常捕捉处理
当挺婷后面又转型到 Java 开发的时候,发现其实将错误处理比较好的模式是:try-catch-finally 模式。
try {  ... // 正常的业务代码} catch (Exception1 e) {  ... // 处理异常 Exception1 的代码} catch (Exception2 e) {  ... // 处理异常 Exception2 的代码} finally {  ... // 资源清理的代码}这个模式,就是将错误分门别类的归类好,看起来函数写得很干净。
使用这种异常处理方式,有以下好处:

  • 函数接口在输入和输出、以及错误处理的语义是比较清楚的
  • 正常逻辑的代码和错误处理的代码分开,提高了代码可读性
  • 异常不能被忽略,即使要被忽略,也要被 catch
  • 在面向对象的语言中,异常是个对象,所以,可以实现多态式的异常对象 catch
但是,try-catch-finally 有个特别致命的问题,那就是在异步运行的世界里,try 语句块的函数运行到另一个线程中,其中抛出的异常却没有办法在调用者的这个线程中被捕捉。这是个比较大的问题。
5

错误码返回还是异常捕捉?
对于使用哪种方式去处理错误,这是一个很容易引起争论的问题,有人说,如果是很底层的错误,可以使用错误码的形式;有人说,如果是偏上层逻辑的业务,可以使用异常捕捉。但具体用哪个形式,其实,还是要根据场景和错误的类别来讨论,才是正确的姿势。
我们常见的错误类别,按耗子叔的观点,大致分为三类:

  • 资源错误。即请求资源时发生的错误,例如没有权限读取某个文件,发送文件到网络时发现网络故障。这一类属于程序运行环境发生的错误,对于这类错误,我们有的可以处理,有的则无法处理,例如栈溢出,内存空间不足等关键性资源不够时,我们只能停止程序,甚至退出程序。
  • 程序逻辑的错误,例如空指针、非法参数,这是我们自己的错误,最好要日志记录下来,写入日志,甚至触发监控报警。
  • 用户的错误,比如 Bad request 这类用户不合法输入所带来的错误,这类错误基本是 API 上的问题,比如,解析一个 XML 或 JSON 文件出错,或用户输入的字段不合法。
    对于这类问题,我们要向用户报错,提示他们修正自己的输入,但同时也可以做好相应错误率的统计,以便改善我们自身的软件,又或是可以方便我们监测到恶意的用户请求。
从上述的错误分类可以看到,这几类错误中,有些是我们要杜绝发生的,比如 bug;有些是我们无法杜绝的,例如用户的错误输入,而对运行环境中的一些错误,则是我们希望可以恢复的。基于上述逻辑,我们大致可以在逻辑上进行分类:
1、对于我们并不期望会发生的事情,用异常捕捉
2、对于我们觉得可能发生的事情,用错误码返回
比如,你的函数入参传入了一个 null,其实你并不期望传入一个 null 对象,那么一旦传入了 null 对象,函数就可以抛异常,因为我们总不期望发生这样的事情。
而对于一个需要检查用户输入信息是否正确的事,比如电子邮箱格式是否正确,我们返回一个错误码可能会合适些。
除了用错误的分类来决定使用错误码还是异常捕捉之外,我们还要从程序设计的角度来考虑使用哪种方式好。
因为异常捕捉在编程上的好处会比返回值好很多,所以异常捕捉的代码在可读性上会清晰许多。而返回码更容易被忽略,所以,使用返回码的代码需要做好测试才能得到更好的软件质量。
不过,我们也要了解,在某些情况下,我们只能使用其中一种处理方式,例如:

  • 在 C++重载操作符的情况下,你就很难返回错误码,只能抛出异常。
  • 异常捕捉只能在同步的情况下调用,在异步模式下,这事就不行了,需要通过检查子进程退出码或是回调函数来解决;
  • 在分布式情况下,调用远程服务只能看错误返回码,比如调用第三方平台的开发者接口,能拿到的都是错误码。
所以,很多情况下,我们会混用两种错误处理,甚至两者之间会互相转换。而错误处理方法多种多样,而且会在不同的层面上处理错误。有些底层错误就需要自己处理掉(比如:底层模块会自动重建网络连接),而有一些错误需要更上层的业务逻辑来处理(比如:重建网络连接不成功后让上层业务来处理?降级使用本地缓存或是直接报错给用户?)
你可能会问,那具体是使用错误码还是异常捕捉呢?两者都可,就看我们的错误处理流程以及代码组织怎么写会更清楚了。
End
今天是日更的148/365天。
我们明儿见。
回复

使用道具 举报

1

主题

8

帖子

15

积分

新手上路

Rank: 1

积分
15
发表于 2025-4-9 05:14:01 | 显示全部楼层
发发呆,回回帖,工作结束~
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

云顶设计嘉兴有限公司模板设计.

免责声明:本站上数据均为演示站数据,如购买模板可以上DISCUZ应用中心购买,欢迎惠顾.

云顶官方站点:云顶设计 模板原创设计:云顶模板   Powered by Discuz! X3.4© 2001-2017 Comsenz Inc.

快速回复 返回顶部 返回列表