• C++异常处理


    我好像认识很多个用C的方式写C++的人。难道是因为我圈子里大多是嵌入式工程师嘛?

    上周我跟北京研发的一个哥们合作,因为他提供的动态库总是段错误,我说你提供的这个接口没有做入参的检查吧,你发现入参不合法之后给我抛个异常出来吧,别让它再段错误了。

    他说我抛了啊,我怎么可能连异常都不知道呢?

    于是我说你让我看一眼你是怎么抛异常的。

    1.  if(pos > MAX){
    2.      throw "out of range!"
    3.  }

    我tm想把他和追梦格林扔到八角笼中决斗一番。

    1

    一知半解有时候甚至还不如完全不懂,后者由于自知害怕出错,一般会采取比较稳妥的方式,前者经常不懂装懂贪功冒进。

    让我们拨动秒针,穿梭时空,回到自己刚刚学C语言的时候。

    老师说,函数虽然可以写多个入参,但只能有一个返回值(某Go语言开发者:what?),我们需要在写函数的时候指明返回值的类型。

    当然,大家的技术水平都是比较高的,多数情况下,函数都会正常的运行。但老虎也有打盹的时候,偶尔也会因为一些疏忽导致函数出错。此时我们应该返回什么呢?

    我总结了三种情况,错误码,NULL,空

    但这三种情况的用法某种意义上是一样的:都需要对返回值做判断。即如果返回值正常,则继续往下走;如果返回值出错/为NULL/为空则不能继续,因为会引发各种错误。

    此时C++迈着大步跑了过来,“加上我,我还有一种情况,叫做异常!”

    2

    错误码的机制有点像健康码,园区保安需要核对每个人的健康码是绿色还是红色,决定是否让你进入园区工作;当然会很有效,但缺点是太过繁琐,效率低下,影响美观。

    异常的机制则像有个人站在30层的楼顶要往下跳,如果没人拿防护网接住他(try-catch),那么他将崩溃殒命。但你也可以选择在1楼到29楼的任何一层拿防护网接住他,询问他崩溃的原因,那么悲剧将有可能被挽回。

    当然,你也可以选择先接住他,问明原因后再把他扔出去(throw),告诉他人间的悲欢并不相通,我只是有点八卦。

    至于扔出去之后他是挂掉还是又被人接住,取决于你这层楼的下面还有没有防护网。

    这30层楼就是函数层层调用搭起来的高楼大厦,所以异常跳楼的时候也是层层传递。即在20层楼处搭建的防护网只能捕捉到20楼以上跳楼的靓仔,却捕获不到从19楼跳楼的住户。所以出于安全起见,我们一般会在一楼支起一个防护网捕捉所有跳楼的靓仔,然后安稳的睡去。

    当然如果你选择在30层楼的每一层都搭一个防护网,那我建议你别干消防了,去园区门口当保安查健康码吧,那个适合你。

    3

    错误码的返回一般有两种形式,一种是占用函数返回值,形如

     int fd = open(char* filename);
    

    一种是返回值另有他用,所以需要占用一个入参或者使用修改全局变量的方式。函数将错误码填入传进来的入参中,一般为指针形式,形如

    1.  double val = GetValue(RetStatus* errcode);
    2.  // 或者定义一个全局变量
    3.  RetStatus ErrCode;
    4.  double val = GetValue();

    详细错误信息的获取也有两种方式,一种是直接返回一个错误码和错误信息的结构体,形如

    1.  struct RetStatus{
    2.      int errCode;
    3.      char[512] errMsg;
    4.  }
    5.  if (/*错误1*/){
    6.      Return RetStatus{1,"错误1"}
    7.  }else if (/*错误2*/){
    8.      Return RetStatus{2,"错误2"}
    9.  }else{
    10.      Return RetStatus{3,"错误3"}
    11.  }

    一种是只返回错误码,错误信息定义为全局变量。接收者通过strerror(errno)的方式获取详细的错误信息。

    无论采取什么样的形式来设计,错误码的机制都决定了接收者需要对其进行判断;而异常则不用层层处理,可以避免“必须检查返回值,不能遗漏一个的情况”。

    以读文件里面的read函数举例,

    int read(int fd,char* buf,int count);
    

    read函数的返回值,在正常时返回读取到的字节数,在文件末尾调read时返回0,出错时返回-1并设置errno。

    假如A函数调用了read,用于将读取到的数据做分割、提取、处理等工作;

    B函数调用了A函数,将处理后的数据用plot控件绘制成图形;

    C函数调用了B函数,将图形显示到UI界面上。

    那么ABC函数得这么写:

    1.  int A(char* input,char* output){
    2.      int ret = read(fd,input,len);
    3.      if(ret<0){
    4.          return ret;
    5.      }
    6.      // ret>=0 继续做处理
    7.      return len(output);
    8.  }
    9.  int B(char* buf,char* plot){
    10.      char[512] output;
    11.      int ret = A(buf,output);
    12.      if(ret<0){
    13.          return ret;
    14.      }
    15.      // ret>=0 继续做处理
    16.      return len(plot);
    17.  }
    18.  bool C(char* data,char* plot){
    19.      int ret = B(data,plot);
    20.      if(ret<0){
    21.          printf("Error:%s\n",strerror(errno));
    22.          return false;
    23.      }
    24.      // ret>=0 继续做处理
    25.      return true;
    26.  }

    由此可见,我们需要在每一层都得判断一下函数返回值是否正确,NULL和空也是一样的情景。这样写一方面写的人觉得繁琐,另一方面看的人也觉得啰嗦。

    作为对比,异常则是只需在read函数里throw,在函数C里catch即可。当然前提是A,B函数里的代码要保证异常安全

    1.  void A(char* input,char* output){
    2.      read(fd,input,len);   
    3.      // 继续做处理    
    4.  }
    5.  void B(char* buf,char* plot){
    6.      char[512] output;
    7.      A(buf,output);
    8.      // 继续做处理    
    9.  }
    10.  void C(char* data,char* plot){
    11.      try{
    12.          B(data,plot); 
    13.          // 继续做处理
    14.      }catch(const std::exception& e){
    15.          std::cerr << e.what() << std::endl;
    16.          // 一些释放资源的操作        
    17.      }
    18.  }

    上述例子比较简单,工作的时候为了保证异常安全,一般建议大家使用RAII的思想,退出作用域,资源随即释放。即使发生异常,现场会恢复到调用前的状态,资源也不会有任何泄漏。也就是所谓的异常安全了。

    4

    错误码还有一个致命的问题是,使用者可以不接收,不判断。一旦他选择忽略,程序拿着一个错误的结果继续往下走,鬼知道会出什么事情。就好像园区保安有点累,不检查健康码就放人进去,有可能会引起全上海封城3个月的严重后果。

    作为对比,异常是不能忽略的,有人跳楼你不处理,那这个程序肯定就挂了。

    C++的标准库就是用异常来作为错误处理方式的。如果你使用过std::vector和std::map这类常见容器,一定对out of range,bad alloc,map::at这类异常出错记忆深刻。

    使用异常的理由千千万,这儿还有一条最重要的理由:

    1.  class Student{
    2.      Student()=default;
    3.      ~Student()=default;
    4.  }

    构造函数和析构函数连返回值都没有,我怎么返回错误码?

    5

    C++标准库中定义了异常类,并且有继承派生关系。我们只能以默认初始化的方式取初始化bad_alloc,bad_cast这些对象,不允许为其提供初始值;而logic_error和runtime_error这些类型的对象,则必须提供字符串初始值来初始化。

    这两种常用异常类型的区别是:

    logic_error:理论上无需程序运行,读代码就能看出来的异常;

    runtime_error:理论上只有程序运行起来才能检测出的异常。

    开头北京同事如果这样抛异常,我这边就不会出错啦。

    1.  if(pos > MAX){
    2.      throw std::logic_error("out of range!"); 
    3.  }

    6

    当然了,异常处理也是有一些成本的。为了在运行时处理异常,程序内部要记录大量的信息,标记的对象需要跟踪,抛出和处理都需要编译器对代码进行相应的优化。

    所以如果某些函数是绝对不会抛出异常的时候,我们可以在其后标记noexcept,来显式的告知编译器:这个人是个老实人,不会跳楼,不用对他做什么优化工作。

    int add(int,int) noexpect;
    

    一般而言,类内的移动构造函数、移动赋值运算符和 swap 函数都需要保证不抛异常并标为 noexcept。

    不过这个noexcept只是一种承诺和保证,虽然对外承诺说我保证不抛异常,但并不意味着真的不抛异常。出异常了只能说这里发生了一些不符合预期的事情,此时系统会直接调用std::terminate中断程序的执行。

    所以如果被标记为不会跳楼的老实人真的跳楼了,那他会死的很干脆。虽然有点出乎意料,但好像确实符合老实人的实际处境。

    7

    当然了,完全不用异常和完全不用错误码都是比较极端的做法,异常和错误码配合使用味道更佳。

    在一些允许频繁出错的地方,比如网络波动引起的错误,硬件设备操作的故障等等,还是老老实实使用错误码比较好。毕竟检查健康码的成本,总比防护跳楼的成本要低一些。

    说到跳楼,神探夏洛克 S2E3,被莫里亚蒂用自杀逼入死局的夏洛克,由于事先已经写好了异常处理机制,他自信的站在圣巴塞洛缪医院四层的楼顶,看了看左手边的圣保罗教堂,望着基友华生的侧脸,

    纵身一跃。

  • 相关阅读:
    网络安全工程师日常工作有哪些?初学者怎么适应
    罗马数字转整数
    2022-11-27 ARM- 用C语言实现stm32的三盏灯的点亮
    openmp 超越通用核心
    C++模板介绍
    华为云云耀云服务器L实例评测|使用Benchmark工具对云耀云服务器Elasticsearch的性能测试
    uart_printf自定义串口printf输出
    了解CSS Flex:解析实例、用法和案例研究
    【每日一题Day361】LC2558从数量最多的堆取走礼物 | 大顶堆
    【C语言】自定义类型——枚举和联合体
  • 原文地址:https://blog.csdn.net/qq_38639426/article/details/125442087