浅谈Kotlin的Checked Exception机制

1,167 阅读13分钟

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。

现在使用 Kotlin 的 Android 开发者已经越来越多了。

这门语言从一开始的无人问津,到后来成为 Android 开发的一级语言,再到后来 Google 官宣的 Kotlin First。Kotlin 正在被越来越多的开发者接受和认可。

许多学习 Kotlin 的开发者之前都是学习过 Java 的,并且本身 Kotlin 就是一款基于 JVM 语言,因此不可避免地需要经常和 Java 进行比较。

Kotlin 的诸多特性,在熟悉 Java 的开发者看来,有些人很喜欢,有些人不喜欢。但即使是不喜欢的那些人,一旦用熟了 Kotlin 进行程序开发之后,也难逃真香定律。

今天我想跟大家聊一聊的话题,是 Kotlin 在早期的时候争议比较大的一个特性:Checked Exception 机制。

由于 Kotlin 取消了 Checked Exception,这在很多 Java 开发者看来是完全不可接受的,可能也是许多 Java 支持者拒绝使用 Kotlin 的原因。但目前 Kotlin 已经被 Google 转正两年多了,开发了成千上万的 Android 应用。你会发现,即使没有 Checked Exception,Kotlin 编写出的程序也并没有出现比 Java 更多的问题,因此编程语言中对于 Checked Exception 的必要性可能并没有许多人想象中的那么高。

当然,本篇文章中我并不能给出一个结论来证明谁对谁错,更多的是跟大家谈一谈我自己的观点和个人心得,另外引用一些大佬的权威观点。

另外,这个问题永远是没有正确答案的,因为世界上没有最好的编程语言(PHP 除外)。每个编程语言选择不同的处理方式都有着自己的一套理论和逻辑,所以与其去争论 Java 中的 Checked Exception 机制是不是多余的,不如去论证 Kotlin 中没有 Checked Exception 机制为什么是合理的。

那么,我们首先从什么是 Checked Exception 开始说起。

Checked Exception,简称 CE。它是编程语言为了保证程序能够更好的处理和捕获异常而引入的一种机制。

具体而言,就是当一个方法调用了另外一个可能会抛出异常的接口时,要么将这个异常进行捕获,要么将这个异常抛出,交给上一层进行捕获。

熟悉 Java 语言的朋友对这一机制一定不会陌生,因为我们几乎每天都在这个机制的影响下编写程序。

观察如下代码:

public void readFromFile(File file) {
    FileInputStream in = null;
    BufferedReader reader = null;
    StringBuilder content = new StringBuilder();
    try {
        in = new FileInputStream(file);
        reader = new BufferedReader(new InputStreamReader(in));
        String line = "";
        while ((line = reader.readLine()) != null) {
            content.append(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这段代码每位 Java 程序员应该都非常熟悉,这是一段 Java 文件流操作的代码。

我们在进行文件流操作时有各种各样潜在的异常可能会发生,因此这些异常必须被捕获或者抛出,否则程序将无法编译通过,这就是 Java 的 Checked Exception 机制。

有了 Checked Exception,就可以保证我们的程序不会存在一些隐藏很深的潜在异常,不然的话,这些异常会像定时炸弹一样,随时可能会引爆我们的程序。

由此看来,Checked Exception 是一种非常有必要的机制。

Kotlin 中是没有 Checked Exception 机制的,这意味着我们使用 Kotlin 进行上述文件流操作时,即使不捕获或者抛出异常,也可以正常编译通过。

熟悉 Java 的开发者们是不是觉得这样严重没有安全感?

那么我们就来尝试分析和思考一下,为什么 Kotlin 中没有 Checked Exception。

我在学习 Kotlin 时,发现这门语言在很多设计方面都参考了一些业内的最佳编程实践。

举个例子,《Effective Java》这本书中有提到过,如果一个类并非是专门为继承而设计的,那么我们就应该将它声明成 final,使其不可被继承。

而在 Kotlin 当中,一个类默认就是不可被继承的,除非我们主动将它声明成 open。

类似的例子还有很多很多。

因此,Kotlin 取消 Checked Exception 也肯定不是随随便便拍脑瓜决定的,而是有很多的理论依据为其支持。

比如说,《Thinking in Java》的作者 Bruce Eckel 就曾经公开表示,Java 语言中的 Checked Exception 是一个错误的决定,Java 应该移除它。C# 之父 Anders Hejlsberg 也认同这个观点,因此 C# 中是没有 Checked Exception 的。

那么我们大多数 Java 开发者都认为非常有必要的 Checked Exception 机制到底存在什么问题呢?

这些大佬们例举了很多方面的原因,但是我个人认为最主要的原因其实就是一个:麻烦。

Checked Exception 机制虽然提升了编程语言的安全性,但是有时却让我们在书写代码时相当抓狂。

由于 Checked Exception 机制的存在,对于一些可能发生潜在异常的代码,我们必须要对其进行处理才行。处理方式只有两种:要么使用 try catch 代码块将异常捕获住,要么使用 throws 关键字将异常抛出。

以刚才的文件流操作举例,我们使用了两次 try catch 代码块来进行潜在的异常捕获,但其实更多只是为了能让编译器满意:

public void readFromFile(File file) {
    BufferedReader reader = null;
    try {
        ...
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这段代码在 Java 当中是最标准和规范的写法,然而你会发现,我们几乎没有人能在 catch 中写出什么有意义的逻辑处理,通常都只是打印一下异常信息,告知流发生异常了。那么流发生异常应该怎么办呢?没人知道应该怎么办,理论上流应该总是能正常工作的。

思考一下,是不是你在 close 文件流时所加的 try catch 都只是为了能够让编译通过而已?你有在 close 的异常捕获中进行过什么有意义的逻辑处理吗?

而 Checked Exception 机制的存在强制要求我们对这些未捕获的异常进行处理,即使我们明确不想对它进行处理都不可以。

这种机制的设计思路本身是好的,但是却也间接造就了很多填鸭式的代码,只是为了满足编译器去编程,导致编写了很多无意义的 try catch 语句,让项目代码看来得变得更加臃肿。

那么如果我们选择不对异常进行捕获,而是将异常向上抛出呢?事实证明,这可能也并不是什么特别好的主意。

绝大多数 Java 程序员应该都使用过反射的 API,编写反射代码时有一点特别讨厌,就是它的 API 会抛出一大堆的异常:

Object reflect(Object object, String className, String methodName, Object[] parameters, Class<?>[] parameterTypes)
	    throws SecurityException, IllegalArgumentException, 
		IllegalAccessException, InvocationTargetException, 
		NoSuchMethodException, ClassNotFoundException {
	Class<?> objectClass = Class.forName(className);
	Method method = objectClass.getMethod(methodName, parameterTypes);
	return method.invoke(object, parameters);
}

这里我只是编写了一段最简单的反射代码,竟然有 6 个异常要等着我去处理。其中每个异常代表什么意思我也没能完全搞明白,与其我自己去写一大堆的 try catch 代码,还不如直接将所有异常都抛出到上一层得了,这样代码看起来还能清爽一点。

你是这么想的,上一层的人也是这么想的,更过分的是,他可能还会在你抛出异常的基础之上,再增加一点其他的异常继续往上抛出。

根据我查阅到的资料,有些项目经过这样的层层累加之后,调用一个接口甚至需要捕获 80 多个异常。想必调用这个接口的人心里一定在骂娘吧。你觉得在这种情况下,他还能耐心地对每一种异常类型都细心进行处理吗?绝对不可能,大概率可能他只会 catch 一个顶层的 Exception,把所有异常都囊括进去,从而彻底地让 Checked Exception 机制失去意义。又或者,他可能会在当前异常抛出链上再加一把火,为抛出 100 个异常做出贡献。。。

最终我们可以看出,Java 的 Checked Exception 机制,本身的设计初衷确实是好的,而且是先进的,但是却对程序员有着较高的编码规范要求。每一层方法的设计者都应该能清楚地辨别哪些异常是应该自己内部捕获的,哪些异常是应该向上抛出的,从而让整个方法调用栈的异常链都在一个合理和可控的范围内。

然而比较遗憾的现实是,绝大多数的程序员其实都是做不到这一点的,滥用和惰性使用 CE 机制的情况广泛存在,完全达不到 Java 本身设计这个机制所预期的效果,这也是 Kotlin 取消 Checked Exception 的原因。

许多 Java 程序员会比较担心这一点,Kotlin 取消了 Checked Exception 机制,这样不会导致我的程序变得很危险吗?每当我调用一个方法时,都完全不知道这个方法可能会抛出什么异常。

首先这个问题在开头已经给出了答案,经过两年多的实践发现,即使没有 Checked Exception,Kotlin 开发出的程序也并没有比 Java 开发的程序出现更多的异常。恰恰相反,Kotlin 程序反倒是减少了很多异常,因为 Kotlin 增加了编译期处理空指针异常的功能(空指针在各类语言的崩溃率排行榜中都一直排在第一位)。

那么至于为什么取消 Checked Exception 并不会成为导致程序出现更多异常的原因,我想分成以下几个点讨论。

第一,Kotlin 并没有阻止你去捕获潜在的异常,只是不强制要求你去捕获而已。

经验丰富的程序员在编写程序时,哪些地方最有可能发生异常其实大多是心中有数的。比如我正在编写网络请求代码,由于网络存在不稳定性,请求失败是极有可能发生的事情,所以即使没有 Checked Exception,大多数程序员也都知道应该在这里加上一个 try catch,防止因为网络请求失败导致程序崩溃。

另外,当你不确定调用一个方法会不会有潜在的异常抛出时,你永远可以通过打开这个方法,观察它的抛出声明来进行确定。不管你有没有这个类的源码都可以看到它的每个方法抛出了哪些异常:

public class FileInputStream extends InputStream {

    public FileInputStream(File file) throws FileNotFoundException {
        throw new RuntimeException("Stub!");
    }

    public int read(byte[] b, int off, int len) throws IOException {
        throw new RuntimeException("Stub!");
    }

    public void close() throws IOException {
        throw new RuntimeException("Stub!");
    }
    ...
}

然后当你觉得需要对这个异常进行捕获时,再对它进行捕获即可,相当于你仍然可以按照之前在 Java 中捕获异常的方式去编写 Kotlin 代码,只是没有了强制的要求,你可以自由选择要不要进行捕获和抛出。

第二,绝大多数的方法其实都是没有抛出异常的。

这是一个事实,不然你绝对不会爱上 Checked Exception 机制,而是会天天咒骂它。

试想一下,假如你编写的每一行代码,调用的每一个方法,都必须要对它 try catch 捕获一下才行,你是不是想摔键盘的心都有了?

我说的这种情况在 Java 中真的有一个非常典型的例子,就是 Thread.sleep() 方法。由于 Thread.sleep() 方法会抛出一个 InterruptedException,所以每次我们调用这个方法时,都必须要用 try catch 捕获一下:

public class Main {
    
    public void test() {
        
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
    }
    
}

这也是我极其不喜欢这个方法的原因,用起来就是一个字:烦。

事实上,可能绝大多数 Java 程序员甚至都不知道为什么要捕获这个异常,只知道编译器提醒我必须捕获。

之所以我们在调用 Thread.sleep() 方法时需要捕获 InterruptedException,是因为如果在当前线程睡眠的过程中,我们在另外一个线程对中这个睡眠中的线程进行中断(调用 thrad.interrupt() 方法),那么 sleep() 方法会结束休眠,并抛出一个 InterruptedException。这种操作是非常少见的,但是由于 Checked Exception 的存在,我们每个人都需要为这一个少见的操作买单:即每次调用 Thread.sleep() 方法时,都要写一段长长的 try catch 代码。

而到了 Kotlin 当中,你会不再讨厌使用 Thread.sleep() 方法,因为没有了 Checked Exception,代码也变得清爽了:

class Main {

    fun test() {
        
        Thread.sleep(1000)
        
    }

}

第三,拥有 Checked Exception 的 Java 也并不是那么安全。

有些人认为,Java 中拥有 Checked Exception 机制,调用的每个方法你都会感到放心,因为知道它会抛出什么异常。而没有 Checked Exception 的话,调用任何方法心里都感觉没底。

那么这种说法有道理吗?显然这不是真的。不然,你的 Java 程序应该永远都不会崩溃才对。

事实上,Java 将所有的异常类型分成了两类:受检查异常和不受检查异常。只有受检查异常才会受到 Checked Exception 机制的约束,不受检查异常是不会强制要求你对异常进行捕获或抛出的。

比如说,像 NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException 这些都是不受检查的异常,所以你调用的方法中即使存在空指针、数组越界等异常风险,Checked Exception 机制也并不会要求你进行捕获或抛出。

由此可见,即使 Java 拥有 Checked Exception 机制,也并不能向你保证你调用的每个方法都是安全的,而且我认为空指针和数组越界等异常要远比 InterruptedException 之类的异常更加常见,但 Java 并没有对此进行保护。

至于 Java 是如何划分哪些异常属于受检查异常,哪些属于不受检查异常,这个我也不太清楚。Java 的设计团队一定有自己的一套理论依据,只不过这套理论依据看上去并没有被其他语言的设计者所认可。

因此,你大概可以理解成,Kotlin 就是把异常类型进一步进行了简化,将所有异常都归为了不受检查异常,仅此而已。

所以,最终的结论是什么呢?

很遗憾,没有结论。正如任何事物都有其多样性一样,关于 Checked Exception 这个问题上面,也没有一个统一的定论。

Java 拥有 Checked Exception 机制并不是错误的,Kotlin 中取消 Checked Exception 机制也不是错误的。我想这大概就是你阅读完本文之后能够得出的结论吧。

但是,希望你自此往后,在使用 Kotlin 编程程序时,不要再为有没有 Checked Exception 的问题所纠结了。

如果想要学习 Kotlin 和最新的 Android 知识,可以参考我的新书 《第一行代码 第 3 版》点击此处查看详情