程序设计语言中的错误与异常

程序设计语言对错误的支持机制

Posted by John Mactavish on April 14, 2020

在程序的编写与运行过程中,错误与异常的出现几乎是不可避免的:用户可能会输入非法的字符串, 网络连接有可能会突然中断,可能没有权限打开文件,再到基础一点的,算术除零也会引发异常。 那么我们就希望能有一种机制能够尽可能地降低错误与异常给我们带来的损失。

错误码

在较为原始的 C 语言中,人们有时会用一些整数来代表特定的错误。他们约定当函数返回某个值 (一般是 0 )时即为正常执行,而如果返回其他值则按照事先确定的错误码含义进行处理。 Unix 也类似地提供有 errno (So-called “errnos” are symptoms of operating system errors.) 这种东西。一般可以通过宏定义或全局常量来表示这些错误码,或者为其定义枚举值。

Errno 1: Operation not permitted
Errno 2: No such file or directory
Errno 3: No such process
Errno 4: Interrupted system call
Errno 5: Input/output error
Errno 6: No such device or address
Errno 7: Argument list too long
Errno 8: Exec format error
Errno 9: Bad file descriptor

......

优点是简单,并不需要对其进行特别地处理。至于缺点嘛,第一个就是完全基于约定,增加了不稳定因素与复杂度。 需要将错误码的约定,在头文件等地方写清楚,并通过文档、注释讲清楚;当项目的规模扩大时, 会出现大量这样的约定,从而造成开发者的心智负担迅速扩大,也容易在错误处理处犯错。 第二个问题是这样定义的值通常不方便给出更详细的错误信息。错误码在调试器中通常会表现为枚举用的整型字面量, 会给调试带来困难。

自定义错误类型

一种改进的方法是自己定义特别的错误类型。通常有两种实现方式:第一种,将函数调用返回的结果与是否错误的标记位 都包括在该类型当中,获取结果时,检查其是否出错;若出错进行错误处理,否则从中取出结果。另一种则仅用其 传递错误信息,至于需要返回的结果,一般通过函数参数传指针实现。

通过这种方案,我们可以在生成错误时,选择添加一些附加信息以帮助调试。同时这让为该类型构造一系列的 辅助函数变得更方便。

typedef unsigned char = bool;
// error type contains result
typedef struct Result_t{
     double result  ,
     bool   has_err ,
     int    err_code,
     char*  err_msg ,
} Result_t;

// error type contaions NO result
typedef struct Error_t{
     bool   has_err ,
     int    err_code,
     char*  err_msg ,
} Error_t;

// helper functions
bool  has_error(*Error_t t);
char* get_error_message(*Error_t t);

// usage
float ans;
Error_t* res = calc(&ans);
if(has_error(res)){
     printf("calc: %s",get_error_message(res));
     exit(-1);
}
record_answer(ans);

另外还有一些变种是利用了语言提供的其他 feature (如元组,多返回值)来实现类似的机制。

val (asn,err) = calc() // tuple

if ans,err := calc();err != nil{
     // 多返回值
}

但是,编译器尽管可以在某些情况下,对于我们不处理返回值的情况报警,但却没有办法区分哪个类型是返回值, 进而无法检查错误是否处理了。而且它没有很好地区分错误与异常,很多情况下连续多层调用者都未必真有办法能够对错误 作出处理,我们都只能通过返回值来将错误层层向上传递。这一方面导致了错误处理模板的反复出现,在另一个方面, 错误传递层数过多,会对追查错误来源造成不利的影响,因为不是每一层调用者都会包裹自己的上下文信息。

对于后一方面的问题,LET IT CRASH 思想可以起到一定的缓解作用。对于调用者可能也无法解决的问题,不如 放弃继续传递错误,转而就地崩溃,终止程序,防止问题被隐瞒下去直至最后通过另一个不相关的问题暴露出来导致 难以溯源。

func main(){
     if err:=foo();err!=nil{
          Log(err)
          // what if it only tells you `File Not Found`
          // how can you know where go wrong
     }
}

func foo() error{
     // some works
    return bar() // doesn't wrap foo's context
}

func bar() error{
     // some works

     // this is what we call err-handling template
     // almost every function does the checking over and over again
     if ans,err:=some();err!=nil{ 
          return err
     }

     // continue its works
}

// ......

明确区分开异常与错误

在一些现代的语言(例如 Java)里面,引入了异常机制,常用的实现是 try catch 组合。对于一些常见的 IO 错误、 权限错误等,直接抛出异常。仅让有能力处理的函数出来捕获并解决它。从而有效避免了,对于一些错误进行的 不必要的向上转发。如果异常没有被有效地处理,程序崩溃并通过打印函数调用堆栈来显示错误来源。

try{
    throw new IOException();
}
catch(IOException e){
    // ........
}

但是 Java 里面还是没有很好地处理错误。对于普通的错误,常用的处理方法是,返回一个类型空值(null)。 这样比较像原始的自己设计错误类型的对策,都做到了将错误信息与正常结果同时传递(正常结果是有效的引用, 错误则表示为无效的 null),只不过现在语言的类型系统原生支持。为了做到这一点,在 Java 的类型系统上 规定 null 可以赋值给所有引用类型的变量。同时因为 null 并不包含任何实际有效的值,所以在 null 上调用方法时会触发空指针异常(null pointer exception)。这样就可以在出错的第一时间暴露问题

但遗憾的是,这仍然不是一个好设计(null 的引入被称为百万美元问题,意指带来了百万美元的损失)。首先, 这样的错误返回方式仍然不携带任何附加的信息(你只知道它为空)。 以至于为了能够携带错误信息,开发者不得不把很多本应该定性为“错误”的问题当作“异常”来处理, 自定义太多异常开销也不小。而且异常表现为运行时错误,换句话说,它没能把问题在开发时期也就是编译期 就立刻暴露出来;在运行期,面对更复杂的情境,更难以捉摸的上下文条件,调试程序变得更加困难。 这也是因为这个设计并没有提供太多可帮助编译器进行检查的信息。 编译器当然可以检查静态类型是否匹配,但问题在于 null 可以赋值给任何引用类型的变量。 而一些错误,如 IO 错误、文件权限错误,只有在运行时才会暴露出来。编译器无法判断某个类型的变量在 运行时是否可能为 null,也就无从推导哪些变量需要“判空”。换句话说,所有的类型都是非安全的。 这导致了我们采取一些辅助性的手段,比如通过注解,来标识函数参数、返回值的可空性 (例如 @NotNull, @Nullable)。这样编译器就可以为我们在一定程度上提供帮助。

  • 当 @NotNull 注解一个方法参数的时候,IDE 会在调用处提示你处理 null 的情况,除非 IDE 发现 你传进去的参数是也是 NotNull的(当场 new 一个对象作为参数传入或者另一个 @NotNull 变量); 当它注解一个有返回值的方法的时候,它会检查返回值是否真的不可能是 null 。
  • 当 @Nullable 注解一个方法参数的时候,IDE 会在方法内部提示你处理该参数为 null 的情况; 当它注解一个有返回值的方法的时候,会在调用处提示你处理方法返回值为 null 的情况。

这在一定程度上也减轻了开发人员的心智负担,因为他们现在明确了哪些部分是相对较安全的。

明确变量的可空性

我们已经知道了 Java 的错误处理仍然不完善。现在可以在 Kotlin (目标似乎是取代 Java ) 中看到一种很有趣的解决方案。它相对于 Java 中原有的可空的引用类型,平行地给出一组不可为空的类型。 等于把 @NotNull 与 @Nullable 在语言级别给予了原生的支持。这样开发者仅需要遵循这样的原则: 对于不会为空的变量始终优先使用非空的类型,就可以保证程序的健壮性。同时,因为语言有了原生的支持, 那么编译器便可以在我们尝试将可空值传递给非可空变量的时候予以报警,将问题扼杀在编译期

var name: String = null // Error: Null can not be a value of a non-null type String
var name1: String? = null // 可空类型,可以赋值为 null
var name2: String = name1 // Error: can't assign Nullable value to a value of a non-null type String

同时 Kotlin 还带来了一些有趣的语法糖来简化错误处理的模板。虽然有人争议说,这些语法糖有些过分甜了, 到了可能会影响语言可读性的程度。但是人们对事物的看法往往都随着接触时间的增加而发生改变, 也许日后人们就会习惯这种新的语法,这还有待于时间的检验。这些语法糖带来了一个特别的好处, 就是现在可以进行安全的链式调用了。当链式调用前面的值为空时,相当于发生了短路运算, 整个表达式直接返回 null。这样有效地应对了我们不希望处理中间过程不必要的错误的情况。

// in Java

// wait, what if getInput returns null, opp...p, Null Pointer Exception
int num = getInput().getLines().getLength();

// too verbose
Input in = getInput();
if(in==null){ ... }
List<Line> lines = in.getLines();
if(lines==null){ ... }
// ...
// of course, in real cases, things are much better as you ought to obey some
// rules to limit the number of Nullable variables.

// in Kotlin
// for expression a?.b(), if a is not null, call method b(), otherwise the expression is simply null
val num = getInput()?.getLines()?.getLength();

另外一个值得注意的是,Kotlin 对异常处理部分也做了一些改变。在 Java 中,如果你要调用一个 会抛出异常的程序,那么你要么用 try catch 捕获它,要么继续把异常向上抛出;而后者要求你在 函数签名中显式声明你可能会抛出的异常。而在 Kotlin 当中,不再需要在函数签名中明确指出会抛出的异常的类型, 据称这样是为了解决异常声明层层传递的问题,防止我们模块里面的代码,不得不大量携带上底层函数的异常签名, 就像之前错误返回值层层传递的那样。 但是其带来的隐患也是明确的,编译器现在在一定程度上失去了对异常处理的控制能力, 从而也就不能对用户不捕获异常的行为作出警告。在我看来,很难说这种设计究竟是好是坏, 这也只能看看工程实际对这种变化的反馈了。

// too many exception declaration for me
public void foo() throws MyException,IOException,SomeException{
     // I don't catch possible exceptions, so I
     // must declare this fact clearly through signature.
     bar();
     some();
}

public void foo() throws MyException,IOException{
     // ...
     sum();
}

public void some() throws SomeException{
     // ...
}

public void sum() throws IOException{
     // ...
}

Union Type 支持

最后我们再来看一种更为先进的设计方式。在一些函数式语言当中,我们不再把错误当做是程序中的插曲或毛刺, 而是把它和正确结果放在一个平等的地位上,从而借助类型系统当中的 Union Type 来辅助我们进行错误处理。 所谓 Union Type,可以看作是类型的或运算,例如

data Person = Teacher | Student

那么 Person 类型的变量,可能实际是 Student 类型的,也可能是 Teacher 类型的,但是它同时只能是一种类型。 对于这种类型的变量,我们一般要辅以 pattern match 方法,来检查它究竟是什么类型, 从而决定下一步的行为。那么我们不难想象到错误处理要怎样进行,我们把正确值和错误做个“或运算”。 那么,我们的调用者就可以通过 pattern match 来检查有无错误,并从中提取正确的结果。

// in Scala

sealed trait Option[+A] 
case class Some[+A](result: A) extends Option[A] // 我们要的结果
case object None extends Option[Nothing] // 空值

def getOrElse[B >: A](default: => B): B =
     this match { // pattern match
          case Some(result) => result // 获取结果
          case None => default // 如果是空值,返回事先提供的默认值
     }

// 如果是空值,直接返回空,否则对其应用函数 f
// 所以类似与 Kotlin 中的 a?.b(),可以有 a.map(b _)
// 进而也可以安全高效地进行链式调用
def map[B](f: A => B): Option[B] =
    this match {
      case Some(result) => Some(f(result))
      case None => None // 空值
    }


// 替代空值,也可以带上错误信息
sealed trait Either[+E,+A]
case class Error[+E](get: E) extends Either[E,Nothing]
case class Right[+A](get: A) extends Either[Nothing,A]

def mean(xs: IndexedSeq[Double]): Either[String, Double] =
     if (xs.isEmpty)
          Error("mean of empty list!") // 错误信息
     else
          Right(xs.sum / xs.length) // 正确结果

我们可以发现 Kotlin 其实就是这种实现方式的一种特例。它把 Union Type 局限在了不可空的引用类型与 null 之间。而我们现在的这个设计,仅仅是借助了语言原生的类型系统,它不仅仅可以拿来做错误处理, 本来也可以拿来做其他的一般性的程序。当然 Kotlin 那样设计,也有它的道理。毕竟,对 null 的处理, 确实是需要广泛支持的,为它提供一个特例,也未尝不可。

这样我们也很容易在编译器上面提供错误信息,因为我们返回的是一个 Union Type 变量而不是实际值, 它根本没办法被当作结果类型的变量来使用。所以不仅仅可以让错误处理获得编译器的帮助,事实上, 如果我们不进行处理,代码将根本无法编写下去。

结语

上面我们就如何简化代码、如何降低复杂度、如何让编译器提供更多帮助等标准,从较为原始的错误码开始, 依次讨论了错误处理机制应该解决的问题,分析了语言原生支持的重要性,明确区分开了异常与普通错误, 最后介绍了目前较为先进的基于 Union Type 的错误处理机制。通过本文,程序设计语言中的错误与异常机制 基本上就介绍了个大概。我们日常使用的语言未必就提供了完善的机制,但是通过把握整体的错误处理机制发展趋势, 体会其中的思想,我们也能够学会使用不同的语言编写同样健壮的程序。