本章讨论方法设计的几个方面:
如何处理参数和返回值?
如何设计方法签名?
如何为方法编写文档?
焦点集中在可用性、健壮性和灵活性上。
内容导图如下:
1. 检查参数的有效性
绝大多数方法和构造器对于传递给它们的参数值都会有某些限制,应该在文档中清楚地指明这些限制,并且在方法体的开头处检查参数,以强制施加这些限制。这样做可以及早地发现并处理错误。
对于非公有的方法通常应该使用断言来检查它们的参数。
对于有些参数,方法本身没有用到,只是被保存起来供以后使用,检验这类参数的有效性尤为重要。构造器就是这样的方法,检查构造器参数的有效性是非常重要的,可以避免构造出来的对象违反这个类的约束条件。
对参数的有效性进行检查,并不是说对参数的任何限制都是件好事。相反,在设计方法时,应该使它们尽可能地通用,并符合实际的需要。
编写方法或构造器时,应该考虑它的参数有哪些限制,应该把这些限制写到文档中,并且在这个方法体的开头处,通过显式的检查来实施这些限制。
2. 必要时进行保护性拷贝
要防止对象的状态被无意或恶意地修改,造成各种不可预期的行为。
public final class Period {
private final Date start;
private final Date end;
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;
}
...
}
上述代码本意是表示一段不可变的时间周期,且周期的起始时间(start)不能在结束时间(end)之后。但是代码的实现却极易被攻击:
//攻击方法一
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78);
//攻击方法二
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78);
出现上述问题的原因是对象无意识地提供了使外界修改它内部状态的方法。为避免对象的内部状态受到类似攻击,对可变参数进行保护性拷贝是必要的。
//修补一
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 new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
需要考虑进行保护性拷贝的情形如下:
- 当编写方法或构造器时,如果它允许客户提供的对象进入到对象数据结构内部,则有必要考虑一下,客户提供的对象是否可能是可变的,如果是可变的,且可能会对对象造成不可接受的影响,就必须对该对象进行保护性拷贝。
- 在把一个指向内部可变组件的引用返回给客户端之前,应该倍加认真地考虑,返回的引用是否应该进行保护性拷贝,防止客户通过该引用修改对象内部的可变组件,对对象的功能造成破坏。
如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改组件,就可以在文档中指明客户端的职责是不得修改受到影响的组件,以此来代替保护性拷贝。
只要有可能,都应该使用不可变的对象作为对象内部的组件,这样就不必考虑保护性拷贝了。
3. 谨慎设计方法签名
下述几条是关于API设计的技巧:
a. 谨慎地选择方法的名称
方法名应遵循命名习惯,易于理解,包内的命名风格保持一致。
b. 不要过于追求提供便利的方法
每个方法应该尽其所能,方法太多会使类难以学习、使用、文档化、测试和维护。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到的时候,才考虑为它提供快捷方式。
c. 避免过长的参数列表
目标是四个参数,或者更少。
相同类型的长参数序列格外有害。
对于参数类型,要优先使用接口而不是类。
对于boolean参数,要优先使用两个元素的枚举类型。
有三种方法可以缩短过长的参数列表:
a. 把方法分解成多个方法,每个方法只需要这些参数的一个子集
b. 创建辅助类,用来保存参数的分组。辅助类一般为静态成员类。
c. 从对象构建到方法都采用Builder模式。
使用接口作为参数可以根据需求灵活地注入不同的实现;
boolean参数采用枚举类型使代码更易于阅读和编写。
4. 慎用重载
理解本节内容的关键是要区分清楚重载(overload)和重写(override)。
重载:在一个类里面,方法名字相同,而参数不同,返回类型可以相同也可以不同。
重写:子类对父类允许访问方法的实现过程进行重新编写,返回值和形参都不能改变。
程序运行时调用哪个重载方法是在编译时决定的,选择依据就是重载方法的编译时参数类型。
程序运行时调用哪个重写方法是在运行时决定的,选择依据就是重写方法所在对象的运行时类型。
public class Classifier {
public static String classify(Set<?> s) {
return "Set";
}
public static String classify(List<?> l) {
return "list";
}
public static String classify(Collection<?> c) {
return "Collection";
}
public static void main(String[] args) {
Collection<?>[] colls = {new HashSet<String>(), new ArrayList<String>(), new HashMap<String, String>().values()};
for(Collection<?> c : colls) {
System.out.println(classify(c));
}
}
}
执行结果为:
Collection
Collection
Collection
上述程序的执行结果与期望是不一样的,原因就是这里使用的方法的重载,而重载方法的调用是根据参数的编译时类型决定的,程序在编译时,参数类型都是Collection,这就决定了程序运行时调用的方法都是classify(Collection<?> c)
。
对于重载来说,下面三种使用方式是容易让人产生混淆,也是可能会导致错误发生的:
a. 两个重载方法具有相同参数数目
b. 方法使用可变参数
c. 两个重载方法参数数目相同,且类型可以转换
对于上述情形,安全而保守的方式就是不要去重载它。像ObjectOutputStream类,它并没有使用重载方法去写出不同的数据类型:
//利用重载的方式
write(long val);
write(float val);
write(double val);
...
//采用不同的命名模式,采用这种方式,客户是不可能调用错方法的
writeLong(long val);
writeFloat(float val);
writeDouble(double val);
...
构造器的重载不能使用命名的方式避免重载,可以选择使用静态工厂模式。
"能够重载方法"并不意味着就“应该重载方法”,对重载的使用要始终谨慎。一般情况下,对具有相同参数数目的方法来说,应尽量避免重载它,尤其是同一组参数只需经过类型转换就可以被传递给不同的重载方法这种情形。
5. 慎用可变参数
JDK1.5版本增加了对可变参数方法的支持。可变参数方法接受0个或者多个指定类型的参数。它的实现机制是:先创建一个数组,数组的大小为在调用位置所传递的参数数量,然后将参数传到数组中,最后将数组传递给方法。
static int sum(int... args) {
int sum = 0;
for (int arg : args) {
sum += arg;
}
return sum;
}
在重视性能的情况下,使用可变参数机制要特别小心。可变参数方法的每次调用都会导致进行一次数组分配和初始化。如果凭经验确定无法承受这一成本,但又需要可变参数的灵活性,可采用下面这种实现模式:
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) {}
如JDK源码中EnumSet类对它的静态工厂使用这种方法,最大限度地减少创建枚举集合的成本。
在定义参数数目不定的方法时,可变参数方法是一种很方便的方式,但是它们不应该被过度滥用。如果使用不当,会产生混乱的结果。
6. 返回零长度的数组或者集合,而不是null
对于一个返回null而不是零长度数组或集合的方法,客户端中必须要有额外的代码来处理null返回值。这样做很容易出错,因为很可能会忘记写这种专门的代码来处理null返回值。
//返回零长度数组
private final List<Cheese> cheesesInStock = ...;
public Cheese[] getCheeses() {
if (cheesesInStock.size() == 0) {
return null; //应该返回 Cheese[0]
}
...
}
//返回零长度集合
public List<Cheese> getCheeseList() {
if ( cheesesInStock.isEmpty() ) {
return null; //应该返回 Collections.emptyList()
} else {
return new ArrayList<Cheese>(cheesesInStock);
}
}
7. 为所有导出的API元素编写文档注释
如果要想使一个API真正可用,就必须为其编写文档。Java提供了Javadoc工具,利用特殊格式的文档注释,根据源代码自动产生API文档。
为了正确地编写API文档,必须在每个被导出的类、接口、构造器、方法和域声明之前增加一个文档注释。
如果类是可序列化的,也应该对它的序列化形式编写文档。
方法的文档注释应该简洁地描述出它和客户端之间的约定。这个约定应该说明这个方法做了什么,而不是说明它是如何完成这项工作的。
/**
* Returns the elements at the specified position in this list.
*
* <p>This method is <i>not</i>guaranteed to run in constant
* time. In some implementations it may run in time proportional
* to the element position.
*
* @param index index of element to return; must be
* non-negative and less than the size of this list
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= this.size()}})
*
*/
E get(int index);
要为API编写文档,文档注释是最好、最有效的途径。对于所有可导出的API元素来说,使用文档注释应该被看作是强制性的。要采用一致的风格来遵循标准的约定。在文档注释内部出现任何HTML标签都是允许的,但必须要经过转义。