49 检查参数的有效性
当编写方法或构造方法时,都应该考虑其参数应该有哪些限制。应该把这些限制写到文档里,并在方法体的开头显式检查。
大多数方法和构造方法对于传递给他们的参数有一些限制。例如,索引值必须是非负数,对象引用必须为非null。我们应该在文档里清楚地指明这些限制,并且在方法的最开始进行检查。
如果没有验证参数的有效性,可能会导致违背失败原子性:
该方法可能在处理过程中失败,该方法可能会出现费解的异常
该方法可以正常返回,会默默地计算出错误的结果
该方法可以正常返回,但是使得某个对象处于受损状态,在将来某个时间点会报错
对于public和protected方法,要用Java文档的@throws注解来说明会抛出哪些异常,通常为:IllegalArgumentException,IndexOutOfBoundsException 或 NullPointerException,例如:
/** * Returns a BigInteger whose value is (this mod m). This method * differs from the remainder method in that it always returns a * non-negative BigInteger. * * @param m the modulus, which must be positive * @return this mod m * @throws ArithmeticException if m is less than or equal to 0 */ public BigInteger mod(BigInteger m) { if (m.signum() <= 0) throw new ArithmeticException("Modulus <= 0: " + m); ... // Do the computation }
在Java 7中添加的 Objects.requireNonNull 方法灵活方便,因此没有理由再手动执行null检查。该方法返回其输入的值,因此可以在使用值的同时执行null检查:
this.strategy = Objects.requireNonNull(strategy, "strategy");
对于不是public的方法,通常应该使用断言来检查参数:
private static void sort(long a[], int offset, int length) { assert a != null; assert offset >= 0 && offset <= a.length; assert length >= 0 && length <= a.length - offset; ... // Do the computation }
不同于一般的有效性检查,如果它们没有起到作用,本质上也没有成本开销。
在某些场景下,有效性检查的成本很高,且在计算过程里也已经完成了有效性检查,例如对象列表排序的方法Collections.sort(List)。
如果List里的对象不能互相比较,就会抛ClassCastException异常,这正是sort方法该做的事情,所以提前检查列表中的元素是否可以互相比较并没有很大意义。
有些计算会隐式执行必要的有效性检查,如果检查失败则会抛异常,这个异常可能和文档里标明的不同,此时就应该使用异常转换将其转换成正确的异常。
50 必要时进行保护性拷贝
Java是一门安全的语言,它对于缓存区溢出、数组越界、非法指针以及其他内存损坏错误都自动免疫。
但仅管如此,我们也必须保护性地编写程序,因为代码随时可能会遭受攻击。
如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但对象可能会在无意的情况下提供这样的帮助。例如,下面的代码表示一个不可变的时间周期:
public final class Period { private final Date start; private final Date end; /** * @param start the beginning of the period * @param end the end of the period; must not precede start * @throws IllegalArgumentException if start is after end * @throws NullPointerException if start or end is null */ public Period(Date start, Date end) { if (start.compareTo(end) > 0) throw new IllegalArgumentException(start + " after " + end); this.start = start; this.end = end; } public Date start() { return start; } public Date end() { return end; } ... // Remainder omitted }
上面代码虽然强制令period 实例的开始时间小于结束时间。然而,Date 类是可变的,很容易违反这个约束:
Date start = new Date(); Date end = new Date(); Period p = new Period(start, end); end.setYear(78); // Modifies internals of p!
从Java 8 开始,解决此问题的显而易⻅的方法是使用 Instant(或LocalDateTime 或 ZonedDateTime)代替Date,因为他们是不可变的。但Date在老代码里仍有使用的地方,为了保护 Period 实例的内部不受这种攻击,可以使用拷⻉来做 Period 实例的组件:
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(this.start + " after " + this.end); }
有了新的构造方法后,前面的攻击将不会对Period 实例产生影响。注意:保护性拷⻉是在检查参数的有效性之前进行的,且有效性检查是在拷贝实例上进行的。
这样做可以避免从检查参数开始到拷贝参数之间的时间段内,其他的线程改变类的参数
也被称作 Time-Of-Check / Time-Of-Use 或 TOCTOU攻击
看了之前章节的同学可能有疑问了,这里为什么没用clone方法来进行保护性拷贝?
答案是:Date不是final的,所以clone方法不能保证返回类确实是 java.util.Date 的对象,也可能返回一个恶意的子类实例。
但是普通方法就不一样了,它们在进行保护性拷贝是允许使用clone方法,原因是我们知道Period内部的Date对象类型确实是java.util.Date。
对于参数类型可能被恶意子类化的参数,不要使用 clone 方法进行防御性拷⻉。
其实,改变Period实例仍是有可能的:
Date start = new Date(); Date end = new Date(); Period p = new Period(start, end); p.end().setYear(78); // Modifies internals of p!
修改方法也很简单:
public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); }
上面的分析带来的启发是:应该尽量使用不可变对象作为对象内部的组件,这样就不必担心保护性拷⻉。在 Period 示例中,使用Instant(或LocalDateTime或ZonedDateTime)。另一个选项是存储Date.getTime() 返回的long类型来代替Date引用。
最后,如果拷贝成本较大的话,并且我们新人使用它的客户端不会恶意修改组件,则可以在文档中指明客户端不得修改受到影响的组件,以此来代替保护性拷贝。
51 谨慎设计方法
这一条介绍了若干经验:
1. 谨慎给方法起名
方法名应该选易于理解的,并且与同一个包里其他名称的风格一致
选择大众认可的名称
2. 不要过于追求提供便利的方法
方法太多会使类难以学习、使用、文档化、维护。只有当一项操作被经常用到时,才考虑为它提供快捷方式(shorthand)
3. 避免过长的参数列表,相同类型的长参数序列格外有害
参数个数不超过4个
有三种技巧可以缩短过长的参数列表:
把一个方法分解成多个方法,每个方法只需要这些参数的一个子集。例如:java.util.List接口里没有提供在子列表中查找元素的第一个索引和最后一个索引的方法。相反,它提供了 subList 方法,返回子列表。此方法可以与 indexOf 或 lastIndexOf 方法结合使用来达到所需的功能。
创建辅助类用来保存参数的分组。例如:编写一个表示纸牌游戏的类,发现需要两个参数来表示纸牌的点数和花色,这时就可以创建一个类来表示卡片。
从对象构建到方法调用全都采用Builder模式
4. 优先使用接口作为入参类型
只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如:在编写方法时使用Map接口作为参数
5. 对于boolean型参数,优先使用有两个元素的枚举
例如,有一个 Thermometer 类型的静态工厂方法,这个方法的签名需要以下这个枚举的值:
public enum TemperatureScale { FAHRENHEIT, CELSIUS }
1
Thermometer.newInstance(TemperatureScale.CELSIUS) 不仅比Thermometer.newInstance(true) 更有意义,而且可以在将来的版本中将新的枚举值添加到 TemperatureScale 中,而无需向 Thermometer 添加新的静态工厂。
52 慎用重载
下面这个程序试图将一个集合进行分类:
public class CollectionClassifier { public static String classify(Set<?> s) { return "Set"; } public static String classify(List<?> lst) { return "List"; } public static String classify(Collection<?> c) { return "Unknown Collection"; } public static void main(String[] args) { Collection<?>[] collections = { new HashSet<String>(), new ArrayList<BigInteger>(), new HashMap<String, String>().values() }; for (Collection<?> c : collections) System.out.println(classify(c)); } }
运行结果是打印了三次Unknown Collection。为什么会这样呢?
原因就是classify方法被重载了,要调用哪个重载方法是在编译时做出决定的。for循环里参数的编译时类型一直是Collection<?>,所以唯一适合的重载方法是classify(Collection<?> c)
有一个很有意思的事实:重载(overloaded)方法的选择是静态的,重写(overridden)方法的选择是动态的。
重写方法的选择是在运行时进行的,依据是被调用的方法所在的对象的运行时类型。
以下面这个例子具体说明:
class Wine { String name() { return "wine"; } } class SparklingWine extends Wine { @Override String name() { return "sparkling wine"; } } class Champagne extends SparklingWine { @Override String name() { return "champagne"; } } public class Overriding { public static void main(String[] args) { List<Wine> wineList = Arrays.asList( new Wine(), new SparklingWine(), new Champagne()); for (Wine wine : wineList) System.out.println(wine.name()); } }
这段代码打印出wine,sparkling wine和champagne,尽管在每次迭代里,实例的编译类型都是Wine,但总是会执行最具体(most specific)的重写方法,也就是在子类上调用的就执行被子类覆盖的方法。
在CollectionClassifier示例中,程序的目的是根据参数的运行时类型自动执行适当的方法重载来辨别参数的类型。但方法重载完全没有提供这样的功能,这段代码最佳修改方案是:用单个方法来替换这三个重载的classify方法,代码逻辑里用instanceof判断:
public static String classify(Collection<?> c) { return c instanceof Set ? "Set" : c instanceof List ? "List" : "Unknown Collection"; }
如果API的普通用户根本不知道哪个重载会被调用,使用这样的API就会报错。所以,应该避免混淆使用重载。
安全保守的策略是:一个安全和保守的策略是永远不要编写两个具有相同参数数量的重载。
因为我们始终可以给方法起不同的名字,避免使用重载。
例如,考虑ObjectOutputStream类。对于每个类型,它的write方法都有一种变体,例如writeBoolean(boolean)、writeInt(int)和writeLong(long)。这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()、readInt()和readLong()。
一个类的多个构造器总是重载的,可以选择导出静态工厂。
对于每一对重载方法,至少要有一个形参在这两个重载中具有「完全不同的」类型。这时主要的混淆根源就没有了。例如ArrayList有接受int的构造方法和接受Collection的构造方法。
Java有一个自动装箱的概念,他们的出现也引入了一些麻烦:
public class SetList { public static void main(String[] args) { Set<Integer> set = new TreeSet<>(); List<Integer> list = new ArrayList<>(); for (int i = -3; i < 3; i++) { set.add(i); list.add(i); } for (int i = 0; i < 3; i++) { set.remove(i); list.remove(i); } System.out.println(set + " " + list); } }
实际上,程序从Set中删除非负值,从List中删除奇数值,并打印 [-3, -2, -1] 和 [-2, 0, 2]。
set.remove(i)选择重载了remove(E)方法,执行结果正确
list.remove(i)的调用选择重载remove(int i)方法,它将删除列表中指定位置的元素,所以最终打印 [-2, 0, 2]
有两种手段可以解决这个问题:
强制转换list.remove的参数为Integer
调用Integer.valueOf(i),将结果传递list.remove方法
for (int i = 0; i < 3; i++) { set.remove(i); list.remove((Integer) i); // or remove(Integer.valueOf(i)) }
Thread 构造方法调用和submit方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的(System.out::println)。因为sumbit方法有一个带有Callable <T>参数的重载,而Thread构造方法却没有。在submit这里不知道应该调用哪个方法。
在更新现有类时,可能会违反这一条目中的指导原则。例如,从Java 4开始就有一个contentEquals(StringBuffer)方法。在Java 5中,添加了contentEquals(CharSequence)接口。但只要这两个方法返回相同的结果就可以,例如下面的代码:
public boolean contentEquals(StringBuffer sb) { return contentEquals((CharSequence) sb); }
Java类库在很大程度上遵循了这一条中的建议,但是有一些类违反了它。例如,String导出两个重载的静态工厂方法valueOf(char[])和valueOf(Object),这应该被看成是一种反常行为。
53 慎用可变参数
可变参数方法接受0个或多个指定类型的参数,首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中,最后将数组传递给方法。
例如,这里有一个可变参数方法,返回入参的总和:
static int sum(int... args) { int sum = 0; for (int arg : args) sum += arg; return sum; }
有时,编写一个需要某种类型的一个或多个参数的方法是合适的,而不是0个或者多个。可以在运行时检查数组⻓
度:
static int min(int... args) { if (args.length == 0) throw new IllegalArgumentException("Too few arguments"); int min = args[0]; for (int i = 1; i < args.length; i++) if (args[i] < min) min = args[i]; return min; }
最严重的是,如果客户端在没有参数的情况下调用此方法,则它在运行时而不是在编译时失败。
有一种更好的方法可以达到预期的效果。声明方法采用两个参数,一个指定类型的普通参数,另一个此类型的可变参数。
static int min(int firstArg, int... remainingArgs) { int min = firstArg; for (int arg : remainingArgs) if (arg < min) min = arg; return min; }
在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。
还有一种模式可以让你如愿以偿:
public void foo() { } public void foo(int a1) { } public void foo(int a1, int a2) { } public void foo(int a1, int a2, int a3) { } public void foo(int a1, int a2, int a3, int... rest) { }
当参数数目超过3个时需要创建数组。
EnumSet类的静态工厂使用这种方法,将创建枚举集合的成本降到最低。