Clean Code - 錯(cuò)誤處理
當(dāng)錯(cuò)誤發(fā)生時(shí),程序員有責(zé)任確保代碼照常工作。
錯(cuò)誤處理很重要,但如果它搞亂了代碼,就是錯(cuò)誤的做法,接下來的內(nèi)容將會(huì)談及如何優(yōu)雅地進(jìn)行代碼的錯(cuò)誤處理。
1、使用異常而非返回碼
反例:
public?class?DeviceController?{?...
????public?void?sendShutDown()?{?
????????DeviceHandle?handle?=?getHandle(DEV1);?//?Check?the?state?of?the?device
????????if?(handle?!=?DeviceHandle.INVALID)?{
????????????//?Save?the?device?status?to?the?record?field?
????????????retrieveDeviceRecord(handle);
????????????//?If?not?suspended,?shut?down
????????????if?(record.getStatus()?!=?DEVICE_SUSPENDED)?{
????????????????pauseDevice(handle);?
????????????????clearDeviceWorkQueue(handle);?
????????????????closeDevice(handle);?
????????????}?else?{
????????????????logger.log("Device?suspended.?Unable?to?shut?down");
????????????}
????????}?else?{
????????????logger.log("Invalid?handle?for:?"?+?DEV1.toString());?
????????}
????}
...?
}
使用返回碼的問題在于,他們搞亂了調(diào)用者代碼。對(duì)于使用返回碼的函數(shù),調(diào)用者在得到返回碼之后要立即使用 if 語句去驗(yàn)證返回碼。不幸的是,這個(gè)步驟很容易被遺忘。
此外,使用返回碼的話,業(yè)務(wù)邏輯和錯(cuò)誤處理代碼耦合在一起,這對(duì)于代碼的可讀性和結(jié)構(gòu)的合理性都是極大的挑戰(zhàn)。
所以,遇到錯(cuò)誤時(shí),最好的辦法是拋一個(gè)異常。當(dāng)程序出現(xiàn)錯(cuò)誤時(shí),調(diào)用者能夠立即接收到這個(gè)異常,無需調(diào)用者去判斷。
此外,使用異常處理能讓業(yè)務(wù)邏輯和錯(cuò)誤處理在代碼結(jié)構(gòu)上分離,調(diào)用代碼很整潔,其邏輯不會(huì)被錯(cuò)誤處理搞亂。
正例:
public?class?DeviceController?{?
????...
????public?void?sendShutDown()?{?
????????try?{
????????????tryToShutDown();
????????}?catch?(DeviceShutDownError?e)?{
????????????logger.log(e);?
????????}
????}
????private?void?tryToShutDown()?throws?DeviceShutDownError?{?
????????DeviceHandle?handle?=?getHandle(DEV1);
????????DeviceRecord?record?=?retrieveDeviceRecord(handle);
????????pauseDevice(handle);?
????????clearDeviceWorkQueue(handle);?
????????closeDevice(handle);
????}
????private?DeviceHandle?getHandle(DeviceID?id)?{?
????????...
????????throw?new?DeviceShutDownError("Invalid?handle?for:?"?+?id.toString());
????????...?
????}
...?
}
見上述代碼,業(yè)務(wù)邏輯與錯(cuò)誤處理部分截然分開,錯(cuò)誤處理部分被隔離到 Exception 的子類 DeviceShutDownError 中處理了。
2、先寫 Try-Catch-Finally 語句
當(dāng)遇到需要做異常處理的時(shí)候,首先把try-catch-finally塊寫出來,這能幫助你寫出更好的異常處理代碼。
3、使用不可控異常
可控異常(也叫可檢查異常,checked exception):
這類異常是可以預(yù)測(cè)的,我們必須在程序中做處理,try...catch 捕獲或者 throw 拋出。比如 IOException(網(wǎng)絡(luò)異常)、SQLException(SQL異常) 等等。
不可控異常(也叫不可檢查異常,unchecked exception,也叫運(yùn)行時(shí)異常):
這類異常是程序運(yùn)行時(shí)發(fā)生的,是無法預(yù)測(cè)的。比如 NullPointerException(空指針異常)、ClassCastException(類型轉(zhuǎn)換異常)、 IndexOutOfBoundsException(數(shù)組越界異常)等等。
如果在某些特殊的情況下必須要捕獲異常并作出處理,那么不得不使用可控異常。但是在通常的開發(fā)過程中應(yīng)當(dāng)避免使用可控異常。
原因在于可控異常其違反了開放-封閉原則。如果你在方法中拋出可控異常,而 catch 語句在三個(gè)層級(jí)之上,你就得在 catch 語句和拋出異常處之間的每個(gè)方法簽名中聲明該異常。這意味著對(duì)軟件中較低層級(jí)的修改,都將波及到較高層級(jí)的修改。修改好的模塊必須重新構(gòu)建、發(fā)布,即便它們自身所關(guān)注的任何東西都沒有修改過。
以某個(gè)大型系統(tǒng)的調(diào)用層級(jí)為例。頂端函數(shù)調(diào)用它們之下的函數(shù),逐級(jí)向下。假設(shè)某個(gè)位于最底層的函數(shù)被修改為拋出一個(gè)異常,如果這個(gè)異常是可控異常,則函數(shù)簽名就要添加 throws 子句。這意味著每個(gè)調(diào)用該函數(shù)的函數(shù)都要修改,捕獲新異常,或者在其簽名中添加 throws 子句,以此類推,最終得到的就是一個(gè)從軟件最底端貫穿到最高端的修改鏈!
示例 1(最底層函數(shù)不拋異常):
public?void?function1(){
??function2();
}
public?void?function2(){
??function3();
}
public?void?function3(){
??function4();
}
public?void?function4(){
??/*...*/
}
示例 2(底層函數(shù)拋出一個(gè)可控異常):
public?void?function1(){
??try{
????function2();
??}catch(IOException?exception){
????logger.info("function1?出現(xiàn)異常",?exception);
??}
}
public?void?function2()?throws?IOException{
??function3();
}
public?void?function3()?throws?IOException{
??function4();
}
public?void?function4()?throws?IOException{
??/*...*/
}
底層函數(shù) function4() 拋出一個(gè)可控異常 IOException。然后 function3() 調(diào)用 function4(),那么 function3() 要么 try catch 處理這個(gè)異常,要么不處理拋給上一層,我們這里選擇拋出異常 IOException。再然后 function2() 調(diào)用 function3() ,那么 function2() 也拋出異常 IOException。最后最上層函數(shù) function1() 調(diào)用 function2(),由于 function1() 是最上層函數(shù),所以,我們采用 try catch 的方式,捕獲異常并處理異常。
由于底層函數(shù)的修改,導(dǎo)致整個(gè)函數(shù)調(diào)用鏈路的修改,這明顯違反了開閉原則。
4、給出異常發(fā)生的環(huán)境說明
拋出的每個(gè)異常,都應(yīng)當(dāng)提供足夠的環(huán)境說明,以便判斷錯(cuò)誤的來源和處所,即錯(cuò)誤信息應(yīng)當(dāng)充分,讓追蹤調(diào)用棧的排查者更容易查找到錯(cuò)誤原因。
5、依調(diào)用者的需求定義異常類
對(duì)錯(cuò)誤分類有很多方式。可以依其來源分類:是來自組件還是其他地方?或依其類型分類:是設(shè)備錯(cuò)誤、網(wǎng)絡(luò)錯(cuò)誤還是編程錯(cuò)誤?不過,當(dāng)我們?cè)趹?yīng)用程序中定義異常類時(shí),最重要的考慮應(yīng)該時(shí)它們?nèi)绾伪猾@取。
反例:
下面的 try catch finally 語句是對(duì)某個(gè)第三方 API 的調(diào)用,我們把調(diào)用可能拋出的異常都列了出來。我們可以看到,語句中包含了大量的重復(fù)代碼(一長(zhǎng)串的 catch(){/.../} )。
ACMEPort?port?=?new?ACMEPort(12);
try?{
??port.open();
}catch(DeviceResponseException?e){
??reportPortError(e);
??logger.log("Device?response?exception",?e);
}catch(ATM1212UnlockedException?e){
??reportPortError(e);
??logger.log("Unlock?exception",?e);
}catch(GMXError?e){
??reportPortError(e);
??logger.log("Device?response?exception");
}finally{
??/*...*/
}
正例:
我們定義一個(gè)通用異常類型 PortDeviceFailure,以及一個(gè)打包類 LocalPort,然后將上述的 API 調(diào)用以及異常處理代碼封裝到這個(gè)打包類中,最后讓打包類返回通用異常類型,從而簡(jiǎn)化代碼。
LocalPort?port?=?new?LocalPort(12);
try{
??port.open();
}catch(PortDeviceFailure?e){//?捕獲通用異常類型
??reportError(e);
??logger.log(e.getMessage(),?e);
}finally{
??/*...*/
}
//?定義一個(gè)通用異常類型
public?class?PortDeviceFailure?extends?Exception{
}
//?定義一個(gè)打包類
public?class?LocalPort{
??private?ACMEPort?innerPort;
??public?LocalPort(int?portNumber){
????innerPort?=?new?ACMEPort(portNumber);
??}
??public?void?open(){
????try?{
??????innerPort.open();
????}catch(DeviceResponseException?e){
??????throw?new?PortDeviceFailure(e);//?讓打包類返回通用異常類型?
????}catch?(ATM1212UnlockedException?e){
??????throw?new?PortDeviceFailure(e);
????}catch(GMXError?e){
??????throw?new?PortDeviceFailure(e);
????}
??}
??/*...*/
}
在反例中,我們?cè)?catch 異常時(shí),把第三方可能拋出的異常都 catch 住了,整段代碼有很多重復(fù)的地方。經(jīng)過優(yōu)化,我們用一個(gè)通用的異常代替了這些異常,然后把具體的 API 調(diào)用以及異常處理代碼封裝到一個(gè)打包類中,通過這種方式可以大大地簡(jiǎn)化代碼。
實(shí)際上,對(duì)第三方類庫中的 API 進(jìn)行封裝會(huì)帶來很多好處。
封裝的好處在于你可以不需要一定遵循這個(gè)類庫的設(shè)計(jì)來使用它,你可以定義自己感覺舒服的 API。在上例中,我們?yōu)?port 設(shè)備錯(cuò)誤定義了一個(gè)異常類型,然后發(fā)現(xiàn)這樣能寫出更整潔的代碼。
6、定義常規(guī)流程
雖然我們可以寫出很好的錯(cuò)誤處理代碼,它們外形優(yōu)雅、結(jié)構(gòu)清晰。但是錯(cuò)誤處理不能亂用,它只能用于以下這種情況--因?yàn)槌绦虺霈F(xiàn)錯(cuò)誤而想要終止代碼的操作,不能將它用于業(yè)務(wù)邏輯處理。
我們舉個(gè)栗子解釋一下,下面代碼來自某個(gè)記賬應(yīng)用的開支總計(jì)模塊。
反例:
try{
??MealExpenses?expenses?=?expenseReportDAO.getMeals(employee.getID());
??m_total?+=?expenses.getTotal();
}catch(MealExpensesNotFound?e){
??m_total?+=?getMealPerDiem();
}
上面這段代碼的業(yè)務(wù)邏輯是,如果消耗了餐食,則計(jì)入總額,如果沒有,則拋出 MealExpensesNotFound 異常,員工得到當(dāng)日餐食補(bǔ)貼。
上述代碼中的異常是為了處理業(yè)務(wù)邏輯的一種情況--員工沒有消耗餐食,而不是要中止計(jì)算而拋出異常,這是不規(guī)范的,它可以被重構(gòu)為如下的樣子。
可以修改 ExpenseReportDAO,使其總是返回 MealExpenses 對(duì)象。如果沒有餐食消耗,就返回一個(gè)返回餐食補(bǔ)貼的 MealExpenses 對(duì)象。
正例:
MealExpenses?expenses?=?expenseReportDAO.getMeals(employee.getID());
m_total?+=?expenses.getTotal();
//這里引入一個(gè)新的特殊情況的類
public?class?PerDiemMealExpenses?implements?MealExpenses?{
??public?int?getTotal()?{
????//?return?the?per?diem?default
??}
}
這種編程模式被叫做特例模式(也叫特殊情況模式),它創(chuàng)建一個(gè)新的類或配置一個(gè)對(duì)象,來處理這種特殊情況。
7、別返回 null 值
null 是邪惡的,不要在代碼中返回 null 值。
如果你的代碼有返回 null 值的情況,那么對(duì)于每一個(gè)可能為 null 的對(duì)象,都要對(duì)它進(jìn)行 null 判斷,否則就會(huì)拋出空指針異常,見如下代碼。
public?void?registerItem(Item?item){?
??if(item?!=?null){//?item?是一個(gè)對(duì)象,需要做?null?判斷,否則會(huì)出現(xiàn)空指針異常
????ItemRegistry?registry?=?peristentStore.getItemRegistry();?
??????if(registry?!=?null){//?registry?是一個(gè)對(duì)象,需要做?null?判斷,否則會(huì)出現(xiàn)空指針異常
????????Item?existing?=?registry.getItem(item.getID());
??????????if(existing.getBillingPeriod().hasRetailOwner()){
????????????existing.register(item);?
??????????}
??????}
??}
}
這就會(huì)造成以下幾個(gè)問題:
(1)增加自己的工作量,你的代碼中需要有一大堆的判斷一個(gè)對(duì)象是否為 null 的代碼。
(2)如果疏忽了,只要有一處沒有檢查 null 值,應(yīng)用程序就會(huì)失控。
可以使用以下幾種方法,來避免返回 null。
- 拋出異常
- 返回特例對(duì)象,即永遠(yuǎn)返回一個(gè)有值的對(duì)象。
- 如果你在調(diào)用某個(gè)第三方 API 中可能返回 null 值的方法,可以考慮用新方法打包這個(gè)方法,在新方法中拋出異常或返回特例對(duì)象。
下面我們用一個(gè)例子,詳述如何通過返回特例對(duì)象,來避免返回 null 值。
反例:
public?List?getEmployees() {
??if(..there?are?no?employees?..){
????return?null;
??}
}
List?employees?=?getEmployees();?
if(employees?!=?null){//?對(duì)?getEmployees()?的返回值做?null?判斷
??for(Employee?e?:?employees){?
????totalPay?+=?e.getPay();
??}?
}
在這個(gè)例子中,在 getEmployees() 方法中,當(dāng)沒有員工時(shí),返回 null 值。那么當(dāng)我們使用 getEmployees() 方法時(shí),就必須對(duì)它的返回值做 null 判斷,否則會(huì)出現(xiàn)空指針異常。
正例:
public?List?getEmployees() {
??if(..there?are?no?employees?..){
????return?Collections.emptyList();
??}
}
List?employees?=?getEmployees();?
for(Employee?e?:?employees){?
??totalPay?+=?e.getPay();
}?
在這個(gè)例子中,在 getEmployees() 方法中,當(dāng)沒有員工時(shí),返回一個(gè)空列表,此時(shí)就不需要 null 判斷了,代碼變得整潔多了。
8、別傳遞 null 值
在方法中返回 null 值是糟糕的做法,但將 null 值傳遞給其他方法就更糟糕了。除非 API 要求你向它傳遞 null,否則禁止傳遞 null 值。
反例:
public?class?MetricsCalculator{
??public?double?xProjection(Point?p1,?Point?p2){
????return?(p2.x?-?p1.x)?*?1.5;
??}
}
//?調(diào)用?xProjection()?方法時(shí),第一個(gè)參數(shù)傳入?null
calculator.xProjection(null,?new?Point(12,?13));
9、總結(jié)
這一章主要做的就是讓錯(cuò)誤處理不影響代碼可讀性,并且利用錯(cuò)誤處理加強(qiáng)代碼魯棒性,讓二者不沖突。
