《Effective Java》第八章 第49条:检查参数的有效性
一、核心思想与重要性
核心思想:方法或构造器在开始执行前,应该对其参数的有效性进行检查。如果参数无效,应该立即抛出适当的异常。
重要性:
- 防御性编程:防止错误在系统中传播,避免在后续执行中产生难以追踪的问题
- 快速失败(Fail-Fast):在错误发生的第一时间就暴露出来,便于调试和定位问题
- 契约明确:明确方法的前置条件(preconditions),形成清晰的API契约
二、基本检查方式与示例
1. 简单的参数检查
原始示例分析:
/**
* 返回一个正整数n的模平方根
*
* @param n 正整数
* @return 模平方根
* @throws ArithmeticException 如果n <= 0
*/
public static BigInteger modSqrt(BigInteger n) {
if (n.signum() <= 0)
throw new ArithmeticException("n必须是正数: " + n);
// ... 实际计算逻辑
}
深入解析:
signum()方法:BigInteger的signum()返回-1(负数)、0(零)、1(正数)- 检查时机:在方法的最开始进行检查,确保后续计算基于有效参数
- 异常选择:使用
ArithmeticException,因为这是数学计算相关的参数错误
对比示范(错误的做法):
// 错误的做法 - 不进行检查
public static BigInteger badModSqrt(BigInteger n) {
// 直接开始计算,如果n为负数,可能:
// 1. 抛出令人困惑的异常
// 2. 返回错误的结果
// 3. 进入无限循环
return n.sqrt();
}
2. 对象引用检查
原始示例分析:
/**
* 根据策略和定时器执行一个动作
*
* @param strategy 策略对象,不能为null
* @param timeout 超时时间,必须为正数
* @throws NullPointerException 如果strategy为null
* @throws IllegalArgumentException 如果timeout <= 0
*/
public void execute(Strategy strategy, long timeout) {
// Objects.requireNonNull是Java 7引入的工具方法
Objects.requireNonNull(strategy, "strategy不能为null");
if (timeout <= 0)
throw new IllegalArgumentException("timeout必须是正数: " + timeout);
// ... 执行逻辑
}
深入解析:
-
Objects.requireNonNull():这是Java 7引入的便捷方法,内部实现为:public static <T> T requireNonNull(T obj, String message) { if (obj == null) throw new NullPointerException(message); return obj; } -
返回值:该方法返回传入的对象,可以用于内联赋值
-
异常信息:提供清晰的错误信息,帮助调用者快速定位问题
使用示范:
// 内联使用方式
public class Processor {
private final Strategy strategy;
public Processor(Strategy strategy) {
// 在构造器中检查并赋值
this.strategy = Objects.requireNonNull(strategy, "strategy不能为null");
}
}
三、文档化的重要性
1. Javadoc文档化示例
/**
* 计算二项式系数 C(n, k)
*
* @param n 总元素数量,必须满足 n >= 0
* @param k 选择元素数量,必须满足 0 <= k <= n
* @return 二项式系数 C(n, k)
* @throws IllegalArgumentException 如果n < 0, k < 0, 或k > n
*/
public static BigInteger binomial(int n, int k) {
// 参数有效性检查
if (n < 0)
throw new IllegalArgumentException("n不能为负数: " + n);
if (k < 0 || k > n)
throw new IllegalArgumentException("k必须满足 0 <= k <= n: k=" + k + ", n=" + n);
// 计算逻辑
BigInteger result = BigInteger.ONE;
for (int i = 0; i < k; i++) {
result = result.multiply(BigInteger.valueOf(n - i))
.divide(BigInteger.valueOf(i + 1));
}
return result;
}
2. 文档化的关键要素
必须包含的内容:
- 每个参数的限制条件
- 参数违反限制时抛出的异常类型
- 异常抛出的具体情况
文档示例分析:
@param n 总元素数量,必须满足 n >= 0
@throws IllegalArgumentException 如果n < 0
这种文档化方式:
- 指导调用者:明确告知参数要求
- 便于维护:后续维护者了解方法的前提条件
- 工具支持:IDE可以根据文档提供提示
四、特殊情况与考量
1. 非公有方法的检查
私有方法示例:
private static void sort(long a[], int offset, int length) {
// 断言(assert)用于私有方法的参数检查
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
// ... 排序逻辑
}
断言(assert)的特点:[补充:1]
- 默认禁用:需要在JVM启动时添加
-ea或-enableassertions参数启用 - 适用于私有方法:因为作者可以控制所有调用
- 性能考虑:断言被禁用时不会产生性能开销
重要区别:
// 公有方法 - 使用异常
public void publicMethod(Object param) {
if (param == null)
throw new NullPointerException("param不能为null");
// ...
}
// 私有方法 - 可以使用断言
private void privateMethod(Object param) {
assert param != null : "param不能为null";
// ...
}
2. 构造器参数检查的特殊性
构造器检查示例:
public class Period {
private final Date start;
private final Date end;
/**
* @param start 开始时间
* @param end 结束时间,必须晚于或等于开始时间
* @throws IllegalArgumentException 如果start在end之后
* @throws NullPointerException 如果start或end为null
*/
public Period(Date start, Date end) {
// 防御性拷贝之前的检查
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
// 有效性检查(在防御性拷贝之后!)
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + "晚于" + end);
}
}
关键要点:
- 检查时机:在防御性拷贝之后进行检查,确保检查的是实际存储的数据
- 不可变对象(final修饰):对于不可变对象,构造器是唯一设置状态的机会,必须严格检查
3. 检查的例外情况
不进行检查的情况:
// 情况1:检查开销极大,且检查在计算过程中自然进行
public void processLargeArray(int[] array) {
// 不预先检查数组长度,在遍历过程中遇到问题再处理
for (int i = 0; i < array.length; i++) {
// 处理逻辑
}
}
// 情况2:检查会破坏互操作性
// 例如:实现某个接口,该接口没有定义参数无效时的行为
有效性检查的成本考量:
// 需要权衡的例子:集合排序
public void sortList(List<Integer> list) {
// 是否要检查list是否为null?
// 是否要检查list中的元素是否为null?
// 这取决于方法契约和性能要求
// Collections.sort()的做法:让NPE自然发生
Collections.sort(list);
}
五、最佳实践总结
1. 检查原则
必须检查的情况:
- 公有方法、受保护方法、包级私有方法的参数
- 构造器参数
- 会存储起来供后续使用的参数
检查方式选择:
// 方式1:手动检查(最直接)
if (param == null) {
throw new NullPointerException("参数不能为null");
}
// 方式2:使用工具类(更简洁)
Objects.requireNonNull(param, "参数不能为null");
// 方式3:使用断言(仅私有方法)
assert param != null;
// 方式4:第三方库(如Guava的Preconditions)
Preconditions.checkNotNull(param, "参数不能为null");
Preconditions.checkArgument(param > 0, "参数必须为正数: %s", param);
2. 异常选择指南
| 异常类型 | 使用场景 | 示例 |
|---|---|---|
NullPointerException | 参数为null,且null不是有效值 | Objects.requireNonNull(param) |
IllegalArgumentException | 参数值不合法(非null) | if (value < 0) throw new IllegalArgumentException(...) |
IndexOutOfBoundsException | 索引参数越界 | `if (index < 0 |
ArithmeticException | 数学计算相关的参数错误 | if (divisor == 0) |
3. 现代Java的增强
Java 8+ 的改进:
// 使用Objects新增的方法
public void process(String text, int count) {
// 检查多个条件
Objects.checkFromIndexSize(offset, length, array.length);
Objects.checkIndex(index, size);
}
// 结合Optional(用于返回类型,而非参数)
public Optional<String> findNameById(long id) {
Objects.requireNonNull(id);
// ...
}
4. 综合示例
/**
* 用户注册服务
*
* @param username 用户名,不能为null或空,长度3-20字符
* @param password 密码,不能为null,长度8-50字符
* @param age 年龄,必须18-120岁
* @throws NullPointerException 如果username或password为null
* @throws IllegalArgumentException 如果参数值不合法
*/
public void registerUser(String username, String password, int age) {
// 层次化检查
Objects.requireNonNull(username, "用户名不能为null");
Objects.requireNonNull(password, "密码不能为null");
if (username.trim().isEmpty())
throw new IllegalArgumentException("用户名不能为空");
if (username.length() < 3 || username.length() > 20)
throw new IllegalArgumentException("用户名长度必须为3-20字符");
if (password.length() < 8 || password.length() > 50)
throw new IllegalArgumentException("密码长度必须为8-50字符");
if (age < 18 || age > 120)
throw new IllegalArgumentException("年龄必须为18-120岁");
// 业务逻辑
validatePasswordComplexity(password);
checkUsernameAvailability(username);
// 创建用户
User user = new User(username, password, age);
userRepository.save(user);
}
六、作者思想要点总结
- 防御性编程是责任:作为API设计者,有责任防止无效参数破坏系统状态
- 快速失败原则:尽早发现问题,避免错误传播
- 文档与实现一致:文档中声明的限制必须在代码中强制执行
- 区分公有与非公有:公有API必须严格检查,私有方法可以适当放宽
- 性能权衡:在99%的情况下,参数检查的开销是值得的
- 异常也是API的一部分:抛出的异常类型和消息是API契约的重要组成部分
通过遵循这些原则,可以创建更健壮、更易于使用和维护的Java代码。参数有效性检查是高质量API设计的基石之一。
补充:
1、深入理解Java断言(assert)的优势与使用场景
一、断言的核心哲学:开发阶段的调试工具
1. 断言的本质定位
断言不是参数验证机制,而是内部不变性(internal invariants)的检查工具。
// 错误的用法:将断言用于参数验证
public void processOrder(Order order) {
assert order != null : "订单不能为null"; // ❌ 错误用法!
// ...
}
// 正确的用法:检查内部不变性
private void calculateDiscount(double price) {
// 经过一系列复杂计算后...
double finalPrice = applyDiscount(price);
// 断言检查:确保我们的算法没有产生不可能的结果
assert finalPrice >= 0 : "最终价格不能为负数: " + finalPrice;
assert finalPrice <= price : "折扣后价格不能超过原价: " + finalPrice;
return finalPrice;
}
二、断言的实际优势
1. 性能优势:零开销发布
生产环境代码 vs 开发环境代码:
// 场景:图像处理库中的私有方法
private void transformImage(int[] pixels, int width, int height) {
// 方式1:使用if检查(始终执行)
if (pixels == null) throw new NullPointerException();
if (width <= 0 || height <= 0) throw new IllegalArgumentException();
if (pixels.length != width * height) throw new IllegalArgumentException();
// 这些检查在生产环境中每次都会执行,可能影响性能
// 方式2:使用断言(仅开发环境执行)
assert pixels != null;
assert width > 0 && height > 0 : "尺寸必须为正数";
assert pixels.length == width * height :
"像素数组长度必须等于宽度×高度: " + pixels.length + " != " + (width * height);
// 核心算法(在紧密循环中执行)
for (int i = 0; i < pixels.length; i++) {
// 复杂的图像变换算法
pixels[i] = transformPixel(pixels[i]);
}
}
性能对比:
- 生产环境(断言禁用):断言代码被完全移除,零性能开销
- 开发环境(断言启用):可以捕获算法实现的bug
2. 代码清晰性:分离业务逻辑与调试代码
// 不使用断言的情况
private void optimizeRoute(List<Point> waypoints) {
// 参数检查(业务逻辑的一部分)
Objects.requireNonNull(waypoints, "路径点列表不能为null");
if (waypoints.size() < 2) {
throw new IllegalArgumentException("至少需要两个路径点");
}
// 调试检查(与业务逻辑无关,只是验证实现正确性)
if (!isSortedByDistance(waypoints)) {
log.error("算法错误:路径点未按距离排序");
// 但这里不能抛出异常,因为不是业务错误
}
// 核心算法...
}
// 使用断言的情况
private void optimizeRoute(List<Point> waypoints) {
// 业务逻辑的参数检查
Objects.requireNonNull(waypoints, "路径点列表不能为null");
if (waypoints.size() < 2) {
throw new IllegalArgumentException("至少需要两个路径点");
}
// 内部不变性检查(使用断言)
assert isSortedByDistance(waypoints) : "路径点应该已按距离排序";
assert isValidCoordinateRange(waypoints) : "坐标值超出有效范围";
// 核心算法...
List<Point> optimized = doOptimization(waypoints);
// 后置条件检查
assert optimized.size() <= waypoints.size() :
"优化后路径点不应增加: " + optimized.size() + " > " + waypoints.size();
}
三、断言与异常的关键区别
1. 目的不同
| 特性 | if检查 + 异常 | 断言 |
|---|---|---|
| 目的 | 验证外部输入的有效性 | 验证内部逻辑的正确性 |
| 目标用户 | API调用者 | 代码开发者 |
| 生产环境 | 必须启用 | 应该禁用 |
| 错误性质 | 可预期的错误条件 | 理论上不可能发生的情况 |
五、为什么断言默认禁用?
1. 设计哲学
// 假设这个断言被误用于参数验证
public void processData(String data) {
assert data != null : "数据不能为null"; // ❌ 错误用法
// 生产环境(断言禁用):
// 如果调用者传入null,会直接导致NullPointerException
// 但可能在很深的调用栈中,难以调试
int length = data.length(); // 这里可能抛出NPE
// ...
}
正确的做法:
public void processData(String data) {
// 业务逻辑验证:使用异常
Objects.requireNonNull(data, "数据不能为null");
// 内部逻辑验证:可以使用断言
assert data.length() > 0 : "数据不应该为空字符串";
assert isValidFormat(data) : "数据格式不正确";
// ...
}
六、 黄金法则
- 对外(public/protected方法):总是使用异常进行参数验证
- 对内(private方法):
- 如果调用者是你控制的代码,可以使用断言
- 但更安全的做法是:重要的验证仍用异常,内部不变性检查用断言
- 不变性检查(不变性:在程序执行过程中始终保持为真的条件或属性。):总是使用断言
- 前置条件(假设调用前已满足的条件)
- 后置条件(方法执行后必须成立的条件)
- 类的不变条件(对象状态必须始终满足的条件

223

被折叠的 条评论
为什么被折叠?



