浅谈Kotlin的Checked Exception机制

susmote 2020-09-30

浅谈Kotlin的Checked Exception机制

现在使用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? /

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中没有CE? /

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的原因。

/ 没有CE不会出现问题吗? /

许多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() { 
        // do something before 
        try { 
            Thread.sleep(1000); 
        } catch (InterruptedException e) { 
            e.printStackTrace(); 
        } 
        // do something after 
    } 
 
} 

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

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

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

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

class Main { 
 
    fun test() { 
        // do something before 
        Thread.sleep(1000) 
        // do something after 
    } 
 
} 

第三,拥有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的问题所纠结了。

相关推荐