Clean Code - 错误处理

程序媛和她的猫

共 7443字,需浏览 15分钟

 · 2022-06-11

当错误发生时,程序员有责任确保代码照常工作

错误处理很重要,但如果它搞乱了代码,就是错误的做法,接下来的内容将会谈及如何优雅地进行代码的错误处理。

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()); 
        }
    }
... 
}

使用返回码的问题在于,他们搞乱了调用者代码。对于使用返回码的函数,调用者在得到返回码之后要立即使用 if 语句去验证返回码不幸的是,这个步骤很容易被遗忘。

此外,使用返回码的话,业务逻辑和错误处理代码耦合在一起这对于代码的可读性和结构的合理性都是极大的挑战。

所以,遇到错误时,最好的办法是抛一个异常。当程序出现错误时,调用者能够立即接收到这个异常,无需调用者去判断。

此外,使用异常处理能让业务逻辑和错误处理在代码结构上分离,调用代码很整洁,其逻辑不会被错误处理搞乱。

正例

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());
        ... 
    }
... 
}

见上述代码,业务逻辑与错误处理部分截然分开,错误处理部分被隔离到 Exception 的子类 DeviceShutDownError 中处理了。

2、先写 Try-Catch-Finally 语句

当遇到需要做异常处理的时候,首先把try-catch-finally块写出来,这能帮助你写出更好的异常处理代码。

3、使用不可控异常

可控异常(也叫可检查异常,checked exception):
这类异常是可以预测的,我们必须在程序中做处理,try...catch 捕获或者 throw 抛出。比如 IOException(网络异常)、SQLException(SQL异常) 等等。

不可控异常(也叫不可检查异常,unchecked exception,也叫运行时异常):
这类异常是程序运行时发生的,是无法预测的。比如 NullPointerException(空指针异常)、ClassCastException(类型转换异常)、 IndexOutOfBoundsException(数组越界异常)等等。

如果在某些特殊的情况下必须要捕获异常并作出处理,那么不得不使用可控异常。但是在通常的开发过程中应当避免使用可控异常。

原因在于可控异常其违反了开放-封闭原则。如果你在方法中抛出可控异常,而 catch 语句在三个层级之上,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常。这意味着对软件中较低层级的修改,都将波及到较高层级的修改。修改好的模块必须重新构建、发布,即便它们自身所关注的任何东西都没有修改过

以某个大型系统的调用层级为例。顶端函数调用它们之下的函数,逐级向下。假设某个位于最底层的函数被修改为抛出一个异常,如果这个异常是可控异常,则函数签名就要添加 throws 子句。这意味着每个调用该函数的函数都要修改,捕获新异常,或者在其签名中添加 throws 子句,以此类推,最终得到的就是一个从软件最底端贯穿到最高端的修改链!

示例 1(最底层函数不抛异常):

public void function1(){
  function2();
}

public void function2(){
  function3();
}

public void function3(){
  function4();
}

public void function4(){
  /*...*/
}

示例 2(底层函数抛出一个可控异常):

public void function1(){
  try{
    function2();
  }catch(IOException exception){
    logger.info("function1 出现异常", exception);
  }
}

public void function2() throws IOException{
  function3();
}

public void function3() throws IOException{
  function4();
}

public void function4() throws IOException{
  /*...*/
}

底层函数 function4() 抛出一个可控异常 IOException。然后 function3() 调用 function4(),那么 function3() 要么 try catch 处理这个异常,要么不处理抛给上一层,我们这里选择抛出异常 IOException。再然后 function2() 调用 function3() ,那么 function2() 也抛出异常 IOException。最后最上层函数 function1() 调用 function2(),由于 function1() 是最上层函数,所以,我们采用 try catch 的方式,捕获异常并处理异常。

由于底层函数的修改,导致整个函数调用链路的修改,这明显违反了开闭原则。

4、给出异常发生的环境说明

抛出的每个异常,都应当提供足够的环境说明,以便判断错误的来源和处所,即错误信息应当充分,让追踪调用栈的排查者更容易查找到错误原因。

5、依调用者的需求定义异常类

对错误分类有很多方式。可以依其来源分类:是来自组件还是其他地方?或依其类型分类:是设备错误、网络错误还是编程错误?不过,当我们在应用程序中定义异常类时,最重要的考虑应该时它们如何被获取。

反例

下面的 try catch finally 语句是对某个第三方 API 的调用,我们把调用可能抛出的异常都列了出来。我们可以看到,语句中包含了大量的重复代码(一长串的 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{
  /*...*/
}

正例

我们定义一个通用异常类型 PortDeviceFailure,以及一个打包类 LocalPort,然后将上述的 API 调用以及异常处理代码封装到这个打包类中,最后让打包类返回通用异常类型,从而简化代码。

LocalPort port = new LocalPort(12);
try{
  port.open();
}catch(PortDeviceFailure e){// 捕获通用异常类型
  reportError(e);
  logger.log(e.getMessage(), e);
}finally{
  /*...*/
}

// 定义一个通用异常类型
public class PortDeviceFailure extends Exception{
}

// 定义一个打包类
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);
    }
  }
  /*...*/
}

在反例中,我们在 catch 异常时,把第三方可能抛出的异常都 catch 住了,整段代码有很多重复的地方。经过优化,我们用一个通用的异常代替了这些异常,然后把具体的 API 调用以及异常处理代码封装到一个打包类中,通过这种方式可以大大地简化代码。

实际上,对第三方类库中的 API 进行封装会带来很多好处

封装的好处在于你可以不需要一定遵循这个类库的设计来使用它,你可以定义自己感觉舒服的 API。在上例中,我们为 port 设备错误定义了一个异常类型,然后发现这样能写出更整洁的代码。

6、定义常规流程

虽然我们可以写出很好的错误处理代码,它们外形优雅、结构清晰。但是错误处理不能乱用,它只能用于以下这种情况--因为程序出现错误而想要终止代码的操作,不能将它用于业务逻辑处理

我们举个栗子解释一下,下面代码来自某个记账应用的开支总计模块。

反例

try{
  MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
  m_total += expenses.getTotal();
}catch(MealExpensesNotFound e){
  m_total += getMealPerDiem();
}

上面这段代码的业务逻辑是,如果消耗了餐食,则计入总额,如果没有,则抛出 MealExpensesNotFound 异常,员工得到当日餐食补贴。

上述代码中的异常是为了处理业务逻辑的一种情况--员工没有消耗餐食,而不是要中止计算而抛出异常,这是不规范的,它可以被重构为如下的样子。

可以修改 ExpenseReportDAO,使其总是返回 MealExpenses 对象。如果没有餐食消耗,就返回一个返回餐食补贴的 MealExpenses 对象。

正例

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

//这里引入一个新的特殊情况的类
public class PerDiemMealExpenses implements MealExpenses {
  public int getTotal() {
    // return the per diem default
  }
}

这种编程模式被叫做特例模式(也叫特殊情况模式),它创建一个新的类或配置一个对象,来处理这种特殊情况。

7、别返回 null 值

null 是邪恶的,不要在代码中返回 null 值。

如果你的代码有返回 null 值的情况,那么对于每一个可能为 null 的对象,都要对它进行 null 判断,否则就会抛出空指针异常,见如下代码。

public void registerItem(Item item)
  if(item != null){// item 是一个对象,需要做 null 判断,否则会出现空指针异常
    ItemRegistry registry = peristentStore.getItemRegistry(); 
      if(registry != null){// registry 是一个对象,需要做 null 判断,否则会出现空指针异常
        Item existing = registry.getItem(item.getID());
          if(existing.getBillingPeriod().hasRetailOwner()){
            existing.register(item); 
          }
      }
  }
}

这就会造成以下几个问题:

(1)增加自己的工作量,你的代码中需要有一大堆的判断一个对象是否为 null 的代码。

(2)如果疏忽了,只要有一处没有检查 null 值,应用程序就会失控。

可以使用以下几种方法,来避免返回 null。

  • 抛出异常
  • 返回特例对象,即永远返回一个有值的对象。
  • 如果你在调用某个第三方 API 中可能返回 null 值的方法,可以考虑用新方法打包这个方法,在新方法中抛出异常或返回特例对象。

下面我们用一个例子,详述如何通过返回特例对象,来避免返回 null 值。

反例:

public List getEmployees(){
  if(..there are no employees ..){
    return null;
  }
}

List employees = getEmployees(); 
if(employees != null){// 对 getEmployees() 的返回值做 null 判断
  for(Employee e : employees){ 
    totalPay += e.getPay();
  } 
}

在这个例子中,在 getEmployees() 方法中,当没有员工时,返回 null 值。那么当我们使用 getEmployees() 方法时,就必须对它的返回值做 null 判断,否则会出现空指针异常。

正例:

public List getEmployees(){
  if(..there are no employees ..){
    return Collections.emptyList();
  }
}

List employees = getEmployees(); 
for(Employee e : employees){ 
  totalPay += e.getPay();

在这个例子中,在 getEmployees() 方法中,当没有员工时,返回一个空列表,此时就不需要 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;
  }
}

// 调用 xProjection() 方法时,第一个参数传入 null
calculator.xProjection(nullnew Point(1213));

9、总结

这一章主要做的就是让错误处理不影响代码可读性,并且利用错误处理加强代码鲁棒性,让二者不冲突。


浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报