如何評價王垠的《Kotlin和Checked Exception》?
Kotlin 和 Checked Exception
王垠用了很大的篇幅來闡述《Checked Exception(CE)的重要性》。王垠的心情我可以理解,異常處理確實很重要,但王垠可能犯了「訴諸動機」的謬誤。
因為實踐中我沒有見過任何庫符合王垠理想的Checked Exception用法。
破壞向後兼容性
按王垠的理論,Checked Exception可以在方法簽名上標明具體錯誤原因,但實踐中,每次在簽名上增加一個新的異常,都會破壞向後兼容性。對於需要復用的框架和庫來說,直接就給這個功能判了死刑。
王垠推薦的 FileNotFoundException 這種寫法,實踐中舉步維艱,現在已經越來越罕見了。
比如說你十年前寫了個 naive 的 FileOutputStream 構造函數用來打開文件,會拋 FileNotFoundException。現在你發現其實除了 FileNotFoundException 之外,你還需要處理許可權不足的問題,但為了保持向後兼容性又不可能在簽名中加入許可權不足異常。這時 Checked Exception 就十分尷尬了。
這就是為什麼現在 Java 7的 Files.newOutputStream 就只標記為 IOException 而不寫FileNotFoundException這種具體類型。
類似的異常還有 SQLException 、 ServletException 等,都不寫明具體原因了。很多庫明明就算拋了異常,還要在異常裡面包裝一個errorCode,而不用繼承的異常,也是這個緣故。
所以王垠在文章中作為反例的「最糟糕的異常處理代碼」,即捕獲異常的超類而不捕獲具體異常,恰好是實踐中所有框架,包括 Java 標準庫的做法。
Checked Exception和Java標準庫自相矛盾
Checked Exception的本意是類型安全。但是,Java標準庫一直以一種不類型安全的方式使用異常,比如我們知道 Java 里有一個 Callable ,長成這樣:
public interface Callable&
V call() throws Exception;
}
你看,這個介面會拋出一切異常,正好就是王垠所說的「最糟糕的異常處理代碼」。換句話說,只要你使用Java標準庫,Java標準庫就會強迫你寫出王垠眼中「最糟糕的異常處理代碼」。
實際上 Java 語言本身支持泛型異常,比如 Callable 完全可以設計成這樣:
public interface Callable&
V call() throws E;
}
不幸的是,看起來 Java 標準庫並沒有用這種更安全的設計。
Oracle 收購 Sun 以後,似乎 Checked Exception 越來越被標準庫所摒棄。Java 7的Files API 用的還是基類IOException,Java 8的Function、BiFunction,壓根就不支持 Checked Exception。如果你要用 Stream API 的話,必須手動在 UncheckedIOException 和 IOException 之間包來包去。
把異常不分青紅皂白包裝成 RuntimeException 的「設計模式」,早已是 Java 程序員大家都知道的秘密,但標準庫中加入 UncheckedIOException 可能算是第一次官方承認這個秘密吧。
結論
在幾乎所有的 Java 框架和庫中,包括 Java 標準庫,Checked Exception 都從未以王垠理想的方式使用過。
我認為CE的麻煩之處,其實並不在於強制要求處理。真正麻煩的地方在於一下幾點:
1. CE成為函數兼容性檢測的一部分。
在Java中,throws列表也會成為方法的兼容性檢測的一部分,所以對於這樣的一個介面
public interface Function& {
void invoke(A a);
}
他的子類型中用於實現這個方法的invoke方法簽名中就不被允許拋出任何受檢異常了。乍一看來不是太大的事情,問題是實際運用起來還是造成了一些影響。譬如我們在一個集合類中聲明了一個foreach方法:
public class MyList& {
//......
public void foreach(Function& super A&> f) {
//........
}
//......
}
我們傳入的lambda表達式就不能夠拋出受檢異常了,像這樣:
new MyList&
if (s != null) return null;
else throw new Exception();
}); //error!
因為異常不兼容,所以這樣的調用是不被允許的,我們需要另一個CheckedFunction:
public interface CheckedFunction& {
void invoke(A a) throws Exception;
}
但是這樣又給foreach的設計者造成了很大的麻煩。foreach對這個異常應該是一無所知的,卻有責任處理這個異常。較為自然的處理方法應該是在這個foreach方法中拋出這個異常,但是這由要求我們foreach的簽名里有一個throws Exception,然後每個foreach的調用者就又需要來try或者throws一下,這帶來了很大的困擾。
2.一個異常是否為Checked Exception只與異常本身的種類有管,而與throws沒有關聯,我認為這也是不合理的行為。一個異常是否必須被處理,應該只與throws相關,而不是和異常本身的種類相關。Java的這種設計導致,明明原因相似的異常卻需要分為兩種來看待。
3.Java中異常處理的方式太過貧瘠。我們有的只有try和throws等有限的幾種方式來處理一個exception,這是我們感到繁瑣的關鍵性原因之一。如果我們在調用的那一行就能選擇忽略這個異常,或者是選擇其他的處理方式,那麼ce也不會被吐槽這麼多。
4.ce容易被濫用。我認為Java標準庫就屬於濫用ce了。大量ignore異常的行為,表示這個異常本身就不是那麼需要被強制要求,而是應該有選擇性的處理。如果Java選擇Exception默認不是ce,只有必須的情況才會被強制處理,那麼情況會好的多。
====更新====
其實我覺得CE的問題在哪呢?就在你不能光把他標記出來,你還得標記出每一個exception出現的條件,這樣才能最大程度阻止讓人心煩的try和複製CE的問題。但是這對程序員的要求太高了,所以還得靠靜態分析——那麼為啥不從一開始就在團隊里強制打開靜態分析,跑不過就自動從repo那裡revert掉呢(逃
一個函數會拋異常是沒有問題的,但是在絕大多數情況下,我對他的調用方法決定了這個調用不會拋異常。但是我又沒有任何方法可以表達這個事情,也沒有任何方法可以把這個事情變成編譯錯誤,多煩啊。CE要是做不到這點,乾脆還是不要CE了,反正他又不影響運行時的行為。我就不抓,到時候還不是一樣會崩潰,自動化測試裡面都會發現。
====原答案====
我也不說他說得對不對了,大家也看過關於erlang的fail fast的文章,我只指出一個事實上的錯誤:
另外,Hejlsberg 還指出 C# 代碼里沒有被 catch 的異常,應該可以用「靜態分析」檢查出來。可以看出來,他並不理解這種靜態檢查是什麼規模的問題。要能用靜態分析發現 C# 代碼里被忽略的異常,你必須進行「全局分析」,也就是說為了知道一個函數是否會拋出異常,你不能只看這個函數。你必須分析這個函數的代碼,它調用的代碼,它調用的代碼調用的代碼…… 所以你需要分析超乎想像的代碼量,而且很多時候你沒有源代碼。所以對於大型的項目,這顯然是不現實的。
對於.net,沒有代碼也沒關係,反正exe和dll裡面的中間代碼已經包含了所有該知道的信息了。所以只要你的程序可以跑起來,就絕對有辦法分析。這個時候你無法知道的只是,萬一你調用了一個native dll,裡面炸了怎麼辦呢?但是這個問題本來就是沒有辦法的,C#也catch不了C++的exception。
所以Anders說「沒抓住的異常可以被靜態分析檢查出來」這句話,是沒有問題的。至於規模嘛,在微軟裡面寫代碼,Office一次編譯一天,還管什麼規模(逃,
作為一個職業 C# 碼農, 我部分贊同 yin 神的觀點(kotlin 部分),關於 CE 部分不敢點贊。
異常的處理跟當前運算的業務邏輯是緊密相連的,怎麼可能用一個抽象的統一的邏輯去處理異常,調用第三方庫的時候我希望有一個顯示的地方告訴我會扔出什麼異常,java 體現在函數的尾部,而微軟的標準庫都寫在 summary 裡面了…java 強制你要處理否則你就扔出去,而 C# 不強制但是只要你正確閱讀文檔你應該知道,我假設寫這兩份代碼的人都已經知道了,這就行了吧。而做ui展現活著是輸入檢測的時候,異常需要有好的被翻譯成終端真正的用戶可讀可知可修整的錯誤提示,這個時候 C# 的程序員也得被迫去 catch
住,然後走業務邏輯翻譯成提示顯示在頁面上。有些c#程序員看不慣 java 強制 ce,說不自由
有些 java 程序員說其它語言沒ce 不嚴謹又有些c#程序員覺得有時候有ce也是好的
有些 java 程序員覺得老子就轉個字元串有異常老子下面的代碼會用其它分支轉換別老讓我的代碼 在try catch 裡面緩緩繞 好么?語言是這樣的,寫的深入了你會中毒,你總會找到讓自己繼續信仰這門語言的諸多借口,寫的種類多了,就會出現對比(比如我 yin神)就會找諸多借口來證明某一門語言的的設計不如另一門。
yin 神你已經發現了多黨制的好處,確定要回國么?==== 更新 2017-05-26 ====
某位知友的評論切中要害(抱歉,找不到是誰了):異常是與實現相關的,而不是介面。同是 InputStream 這個介面,
1、如果是基於數組的實現,那麼只會有超出容量的異常;
2、如果是基於磁碟文件的,可能文件突然被刪、磁碟壞了、磁碟被unmount;
3、如果是遠程文件還可能有超時、通信中斷;
4、如果嵌套了ZipInputStream,可能會有解碼錯誤。
5、。。。。。(無數種實現、無數種可能)
但是,InputStream 作為一個介面,只能使用一個泛泛的 IOException。各具體實現則要把自己的異常包裝成 IOException。對於調用者,如果不知道具體實現是哪些類,那麼除了彙報錯誤,是不可能做出其它有意義的處理的。顯然,只有在 new ConcreteInputStream 的地方才知道實現是什麼,區別性的異常處理也只可能在這裡做到。而且,你不得不查看 ConcreteInputStream 的文檔才知道應該處理哪些異常。既如此,那麼給 nputStream 聲明 IOException 這個異常的意義在哪?
Java 標準庫中類似 InputStream 這樣使用 CE的情況俯拾皆是,這說明CE 的意義在設計之初就被誤解了。重申我的觀點就是,CE 是個有用的機制,但是不應該在庫層面使用。
==== 原回答 ====
Exception 本身一個非常好的機制,只在可以處理地方catch下來,避免了C語言那種層層 if 判斷向上返回錯誤的繁瑣。然而,絕大多數商業應用場景中,唯一合理的處理方式就是彙報異常並放棄執行。既然handle的代碼一樣,那麼 catch(Exception) 就自然成為絕大多數程序員的選擇。這可能是引入 Exception 機制之初所始料未及的。鑒於這樣的事實,CE的必要性就值得懷疑了。
舉個例子。某個函數接受 InputStream in參數,而 in.readInt()失敗的時候,能做什麼處理呢? 除了彙報,就是傳遞給調用者。結果,調用者是用這個函數循環讀取文件中的一組記錄,現在出錯了,能做什麼呢?繼續向上傳遞!調用者的調用者打開了一個叫「通訊錄」的文件,試圖讀取其中的所有記錄,能做什麼,列印錯誤,終止任務。
要想做到非彙報性的異常處理需要三個條件:一、知道宏觀上在做什麼事;二、知道出了什麼錯誤;三、具備處理錯誤所需的資源。這使得「在靠近異常發生的地方處理」幾乎不可能做到,因為缺少一、三。經過層層傳遞,到了那個知道一的地方,往往又缺少三。仍以上面的例子來說,如果讀「通訊錄」文件出錯了,「理想的」處理方法檢查出錯原因,並針對性修復,然後重新讀取。如果是硬碟壞了(假設catch里做了磁碟檢查,有點誇張),那還能怎樣,仍然只能報錯。如果是文件壞了,需要調取備份並重讀,可是備份功能屬於更高的架構層次,此函數根本不知道備份存在,沒有相應的備份管理對象做相關操作。當然,你會認為不應該在此處catch,而是繼續傳遞到那個掌握著備份管理對象的地方處理。這意味著,關於文件讀寫異常,系統中存在確定的點具備相應的資源來處理這類異常,而且開發者知道這點在哪裡,那麼在這個點上主動catch就好,不需要都要求添加 CE clause。
接下來,你可能會有另一個想法,為什麼不把備份管理對象傳遞到每一個文件讀寫的地方呢,比如在打開「通訊錄」的地方就能引用到它。如果只是一種異常,當然可以,但是系統中有各種異常,如果把各種資源管理對象層層下傳,繁瑣在其次,更重要的是破壞了各層介面的抽象性。
還有一個更酷的想法,乾脆把備份功能實現在存儲層,用具備備份替換功能的文件系統做底層,比如 HDFS。現在InputStream in 其實是一個更可靠的實現,那麼問題來了,它會不會產生異常?當然會了,備份也可能出錯,網路也可能出錯,僅僅是概率低了(沒準還高了呢,畢竟下面多了一堆複雜東西,多出的代碼也可能有bug,多涉及的硬體也會出問題)。這些錯誤仍然要聲明在 CE clause,那麼對於這些錯誤,你該在何處catch?catch之後,能做什麼處理?是不是報錯並終止執行?要想搞些區別性的處理,該怎麼辦?問題是不是循環了?
最終,你會發現,CE 只能在明確已知的那些個 知道一二三的點catch。既然這些點是確定知道的(被設計為處理錯誤的點),那麼顯示聲明CE的意義在哪裡?畢竟,不論有沒有聲明CE,那幾個點都會做 catch。
所以我的觀點是,Exception機制的作用是異常的自動傳遞以及確保在退棧過程中釋放資源釋,CE則是畫蛇添足的一筆(目前看來)。關於確保資源釋放,我覺得 C++ 的棧對象和Java 的 with-resource clause 都是非常不錯的。
那是不是說不應該有CE呢?未必。準確地講,我所反對的是(Java)當前的(絕大多數)CE並不應該定義為CE,因為這些異常和 NullPointerException, OOM 一樣,除了報錯,沒什麼可特別處理的。 也就是說,Java的設計者錯誤地理解了 CE。那麼,異常應該怎樣分類呢?我是這麼看的。
1、你給我的數據錯了(如用戶輸入錯誤、讀取的文件內容不合規範):只能報錯並終止
2、我依賴的其它系統出現故障(如網路錯誤、遠程系統的宕機):基本上是報錯終止
3、我自己出錯了:也就是我自己的程序有bug。也只能報錯終止。
4、我也沒錯,就是計算機能力有限(如算術運算溢出了):這種情況語言往往不會提供異常,需要自己寫程序發現並拋出。除了報錯也沒什麼可處理的。
我認為,真正的CE是編程者專門設計的,用於傳遞特定信息,觸發特定操作的一種編程機制。也就是說,throw點和catch點是一個整體設計的兩部分,而不是「不管你怎麼處理,反正我會throw 它」。反觀Java,在類庫層面提供的大量通用CE,都是毫無意義的。
Java 8新增了一個類UncheckedIOException。只要大家都盡量用unchecked exception(繼承RuntimeException),就能避免繁冗的介面。以前的Java庫喜歡用IOException, RemoteException等checked exception,這是庫的設計失誤。
至於checked exception作為語言特性是否值得存在,眾說紛紜。有人說對函數式編程不友好,是的!
至於Hejlsberg說『沒抓住的異常可以被靜態分析檢查出來』,並非如此!先不說性能,如果你用依賴注入的方式調了一個介面,而介面的實現是運行時才提供(甚至可能是腳本語言實現的),就不能靜態分析了。
首先Unchecked Exception無論如何是必須的。許多情況下如果不能拋出UncheckedException,就只能學Go一樣panic掉了。
其次,絕大多數的異常本來就應該是Unchecked Exception,因為屬於實現細節,而且拋出異常的理由就是當前過程因故無法正常執行,通常來說沒有任何方法來將執行送回軌道,比如說底層訪問某個文件時發現沒有許可權,但是調用者並不知道你這個介面的實現訪問了文件,同一個介面的其他實現也並不拋出這個異常,那麼唯一合理的做法是拋出UncheckedException,或者定義通用的CheckedException類型,後者除了給自己添堵增加代碼量以外沒有什麼意義。
唯一有意義的CheckedException是定義介面時預先知道會拋出的異常,比如創建連接時一定存在超時的情況。但是仔細分析就會明白,既然所有介面都可能拋出checked和unchecked異常,那強制進行編譯檢查其實意義不大,未處理的異常直接當做unchecked異常拋出就行了,那不如所有的異常都是unchecked異常。
王是個理想主義者,checked exception也像他一樣理想主義:異常和返回值一樣是介面類型的一部分,嚴格的類型檢查可以保證拋出異常的類型都是安全的。他忘了異常本來就是處理dirty works的工具,是做不了那麼漂亮的。
==============================================================
我還想說一下關於多態與catch(Exception)的問題。
作為面向對象語言,多態是Java這門語言里最本質的設計,調用介面時可能使用各種不同的實現,這也就同時帶來了一個問題:風險不可控。你必須要考慮自己的代碼與非一體化設計的其他人的代碼共同協作的問題,其他人代碼中可能拋出各種各樣的異常,你必須保證它對你的代碼的干擾在最小程度。解決這個問題的方案根本就不是CheckedException,而是安全隔離。對一個庫或者框架來說,一般來說調用你自己的代碼,或者你信任的第三方庫代碼,可以叫做可信區域;調用你的庫的代碼,以及通過回調、注入等方式被你調用的未知代碼,可以叫做不可信區域。一般來說,對於不可信區域:
- 不和不可信區域的代碼共享任何數據和對象,所有傳入傳出的數據都進行複製;只向不可信區域的代碼暴露可控的介面。
- 所有傳入的參數需要進行必要的檢查,對於經常出現的錯誤應當拋出異常進行提示
- 通過回調、注入調用外部代碼時,攔截外部代碼拋出的所有異常並做合適的處理(比如記錄日誌,然後忽略或者拋出到合適的調用方)
經過這樣的處理,不可信區域與可信區域基本上被分割成了獨立的程序,降低了相互之間的耦合度,兩邊各自的實現細節基本可以互相不干擾。我們可以看到第三步中,基本上來說一定會使用catch(Exception)的方法攔截異常,這是正確的做法,因為大部分情況下我們不能讓不可信區域的代碼破壞可信區域的邏輯,比如一個WebServer,不可以在Servlet拋出異常的時候就讓整個Server退出,否則穩定性不可控。這做法相當於在可信代碼與不可信代碼之間插入了一道防火牆,可以有效阻止實現bug的擴散。
可信區域的代碼之間一般來說是不應當這麼做的,如果有bug應當及時消除,而不是通過防禦機制掩蓋bug的發生。
完全贊同王垠對CE的看法,他真正想推薦的應該是typed racket中的union type,這也非常有啟發。
======
在調用一個函數的時候,除了正常的返回值,還很可能需要有響應的各種『異常』,而異常應該是可以被『窮舉』,並且被確保處理的。
在很多時候,『異常』的處理流程,也是在程序所預先定義好的『流程』當中。
函數正常執行,獲得預期的『返回值』,這是主流程;但也可以有分支流程,使用異常來『觸發』分支流程是合理的做法。
CE的存在,確保了函數調用者,必須對函數存在的『所有』分支流程都進行處理。這對提高程序的健壯性非常有幫助。
舉個例子,用戶註冊。
正常的情況下,用戶註冊是可以成功的,函數會返回註冊成功的用戶信息;但是也可能不成功,比方說用戶名重複,比方說電話號碼重複,比方說會員總數到了等等。
這些都是在『事先定義』的分支流程。
有CE,由編譯器來檢查這些『分支』流程都被一一處理了,我覺得這是很好的事情。
CE提供了一套『分支流程』務必被逐一處理的機制,非常好!
======
『異常』與『錯誤』是兩種不同的概念,比方說,程序報OOM error,這就不是『事先定義』好的分支流程了。
遇到『錯誤』,那麼進程就直接掛掉好了。
逐層的通過CE去報,一層層 catch然後重新throw,那就完全木有必要了。
這不是CE適用的場景。
什麼時候是『異常』,什麼時候是『錯誤』,我覺得go語言也對此做出了很好的示範,只不過在go裡面,『異常』被叫做error,而『錯誤』被叫做panic。
======
明確『異常』跟『錯誤』的區別,然後對於『異常』,利用CE,去確保調用方對所有『分支流程』逐一做顯式處理,這是很好的語言特性。
要不要用CE?我的判斷標準是相應的『異常』是否是一個『事先定義』好的『分支流程』。
是的話,就用;不是的話,就不用;要麼直接掛掉,要麼吃掉。
======
很多RPC方案,都會有『返回值』跟『錯誤值』的區分。
比方說,thrift Thrift: The Missing Guide。在thrift中,可以這樣定義一個遠程服務:
double divide(1:double divisor, 2:double dividend) throws (1:InvalidOperation ouch),
如果除法可以正常計算,那麼就返回一個double,如果不能,就拋一個InvalidOperation的異常,並且如果有CE能夠確保調用方務必對異常進行處理,是不是挺合理的嘛?
=====
以go為例,上面的thrift介面,實際上是導致以下的代碼生成:
type CalculatorDivideResult struct {
Success *float64 `thrift:"success,0" db:"success" json:"success,omitempty"`
Ouch *InvalidOperation `thrift:"ouch,1" db:"ouch" json:"ouch,omitempty"`
}
也就是說,thrift的編譯器,會默默的生成一個新的類型,這個類型有兩個屬性:
一個叫Sucess,類型與函數的正常返回值一致。
一個的命名與類型則與函數的異常一致。
然後,thrift生成出來代碼還會有這樣:
if result.Ouch != nil {
err = result.Ouch
return
}
value = result.GetSuccess()
return
thrift會默默生成一個『包含正常值+異常值』的『隱含類型』,遠程返回的數據,其實都是這個隱含類型,然後,它會判斷這個類型的異常是否有值,如果有值就『拋異常』,如果沒有,就『返回正常值』。
(用go來演示其實不夠直觀,但我一時間沒在網上找到別的語言的thrift生成好的代碼,上的代碼是從 glycerine/golang-thrift-minimal-example 裡面搬的,但我做了簡化。)
不過,重點是:有一個新類型,這個類型別的都沒有,就只用來保存函數的返回值 + 異常。
有異常的時候返回異常,不然就返回返回值。
我們再來看王垠提到的typed rocket中union type的定義 4 Types in Typed Racket:
4.4 Union Types
Sometimes a value can be one of several types. To specify this, we can use a union type, written with the type constructor U.
&> (let ([a-number 37])
(if (even? a-number)
"yes
"no))
- : Symbol [more precisely: (U "no "yes)]
"no
Any number of types can be combined together in a union, and nested unions are flattened.
(U Number String Boolean Char)
"a value can be one of several types",這不就是可以用一個『類型』,包含多個屬性,每個屬性都是不同類型,然後,我們使用這個類型的時候,永遠都使用其中一個屬性的值嘛?
在垠神的pysonar2中也可以看到這樣的代碼:
if (realType instanceof UnionType) {
for (Type t : ((UnionType) realType).types) {
if (t instanceof ClassType) {
realType = t;
break;
}
}
}
(垠神把pysonar2的代碼從他的github中拿掉啦~但根據他之前開源出來的版本版權寫明了是Apache 2.0,我這裡這樣引用pysonar2的代碼應該沒有問題吧?)
如果,我們能夠在語言層面,支持Union Type,即一個值可能是多種不同類型,但每次都只能是某個具體類型,然後編譯器確保我們對各種類型都做出具體的處理,不會遺漏。
這不是很理想嘛?
=======
我一直都知道thrift中生成『隱含類型』的實現,但是這次看到垠神的博客,才恍然說,原來這可以是『Union Type』,一下子從只有『兩個屬性』的具體實現,上升到了『語言理論』的層面。
所以我說:完全贊同王垠對CE的看法,他真正想推薦的應該是typed racket中的union type,這也非常有啟發。
感謝垠神,15塊錢我付了!
=======
這個回答就先這樣吧~『妥協與無奈』部分啥時候等我給公司招到足夠多的小夥伴 【ezbuy_ezbuy招聘】前海煦逸信息技術(深圳)有限公司招聘信息-拉勾網 再來寫~
========
但是他對於Hejlsberg的評價則無法讓人苟同,我會認為這體現了王垠自己的思維局限:他沒有搞懂這個世界是存在妥協與無奈的。
Hejlsberg在做無奈的妥協而已,王垠卻以為Hejlsberg的邏輯「荒謬」。
王垠說的應該是 異常機制是一個很好的模塊化處理的機制,它不是完美的,但是為我們提供了極大的便利。比如 我要寫 一個去水果店購買蘋果的事件,可能會出現 蘋果被賣完了,可能買蘋果的時候地球毀滅了,可能蘋果店老闆愛上你不給蘋果了,有可能買蘋果的時候穿越到大明朝當太監了,等等異常觸發了買不到蘋果的結果,你不用反覆的去寫if else then去判斷,僅此而已。作為編程者,你沒有必要捕獲所有的異常,僅僅將異常機制當作一個工具,捕獲有可能會出現的情況即可。
----
補充:有人說王垠的意圖是想讓異常檢查層層傳遞向後兼容,我覺得這是大家對文章理解上的偏差。我個人認為異常只能當作一個提供便利的模塊化工具來使用。並不能解決大而全的問題以及兼容問題。
王同學沒搞清一個基本概念,exception不是拿來做「錯誤」處理的,否則就應該叫error了。
CE之所以沒什麼用,最根本的原因是,你不應該去捕獲其它函數拋出的異常,而應該去捕獲你需要處理的異常,在函數簽名里寫或者不寫某個東西對這一目標毫無幫助。
需要捕獲的異常僅僅包括運行時才會產生的、事先可以預料到的、並且可以恢復的異常,比如磁碟滿網路斷了之類,其它所有的異常只意味著程序錯誤,掩蓋它們是沒用的。王同學的文章一直清楚的表明他缺乏實際的工程經驗,這一篇也不例外。我看到「強制處理異常」的代碼都會很奇怪:如果異常一天到晚發生老要立刻處理,它叫「異常」是否妥當?都把異常當控制語句用了。
如何處理成功率低,最好馬上處理錯誤的操作呢?直接返回 (error, result) 做模式匹配,然後 if (error) { ... } 就好了,完全不需要CE這種語法。
只有運行時異常才能減少錯誤處理代碼的複雜度,如果不認同,那就乾脆不要異常,全返回error好了。我也感覺 checked exception 是有用的,但不是 Java 那樣的,而是像 Vala 那樣的設計。
Projects/Vala/Tutorial - GNOME Wiki!GLib has a system for managing runtime exceptions called GError. Vala translates this into a form familiar to modern programming languages, but its implementation means it is not quite the same as in Java or C#. It is important to consider when to use this type of error handling - GError is very specifically designed to deal with recoverable runtime errors, i.e. factors that are not known until the program is run on a live system, and that are not fatal to the execution. You should not use GError for problems that can be foreseen, such as reporting that an invalid value has been passed to a method. If a method, for example, requires a number greater than 0 as a parameter, it should fail on negative values using contract programming techniques such as preconditions or assertions described in the previous section.
Vala errors are so-called checked exceptions, which means that errors must get handled at some point. However, if you don"t catch an error the Vala compiler will only issue a warning without stopping the compilation process.
簡單說明:
1. checked exception 僅用於無法預測且可恢復的錯誤。Vala 不支持 unchecked exception。對於可預測或者不可恢復的錯誤,要麼事先避免,要麼用 assert 讓程序掛掉,然後修 bug。
2. 未捕獲 exception 的程序可以通過編譯,但會有編譯器警告。
Java 很多地方對 checked exception 的使用是很混亂的…
關於Java對Exception的處理,我贊同王垠的意見。
在稍微複雜一點,大型一點,對穩定性要求高的,的系統裡頭,調用一個API之前,我就是需要知道它可能會拋出哪些情況,沒有語法和靜態類型檢查的支持,你讓我去看文檔?這不是人肉去做全局靜態分析么?這不現實。你能確保文檔和代碼邏輯同步?
exception不是error,讀寫磁碟失敗,那叫error。
但format not match是exception,用unzip來解壓rar就是exception,用word打開excel就是exception,你要不要處理?
很多人拿「我不需要也不關心exception」的場景來懟Java,那你們有沒有考慮過那些「我就需要先知道這個API考慮了多少exception才決定要不要調用」的場景?
金融領域,轉賬失敗,transaction failure你要不要處理?要不要知道原因?遠程調用,RPC failure,你要不要處理?是網路斷了呢?還是參數不對?還是Auth Token驗證失敗?你要不要知道原因?
雖然CE不可能標記完所有的exception,但起碼,設計階段已知的exception,是可以標記出來的。也應該被標記出來。
尤其是IoC框架通過exception的捕捉,註冊exception mapper 來統一異常處理邏輯,能省下一大票問題。
也有人提到用返回值來帶exception,出門左轉有go,error value pair配合if else寫的很爽是吧?
在鄉間田野小路,沒有紅綠燈,沒有限速,沒有劃線,你怎麼開都行。出了事也是你自己的事。
但在城市道路上,沒有紅綠燈,沒有規劃線,寸步難行。一個差錯,耽誤你自己事小,耽誤別人事大。
越複雜的、越多人合作的項目,越要偏向一致的編碼風格,偏向技術、語言的確定性和可控性。這時候不追求酷炫高大上,甚至不追求表面的優雅,而是確定、可控。對於大型項目,多一行少一行這個真不是什麼大事。
某些互聯網程序員就是散漫慣了,不搞懂情況就一頓亂懟。黑白不分就懟王垠,可能是知乎碼農的政治正確吧。
看了這個問題下的回復和評論,你就知道各種語言里的異常處理這塊都有缺陷,這就是計算機軟體開發領域最亂的垃圾筐里最亂的部分,這都神碼跟神碼啊????
真不知道自從軟體開發語言里誕生了「異常處理」這個概念是來幫助程序員的還是來禍害程序員的,用「異常處理」能讓你的程序更健壯還是更混亂?這個問題才更值得深思。。。。
kotlin不了解。
關於CE的討論基本上沒說錯。
CE最少可以讓調用者知道你拋出了什麼異常,至於調用者要不要處理要怎麼處理是調用者的事兒。
關於他對Hejlsberg的攻擊,我本來沒看過Hejlsberg的言論,不能聽一面之詞也不知道他是不是尋章摘句斷章取義了。但即使如此,我也不認為他批駁的那個語言設計沒有CE的理由是荒謬的。這就跟「明明三個底部操作鍵更方便,iPhone設計成一個鍵是荒謬的「一樣沒道理。
最後:php是最好的語言。
贊同文章開頭關於kotlin的部分,昨晚繼續看gio的視頻,看了kotlin講解的那一集,心裡實在忍不住吐槽,「簡潔」的未必都是「優雅」的,「簡潔」又「優雅」的也未必就是實用的。
裡面有些簡潔確實增加了可讀性,但還有很多簡潔是大幅降低了可讀性。尤其是可選擇的語法習慣,語法上有多種選擇不是什麼好事。
關於CE的部分,不認同垠神的說法。
Bruce Eckel和Hejlsberg那篇文章我也很早就看過,前者在我看來確實是常年偽裝成用Java思考的C信徒,批評Java也說不到點上;然而後者在語言的理解方面確實是大師,C#繞開CE也是明智的選擇。
我幾年前剛讀完EJ2,跟同事爭論過CE的問題,我覺得exception的確應該是方法協議的一部分,用RuntimeException寫到javadoc里即可,編譯強制走得太遠了,幫不了壞猿,還綁了好猿的腳。當時舉的栗子如下:
對於守交規的行人,你有紅綠燈就夠了,對沒什麼交規概念的莽漢,你亮紅燈的時候豎一堵牆,他也會翻牆闖紅燈。
CE會強迫從exception拋出層到最終的異常處理層之間所有的中間層跟異常類型緊耦合,對中間層的代碼復用顯然是不利的。CE光從設計的理念上來說的確是很站得住腳的,王yin說的那些好處都是實際存在的,但是實際代碼中多得是那種絕對不會拋出異常的地方,讓你try圍起來,就很煩,於是就開始一股腦用@SneakyThrows這個工具了。
但是不用CE的壞處就不是很認同,拋開靜態分析這個不說,異常直接拋到頂然後崩潰,一目了然,其實也不是什麼壞事,總比無腦try然後忽略異常好。
強烈反對Checked Exception這種設計,事實上這是我最反感Java的地方。
通常情況下邏輯代碼無需關心函數會拋什麼異常,比如我寫一個保存當前狀態到文件的子程序saveToFile,這個函數里有非常複雜的狀態保存邏輯 ,可能會有N層的子函數調用,而內層的文件API調用可能會拋出各種類型的異常,比如文件不存在,磁碟空間不足,許可權不足等等。所有這些異常對保存邏輯來說全都沒法進行處理,通常遇到這些異常內層的子函數完全不必理會,只需要在最外層的saveToFile里catch住所有的IO異常,然後清理下現場,告知下用戶就可以了。整個代碼乾淨整潔,不用擔心內層的函數忘記檢查異常,出現程序失控,同時對異常也進行了良好的處理。反之,如果有了checked exception機制,所有內層函數都必須聲明會拋出IO異常,多了很多無謂的聲明代碼。如果哪個新手在聲明函數時忘記了聲明異常,然後在寫實現代碼的時候又遇到了編譯錯誤,於是根據編輯器提示加上了try catch實現以通過編譯,在catch里也又什麼也不做,結果就導致了忽略異常的嚴重邏輯錯誤。所以checked exception通常並不能使你的代碼變的更安全,反而因為繁瑣的聲明更容易誤導程序員做出危險的操作。另外更嚴重的是,如果現在我要擴展功能添加一個saveToDB的函數,中間層的一些子函數本來可以直接重用,只要把底層函數的寫文件API替換成寫DB的API就可以,但是因為有了checked exception就沒法直接重用了,因為底層拋出的異常類型變了,所以上層的函數聲明也要跟著改,這樣寫文件和寫DB的代碼就變得完全不具重用性,甚至連interface都不可重用。從本質上來說,函數聲明是介面,而拋什麼異常是具體實現決定的,把實現寫在介面里違背了介面抽象的原則,又因為工程上的繁瑣讓checked exception徹底變成了無用的添亂特性。因此,大部分成熟的編程語言都不再採納這個特性了。再補充下,java的CheckedException特性在工程實踐中一直是很糟糕的體驗,所以很多項目乾脆直接把異常包裝成RuntimeException以規避Check,直到UncheckedIOException 的出現,連官方都鼓勵不要check了。所以那些嘴上說要check說要嚴謹的,身體卻很誠實的選擇了uncheck。首先,說一下我對老王這篇blog的看法,他對CE的觀點我是非常贊同的。
如果你忘了寫 catch (Exception),那麼你的代碼可能運行了一段時間之後當掉,因為忽然出現一個測試時沒出現過的異常……
我相信大部分覺得CE其實是「脫了褲子放屁多此一舉」的程序員其實是完全誤解了老王的意思。
老王考慮CE機制的核心是出於對程序的調試和bug定位為目的的。我記得在防禦式編程中曾經讀到過:為了確保代碼的健壯性和正確性,開發者應當對所有可能的異常情景進行處理。而實際上CE就是一個及其優秀的幫助進行防禦式編程的語言機制。以我曾在的多個團隊的實踐方案為例子說明。
現在想像要處理一段抽象語法樹的聲明語句結構(假設是C語言的DeclarationSpecifiers),這個東西的產生式大概是這樣的:
DeclSpec |--&> (TypeSpec | StorageClassSpec | TypeQualifier) (DeclSpec)?
其實如果學過C的各位都知道,存儲關鍵字StorageClassSpecifier在一個聲明規範中只會出現一次,假設我們要提取這個聲明語句中的類型信息,那麼就會處理下面的代碼:
/** derive storage class keyword from declaration specifiers **/
StorageSpec derive_storage_class_keyword(DeclSpec spec) {
StorageClassSpec storage_spec = null;
while(spec != null) {
ASTNode child0 = spec.get_child(0);
if(child0 instanceof StorageClassSpec) {
storage_spec = child0;
break;
}
if(spec.size_of_children() == 2) {
spec = (DeclSpec) spec.get_child_at(1);
}
}
return storage_spec;
}
表面上看,這段代碼是沒什麼毛病的,但是,如果從防禦式編程的角度來看,用戶可能會給你任何可能的奇葩輸入,例如:
- 輸入一個null節點
- 輸入一個語法結構不符合上述產生式的節點,例如,DeclSpec輸入包含了三個以上的子節點
- 輸入一個包含了多個storage class關鍵字的節點。
對於第一種情況,上述代碼直接返回null (不執行循環),這樣一來,調用者會以為這個非常節點沒有存儲關鍵字,似乎很「合理」,其實這已經埋下了一個定時炸彈,因為非法節點可能被拿去干其它事情,並在其它更深層的代碼中引發NULL引用錯誤。
對於第二種,這可能是由於parser在轉換的時候產生的錯誤(鬼知道會產生什麼錯誤),然而這段代碼並不會檢查這種奇葩的產生結構,條件:
spec.size_of_children() == 2
只考慮了正確產生式的情形。最後一種情況,則是一種錯誤的C聲明結構(不允許包括多個存儲關鍵字)。而上述代碼在獲取第一個storage class關鍵字之後就break了,如果這個聲明存在錯誤,那麼就會被保留到後續的代碼分析模塊,造成更為嚴重的深層bug,到那時候再定位就複雜了。顯然,如果從防禦式編程的角度編寫這個函數,應該是做如下處理:
StorageSpec derive_storage_class_keyword(DeclSpec spec) throws Exception {
if(spec == null) {
throw new InvalidArgumentException(
"ScopeAndTypeExtractor.derive_storage_class_keyword: null");
}
StorageClassSpec storage_spec = null;
while(spec != null) {
ASTNode child0 = spec.get_child(0);
if(child0 instanceof StorageClassSpec) {
if(storage_spec != null)
throw new SyntaxErrorException(
"Duplicated storage class: " + print_error(child0));
else storage_spec = child0; /* don"t break out */
}
switch(spec.size_of_children()) {
case 1: spec = null; break;
case 2: spec = spec.get_child(1); break;
default: throw new SyntaxErrorException(
"Invalid production: " + print_production(spec));
}
} /* end while */
return storage_spec;
}
可以看到,這個函數拋出一切可能的異常(當然比較好的實踐是將異常的類型也聲明一下,這裡因為篇幅問題,我就不一一闡述了)。當一個用戶調用這個介面時,他就能很準確的定位,到底自己的輸入存在哪些問題了。對於類似於抽象語法分析這種邏輯複雜的業務流程,採用這種步步檢查-拋出異常的形式其實非常有利於定位bug。如果沒有上述的異常拋出處理,就會有該死的程序員告訴你:你看我的代碼里
static auto x;
明明有一個auto,為何你只提取了static呢?回去重新改寫你的程序 P:)其實這明明就不是你的鍋。拋出異常和防禦式編程最大的好處其實就是摔鍋,明確告訴上層用戶,如果出錯了,先去檢查異常,修改你們的調用方式,而不是過來問我這些該死的問題。
向後兼容與CE
我看到有某位高票答主聲稱王垠對於CE的認知可能破壞程序介面的向後兼容性,確實,我的函數func在第一版可能只拋出FileNotFoundException,到了第二版可能就變成了SQLException了,為了向後兼容,應該改成通用化的Exception,或者至少,拋出異常這種「介面聲明」方式本身,就是給介面「判死刑」。我不否認CE對向後兼容的影響,但是這是可以處理的(通過修改介面聲明,在發布版中屏蔽介面的exception拋出是可以做到這點的,相反,在內部開發代碼中仍然throw各種exception給開發人員調試和定位bug)我曾在多個團隊中進行過JAVA和C++的系統開發,一個重要的實踐原則是:在開發階段,有什麼exception就拋什麼exception,方便定位bug;寧可拋出異常,也不要產生奇怪的中間結果;到了發布階段,再把那些該死的exception給catch掉,讓用戶看不見那些奇葩的異常。這一實踐的結果是代碼的可靠性有了很大的提升,且極大地降低了定位和調試的成本。而且也並不違反所謂的向後兼容。此外對於向後兼容我需要再強調一些:向後兼容只是一種選擇,絕對不是說是一個必要的存在。一個介面的exception改了,那調用者有幾個選擇:
- 修改上層catch代碼使其具有通用性;
- 再上層代碼中不要catch,直接拋出這個異常(取而代之的用exception拋出)
- 繼續用舊版本的介面和庫代碼
實際上,很多經歷了大量迭代更新的軟體並沒有完全將向後兼容性貫徹始終,就拿那位答主所說的JAVA為例,JAVA 8對於JAVA 6和7可以說是有一些介面的改變的,一些介面被ban掉了,一些則被deprecated了,這些都是後期規範對前期版本判了死刑。當一個人拿著JAVA 6的代碼去JAVA 8上面編譯就會拋出各種警告甚至錯誤。這種時候各位怎麼做的呢?答案當然是:
- 修改自身的代碼,使其能在JAVA 8上編譯運行;
- 臨時用JAVA 6或者低版本的虛擬機運行本地程序;
你看這不是一樣可以用嗎?即使沒有向後兼容性,程序的執行也不受到影響。甚至可以說,許多所謂的向後兼容性根本就是一種「病」。我記得之前 叛逆者 曾經在一篇答案中怒斥過OPENGL過了這麼多年還在用上古時代的API,使其直接被DX給佔據了市場。這是為什麼呢?因為過時的東西遲早是要死的,既然遲早要死,為何不早點給他們留後路判死刑呢?所以我個人的理解是,雖然向後兼容性很重要,但那絕不是否定CE,甚至認為CE是「脫褲子放屁」的一種舉動。其實要說脫褲子放屁,向後兼容有時候就如同早上拉下的一坨屎,放到晚上拌飯吃一樣,令人噁心。
評價一下
老王這篇博客是難得的一片近期基本為本人所認同的文章。
現在我來講一下為什麼 Hejlsberg 對於 CE 的批評是站不住腳的。他的第一個錯誤,俗話說就是「人笨怪刀鈍」。他把程序員對於出錯處理的無知,不謹慎和誤用,怪罪在 CE 這個無辜的語言特性身上。
這其實也是大部分知乎答主反對老王對CE觀點的原因之一,CE之所以為人詬病,是因為人們不想去理解介面使用時需要注意的一些細節,絕大部分程序員寫個讀文件的調用,80%都是百度或者google的用例代碼,有幾個認真去看過javadoc的,知道輸入參數存在哪些錯誤時會拋出哪些異常?不會,他們對於下層介面的這種認知就如同他們對自身業務邏輯的認知一樣,無知的令人髮指。也正是對於自身程序業務邏輯的無知,造成了他們懶於去思考用戶會以怎樣的方式錯誤使用他們的程序,而缺少對這些錯誤的處理和CE,結果只能是:要麼做出一個用戶極不友好的軟體,要麼做出一個有錯誤的軟體,而且自己還不知道怎麼去調試!畢竟連CE都不願意處理的人,就不要指望他們還會去理解「什麼是錯誤」了。
我們應該早睡早起,我們應該適量擼管,我們不應該熬夜.
道理誰都懂,但實際操作起來嘛...
他說的是CE,但其實通篇強調的是FCE(強制異常檢查)
而且他的那句翻譯不太好聽,但還真沒說錯:"大部分傻大笨粗的程序員用不好CE,還是不強制了吧".
事實上kotlin在這點上還真是結合了java和c#的優點,kotlin也是可以顯示指定函數可能拋出的異常的,比如
這裡其實可能拋出很多異常,比如rpc,資料庫(非約束,鎖等業務性)異常,但這些硬體設施異常我是沒辦法處理的,只能最上層aop統一捕獲後,給個提示和log一個快照,但是這個BizException是我明確定義的前驗異常,我仍然可以顯示指明,然後上層調用方需要處理或者繼續拋出這個異常.
Ps:這裡有誤,是java中仍然需要顯示的處理,但如果是kotlin中調用,則其實不需要Check的,那看起來就和C#是一樣的了,不過註解的方式應該還是比xml的注釋要強上那麼一丟丟,未來加lint什麼的,也容易一點.
kotlin這種不強制,但又提供了讓你選擇的CE方式,感覺用起來很舒服,功能完全足夠又沒什麼阻礙
如果是JAVA,那無非就是下面再加一個 Catch (Exception all )罷了,又是何必呢?
//補充分割
對於異常,我其實只分兩種,能處理的和不能處理的,
前者主要是數據重複,衝突,過期,不完整,業務已關閉,外鍵約束沒清理乾淨等,
後者是斷網了,資料庫機器停電了,網路超時等等無法預料或者超出處理能力的錯誤,
異常處理可以很細緻,也可以很粗放,但這個度只應該由程序員自己來掌控,加再多的約束,也抵不過上手就先來個try的,如果不能合理的throw,則調用方得到的要麼是一個不明所以的Exception,要麼是一大推瑣碎的XXException,YYException.
能合理throw異常的lib維護者,自然也會維護好注釋或者註解.
當然我也不是就反對CE了,我的觀點是,這並不重要,CE不算痛點,我也很少遇到就差個CE就能解決問題的情況.
有就用,沒有也行.
借用樓上一句話,淫王只能黑這個小點,看來kotlin真的硬傷不多了.
推薦閱讀:
※如果編譯器可以知道一個try塊不會拋出異常,那整個try-catch部分是不是會被忽略?
※C++,windows異常捕獲及崩潰處理?
※如果一個方法拋出了Runtime異常,它的調用方法必須顯式捕獲或者繼續拋出么?
※Python 的異常機制及規範是否相當不人性化?
※Go 語言的錯誤處理機制是一個優秀的設計嗎?