1.检查方法参数的有效性
-
问题
绝大多数方法和构造器对于传递给他们的参数值都会有某些限制。例如索引值必须是非负数,对象引用不能为null等等。在编写方法有一个普遍的原则是“应该在发生错误之后尽快检测出错误”,按照这样的原则,在实际开发中应该怎样做?
-
解决
- 为了避免由于方法入参不符合规范,将异常扩散到方法执行过程之中,在设计方法或者构造器时,应该考虑方法的入参有哪些限制,应该在方法开头显式的进行参数有效性判断,也应该将这些参数有效性写入在文档中;
- 对于公有的方法,要用Javadoc的@throws标签 在文档中说明违反参数值限制时会抛出的异常。通常这样的异常为
IllegalArgumentException
,IndexOutOfBoundsException
。
-
结论
每当编写方法或者构造器的时候,应该考虑它的参数有哪些限制,应该把这些限制写在文档中,并且在这个方法体的开头处,通过显示的检查来实施这些限制。养成这样的习惯是非常重要的。
2.参数进行保护性拷贝
-
问题
类的内部成员域为引用类型数据,而非是基本类型数据时,就要在类构造器和成员域的访问方法上考虑对这些内部成员域进行保护,以防止外界条件破坏了这种约束,对实例对象的状态发生改变。那么,对类的成员域为引用类型数据时,应该怎样处理?
-
解决
-
对类构造器进行保护性拷贝
示例代码为:
import java.util.Date; 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; } //remainder omitted }
这段代码看上去没有什么问题,本意以为是Period被构造后,状态是不会被改变的,但是由于Date是可变的,Period的状态也是会被改变的,如下面的这样的使用:
Date start = new Date(); Date end = new Date(); Period period = new Period(start, end); end.setYear(78);
因此,为了让Period更加安全可靠,需要对构造器进行保护性拷贝,将上面这段代码改变如下这种形式:
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); } }
注意:保护性拷贝在参数有效性检查之前,并且参数有效性检查针对的是已拷贝的对象,而非是原始对象。
-
对类成员域进行保护性拷贝
如果上例中的成员域提供了访问方法,那么,Period仍然是不安全的。如果不进行保护性拷贝的话,引用类型数据就有可能在类的外部被改变,因此影响类内部的数据结构,污染到类。针对成员域的访问方法,可做如下的保护性拷贝:
public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); }
-
什么时候考虑使用保护性拷贝?
每当编写方法或者构造器时,如果它允许由客户端提供对象进入到类的内部数据结构时,就需要考虑,客户端提供的对象是否有可能是可变的。如果是,就要考虑能否忍受对象可变时,对类的内部的数据结构发生改变。如果不能,则要在构造器或者方法上对外部对象进行保护性拷贝,让拷贝后的对象进入到类,而不是原始的可变的对象。例如,如果使用外部的对象作为Set的元素或者作为Map的key,就应该意识到,这个对象在插入之后再被修改,相应的Set或者Map就会遭到破坏。
-
-
结论
如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改这些组件,就可以在文档中进行说明。
3.谨慎的设计方法签名
-
问题
在设计API时,应该遵守一些哪些通用的规则来设计方法,以保证方法可读性更强,更安全可靠?
-
解决
- 谨慎的选择方法的名称:遵循标准的命名规范;
- 不要过于追求提供便利的方法:每个方法不要做太多逻辑,导致方法体过长,应该尽力去拆分,每个方法都应有尽其所能即可;
- 避免过长的参数列表:方法参数个数不能超过四个,否则让人难以理解参数的意义。减小参数个数有三种方式:1.拆分出子方法;2.将多个参数抽象成一个实体类,这样方法的入参就仅仅只是这一个实体类;3.使用Builder模式
-
结论
上述的这些规则能够保证设计的方法可读性更强,性能更好,在实际开发中,应该遵守这些规则。
4.慎用重载
-
问题
先来看一个错误的例子:
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方法被重载了,实际上调用哪个重载方法,是在编译时就已经决定了。在这个例子中编译器都认为是Collection<?>类,所以输出的是三个Unknown Collection。因此,在使用重载是应该注意哪些问题?
-
解决
调用哪个具体的重载方法是在编译时就决定了,根据方法中参数的编译时类型。而对于被覆盖的方法的选择则是动态的,是根据调用该方法的对象的运行时类型,来选择合适的“被覆盖的版本”。覆盖是用来实现多态的,而重载并不是;
使用重载,安全而保守的策略是:永远不要写两个具有相同参数数目的重载方法;
-
如果一定要重载,那么对于一对重载方法,至少要有一个对应的参数在两个重载方法中的类型“完全不同”。可以看下面这个例子:
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); } 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的输出结果如同我们想的一样,但是List的结果不一样。实际发生的情况是:set.remove(E),选择重载方法的参数实际上是Integer,这里进行了自动装箱,把int装箱成了Integer;对于List,有两个重载函数,这里直接重载了list.remove(i),并没有重载到list.remove(E),是从list的指定位置进行remove,得到结果为-2,0,2。这里最根本的原因在于,由于泛型和自动拆箱和装箱,使得remove(E)和remove(i)这两个方法中的参类型上数并没有“根本的不同”。
-
结论
能够重载并不意味着应该重载,一般来说,对于多个具有相同参数数目的重载方法,还是尽量避免使用重载。如果不能避免重载,就需要保证每一个重载方法的参数类型无论经过怎样的转换(如泛型和自动拆箱和装箱)后都能“完全不同”,从而根据参数类型能够指向不同的重载方法。
5.慎用可变参数
-
问题
当不确定参数个数的时候可以采用可变参数,那么,在使用可变参数时有哪些需要注意的?
-
解决
-
可变参数可以被用来接受0个或者多个指定类型的参数。
示例:当方法需要1个或者多个参数的方法时,直接使用可变参数就会变得不优雅:
public static min(int...args){ if(args.length==0){ throw new IllegalrgumentException("Too few arguments!"); } .... }
由于该方法要求参数至少有1个,但是可变参数可以接受的是0个或者多个指定类型的参数,因此需要判断当前的参数args的长度是否为0,最终的结果是将这种检验放到了运行时而不是编译时。针对这种情况,做这样的调整:
public static min(int defaultParam, int...args){ .... }
由于通过方法入参要求了必须传入一个参数defaultParam,因此就少了参数个数的判断,方法变得优雅。
使用可变参数的规律为:方法强制要求的默认参数,在方法入参明确给出,可变参数用于进行兜底不确定参数个数的情况。
-
遍历数组
采用
Arrays.toString()
方法遍历数组,而不要采用Arrays.toList().toString
去遍历数组,这是因为toList
方法接受的是可变参数,如果想要正确输出数组内容的话,数组里元素必须是对象引用型数据,而不能是基本类型数据。如下例:List<String> homophones = Arrays.asList("to", "too", "two"); System.out.println(homophones); int[] digits = { 1, 2, 3, 4, 5 }; System.out.println(Arrays.asList(digits)); //输出结果为 [to, too, two] [[I@15db9742]
当数组元素为int基本类型数据时,Arrays.asList方法将int类型的数组的引用集中到单个元素数组中,并封装成List。
-
使用可变参数的场景
在重视性能的情况下,使用可变参数机制要小心,因为可变参数方法的每次调用都会导致进行一次数组分配和初始化,有一种折中的解决方案,假设确定某个方法大部分调用会有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}
-
-
结论
总之,和其他规则一样,尽管可变参数是一个很方便的方式,但是它们不应该被过度滥用。除非有必要,尽量不要使用这种方法。
6.返回空集合
-
问题
先来看一个反例:
private final List<Cheese> cheesesInStock = ...; public Cheese[] getCheeses(){ if (cheesesInStock.size == 0) return null; }
调用方客户端代码:
Cheese[] cheeses = shop.getCheeses(); if(cheeses != null && Arrays.asList(cheeses).contains(Cheese.STILTON)){ System.out.println("Jolly good,just the thing.") ; }
也就是说由于cheesesInStock在特殊情况下返回了null,因此,给调用方增加了额外的判断为null的代码,而从业务意义上来说,null一般是指异常情况下的返回值,那么,针对返回值为集合或者数组来说,特殊情况下应该返回什么?
-
解决
对于方法如果返回为null,则调用方每一次都要去判断是否为null,从而解决NullPointException。因此,方法返回类型是数组或者集合时,特殊情况不应该返回null,而是应该返回一个空的集合或者数组。
-
有时候会有人认为:null返回值比零长度数组更好,因为它避免了分配数组所需要的开销。这种观点是站不住脚的,原因有两点。第一这个级别上担心性能问题是不明智的,除非分析表明这个方法正是造成性能问题的真正源头。第二每次都返回同一个零长度数组有可能的,因为零长度数组是不可变的,而不可变的对象有可能被自由的共享。
将上例进行修改:
public List<Cheese> getCheeseList(){ if(cheeseInStock.isEmpt()) return Collections.emptyList();//Always return same list else return new ArrayList<Cheese>(cheesesInStock); }
-
结论
返回类型为数组或者集合的方法没理由返回null,而应该返回一个长度为零的数组或者集合。
7.API文档是关键
-
问题
在每个类、接口、构造器、方法和域生命处都应该有详细的文档注释,那么好的文档注释有哪些元素?
-
解决
- 如果要想使一个API真正可用,就必须为其编写文档。传统意义上的API文档是手动生成的,所以保持文档与代码同步是一件很繁琐的事情。Java环境提供了一种被称为Javadoc的实用工具来完成文档注释的编写;
- 文档注释的三个部分:
- 第一部分是简述。文档中,对于属性和方法都是先有一个列表,然后才在后面一个一个的详细的说明;
- 第二部分是详细说明部分。该部分对属性或者方法进行详细的说明,在格式上没有什么特殊的要求,可以包含若干个点号;
- 第三部分是特殊说明部分。这部分包括版本说明、参数说明、返回值说明等。
- @param b true 表示显示,false 表示隐藏
- @return 没有返回值
- 添加文档注释规范:
- 为了正确地编写API文档,必须在每个被导出的类、接口、构造器、方法和域声明之前增加一个文档注释;
- 方法的文档注释应该简洁的描述出它和客户端之间的约定。这个约定应该说明这个方法做了什么,而不是说明它是如何完成这项工作的。文档注释应该列举如下内容:
- 前提条件(precondition) 前提条件是指为了使客户能够调用这个方法,而必须满足的条件;
- 后置条件(postcondition) 所谓后置条件是指在调用成功完成之后,哪些条件必须要满足;
- 副作用(side effect) 副作用是指系统状态中可以观察到的变化,它不是为了获得后置条件而明确要求的变化;
- 类或者方法的线程安全性(thread safety)(详见70条) 当一个类的实例或者静态方法被并发使用时,这个类行为的并发情况。
-
结论
要为API编写文档,文档注释是最好、最有效的途径。对于所有可导出的API元素来说,使用文档注释应该被看作是强制性的。要采用一致的风格来遵循标准的约定。在文档注释内部出现任何HTML标签都是允许的,但是HTML字符必须要经过转义。