错误处理:用异常代替返回码

错误处理这件事,我见过太多团队翻车了。说白了,代码里最容易被忽视的就是「万一出错了怎么办」。我早年参与过一个支付系统,那代码里全是 if 判断返回码,看得我头皮发麻。后来重构了一次,改用异常,整个代码清爽了不止一个档次。

今天我们就聊聊错误处理的四个核心原则。你想想看,如果调用一个函数还要检查它返回的是 0 还是 -1,那代码还能读吗?

1. 用异常,别用返回码

返回码这种写法,其实是 C 语言时代的遗产。那时候没有异常机制,只能靠返回值传状态。但 Java、Python、C# 这些现代语言都有异常了,为什么还要用返回码?

我举个例子你就明白了:

// 糟糕的做法:返回码
public int saveUser(User user) {
    if (user == null) return -1;
    if (user.getName() == null) return -2;
    // 保存逻辑...
    return 0;
}

// 调用方
int result = saveUser(user);
if (result == -1) {
    // 处理空用户
} else if (result == -2) {
    // 处理名字为空
}

你看,调用方要写一堆 if。而且最要命的是——调用方可能忘了检查返回码。我在项目中遇到过好几次,返回码被直接忽略,bug 藏得特别深。

改成异常就清晰多了:

// 好的做法:异常
public void saveUser(User user) {
    if (user == null) throw new IllegalArgumentException("用户不能为空");
    if (user.getName() == null) throw new IllegalArgumentException("用户名不能为空");
    // 保存逻辑...
}

// 调用方
try {
    saveUser(user);
} catch (IllegalArgumentException e) {
    // 统一处理参数错误
}
核心原则:异常把「错误处理」和「业务逻辑」分开了。调用方不用在每个调用点都写 if,只需要在合适的地方 catch 一下。

2. 定义自己的异常类

很多人喜欢直接用 RuntimeException 或者 Exception,然后塞一个字符串消息。嗯,这里要注意——这样做的后果是,调用方没法根据异常类型做不同的处理。

我个人习惯是,每个模块或每个业务场景定义自己的异常类:

public class UserNotFoundException extends RuntimeException {
    private String userId;
    
    public UserNotFoundException(String userId) {
        super("用户未找到: " + userId);
        this.userId = userId;
    }
    
    public String getUserId() {
        return userId;
    }
}

public class PaymentFailedException extends RuntimeException {
    private String orderId;
    private int errorCode;
    
    public PaymentFailedException(String orderId, int errorCode) {
        super("支付失败,订单: " + orderId + ",错误码: " + errorCode);
        this.orderId = orderId;
        this.errorCode = errorCode;
    }
}

为什么要这么做?因为调用方可以精确 catch:

try {
    processOrder(orderId);
} catch (UserNotFoundException e) {
    // 提示用户重新登录
} catch (PaymentFailedException e) {
    // 提示用户检查支付方式
}
小技巧:异常类名要能清晰表达「发生了什么」。比如 UserNotFoundException,一看就知道是用户没找到。别用太通用的名字,比如 BusinessException,那跟直接用 Exception 没区别。

3. 特例模式(Special Case Pattern)

这个模式很有意思。有时候我们不想抛异常,因为异常会打断流程。比如你查一个用户,如果没找到,抛异常然后上层 catch,代码会变得很绕。

特例模式的做法是:返回一个「特例对象」,这个对象的行为和正常对象一样,但代表的是「边界情况」。

我举个例子:

// 正常情况
public User findUser(String userId) {
    // 从数据库查询
    User user = userRepository.findById(userId);
    if (user == null) {
        return new NullUser(); // 特例对象
    }
    return user;
}

// 特例对象
public class NullUser extends User {
    @Override
    public String getName() {
        return "访客";
    }
    
    @Override
    public boolean isNull() {
        return true;
    }
}

调用方就不用判断 null 了:

User user = findUser("123");
// 直接调用,不用判空
System.out.println(user.getName()); // 如果是 NullUser,输出 "访客"
注意:特例模式不是万能的。如果调用方确实需要区分「正常用户」和「访客」,那可以用 isNull() 方法判断。但大多数场景下,调用方根本不需要知道这个区别。

4. 别返回 null

这个原则我特别想强调。null 是 bug 的温床。你想想看,一个方法返回 null,调用方就得写 if (xxx != null)。写多了,代码里全是判空逻辑,真正的业务逻辑反而被淹没了。

我曾经接手过一个项目,一个方法返回 List,结果可能返回 null。调用方每次都要判空:

List<User> users = getUsers();
if (users != null) {
    for (User user : users) {
        // 处理
    }
}

改成返回空列表就干净多了:

public List<User> getUsers() {
    List<User> result = userRepository.findAll();
    return result != null ? result : Collections.emptyList();
}

// 调用方
for (User user : getUsers()) {
    // 直接处理,不用判空
}
规则:永远不要返回 null。如果返回值是集合,返回空集合;如果返回值是对象,考虑用特例模式或者 Optional。

知识体系图

下面这张图总结了错误处理的四个核心原则,以及它们之间的关系:

错误处理四大原则 ① 用异常代替返回码 分离错误处理与业务逻辑 ② 定义自己的异常类 精确表达异常类型,方便调用方处理 ③ 特例模式 用特例对象代替 null,避免判空 ④ 别返回 null 返回空集合或 Optional 最终目标:让错误处理变得可预测、可维护 调用方不需要猜测「这个方法会不会返回 null」或「返回码是什么意思」

避坑指南

最后分享几个我踩过的坑:

  • 我曾经在一个项目里用异常控制流程——比如用户输入错误也抛异常。结果日志里全是异常堆栈,排查问题反而更困难了。记住:异常只用于「意料之外」的情况,业务逻辑的校验用 if 就好。
  • 我曾经在 catch 块里什么都不做,就打个日志。结果线上出了问题,日志里只有「发生异常」,完全不知道哪里出的问题。catch 块要么处理,要么重新抛,别吞掉异常。
  • 我曾经在 finally 块里 return 了一个值,结果 try 块里的异常被覆盖了。finally 块不要 return,也不要在里面抛异常。
总结一下:用异常代替返回码,定义有意义的异常类,用特例模式处理边界情况,永远不返回 null。这四个原则做到了,你的代码会干净很多。

说白了,错误处理不是为了「不出错」,而是为了「出错的时候,代码还能优雅地处理」。你想想看,如果每个方法都返回 null,那整个系统就像个定时炸弹,随时可能 NullPointerException。用异常和特例模式,至少你知道炸弹在哪里,怎么拆。