第七章 方法
38、 检查参数的有效性
绝大多数方法和构造器对于传递给它们的参数值都会有某些限制。例如,索引值必须是非负的,对象引用不能为null等,这些都是常见的。你应该在文档中清楚地指明所有这些限制,并且在方法体的开头处检查参数,以强制施加这些限制。
应该在方法和构造器体前进行了参数的有效性检查,并且及时向外抛出适当的异常。如果方法没有检查它的参数,就有可能发生几种情形。该方法可能在处理过程中失败,并且产生令人费解的异常,更有可能,该方法可以正常返回,但是会悄悄地计算出错误的结果。
对于公有的方法,要用JavaDoc的@throws标签(tag)在文档中说明违反参数值限制时会抛出的异常(见第62条),这样的异常通常为IllegalArgumentException、IndexOutOfBoundsException或NullPionterException(见第60条),下面是一个例子:
/**
* ...
* @param m m为系数,必须是正数
* @return this mod m
* @throws 如果m小于等于0时抛出ArithmeticException
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0) {
throw new ArithmeticException("Modulus <= 0:" + m);
}
...// do the computations
}
对于那此非公有方法,你可以控制这个方法在哪些情况下被调用,因此你可以,也应该确保只将有效的参数值传递进来。因此,非公有的方法通常应该使用断言来检查它们的参数,具体做法如下:
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
}
断言就是被断言的条件将会为真,否则将会抛出AssertionError。不同于一般的有效性检查,如果它们没有起到作用,本质上也不会有成本开销,除非通过将 –ea(或者 -enableassertions)开关传递给Java解释器,来启用它们。
对于有些参数,方法本身没有用到,却被保存起来供以后使用,则检验这类参数的有效性尤其重要,因为将来在使用时抛出异常时要想找到这个参数的来源就非常困难了,所以这类参数更应该先检查,这个规则也适用于构造函数。
不是所有的参数都就应该在使用前检查他的有效性,因为有的检查是要代价的,或根本是不切实际的,而且有效性检查本身就可以在计算过程中完成。例如,Collections.sort(List)对集合排序的方法,集合中的每个元素都实现了Comparable接口,否则在计算时会抛出ClassCastException,这正是sort方法所应该做的事情,因此,提前检测是否实现了该比较接口是没有多大意义的,再说多遍历一次也是消耗性能的,但此时我们先不进行参数有效性检测,则无效的参数值会导致计算过程抛出异常,而这种异常与文档中标明这个方法抛出的异常不符,在这种情况下,我们应该转换异常,将计算过程中抛出的异常转换为正确的异常。
当然,不是要求所有的参数都要求检查的,有些参数在实际应用中不会产生非法值或在我们需范围内不会有其他值,则就不需要进行检测。
39、 必要时进行保护性拷贝
保护性拷贝的实例:
public final class Period {
private final Date start;
private final Date end;
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 + " after " + end);
}
public Date start() {
return (Date)start.clone();
//return new Date(start.getTime());
}
public Date end() {
return (Date)end.clone();
//return new Date(end.getTime());
}
}
这里两个地方都需要进行保护性拷贝,第一个是进参的地方,这里是构造器;第二个地方是传出去的地方,这里为start与end方法,如果丢掉一个地方,都有可能打破上面约束条件“开始时间一定不大于结束时间”。注,在构造器中,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始对象,这是一定要这么做的,因为如果将检测放在了最前面,则当检测满足条件后另一线程改变了原始对象参数的值,此进检测实质上已无效,所以这里与第38条并不是矛盾的。
同时请注意,构造器中我们没有用Date的clone方法来进行保护性拷贝。因为Date是非final类,不能保证传进来的一点是Date类型对象:它有可能是专门出于恶意的目的而设计的不可信子类的实例,这样我们调用clone方法时实质上不是调用Date上面的clone方法,而是恶意子对象上的,这样在克隆时子类可以在每个克隆实例被创建的时候,把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表,这将使得攻击者可以自由地控制所有的实例。所以,对于传进的参数实例我不要使用clone方法进行保护拷贝,而是直接使用new的创建方式来拷贝。
然而,对于访问方法,与构造器不同,在进行保护拷贝时候是允许使用clone方法的。之所以可以,是因为我们知道,Period内部的Date对象的类是java.util.Date,而不可能是其他某个潜在的不可信子类。
参数的保护性拷贝并不仅仅针对不可变类。每当编写方法或者构造器时,如果它要允许客户提供的对象进入到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的。如果是,就要考虑你的类是否能容忍对象进入数据结构之后发生变化,如果不允许,就必须对该对象进行保护性拷贝,以防止相互影响。
在内部组件返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该考虑是否进行拷贝。
记住长度非零的数组总是可变的。因引,在把内部数组返回给客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图,这两种方法在第13条中已演示过了。
保护性拷贝可能会带来相关的性能损失,如果类信任它的调用都不会修改内部的组件,可能因为类及其客户端都是同一个包的双方,那么不进行保护性拷贝也是可以的。在这种情况下,类的文档必须清楚地说明,调用者绝不能修改受影响的参数或者返回值。
总之,如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改这些组件,就可以在文档中指明不能修改这些受到影响的组件,以此来代替保护性拷贝。
40、 谨慎设计方法签名
1、 谨慎地选择方法的名称。遵循标准命名习惯,风格统一、大众认可的相一致的名称。设计时可以参考类库。
2、 不要过于追求提供便利的方法。每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口,更是这样,方法太多会使用接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到时,考虑为它提供快捷的方式。
3、 避免过长的参数列表。目标是四个参数或者更少。相同类型的长参数序列格外有害,如果不小心弄错了参数顺序时,他们的程序仍然可以编译和运行。有三种方法可以缩短过长的参数列表。第一种是把方法分解成多个方法,每个方法只需要这些参数的一个子集,即将多功能分解成多个单一的小功能。第二种方法是创建辅助类,如果使用FromBean封装了页面上的所有参数然后传到Action。第三种是结合了前两种方法特征,从对象构造到方法调用都采用Builder模式(参见第2条),如果方法有多个参数,其中有些又是可选的,最好定义一个对象来表示所有参数,并允许客户端在这个对象上进行多次“setter”调用,每次调用都设置一个参数或设置一个较小的集合,一旦设置了所需要的参数,客户端就调用这个对象的“执行(execute)”方法,它对参数进行最终的有效性检查,并执行实际的计算。
对于参数传递类型,我们要优先使用接口而不是类(请见第52条),只要有适当的接口可用来定义参数,就优先使用这个接口,而不是这个接口实现。我们没有理由在编写方法时使用HashMap类来作为输入,相反,应当使用Map接口作为参数类型,这使你可以传进各种Map的实现,如果碰巧输入的数据是以其他形式存在,使用具体类类型作为参数时就会导致不必要的转换操作。
对于boolean参数,要优先使用两个元素的枚举类型,这样便于以后第种选择的加入,这样不必要再添加另一个方法。
41、 慎用重载
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”,而没有打印出“Set”与“List”,为什么呢?因为重载方法的选择是静态的,而对于被覆盖的方法的选择则是动态的。选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型;而选择调用那一种方法则是在编译时就已经确定了的。
上面修改如下:
public static String classify(Collection<?> c) {
return c instanceof Set ?"Set": c instanceof List ?"List"
:Unknown Collection";
}
由于重载容易引起方法调用的混乱,有时调用的根本不是我们想要的方法。因此,应该避免胡乱地使用重载机制,而使方法的名称不同。
到底怎样才算胡乱使用重载机制呢?这个问题有点争议。安全保守的策略是,永远不要导出两个具有相同参数数目的重载方法,如果方法使用可变参数,保守的策略是根本不要重载它,除第42条中所述情形之外。例如ObjectOutputStream对于每种基本类型,以及几中引用类型,它的wirte方法都有一种变形,这些变形方法并不是重载write方法,而是如writeInt(int)、writeLong(long)这样的命名模式。
对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(见第1条)。
Jdk1.5引入了自动拆装箱,使用方法的调用更加混乱了,如:
public class SetList {
public static void main(String[] args) {
Set<Integer> set = new TreeSet<Integer>();
List<Integer> list = new ArrayList<Integer>();
for (int i = -3; i < 3; i++) {
set.add(i);
list.add(i);
}
//[-3, -2, -1, 0, 1, 2] [-3, -2, -1, 0, 1, 2]
System.out.println(set + " " + list);
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove(i);
}
//[-3, -2, -1] [-2, 0, 2]
System.out.println(set + " " + list);
}
}
上面set.remove(i)调用remove(Object o)方法,E为元素的类型,将i从int自动装箱到Integer,这是我们所期望的。而list.remove(i)则是调用的remove(int i)方法,而根本没有调用重载的remove(Object o)方法,即调用时采用了参数类型最匹配优先原则,所以没有自动装箱。修改如下:
for (int i = 0; i < 3; i++) {
//显示的自动装箱
list.remove((Integer) i);//或者是 list.remove(Integer.valueOf(i))
}
总之,“能够重载方法”并不意味着就“应该重载方法”,一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法,在这种情况下,我们坚决抵制这数的重载。
42、 慎用可变参数
可变数组方法接受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;
}
下面看看1.4与1.5中的Arrays.asList,1.4中的是这样public static List asList(Object[] a),到了1.5中改成了可变参数成这样public static <T> List<T> asList(T... a) ,现在我们使用这个方法时要格外小心,看看下面几个问题:
public static void main(String[] args) {
String strs[] = new String[] { "str1", "str2" };
int ints[] = new int[] { 1, 2 };
/*
* 1.4输出:[str1, str2]
* 1.5输出:[str1, str2]
*/
System.out.println(Arrays.asList(strs));
/*
* 1.4编译不能通过!!
* 1.5输出:[[I@757aef]
*/
System.out.println(Arrays.asList(ints));
}
由于1.5版本中,令人遗憾地决定将Arrays.asList改造成可变参数方法,现在上面这个程序在1.5中可以通过编译,但是运行时,输出的不是我们想要的结果而是[[I@757aef],这主要是由于基本类型不能用于泛型的原因所致,所以在将一个基本类型数组传给asList(T... a)方法时,将整个基本类型数组看作是可能参数集中的第一个参数了。
但从好的方面来看,本来asList方法就不是用来打印数组中的元素字面值的,它的作用是将数组转换成List而已,这在1.5中得到了修补,并增加了Arrays.toString的方法,它正是专门为了将任何类型的数组转变成字符串而设计的。如果用Arrays.toString来代替Arrays.asList,我们将会得到想要的结果:
//[1, 2]
System.out.println(Arrays.toString(ints));
有两个方法签名是要特别注意的:
ReturnType1 suspect1(Object…args){}
<T> ReturnType2 suspects(T…args){}
带有上述任何一种签名的方法都可以接受任何参数列表,改造之前(asList(Object[] a))进行的任何编译时的类型检查(如不能传递基本类型的数组)都将会丢失,Arrays.asList发生的情形正是说明了这一点。
可变参数会影响性能,方法的每次调用都会导致进行一次数组的分配和初始化,如果考虑性能而又要这种可能参数的灵活性时,并假设对某个方法调用时使用的都是3个或更少的参数,就声明该方法的5个重载方法,每个重载方法带0至3个普通参数,当参数的数目超3个时,就使用一个可变参数方法:
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){}
这种我们可以在EnumSet的静态工厂方法看到这样的引子。
总之,在定义参数数目不定的方法时,可变参数方法是一种很方便的方式,但是它们不应该被过度滥用,如果使用不当,会产生混乱的结果。
43、 返回零长度的数组或者集合,而不是null
对于一个返回null而不是零长度数组或者集合方法,几乎每次用到该方法时都需要额外处理是否为null,这样做很容易出错,因为缩写客户端程序的程序员可能会忘记写这种专门的代码来处理null返回值,如:
private final List<Cheese> cheesesInStock = …;
public Cheese[] getCheeses(){
if(cheesesInStock.size() == 0)
return null;
…
}
客户端使用如下:
Cheese[] cheeses = shop.getCheeses();
if(cheeses != null && …);
有时候有人会认为:null返回值比零长度数组更好,因为它避免了分配数组所需要的开销,这种观点是站不住脚的,原因有二:一是,除非这个方法正是造成性能问题的真正源头。二是,完全可以使用一个零长度的数组共享。
下面处理当一个集合为空时,返回一个零长度数组的有效做法:
private final List<Cheese> cheesesInStock = …;
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];//将零长度的数组设为静态的,以便共享用
public Cheese[] getCheeses(){
//借助于List的toArray方法,将列表转换成数组,如果传进的数组长度为零,则会返这个零长度数组本身,并且这个零长度数组是共享的
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
<T> T[] toArray(T[] a):如果指定的数组能容纳 collection 并有剩余空间(即数组的元素比 collection 的元素多),此时并不创建新的数组,那么会将数组中紧跟在 collection 末尾的元素设置为 null。(这对确定 collection 的长度很有用,但只有 在调用方知道此 collection 没有包含任何 null 元素时才可行。);如果指定的数组的容量比集合容量要小,则会重新创建一个集合大小的新的数组,所以如果数组与集合都是空时将返回一定会返回零长度数组本身。并且返回数组一定是安全的。
注意,toArray(new Object[0]) 和 toArray() 在功能上是相同的,只不过返回的数组恰好是集合中的元素,不多也不少,但如果集合为空时,返回也是零长度数组,不过这是集合为我们重新创建的,我们没有办法让零长度数组在以后共享。
上面是返回零长度数组的做法,下面看一下返回空集合的做法:
Collection能转换成安全的数组,Collections能在需要返回空集合时都返回同一个不可变的空集合(不能向其中添加元素,也不能读取,只知道它的长度为0,并且contains永远返回false),如emptySet、emptyList、emptyMap:
public List<Cheese> getCheeseList(){
if(cheesesInStock.isEmpty())
return Collections.emptyList();//总是返回相同的空的list
else
return new ArrayList<Cheese>(cheesesInStock);
}
总之,返回类型为数组或者集合的方法没有理由返回null,而是返回一个零长度的数组或者集合。
44、 为所有导出的API元素编写文档注释
为了正确地编写API文档,必须在每个导出类、接口、构造器、方法和域声明之前增加一个文档注释(这是强制的)。如果类是可序列化的,也应该对它的序列化形式编写文档(见第75条)。为了编写出可维护的代码,还应该为那些没有被导出的类、接口、构造器、方法和域编写文档注释(非强制的)。
方法的文档注释应该简洁地描述出它和客户端之间的约定,除了专门为继承而设计的类中的方法(见第17条)之外,这个约定应该说明这个方法做了什么,而不是说明它是如何完成这功项工作的。文档注释应该列举这个方法的前置条件与后置条件,前提条件指调用该方法要得到预期的结果必须满足的条件,如参数的约束。而后置就是指调用方法完后要完成预期的功能。
跟在@param标签或者@return标签后面的文字应该是一个名词短语,描述了这个参数或者返回值所表示的值,跟在@throws标签之后的文字应该包含单词“if如果”,紧接着是一个名词短语,它描述了这个异常将在什么样的条件下会被抛出。有时候,也会用表达式来代替名词短语。并且按照惯例,这个标签后面的短语或者子名都不用句点来结束。
类是否线程安全的,也应该在文档中对它的线程安全级别进行说明,如第75条中所述。
在文档注释内部出现任何HTML标签都是允许的,但是HTML元字符必须要经过转义。