这一章主要讨论方法的设计需要注意的方面。

检查参数的有效性

大部分的构造方法和普通方法都对参数由限定,在执行具体的方法逻辑之前应该对方法的参数做校验,以便尽快发现不合要求的参数,进而暴露问题。对于类的public方法,可以使用 Javadoc 的@throws注解来说明参数错误时会抛出的异常,对于非public方法,通常应该使用断言(assertions)来检查参数的有效性,比如。

private static void sort(long a[], int offset, int length) {
    assert a != null;
    assert offset >= 0 && offset <= a.length;
    assert offset >= 0 && length <= a.length - offset;
    // Method  body
}

需要注意的是,有些参数,方法指定过程中并没有用到,但是会保存再来供后续使用,这类参数检查尤为重要,构造方法是这类场景的特例,通常通过构造方法对成员变量进行赋值供后续使用。比如,

public List<Integer> intArrayAsList(final int[] a) {
    if(a == null) {
        throw new NullPointerException();
    }
    return new AbstractList<Integer>() {
        public Integer get(int i) {
            return a[i];
        }
        
        public Integer set(int i, Integer val) {
            int oldVal = a[i];
            a[i] = val;
            return oldVal;
        }
        
        public int size() {
            return a.length;
        }
    };
}

对于参数检查的规则也有特殊的例外,如果参数检查消耗非常大,或者不切实际,而且参数检查隐含在实际代码执行中,那么就不需要在方法逻辑执行前对参数进行检查。比如Collections.sort(List)方法,在方法执行过程中,会进行各个对象之间的比较,如果有个元素的类型和其他元素不一样,那么就会抛出ClassCastException,所以在sort方法中,不需要单独对元素类型进行检查。

但是不要认为方法的任何参数限制都是好事,方法的设计应该尽可能的自然,对方法的参数限制越少,方法的通用性就越强,所以应该根据实际需要来校验参数。

必要时做保护性拷贝

Java 是一门安全的语言,这意味着,缓冲区溢出,数组越界,野指针和其他内存错误不会出现在 Java 中,但是即使是在这样类型安全的语言中,为了编写应对各种异常情况的健壮的代码,还是需要做一些额外的工作。

一般情况下,如果不借助类本身的方法,是不可以修改类的属性的,但是考虑这样一种情况,比如编写一个代表一段时间的不可变类。

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;
	}
}

第一眼看上去,这个类的对象确实是不可变的,但是如果在构造完Period对象之后,直接修改传入到构造函数的参数,那么就可以间接修改Period对象。由于Period对象是不可变的,所以,在其构造方法中,可以对参数进行拷贝后赋值给其成员变量(startend),其构造方法变为。

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);
    }
}

需要注意的是,在给startend赋值时,首先对参数进行了拷贝,利用拷贝的值构造新的对象,这样在Period之外更改参数值,不会改变Period对象。在复制startend参数时,并没有使用java.util.Date类的clone方法,因为clone方法可能会返回用户自定义的java.util.Date类的子类,而在这个子类中,肯能会包含专门出于恶意而设计的代码,比如,在静态代码块中记录一个指向该对象的引用,从而让外部可以访问该引用。

然后,在构造方法里做了复制参数值的改进后,Period类的对象仍然存在被修改的风险。比如调用Period.end()方法就可以获取到Period的对象的end成员变量,而这个变量是一个对象,所以后续可以继续对这个对象做更改。所以对start()end()方法的改进如下。

public Date start() {
    return new Date(start.getTime());
}

public Date end() {
    return new Date(end.getTime());
}

所以对于设置到对象的成员变量的引用值以及通过方法返回的引用,都必须考虑是否会被更改,如果可能被更改,那么就必须做保护性拷贝。在为成员变量选择日期类型时,可以考虑优先使用long替换Date

谨慎定义方法签名

这是若干 API 设计总结,这些技巧将有助于设计易于学习和使用的 API,并且设计出的 API 不容易出错。

  • 谨慎选择方法名 方法名应该易于理解和使用,并且与同一个 package 中的方法名称风格一致。
  • 不要提供过多的便利方法 如果一个类提供的方法过多,那么会导致该类难以学习,使用,测试和维护。所以对于接口支持的每个功能,提供一个完整的方法,只有当某个操作使用较多时才考虑提供一个便利方法。如果不确定,按么就不要提供。
  • 避免过长的参数列表 参数列表过长,将使得方法难以使用,所以方法参数个数应当不超过 4 个。有三种技巧可以减少方法参数个数。
    • 将一个参数过多的方法分割成多个方法,这种方式可能会导致方法过多,应对策略是提升方法的正交性(多个方法可以组合完成一个操作)。
    • 创建一个辅助类来保存参数。
    • 结合前两种技巧,采用Builder模式。
  • 优先选择接口而不是类作为参数类型

  • 优先选择两个值的枚举类型而不是布尔类型 易于扩展

谨慎使用重载

先看这样一段代码,其实写这段代码时期望它输出 SET,List,Unknow Collection 这样的三行文本,但是实际上输出的却时三行 Unknow Collection 的文本。为什么会这样呢?原因是这几个方法时在一个类中的重载方法,而调用重载方法的参数类型是在编译时就确定的,也就是说在编译后,就确定了调用的重载方法时哪个。

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));
		}
	}

}

与之相反,重写(也称覆盖,Override)的方法调用时在运行时动态确定的。

如果能够明显的区分一对重载方法的参数类型,那么使用重载是安全的,否则为了在使用重载机制时不会导致混乱,一种安全且保守的策略是:不要暴露两个方法参数个数相同的重写方法,如果一个方法有可变参数,那么不要重写该方法。例如ObjectOutputStream类中有writeBoolean(boolean)writeInt(int)readBoolean()readInt()等方法,虽然方法作用类似,但是通过具体的方法名加以区分。另外,对于构造方法,没法通过方法名的方式加以区分,如果有必要,可以使用静态工厂的方法来封装构造方法。

谨慎使用可变参数

可变参数的机制是先创建一个参数个数大小的数组,然后将参数值传递到数组中,最后将数组传递给方法。但是可变参数有时却不太适合,因为可变参数列表接收 0 个或多个元素,假设有个方法接收一个或多个参数,那么在使用可变参数时,就需要加上一段检查参数个数的代码,显然这样设计方法是不优雅的,并且如果参数个数为0,也没法在编译时,就发现问题。针对这种需求可以将方法设计为第一个参数是所需要的类型参数,第二个参数是可变参数列表,如

static int fun(int arg1, int... args) {

}

这样就强制调用者至少传一个参数。

可变参数给方法设计带来了很大的便利性,但是在设计方法时,也不要过度使用,如果使用不当会产生意想不到的结果。比如在 JDK 1.4 版本中,System.out.println(Array.asList(new int[]{1,2,3}))会打印出数组的元素的字符串,但是在 JDK 1.5 版本发布以后,就只打印数组对象的字符串(即数组的 toString 结果值。)

返回值使用空数组或集合代替null

对于一个返回数组或者集合类型的方法,如果返回了null值,那么很可能会带来不必要的麻烦,比如在绝大多数场景下返回值都会有一个或多个元素,但是如果在调用时,没有对代码做非 null 值检查,那么很可能会导致一个潜在的 bug,所以应该使用空数组或者空集合代替null作为返回值。空集合可以使用Collections.emptySetCollections.emptyList或者Collections.emptyMap等方法来构造,空数组可以在代码中定义常量空数组来构造,比如private static final Period[] EMPTY_ARRAY = new Period[0];