6.2 如何传达错误

    假设有一个执行时可能出错的函数,取名为 shippai。调用这个函数时,有可能成功也有可能出错。那么该如何分别编写成功时的操作和出错时的操作(错误处理)呢?

    错误处理的编写方法大体可分为两种。一种是利用 shippai 函数的返回值来传达程序出错的信息,函数调用方通过检查返回值来相应地对错误进行处理。另一种是在调用 shippai 函数前设定好错误处理的代码,错误发生时能跳转至相应的错误处理代码。前者至今还在 C 语言等语言中经常使用,后者则被称为异常处理。

    通过返回值传达出错信息

    我们首先来看通过返回值传达出错信息的方法。比如,shippai 函数事先将返回值定义为成功执行时返回 0 值,出错时返回 0 以外的值。函数调用方在调用 shippai 函数后,会检查其返回值,返回非 0 值时会进行错误处理。

    C语言
    if(shippai()){
    / 错误处理 /
    }

    ※ !x 即为 x 非 0。

    这也就是说,出错时把信息写入返回值,接着做返回值检查。与之相类似的有先行定义全局变量接收错误信息的方法,还有在函数调用方定义变量作为引用形参接收错误信息的方法。无论哪种,遵循的都是出错时写入信息然后做检查的思路。

    这种方法在 C 语言等众多语言中广泛使用。但是它有两个问题:

    • 遗漏错误

    • 错误处理导致代码可读性下降

    下面我们来详细讨论这两个问题。

    遗漏错误

    首先是遗漏错误,程序员忘记了对返回值做检查,从而遗漏了错误。

    程序员也是普通人,他们也会时常忘记 shippai 函数有可能会出错。如此一来,shippai 函数调用完后没有对返回值做检查,按照执行成功的固定思维编写了代码 2。

    2比如,C 语言中写入文件操作时常用到 fprintf 这个函数,它在执行出错时返回值为负。你对这个有做检查吗?还是想当然以为执行成功从而遗漏了错误呢?

    如果 shippai 函数是一个极少执行出错的函数,那这段代码在大多数情况下都可以正确运行,程序员就会深信这段代码没有问题。发现有问题时,正是 shippai 函数执行出错,程序执行与预期不一致的时候。谁都不知道这将是什么时候的事了,或许是在产品正式发布了之后。

    这种情况下,由于编写代码和发现问题在时间上已经相差较远,代码问题的追查往往会很辛苦。此外,由于 shippai 函数执行出错,某个值可能与期待值不一样,进而导致别的函数执行出错,如此下去将形成多米诺骨牌式的连锁反应。问题是在看起来和 shippai 函数没有任何关系的地方被发现的,这样一来就更难发现真正的问题所在了。

    理想的情况是,程序员在准备调用函数时,先确认好该函数是否可能执行出错以及出错时返回什么值。如果能严格按此操作进行,使用返回值传达错误的方法应该不会造成遗漏错误的情况。然而,现实中因忘记检查返回值导致错误的情况不胜枚举。

    那该怎么办呢?

    错误处理导致代码可读性下降

    接下来,我们来看第二个问题。知道了第一个问题,对所有的函数调用都做仔细的检查以免遗漏错误,然后着手编写错误处理的代码,然而接踵而来的是另一个问题。由于错误处理,源代码变得很难读懂。

    执行三个可能出错的操作,如果在某处操作失败,接下来的操作就不会被执行转而进行错误处理。这里的三个操作依次是 shippai("A")、shippai("B")、shippai("C"),程序代码如下:

    C语言
    int main(){
    if(shippai("A")){
    / 失败时的处理 /
    / 失败时的处理 /
    / 失败时的处理 /
    }else if(shippai("B")){
    / 失败时的处理 /
    / 失败时的处理 /
    / 失败时的处理 /
    }else if(shippai("C")){
    / 失败时的处理 /
    / 失败时的处理 /
    / 失败时的处理 /
    }
    }

    本来想要执行三个操作,而在代码实现中引入了大量错误处理代码,夹在本意要执行的操作中,这使程序流变得难以读懂。

    那么就没有可读性更好的编写方法吗?如果错误处理相同 3,能不能集中处理?怎么样才能集中起来呢?

    3比如,打开日志文件,增加标识执行失败的信息,然后关闭日志文件这样的错误处理。

    通过跳转集中进行错误处理

    为了集中进行错误处理而使用异常,但 C 语言里并没有异常这一机制。它使用 goto 语句。下面的代码把错误发生时的处理用 goto 语句集中起来 4。

    4Linux 的发明者林纳斯·托瓦兹(Linus Torvalds)在其文章“Linux 内核编码风格”中推荐使用 goto 语句把函数的结尾处理集中起来。英文版可参照:http://www.linuxfromscratch.org/alfs/view/hacker/part2/hacker/coding-style.html
    C语言
    int main(){
    if(!shippai("A")) goto ERROR;
    if(!shippai("B")) goto ERROR;
    if(!shippai("C")) goto ERROR;
    return;
    ERROR:
    / 失败时的处理 /
    / 失败时的处理 /
    / 失败时的处理 /
    }

    我们来看一下这段代码,执行 shippai("A") 如果出错,跳转至 ERROR。成功则移至下一行执行 shippai("B"),如果出错跳转至 ERROR。同样执行 shippai("C"),如果出错,跳转至 ERROR。如果都成功则执行下一行 return,返回跳出函数。这样,错误处理只在跳转至 ERROR 处时才被执行。

    从代码形式上看,这就做到了把针对出错时的代码和记述本来想做的事的代码分离。

    出错则跳转

    到此为止,我们学习了传达错误的方法之一——通过返回值传达错误。现今,这一方法在C语言等很多语言中被广泛使用。

    实际上,在 C 语言诞生以前就已经存在其他错误处理方法。这种方法事先定义好了错误发生时跳转的位置,后来,它演变为现在的异常处理。为更好理解地异常处理产生的原因,我们来回顾一下那段历史。

    UNIVACI

    事实上,错误发生时跳转这一想法的产生甚至比程序设计语言的产生还要早。1950 年设计的计算机 UNIVACI 中就有了这样的功能,在计算中出现溢出时,它会执行在 000 处编写的命令。这种功能被称为“中断”(interrupt),广泛被运用于错误处理等各领域中。比如,键盘上某按键被按下时,CPU 就能收到按键信号,传达这一消息的就是中断功能。

    COBOL

    早期的程序设计语言是如何进行错误处理的呢? 1954 年出现的程序设计语言 FORTRAN 语言中还没有异常处理机制,直到 1959 年,新问世的 COBOL 语言中才设计了两种类型的错误处理机制。与现代异常处理中的通用语句结构不一样的是,这两种类型的错误处理都有各自独特的语句结构。一种结构是用 READ 命令读取文件时,由 AT END 关键字引出没有数据等错误处理的语句。另一种结构是用 ADD 命令做数值的四则运算时,由 ON SIZE 关键字引出溢出等错误处理的语句。

    COBOL
    READ <文件名> AT END <错误处理语句>
    ADD <函数名> ON SIZE ERROR <错误处理语句>

    当时错误处理仅有这两种类型,程序员无法自由增加设计错误的种类,这和现代的异常处理机制是不同的 5。

    5《情報処理月例会資料》(中文译名:信息处理每月例会资料)1965/8/24

    PL/I

    到了 1964 年,PL/I 程序设计语言诞生了,它是 FORTRAN 语言、COBOL 语言、ALGOL 语言的集大成 6,为实现灵活统一的错误处理,引入了 ON 语句结构。当时有的 PL/I 教材中列举 GOTO、IF、DO 和 ON 这四种语句来讲解控制语句,它们分别对应跳转语句、条件语句、循环语句和异常处理 7。

    6《プログラミング言語の歴史と展望》(中文译名:程序设计语言的历史与展望),中田育男,《情報処理》(中文译名:信息处理)Vol.21, No5, 1980 年,p.574。
    7《わかりやすいプログラミング 4》(中文译名:简单易懂的程序设计 4 PL/I),竹下亨,1969 年。

    与 6.2.1 节介绍的 C 语言代码相近,下面的代码是在 PL/I 语言中的表现方式。

    PL/I
    SHORI: procedure;
    on error go to ERROR; /出错时跳转至ERROR处的意思/
    call shippai(1);
    call shippai(2);
    call shippai(3);
    return;
    ERROR: <失败时的处理>;
    <失败时的处理>;
    <失败时的处理>;
    end;

    C 语言代码中通过使用 if 语句来检查返回值,这里已经没有检查操作。这里体现的思想是,不是让程序设计者编写程序时,时刻记着不能忘记返回值检查,而是让语言处理器来自动检查是否出错。

    另外,在 COBOL 语言中仅有的两种错误类型的基础上,PL/I 中可以追加定义新的错误类型,从而可以根据错误类型的不同,轻松地变换错误处理的操作。

    PL/I
    / 定义名为MY_ERROR的条件 /
    dcl MY_ERROR condition;

    on condition (MY_ERROR)
    begin;
    / MY_ERROR发生时的错误处理 /
    end;

    PL/I 语言的错误处理机制还有另一种重要功能,程序可以主动触发新定义的错误类型。在 C 语言通过返回值传达错误的方法中,如果是 return -1,就会返回-1 向调用处传达出错信息。而在 PL/I 语言中,通过语句 signal condition (MY_ERROR),可以触发 MY_ERROR,传达出错信息。

    PL/I
    / 触发MY_ERROR /
    signal condition (MY_ERROR);

    可追加错误类型和可自主触发出错,这两种功能为现代的异常处理机制所继承,具有重要意义 8。

    8当时不叫失败或异常,叫条件(condition)。另外,抛出异常的命令也不是现在一般的 raise 或 throw,而是 signal。