Content Table

怎么理解不要用异常做控制流程

有很多文件介绍异常以及不要用异常做流程控制,如:

即使参考了很多文章,但对于什么是不要用异常做流程控制,什么时候抛出异常还是没有清晰的理解。并且可能会问:抛出异常就终止了函数的执行,这不就是控制了流程吗?

我们这里不去介绍异常的各种理论,而是从编码的角度来看,根据下面几条判断抛出异常的时机:

  • 一个函数的返回类型是 ReturnType
  • 在处理业务逻辑的时候,发现不满足条件要终止函数的执行,则有如下 2 种情况:
    • 类型 ReturnType 定义了无效值: 可以返回错误对应的无效值终止函数,让函数调用者明确的知道发生了什么错误
    • 类型 ReturnType 未定义无效值: 这个时候,为了终止函数,因为不能返回合适的无效值,那么就只能抛出异常终止函数,让函数调用者明确的知道发生了什么错误 (最好在异常中带上错误 code 和错误信息)
  • 函数的返回值要明确,不能有歧义
  • 为了终止函数的执行,没有合适的返回值时就抛出异常

下面以创建用户为例介绍什么时候应该抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Data
public class User {
private long userId;
private String username;
private String password;
}

public class UserService {
/**
* 创建用户
*
* @param user 需要新创建的用户
* @return 返回成功创建的用户
* @throws 创建用户失败抛出 ApplicationException 异常
*/
public User createUser(User user) {
// 校验密码
validatePassword(user.getPassword());

// 校验用户名
if (StringUtils.isBlank(user.getUsername())) {
throw new ApplicationException(1, "用户名不能为空");
}

// 确保用户名可用
if (userDao.isUsernameExisting(user.getUsername())) {
throw new ApplicationException(2, "用户名已经被使用");
}

// 完善用户数据
user.setUserId(nextId());
user.setPassword(encryptPassword(user.getPassword()));

// 创建用户
userDao.insertUser(user);

// 返回成功创建的用户
return user;
}

/**
* 校验密码
*
* @param password 密码
* @return 密码有效返回 true
* @throws 密码无效时抛出 ApplicationException
*/
private boolean validatePassword(String password) {
if (StringUtils.isEmpty(password)) {
throw new ApplicationException(3, "密码不能为空");
}
if (password.length() < 5) {
throw new ApplicationException(4, "密码不能少于 5 位");
}

return true;
}
}

根据上面的介绍,是否应该抛出异常主要是分析函数的返回值和终止函数错误情况:

  • 创建用户失败可能有多种原因,一定要让调用者知道具体的错误,而不是笼统的归一为创建用户失败

  • 推荐抛出异常:

    如果给 User 类定义一个无效的用户,其 userId 为 0,创建用户失败的时候返回这个无效用户,但是不能让调用者知道创建用户失败时的错误原因,这个设计显然不好

  • 推荐抛出异常:

    如果给 User 类定义 N 个无效用户,不同的 userId 表示不同的错误原因,userId 作为错误 code 显然是个很糟糕的设计

  • 返回操作码,不推荐抛出异常:

    修改函数 UserService#createUser 的返回值为整数,0 表示创建成功,非 0 值对应不同的错误码,错误发生时返回对应的错误码,这在 C、C++ 里是很常见的设计,效率高比抛异常高

例如 FileUtils.readAsString(path) 读取文件的内容,如果读取失败返回 null,则不是一个好的设计,由于读取失败的情况有:

  • 文件不存在
  • 没有读权限
  • 文件被其他进程占用导致打开失败

返回 null 时,因其只有一个值,不能判断错误发生的原因,所以读取文件失败的时候应该抛出对应的异常告知调用者失败的原因,而不是返回 null。

也就是说,为了终止函数的执行,没有合适的返回值时就抛出异常,否则就使用返回正确值的方式,毕竟异常处理的效率低一些。在 Java 中,返回操作码作为返回值的设计比较少,因为不够直观,当错误发生时更多是使用异常终止函数的执行,在异常不完善的语言如 C、C++ 里函数返回操作码则是很常见的设计。