从/0开始:聊聊异常

是的,没有打错,标题中是/0而不是0
那么问题就来了:除以0会发生什么?

限定条件是必须的:在CS领域,*nix | win操作系统下任意编程语言中,整数除法运算中除数为零的情况。

答案并不是固定的,在不同的操作系统,不同的编程语言,甚至不同的编译器下,答案都可能是不同的。

除0异常

譬如, 在OS X下,使用C语言,Clang编译,引发除零并不会报错,会返回一个垃圾值。

$ echo 'void main(){printf("%d",1/0);}' > a.c && gcc a.c 2> /dev/null && ./a.out
1512003000

同样的代码在Linux下,使用C语言,GCC编译,就会引发Float point exception

$ echo 'void main(){printf("%d",1/0);}' > a.c && gcc a.c 2> /dev/null && ./a.out
Floating point exception

C++在两种环境中与C表现是一致的。至于Windows,手头没有Windows机器且VS只支持C++,但没记错的话/Od下Windows是会通过SEH抛Exception的,而/O2则会返回垃圾值。But who cares windows here….

相比之下Python与Java在不同的系统中表现是一致的:

$ python -c 'print(1/0)'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ZeroDivisionError: integer division or modulo by zero
$ echo "class DZ{public static void main(String[] args){System.out.println(2/0);}}" > DZ.java && javac DZ.java && java DZ
Exception in thread "main" java.lang.ArithmeticException: / by zero
    at DZ.main(DZ.java:1)

Js这种只有浮点数的奇葩‘巧妙’地用Inf绕开了这个问题,就不讨论了。备注:浮点数除数为0是合法的。

硬件级异常

那么在除0的时候,究竟发生了什么呢?查阅Intel芯片手册可以发现,在x86机器上DIVIDIV指令除数为0时,会引发0号中断,编号#DE(Divide Error),即所谓除零异常。

如果做过王爽《汇编语言》里面的小实验:编写零号中断处理程序,就能知道在硬件机器码与汇编编程的洪荒年代里,异常是怎样处理的:程序员需要自己写一段代码,作为硬件中断的处理程序。

当然,在没有操作系统的环境里,所谓“异常”,其实就是硬件级异常,翻来覆去也就哪几种:除零、溢出、越界、非法指令等等。异常的种类虽然不多,但想找出异常的原因,或者编写合适的处理函数确实是相当让人抓狂的工作。

许多我们耳熟能详的概念,例如进程与文件,都是伴随操作系统的发明而引入的。

在拥有文件概念的现代操作系统中,数据被存放在文件里,有独立的从零开始的寻址空间,程序员只需要通过文件路径就能拿到这坨数据;如果文件不存在,可以通过open的返回值-1和全局的errno来判断究竟是什么原因导致了错误。想一想这是多么幸福的事啊!在洪荒年代,整个计算机就那么一两个寻址空间,对应着内存或者硬盘,数据就放在固定的偏移量,没什么所谓的文件(其实在固定偏移量维护一点元数据,这就是所谓的文件系统了)。如果读取不出有意义的数据那就只能报错挂掉呗,根本没有所谓的“FileNotExistException”。

除了文件,进程也是一样。在没有操作系统的世界里,连栈的概念都不存在。控制流的玩弄可以称得上随心所欲,只要不越界,不跳到非代码段,整个世界真是天高任鸟飞,随你怎么跳。

在洪荒年代里,异常处理就是处理硬件异常。硬件异常的种类只手可数,不除零,不越界,不干蠢事,几乎可以说是百无禁忌。当然这并不一定是好事,人们往往声称向往自由;但在真正的自由面前,很少的人才能把握方向,其他人只能在无穷的选择面前感到焦虑迷茫。

程序员们呼唤着新秩序的到来,于是就有了操作系统。

操作系统级异常

时代在发展,C语言和操作系统出现了,程序员们从洪荒年代进入了远古时代。终于告别了直接和硬件异常打交道的苦日子。但从C语言的错误处理方式中,我们还是能看到那个时代的缩影。

操作系统引入诸多新颖抽象,随之而来的则是各种新颖的异常:文件打开失败,进程fork失败。这些异常,不同于硬件级异常,属于操作系统的异常。POSIX标准中很多系统调用使用返回-1的方式告知调用者出现异常,通过设置全局errno的方式传递异常的具体原因。于是我们经常能看见这样的代码:

if (somecall() == -1) {
  printf("somecall() failed\n");
  if (errno == ...) { ... }
}

但还有一个问题:原来的硬件异常怎么办?

譬如喜闻乐见的野指针越界:Segmentation fault

$ echo 'void main(){int* p;printf("%d",*p);}' > a.c && gcc a.c 2> /dev/null && ./a.out
Segmentation fault

虽然printf并不是系统调用,只是一个库函数。即便如此,发生硬件异常时,库函数并没有如同发生普通的操作系统异常一样返回-1 ,而是直接CoreDump给程序员一个Surprise~,Tada~。

因为这种异常并不是操作系统产生的,操作系统面对硬件异常也要挠头。怎么办?显然,让程序员自己编写0号中断处理程序是不现实的,操作系统能做的就是把接受这个硬件中断包装成一个操作系统的中断,即“信号”的概念,然后发送给进程。这几个异常信号进程要是不处理,默认的行为就是挂掉。

但是到了操作系统的时代,编写除零、越界信号的处理程序往往是没有太大意义的……,因为程序员在此类异常发生后往往无能为力。不然怎么着,越界读写是准备重试?还是不读了跳过?除零错误是准备加个小的抖动偏移量除出一个天文数字?还是准备拿着垃圾值凑数?如果有这个闲工夫写这种Handler,为啥不在错误语句事前加上条件判断呢……。程序能做到的最好程度,无非是handle SIG之后打好日志,保留现场然后老老实实的挂掉……。

所以,在操作系统级(C,C++),我们还是可以清晰地看到硬件异常与操作系统异常处理方式的差异,前者通过信号(Linux),后者通过返回值和错误码。

在Linux下C语言处理硬件异常的方式:

#include <signal.h>
#include <stdio.h>    

void handler(int a) {
    printf("SIGNAL: %d",a);
}

int main() {
    signal(SIGFPE, handler);
    int a = 1/0;
}
$ gcc a.c 2> /dev/null && ./a.out
SIGNAL: 8

高级语言中的异常

C和C++是所谓的“中级”语言,由于标准库的功能非常有限,在不同操作系统中,程序员还是需要与不少Ad-Hoc的细节打交道。Java的出现可以说解决了(Well, at least part of)这一问题。我们可以看到Java中整数除0发生的是java.lang.ArithmeticException,看上去和其他异常并没有什么不同。只是所属的uncheked RuntimeException好像又隐隐地告诉着我们这个异常和其他异常有点不太一样。

虽然说JVM提供了中间字节码的解释器,但最终JVM还是使用C或汇编将字节码映射为系统调用与机器指令。那么操作系统异常与硬件异常仍然是不可避免的。但是JVM会帮程序员打理好这一切:当发生硬件级异常,比如除零错误时,Java捕获SIGFPE,SIGSEGV等异常信号(Linux下),并将其转化为语言内部的异常抛出;相比之下,诸如文件没找着这种系统调用失效,也都会被Java包装相应的异常;在Java的语言概念中,至少在处理方式上并没有对这些异常(硬件异常,操作系统异常,应用逻辑异常)进行区分,程序员想捕获都能用同一种方式来捕捉处理。

世界大同了吗?Java这一类高级语言虽然在形式上消弭了硬件异常、操作系统异常、应用异常的区分,但从语义设计、编程规范、工程实践的方式,却制定了另一种分类方式:

另一种异常划分的方式

先来看一下Java异常与错误的继承关系。这个继承树中有三大类叶子节点:

ErrorRuntimeExceptionBlahblah...Exception

  • BlahblahException就是程序或者库定义的普通异常,需要显式在代码中处理。
  • Error是JVM运行时产生的致命错误,不允许去处理。不过实际上去catch throwable也是可以的……。
  • RuntimeException,又称为unchecked Exception。是不推荐程序员去捕获的异常。

事实上我们可以还原出这样对异常分类的设计初衷,如下表所示:

原因能否处理程序员能处理的(checked)程序员处理不了的(unchecked)
设计缺陷假命题RuntimeException
操作失效普通Exception,需要显式处理Error

老朋友除零异常换了身马甲:java.lang.ArithmeticException藏在了RuntimeException中。

  • 程序员能处理的设计缺陷,本身就是一个矛盾的陈述。
  • 程序员能处理的操作失效,就是Java中普通的异常。这类异常设计的初衷就是提供一种Fancy的控制流,程序员在调用链条中玩起抛绣球游戏,让错误处理变得方便一些。
  • 程序员处理不了的设计缺陷,属于所谓的RuntimeException。这一点需要解释一下:大家都知道防止NPE是程序员的基本修养。除非文档显式指明,拿到参数或者返回值,首先要做的就是检查是否为空。同理,程序员也有义务在逻辑上保证除法的除数不为0。如果程序员没有这么做,那么这就是一个设计缺陷。任何硬件异常,或者可能导致硬件异常的条件(譬如:除0,数组越界、野指针、栈溢出),都应当在运行时抛出RuntimeException
  • 程序员处理不了的操作失效:另一方面,JVM本身也是一个程序。人固有一死,程序固有一挂。无论是因为JVM自己的BUG也好,还是环境条件不符合预期,当JVM陷入严重错误时,程序员对此是毫无办法的(自己去改Jvm不算!),这类异常是所谓的程序员处理不了的操作失效,即Error

对于程序员处理不了的异常,Java处理为unchecked Exception,也就是无需在函数签名后显式列出此类异常。这很好理解,如果这类异常需要指明,那每个使用到指针和除法的地方都可能会抛异常,也就是说几乎每个函数都要在签名后面加上throws RuntimeException,蛋疼无比。所以uncheckRuntimeException所必须的性质。

这就引出另一个问题了,Error也是非检查的异常。Error就是一种特殊的RuntimeException,它只是运行时异常的一个细分子类。其实在程序员看来,只有两种异常:我能处理的,我不能处理的。无论是JVM挂了还是程序员设计缺陷,这些异常都不是程序员能处理的,也不是程序员该处理的。进行细分实在没有必要,无故将事情复杂化。这一点上我觉得Java设计还是蛮恶心的。另外Java的RuntimeException真的是个垃圾筐,什么垃圾异常都往里装。比较合理的设计应当参考C# Runtime Exception 。运行时只会抛出几种异常,都可以与硬件异常对应上,其他的异常都是普通异常。

总结

从程序员的视角,异常分为两种:能处理的应用异常,处理不了的运行时异常

  • 应用异常是程序员或库作者所使用的错误处理方式。这种异常设计就是为了被捕获处理。
  • 运行时异常属于系统异常,产生原因应当包括两个:应用设计缺陷导致的硬件异常。环境条件导致的JVM或者CRT严重操作失效。不管怎样,这种异常设计就是为了让程序赶紧当掉避免造成更大损失的。

从异常的原因来说,异常分为:设计缺陷操作失效

  • 设计缺陷是因为程序员或者库作者的考虑不周导致的,应当立即挂掉暴露出错误来。
  • 操作失效是因为环境条件不满足导致的异常,不太严重的操作失效是可以抢救一下的,例如IO Timeout可以等一段时间重试几次,不行再挂掉,或者可选步骤出错可以直接跳过。严重的操作失效,比如JVM自己尿了,那就没办法了,早死早超生吧。

最后,回到最初的问题

除零会发生什么呢?

在Intel x86_64 Linux下:

  • CPU执行div指令,遇到操作数为0,产生0号中断(#DE)
  • Linux内核捕获0号中断,给相应进程产生一个SIGFPE (8)
  • 进程接受到信号

    • 不处理:产生CoreDump
    • 程序自行处理:例如C中注册SIGFPE信号的handler,实现异常捕获。
    • 运行时压制:比如一些C运行时就偷偷忽略或者压制了这个异常,提着垃圾高高兴兴回家了。(C++标准中,整数除以0是未定义行为,读者可以自行实验。)
    • 运行时包装并抛出:Java和Python的运行时接受到信号后,转换为相应的语言内异常抛出。runtimeException一般不捕获,所以一般来说程序就挂了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值