第38条:检查参数有效性
我们在给类属性赋值的时候会校验数值是否有效,比如:数值类型大于0,对象不能为null,如果没有校验参数,可能会导致系统抛异常,甚至计算出错误的结果,书中建议:
- 对于公有方法,在文档中用javadoc的@throws标签抛出异常,标注抛出的异常,
/**
* Returns a BigInteger whose value is {@code (this mod m}). This method
* differs from {@code remainder} in that it always returns a
* <i>non-negative</i> BigInteger.
*
* @param m the modulus.
* @return {@code this mod m}
* @throws ArithmeticException {@code m} ≤ 0 // 注意此处
* @see #remainder
*/
public BigInteger mod(BigInteger m) {
if (m.signum <= 0)
throw new ArithmeticException("BigInteger: modulus not positive");
BigInteger result = this.remainder(m);
return (result.signum >= 0 ? result : result.add(m));
}
- 对于非公有方法使用断言来检查参数
断言条件为真才会继续执行下去,否则都会失败,直接抛出AssertionError。
// Private helper function for a recursive sort
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
}
备注:此处的assert是java自带的,与单元测试的assert工具类不一样,这样在代码里写assert不一定生效,因为使用java assert需要配置jvm参数才可以生效,在运行含有assert的程序时,必须指定-ea选项,这一点需要注意。
对于类中保留的属性,也要做校验,以后用起来导致程序出错,追溯错误来源可能会比较麻烦,多花很多时间,书中建议在方法中将这些校验的条件写到文档中,这样在调用的时候就可以清楚知道方法限制,减少编程错误。
必要时进行保护性拷贝
这一章也是讲解参数校验,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始的对象,总结一句话就是:必要时,先拷贝,再校验。什么是必要时呢 ?例如针对不可变对象,如果对象是一个引用对象,如例:
// Broken "immutable" time period class
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;
}
}
这个对象是可以被改变的,比如:
// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // 改变对象值
引用对象修改之后,Period类中的对象也会被更改,所以我们必须体用保护性拷贝:
// Repaired constructor - makes defensive copies of parameters
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);
}
这里不允许使用clone方法来得到这个对象,主要是因为:对于参数类型可以被不可信任方子类化的参数,请不要使用clone方法进行保护性拷贝
,这里Date是非final的,不能保证clone方法一定返回类为java.util.Date的对象:它可能返回专门处于恶意的目的而设计的不可信子类的实例。
但上述这样的修改也不一定是安全的:
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // 改变不了
p.getEnd().setYear(78); // 想想会不会有问题 ?
所以还要再做一层防护:
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
总结:
谨慎设计方法签名
本章主要是讲解若干个API设计技巧的总结:
- 谨慎地选择方法的名称。选择与大众认可的名称相一致的名称
比如 toString方法,尽可能参考标准的命名规范,便于其他人理解。 - 不要过于追求提供便利的方法。如果不能确定,还是不提供快捷为好
每个方法都应该尽其所能, - 避免过长的参数列表。目标是4个参数,或者更少
适用法也记不住这么多参数,频繁参考javaDoc,不便于使用接口,而且方法中相同类型的参数对顺序还有要求,如果记错顺序,代码就会计算错误,不便于查错。
3个技巧缩短过长的参数列表:
(1)把多个参数的方法拆开成多个方法,每个方法都是参数的子集
(2)创建辅佐类作为入参,参数都做类属性,
(3)采用Builder模式,特别是参数可变的时候,进行多次setter方式构建Builder对象// 使用建造者模式 ComputerB computerB = new ComputerB.ComputerBuilder("主板","cpu","hd","电源","显卡") .setMouse("鼠标").setMousePad("垫子").build() ;
- 对于参数类型,要优先使用接口而不是类
使用Map接口作为入参,而不是hashMap、hashTable等具体的实现类,如果使用具体类,则限制了方法只等特定的实现,不方便扩展。 对于boolean参数,要优先使用两个元素的枚举类型 ?
慎用重载
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));
}
}
我们期望的结果可能是:
Set
List
Unknown Collection
但实际中三遍都是“Unknown Collection”,这是因为classify方法被重载了,要调用那个方法是在编译的时候做出决定的,对于for循环中的全部三次迭代,虽然运行时类型都是不一样,但参数的编译时类型都是相同的:Collection<?>,所以最终都是用classify(Collection<?> c),每次循环迭代都是这个重载方法。所以重载并不能给我们带来理想的效果,如果上述代码换成覆盖方法,子类方法覆盖父类可以达到同样的效果,但是也可以通过类型,在运行时给与不同的结果:
public static String classify(Collection<?> c) {
return c instanceof Set ? "Set" :
c instanceof List ? "List" : "Unknown Collection";
}
重载可以让代码等到运行时凸显问题,不容易诊断出这些错误,所以应该竟可能避免乱用重载,要求我们永远不要导出两个具有相同参数数目的重载方法,
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);
}
}
实际输出的结果是:
[-3,-2,-1] [-2,0,2]
分析原因就是set.remove(i)调用的是选择的重载方法 remove(E),这里的E是集合的元素类型(Integer),所以调用的方法remove的是具体的元素,list.remove(i)调用的选择重载方法 remove(int i),表示从列表的指定位置去除元素,修改方法就是:
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove((Integer) i);
// or remove(Integer.valueOf(i))
}
这也是java 1.5 引进泛型和自动装箱所带来的问题,破坏了List接口,所以谨慎选择重载方法,
慎用可变参数
可变参数方法接受0个或者多个指定类型的参数。 可变参数的机制是通过先创建一个数组,数组的大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。
// Simple use of varargs - Page 197
static int sum(int... args) {
int sum = 0;
for (int arg : args)
sum += arg;
return sum;
}
我们可能需要对这么一个方法如此那做检查,对于一个取最小值的方法来说:
static int min(int... args) {
if (args.length == 0)
throw new IllegalArgumentException("无参数");
int min = args[0]; // 这里取的第一个数
for (int i = 1; i < args.length; i++)
if (args[i] < min)
min = args[i];
return min;
}
这样写也可以满足需求,但是有两个问题:(1)运行时无参数进来会抛异常。(2)代码不是非常美观。
因为这个取最低值方法会要求入参至少有一个,才可以比较得到最低值,所以可以考虑方法入参为两个:一个是指定类型的正常参数,这个参数来源如可变参数中的一个,另一个入参是这种类型的可变参数,如例:
static int min(int firstArg, int... remainingArgs) {
int min = firstArg;
for (int arg : remainingArgs)
if (arg < min)
min = arg;
return min;
}
当你真的需要让一个方法带有不定数量的参数的时候,可变参数才会变得非常有效。它本来是为printf和反射机制设定的。
在重视性能的情况下,使用可变参数机制要特别小心。可变参数方法每次调用都会导致进行一次数组分配和初始化。如果只是凭经验确定,无法承受这一成本,但是又需要可变参数的灵活性。这时候,需要评估,假如某个方法95%会调用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) { }
看另一个问题:
public static void foo(int a1, int a2) {
System.out.println("a1="+a1+",a2="+a2);
}
public static void foo(int a1, int... rest) {
System.out.println("a1="+a1);
for(int a : rest){
System.out.println("-a:"+a);
}
}
public static void main(String[] args) {
foo(1,2);
}
返回零长度的数组或者集合,而不是null
我们经常看到这样的方法:
private final List<Cheese> cheesesInStock = ...;
/**
* @return an array containing all of the cheeses in the shop,
* or null if no cheese are available for purchase.
*/
public Cheese[] getCheeses(){
if(cheesesInStock.size() == 0)
return null;
...
}
客户端调用这个方法,都要先判断返回的数据是不是null,例如:
Cheese[] cheeses = shop.getCheeses();
if(chesses != null && Arrays.asList(cheeses).contains(Chees.STILTON))
System.out.println("Joll good, just the thing.");
方法返回null,别人调用这个方法就必须处理null情况,这在编写代码中很容易被忽略,极易造成错误。所以我们尽可能的返回零数组或者空集合,比如我们可以定义一个不可变的数组:
数组:
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
空集合:
Collections.emptyList();
public static final <T> List<T> emptyList() {
return (List<T>) EMPTY_LIST;
}
@SuppressWarnings("rawtypes")
public static final List EMPTY_LIST = new EmptyList<>();
特别要注意的地方
:实际代码项目里不要这么写,这个EMPTY_LIST是一个静态不可变的对象,在序列化、反序列号过程中会有问题,场景:你有一个类,有两个List容器属性,但具体的类型不一样,你同样给塞EMPTY_LIST,然后反序列化后可能会,这两个属性编程共享对象了,并且可以添加数据进去,而且一个属性的数据变更 会引起另一个属性的数据变更,即使两者类型不一样,因为此种情况会跳过类型校验,但公司的某次实践中发现了这个问题,特此警告。另一方面,目前jdk高版本已经优化了很多,new 一个list对象的成本已经很低了,并不会初始化一定空间,例如代码:
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
所以还是不要用空的不可变的list对象。
为所有导出的API元素编写文档注释
为方法类编写文档注释方便其他开发人员了解方法的功能,特别是API方法的调用,方便其他人了解方法入参、返回值、约束等等。
/**
* Returns the element 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);
@code标签可用于任何需要展示代码的地方,被该标签包围的内容会以特殊的字体显示,并且不对其中内容做任何HTML解析。
java 1.5 以后新增三个特性:泛型、枚举、注解,
泛型要说明所有的类型参数
/**
* @author net
* @version 1.0
* @param <K> the type of keys maintained by the map
* @param <V> the type of mapped values
*/
public interface Map<K, V> {
//dosomething
}
枚举:要确保在文档中说明常量,以及类型,还有任何公有的方法。
/**
* three primary colours
* @author net
* @version 1.0
* return enum
*/
public enum Color {
/** Red, the color of blood. */
RED,
/** Green, the color of grass. */
GREEN,
/** Blue, the color of sea. */
BLUE;
}
注解:要确保在文档中说明所有成员,以及本身类型。对于该类型的概要描述,要使用一个动词短语,说明当程序元素具有这种类型的注解时它表示什么意思。
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Indicates that the annotated method is a test method that
* must throw the designated exception to succeed.
* @author net
* @version 1.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
/**The exception that the annotated test method must throw
* in order to pass. (The test is permitted to throw any
* subtype of the type described by this class object.) */
Class<? extends Exception> value();
}