淺談Kotlin的Checked Exception機制

郭霖發表於2020-09-29
本文同步發表於我的微信公眾號,掃一掃文章底部的二維碼或在微信搜尋 郭霖 即可關注,每個工作日都有文章更新。

現在使用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的問題所糾結了。

如果想要學習Kotlin和最新的Android知識,可以參考我的新書 《第一行程式碼 第3版》點選此處檢視詳情


關注我的技術公眾號,每個工作日都有優質技術文章推送。

微信掃一掃下方二維碼即可關注:

image

相關文章