49. 检查参数的有效性
编写方法和构造器的时候,要考虑参数的限制,把限制通过文档注明,并通过显示的方式来检查这些限制。
否则,后果是什么
明白为什么不好有时候比怎么做才好更重要。 不检查参数的问题在于:
- 方法可能在处理过程中失败,产生令人费解的结果。当然这是最显而易见的问题。
- 更糟糕的是,方法返回了,但是结果是错误的。
- 更糟糕的是,方法返回了正确结果,但是未来在某个时候,突然给你一个惊喜,而你完全找不到问题出在什么地方。这破坏了所谓的失败原子性(failure atomicity)
最佳实践
对于共有方法,应该使用throw显示地抛出异常,对于私有的方法,应该使用assert。这很好理解,因为保证公有方法调用是客户端的责任,而私有方法,则是我们的责任,assert就够排查了。 首先对于共有方法,以下值得注意的地方:
- 文档中说明参数的限制,以及抛出的异常是为什么
- 显示检查参数,抛出适当的异常
- m非空检查隐藏在
m.signum() 的调用中,该异常应该放在BigInteger的类级文档上,避免每一个方法都注释该异常。
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0)
throw new ArithmeticException("Modulus <= 0: " + m);
...
}
对于私有方法,使用assert显示检查参数。
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;
...
}
然后是,Java中在Objects类中一些有用的方法。
List<Point> pointList = Objects.requireNonNull(points, "points不能为空");
50. 必要时进行保护性拷贝
一个类、某数据结构,如果接受从客户端(外部)得到的可变组件,或者返回内部的可变组件时,就应该进行必要的保护性拷贝。如果确定客户端不会变动,或者保护性拷贝的成本过高,那么必须在文档注明。 其中的关键是:
- 接受外部的可变组件的时候,就必须意识到如果这个组件在传入之后被改变,是否可以忍受
- 返回内部可变组件的时候同样需要谨慎地考虑
对构造器可变参数进行保护性拷贝
以下代码有几个很有意思的点需要注意:
- 拷贝一份,而不直接适用外部传入的可变对象
- 拷贝的时候不要用clone,除非确定该类不会被子类化(子类化覆盖clone可以破环我们的保护性拷贝)
- 合法性校验在保护性拷贝之后,虽然奇怪,但是本身合法性校验应该针对内部拷贝的对象,而不是外部的引用,否则可能混过我们的安全校验
- JDK1.8之后提供了不可变的Date类,Instant,在大部分情况下,优先选择它。
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,因为我们可以确保内部是Date,而不是其他恶意的子类。
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
顺便重复一下15条给数组提供保护性拷贝的两种方式:
- clone
- 提供不可变视图(注意,内部并未进行深复制)
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES =
Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
51. 谨慎设计方法签名
- 方法的命名,认真花一些时间取一个合适的名字是值得的,并保持全局统一
- 不要为了便利提供方法,方法多会影响类库API的可读性,每个方法应该尽其所能。看起来和“方法不要超过一页”好像有点矛盾,因此把方法一通封装,导出多个子方法,这种方式看起来带了的坏处更多
- 避免过长的参数列表,一方面出于可读性保证,另一方面,太多参数测试也不方便。
- 正交分解方法——比较难理解,意思是把方法按功能划分成尽量不相关的小方法,举个例子List API提供了
indexOf, lastIndexOf, subList 却没有提供“找到列表的第一个索引和最后一个索引之间的子列表”的方法,该方法可以用上述正交子方法导出 - 参数实体类
- builder模式
- 参数类型接口优先,拓展性更好
- boolean类型参数用两个元素的enum类型代替,好处是命名有意义,而且方便拓展
52. 明智使用重载
能够使用重载方法的时候不一定要使用重载,因为事实上重载的机制是比较容易引起歧义的(或者让程序员无法把握),所以建议是:
- 理想的情况下,参数数目不一致的时候,不用重载,用另命名来代替
- 对于构造器方法,无法通过命名来解决,则一旦参数数量一样,必须保证有至少一个参数,类型完全不相关(指互相不为子类,无法互相转型)。否则应该考虑工厂模式来解决问题。
- 如果实在参数数目一直,而且会混淆,那么必须保证方法的行为一致。
之所以要求如此激进——尽可能保证重载方法参数数量不一致,那就完全不会出错,那是因为重载本身比较复杂。
重载的问题
关键问题在于,重载从底层来看,是在编译期间决定,通过参数的静态类型(非实际的子类)决定的,而且它会选择“更加合适”的版本。例如以下代码,它选择最终会输出三次"Unknown 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));
}
}
所谓选择“更加合适”的版本,我们来看一个例子。下面代码重载顺序和方法声明顺序是一样的——参数是char,则调用;否则转int;否则转long;否则自动装箱;否则转接口类型;否则转Object;最后才是可变长参数,它的重载优先级最低。
class Overload {
public static void sayHello(char arg){
System.out.println("Hello char");
}
public static void sayHello(int arg){
System.out.println("Hello int");
}
public static void sayHello(long arg){
System.out.println("Hello long");
}
public static void sayHello(Character arg){
System.out.println("Hello character");
}
public static void sayHello(Serializable arg){
System.out.println("Hello Serializable ");
}
public static void sayHello(Object obj){
System.out.println("Hello object");
}
public static void sayHello(char ...arg){
System.out.println("Hello char ...");
}
public static void main(String[] args) {
sayHello('a');
}
}
53. 明智地使用可变参数
可变参数是一种比较便利的形式,它提供一个数组接受不定数量的参数。使用可变参数的时候,要先包含所有必要参数,然后还要考虑可变参数的性能(数组初始化)。
包含必要参数
首先来看以下案例。 这个代码是没有问题的,但它的不好的地方在于:
- 必须显示地检查参数合法性,如果不传入任何参数,则不会在编译期报错,而会在运行期报错
- 可以更加简洁
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;
}
考虑可变参数的性能
可变参数默认会初始化一个数组接收参数,这样的话,在某些情况下会有性能问题。例如,某方法95%的调用都是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) { }
54. 返回空集合,而不是null
永远不要返回null,而不返回零长度的集合(包括数组)。这样没有任何性能优势,还会容易出错,让API更加难用。 例如以下案例:
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? null
: new ArrayList<>(cheesesInStock);
}
if (cheeses != null && cheeses.contains(Cheese.STILTON))
System.out.println("Jolly good, just the thing.");
返回null的缺点:
- 要求客户端必须要额外的代码来处理
- 这种问题时间长了可能被忘记,或忘记处理但是暂时正常运行。
最佳实践:返回集合
这点性能开销可以忽略不记。
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
除非分析判断问题就出在这,这时候可以返回不可变的零长度集合实例,但这种优化常常是没有必要的。
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? Collections.emptyList()
: new ArrayList<>(cheesesInStock);
}
最佳实践:返回数组
public Cheese[] getCheeses() {
return cheesesInStock.toArray(new Cheese[0]);
}
这是一种常见的写法,但是你可能会好奇new Cheese[0] 是干嘛的。简单来说:
- 提供返回类型信息
- 如果列表长度为0,则直接返回该零长度数组实例
源码就是这么做的:
public <T> T[] toArray(T[] a) {
int size = size();
if (a.length < size)
return Arrays.copyOf(this.a, size,
(Class<? extends T[]>) a.getClass());
System.arraycopy(this.a, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
同样的,几乎没有用的优化,除非分析到问题就是在这:
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
顺便一提,不要尝试提前分配,实验表明这个只会影响效率。
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);
55. 明智地返回optional
当方法需要返回“空值”,而这个空值又希望让调用者去处理的时候,可以选择返回optional。但需要注意它是包装了一层,有一些性能方面的影响。此外,不要用optional做返回值以外它其他用途(map中的键值,集合,数组中的元素等)。 理解上述建议,我认为应该先了解optional,并且知道它的最佳实践。
Optional
JDK8新增了Optional类,其实只是一层很浅的包装,有时候会让人觉得多此一举,根本没有必要。那其实是没理解好optional的设计。 简单来说,Optional是用函数式编程形式去减少大量的空值判断逻辑。 这一点需要一个案例来理解:
User user = ...
if (user != null) {
String userName = user.getUserName();
if (userName != null) {
return userName.toUpperCase();
} else {
return null;
}
} else {
return null;
}
User user = ...
Optional<User> userOpt = Optional.ofNullable(user);
return userOpt.map(User::getUserName)
.map(String::toUpperCase)
.orElse(null);
Optional本身在map,filter等等方法已经提供了对空值的处理,支持函数式的编程写法,虽然只是一层包装的,但可以使代码集中于业务。 Optional的封装很轻量,所以容易用错。
Optional常见方法
工厂方法
of(T), ofNullable(T), empty(T) 用于新建optional对象,注意of() 方法如果为参数为null会抛出异常,所以需要确定不会为null才使用,否则用ofNullable() 。
默认值
ofElse(T) 如果调用Optional对象为空,返回传入的默认值。 ofElseGet(Supplier<? **extends **T> other) 参数为lambda,如果为空执行获得默认值,这个方法名应该叫ofElseCompute() orElseThrow(Supplier<? **extends **X> exceptionSupplier) 如果为空,抛出指定的异常
流式方法
map(Function<? **super **T, ? **extends **U> mapper) ,如果不为空对内部应用mapper,并返回新的Optional对象 filter(Predicate<? **super **T> predicate) ,如果不为空,对内部使用predicate过滤 ifPresent(Consumer<? **super **T> consumer) ,如果不为空,应用consumer
不推荐使用方法
isPresent() ,判断内部是否为空 get() ,获得内部value 这两个方法是兜底用的,一般不推荐使用,否则Optional就完全没有意义。例如:
Optional<User> userOpt = Optional.ofNullable(user);
if (userOpt.isPresent()) {
User user = userOpt.get();
} else {
}
和if-else就完全没有区别,isPresent() 本身就是对if-else的封装。
if (user != null) {
} else {
}
Optional的最佳实践
有了上述知识,可以讨论Optional的正确使用方式。
- Optional是用函数式编程形式去减少大量的空值判断逻辑
- 只作为返回值使用,它暗示调用者(客户端)可以使用Optional去处理null值。
?
56. 为所有导出的API元素编写文档注释
文档注释是维护代码和文档同步的最方便的工具,要为API编写统一风格、符合风格的文档注释,并且Java文档注释支持任何HTML元素,记得为非HTML语法的符号转义。以下是一些最佳实践。 支持HTML元素可能会引起一些易读性问题,原则是导出文档和注释都应该是易于阅读的,如果无法兼顾,前者更为重要。
方法注释
方法文档注释应该描述它和客户端(调用者)的约定。
- 说明这个方法做了什么,而不是怎么做。
- 调用这个方法要满足什么前置条件和后置条件。
- 方法的副作用,例如不得已需要返回可变组件(提供保护性拷贝代价太大,或者证明客户端不会错误使用该引用)需要说明。
- 提供
@param, @return, @throws 说明。@throws 应该包含“如果……”,说明什么条件下抛出什么异常。
案例:
E get(int index);
?@implSpec 注释
@implSpec 是为了描述方法和子类之间的约定,注明子类实现该方法应该注意什么。例如在19条中,为了继承而设计的类,实际上不正确的实现会有安全问题,破坏封装性,这个时候应该在父类中用该注释说明,要求子类实现必须遵守约定。 案例:
public boolean isEmpty() { ... }
转义
因为Java文档注释支持任何HTML元素,记得为非HTML语法的符号转义。
概要描述
文档的第一句话应该非常精炼,整个注释区域的内容。
- 如果是方法,说明返回了什么,做了什么。
- 如果是字段,对象,说明是什么。
说明线程安全性和可序列化性
为泛型、枚举、注解提供注释
泛型类型都是什么。
public interface Map<K, V> { ... }
When documenting an enum type, be sure to
枚举,每个常量都简要说明是什么。 注解类型,指出该注解有什么用,什么场合使用。注解类的方法当作字段来注释,指明它是什么。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
|