计算电脑编程领域中,异常处理(exception handling,也意译为异常处理,需注意“异常”一般对应英文abnormality[1]),是对出现的例外的响应处理,在程序执行英语Execution (computing)期间,异常或例外情况需要特殊处理。一般而言,例外打断正常的执行流程并执行预先登记的“例外处理器”;具体如何去做依赖于它是硬件还是软件例外,还有软件例外是如何实现的。

例外是由电脑系统的不同层级来定义的,典型的层级有CPU定义的中断操作系统(OS)定义的信号编程语言定义的例外。每个层级都要求不同例外处理方式,但是它们可以是关联的,比如说CPU中断可能被转变成OS信号。一些例外特别是硬件例外,可以被优雅地处理使得程序执行能在它被中断的地方恢复。

硬件的例外处理

硬件的异常处理机制由CPU完成。这种机制支持错误检测,在发生错误后会将程序流跳转到专门的错误处理例程中。发生异常前的状态存储在栈上。[2]

操作系统的例外处理

针对程序中可能发生的例外,操作系统可能通过IPC来提供对应的处理设施。进程执行过程中发生的中断通常由操作提供的“中断服务子程序”处理,操作系统可以借此向该进程发送信号。进程可以通过注册信号处理器的方式自行处理信号,也可以让操作系统执行默认行为(比如终止该程序)。

从进程的视角,硬件中断相当于可恢复异常,虽然中断一般与程序流本身无关。

编程语言的例外处理

编程语言领域,通常例外(英语:exception)这一术语所描述的是一种资料结构,该资料结构可以存储例外的相关消息。例外处理的常见的一种机制是移交控制权。引发(raise)异常,也叫作抛出(throw)异常,通过该方式达到移交控制权的效果。例外抛出后,控制权会被移交至某处的(catch),并执行处理。

编程语言对例外有着截然不同的定义,而现代语言大致上可分两类:[3]

  • 用作于控制流程的例外,如:Ada、Java、Modula-3、ML、OCaml、Python、Ruby 。
  • 用作于处理异常、无法预测、错误性的情况。如:C++[4]、C#、Common Lisp、Eiffel、Modula-2 。

子程序作者的角度看,如果要表示当前子程序无法正常执行,抛出例外是很好的选择。无法正常执行的原因可以是输入参数无效(比如值在函数的定义域之外),也可以是无法获得所需的资源(比如文件不存在、硬盘出错、内存不足)等等。在不支持例外的系统中,子程序需要通过返回特殊的错误码英语Error code实现类似的功能。然而回传错误码可能导致不完全预测问题英语Semipredicate problem,子程序的使用方需要编写额外的代码,才能将普通的回传值与错误码相区别。

Kiniry强调:“语言设计仅仅部分地影响了对例外的使用,从而影响编程者处理系统执行期间部分或所有失败的方式。其他主要的影响还有在核心库、技术书籍、杂志文章、在线研讨论坛和特定组织的代码标准中的使用示例”。[5]

历史

在1960和1970年代,Lisp语言发展出软件例外。最初版本是在1962年Lisp 1.5的时候,这时候异常通过ERRSET关键词进行捕捉,并在出错时候,通过NIL进行回传,而不是以前的终止程序或者进行调试器。[6]1960年代后半,Maclisp语言通过ERR关键词引入“引发”(Raise)错误机制。[6]Lisp的这种创新不仅仅被应用于抛出错误,还被应用于“非局部控制流”。在在1972年6月,Maclisp语言通过CATCHTHROW两个新的关键词来实现非局部控制流,并保留ERRSETERR专门做错误处理。在1970中后,NIL英语NIL (programming language)(“新实现的LISP”)派生出清除操作UNWIND-PROTECT,对应着现今常见的finally[7]该操作也被Common Lisp使用了。与之同时代,Scheme也诞生了dynamic-wind,用于处理闭包中的异常。Goodenough (1975a)Goodenough (1975b)是介绍结构化的异常处理的开创性文章。[8] 1980年后,异常处理被广泛利用于许多编程语言。

PL/I语言使用的是动态作用域例外,然而稍微现代的编程语言多用词法作用域的例外。PL/I语言的例外处理包含事件(不是错误)、注意(Attention)、EOF、列举了的变量的修改(Modification of listed variables)。虽然现在的一些编程语言支持不含错误资讯的例外,但是他们并不常见。

一开始,软件的例外处理是包含可恢复的例外,它具有恢复语义,就像大部分的硬件例外一样,以及不恢复的例外,它具有终止语义。但是,在1960和1970时代,在实践中得出恢复语义是十分低效的(C++标准相关的讨论可见[9]),因此恢复语义就很少再出现了,通常只能在类似Common Lisp和Dylan这种语言中见到。

批评

1980年Tony Hoare在评论Ada语言时,将异常处理提及为危险特征。[10]

对于软件而言,异常处理经常无法正确的处理,尤其是当这里有多种来自不同原始码的异常时。在对五百万行Java代码进行数据流分析时,我们发现了超过1300个异常处理。[11]这是1999-2004年的前沿报告以及他们的结论,Weimer和Necula写到,异常是一个十分严峻的问题,他们会创造隐藏的控制流途径,这种途径是编程人员很难去推理的。

Go语言的初始版本并没有异常处理,而因此被有的开发者认为控制流十分冗余。[12]后来,追加了类似的异常处理的语法panic/recover机制,但是Go语言的作者建立这仅仅在整个程序不可恢复的错误时候使用它。[13][14][15][16]

异常,作为一个非结构化的流程,它会增加资源泄露的可能性(如:从锁住的代码中逃脱,在打开文件时候逃脱掉),也有可能导致状态不一致。因此,出现了集中异常处理的资源管理技术,最常见的结合dispose pattern和解除保护(unwind protection)一起使用(如finally语句),会在这段代码的控制权结束时自动释放资源。

编程语言相关支持

许多常见的程式设计语言支持异常处理,包括:

多数语言的异常机制的语法是类似的:用throwraise抛出一个异常对象(Java或C++等)或一个特殊可扩展的枚举类型的值(如Ada语言);异常处理代码的作用范围用标记子句(trybegin开始的语言作用域)标示其起始,以第一个异常处理子句(catch, except, rescue等)标示其结束;可连续出现若干个异常处理子句,每个处理特定类型的异常。某些语言允许else子句,用于无例外出现的情况。更多见的是finally, ensure子句,无论是否出现异常它都将执行,用于释放异常处理所需的一些资源。

C语言没有try-catch异常处理,而是使用返回码英语Error code用于错误检查;setjmplongjmp标准库函数可以被用来通过宏实现try-catch处理[17]。一般在异常处理代码的搜索过程中会逐级完成栈卷回(stack unwinding);但Common Lisp中进行异常处理的条件系统,不采取栈卷回,因此允许异常处理完后在抛出异常的代码处原地恢复执行。

C++

C++异常处理资源获取即初始化(RAII)的基础。异常事件在C++中表示为“异常对象”(exception object)。异常事件发生时,由操作系统为程序设置当前异常对象,然后执行程序的当前异常处理代码块,在包含了异常出现点的最内层的try块,依次匹配同级的catch语句。如果匹配catch语句成功,则在该catch块内处理异常;然后执行当前try...catch...块之后的代码。如果在当前的try...catch...块没有能匹配该异常对象的catch语句,则由更外一层的try...catch...块处理该异常;如果当前函数内的所有try...catch...块都不能匹配该异常,则递归回退到调用栈的上一层函数去处理该异常。如果一直回退到主函数main()都不能处理该异常,则调用系统函数terminate()终止程序。

Python

Python中只存在语法错误和例外。语法错误是在运行之前发生的。而例外是在运行时发生的错误,除非进行捕捉处理,否则它将无条件停止程序。可以书写代码来处理选定的例外。[18]

Python语言中对例外处理机制的采用是非常普遍深入的,这种编码风格被称为EAFP(请求原谅比得到许可更容易)[19],它假定有效的键或特性存在,并在这个假定证明失败时捕获例外。Python社区认为这种风格是清晰而快速的,它的特征是会出现很多tryexcept语句。这种技术对立于常见于很多其他语言比如C语言中的LBYL(看好再跳)风格。

Java

Java中异常是异常事件(exceptional event)的缩写。异常是一个事件,它发生在程序运行时并会打乱程序指示的正常流程。当方法出现了错误时,方法会创建一个对象并将它交给运行时系统,所创建的对象叫“异常对象”,该对象包含了错误的资讯(描述了出错时的程序的类型和状态)。创建错误对象和转交给运行时系统的过程,叫抛出异常。[20]

class RuntimeExceptionclass Error均是不检查的异常(Unchecked Exceptions)。[21]错误不等于错误类(class Error),错误类代表着不应该被捕捉的严重的问题。[22]class RuntimeException 意味着程序出现问题了。[21]

Go

Go语言提倡的是错误处理(error handling)。Go语言设计者系统希望用户在错误出时,显式地检查错误。[23] Go虽然不提供与Java语言的try..catch同等的功能语句,但是取而代之,提供了轻型的异常处理机制panic...recover[24]

.NET语言

大多数.NET程式设计语言,内建的异常机制都是沿着函数调用栈的函数调用逆向搜索,直到遇到异常处理代码为止。而 Visual Basic(尤其是在其早于 .net 的版本,例如 6.0 中)走得更远:on error 语句可轻易指定发生异常后是重试(resume)还是跳过(resume next)还是执行程序员定义的错误处理程序(goto ***)。

错误处理

错误处理(error handling)是通过处理函数的返回值的形式从而处理错误的一种编程方式。在Go等返回值可为复数的语言中,可通过将其中一个值设为错误值,从而达到错误处理的效果。

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

在仅仅支持返回状态码的语言里,可通过处理错误码,达到错误处理的效果。shell语言可通过$?获得函数执行的退出码,从而判断是否出错。

在其他语言中,可以通过判断结果的某一个特征,从而达到错误处理部分的效果,但不意味着这些语言自身支持错误处理。如,Java等面向对象的语言往往会通过null值判断是否执行失败,但有时候也会通过异常处理判断是否执行失败。

技术问题

未捕捉异常

如果一个异常抛出后,没有被捕捉,那么未捕捉异常(uncaught exception)将会在运行时被处理。进行该处理的例程叫“未捕捉异常处理器”(uncaught exception handler[25][26]。大部分的处理是终止程序并将错误资讯打印至控制台,该资讯通常包含调试用的资讯,如:异常的描述资讯、栈追踪[27][28][29]通常处于最高级(应用级别)的处理器,即便捕捉到异常也会避免终止自身(如:线程出现异常,主线程也不会终止)。[30][31]

值得了解的是,在即便未捕捉异常导致了程序异常中断(如:异常没被捕捉、滚动未完成、没释放资源),程序仍旧能正常地顺序性地关闭。只要确保运行时系统能正常地运行,因为运行时系统控制着整个程序的执行。

作为默认的未捕捉异常处理器是可以被替换的,不管是全局还是单线程的,新的未捕捉异常处理器可以尝试做这些事情:未捕捉异常导致关闭了的线程,使之重启;提供另一种方式记录日志;让用户报告未捕捉异常等等。在Java中,单一线程可以使用Thread.setUncaughtExceptionHandler[32],全局可以用Thread.setDefaultUncaughtExceptionHandler[33];在python中,可通过修改sys.excepthook[34]

异常的静态检查

检查性异常

Java的设计者设计了[35] 检查性异常(Checked exceptions)[36]。当方法引发“检查性异常”时,“检查性异常”将成为方法符号的一部分。例如:如果方法抛出了IOException ,我们必须显式地使用方法符号(在Java中是try...catch),如果不这样做的话将会导致编译时错误。

异常安全

一段代码是“异常安全的”,如果这段代码运行时的失败不会产生有害后果,如内存泄露、存储数据混淆、或无效的输出。异常安全可分成不同层次:

  1. “失败透明”,也称作“不抛出保证”:代码的运行保证能成功并满足所有的约束条件,即使存在异常情况。如果出现了异常,将不会对外进一步抛出该异常。(异常安全的最好的层次)
  2. “提交或卷回的语义”,或称作“强异常安全”或“无变化保证”:运行可以是失败,但失败的运行保证不会有负效应,因此所有涉及的数据都保持代码运行前的初始值。[37]
  3. “基本异常安全”:失败运行的已执行的操作可能引起了副作用,但会保证状态不变。所有存储数据保持有效值,即使这些数据与异常发生前的值有所不同。
  4. “最小异常安全”,也称作“无泄漏保证”:失败运行的已执行的操作可能在存储数据中保存了无效的值,但不会引起崩溃,资源不会泄漏。
  5. “没有异常安全”:没有保证(最差的异常安全层次)。

例如,考虑一个smart vector类型,如C++的 std::vector或Java的 ArrayList。当一个数据项x插入vector v,必须实际增加x的值到vector的内部对象列表中并且修改vector的计数域以正确表示v中保存了多少数据项;此时如果已有的存储空间不够大,就需要分配新的内存。内存分配可能会失败并抛出异常。因此,vector数据类型如果是“失败透明”保证将会非常困难甚至不可能实现。但vector类型提供“强异常安全”保证却是相当容易的;在这种情况下,x插入v或者成功,或者v保持不变。如果vector类型仅提供“基本异常安全”保证,如果数据插入失败,v可能包含也可能不包含x的值,但至少v的内部表示是一致的。但如果vector数据类型是“最小异常安全”保证,v可能会是无效的,例如v的计数域被增加了,但x并未实际插入,使得内部状态不一致。对于“异常不安全”的实现,程序可能会崩溃,例如写入数据到无效的内存。

通常至少需要基本异常安全。失败透明是难于实现的,特别是在编写库函数时,因为对应用程式的复杂知识缺少获知。

引用

参考文献

外部链接

Wikiwand in your browser!

Seamless Wikipedia browsing. On steroids.

Every time you click a link to Wikipedia, Wiktionary or Wikiquote in your browser's search results, it will show the modern Wikiwand interface.

Wikiwand extension is a five stars, simple, with minimum permission required to keep your browsing private, safe and transparent.