目录
2.避免带有变长参数的方法的重载、别让null威胁到变长方法
25.集合相等只需要关心元素数据(集合类的equals方法)
38.反射访问属性或方法时将Accessible设置为true
1.三元操作符的类型必须一致
三元操作符是if-else的简化写法,它被使用的比较频繁。在Java中三元操作符中非常需要注意的问题就是类型转换,隐式的拆装箱问题。对于类型转换,规则如下:
- 若两个操作数不可转换,则不做转换,返回值为Object类型。
- 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。
- 若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型。
- 若两个操作数都是直接量数字,则返回值类型为范围较大者。
先看一下下面的例子:
int i = 80;
String str1 = String.valueOf(i > 90 ? 100 : 70);
String str2 = String.valueOf(i > 90 ? 100.0 : 70);
System.out.println(str1.equals(str2)); //输出false
输出false的主要原因是:对于str2, 由于三元操作符必须要返回一个数据,而且类型要确定,不可能条件为真时返回double类型,条件为假时返回int类型,编译器是不允许如此的,所以它就会进行类型转换了,int型转换为double类型的70.0。(这里体现的是第4条规则)
对于第二条和第三条规则:
char c = 'c';
int index = 10;
System.out.println(i > 90 ? index : c); //第二条。输出为99。返回值必须由一个固定类型接收,所以char类型的c转换为int类型
System.out.println(i > 90 ? 10 : c); //第三条。输出为c。此处10就是S,T就是char类型。因为10在char的范围内(char类型范围为-128-127),所以最终类型为char类型。
对于第一条比较好理解,只不过需要注意的是如果操作数1是Integer类型,操作数2是Double类型,这两种类型可以转换吗?当然直接转换是不可以的,但是Java有拆装箱操作,所以可以利用此特性进行转换,且会向范围大的转换,即最终转换为Double类型,一个示例如下:
int i = 80;
Integer a = 2;
Double d = 3.0;
Double res = i < 90 ? a : d;
在编译前,我们需要用Double类型来接收这个三元操作符的返回值,因为a和d一个是Integer类型的,一个是Double类型的,返回值只能是一个固定类型(我们在Double和Integer中选一个),Double类型的拆箱类型是double,Integer的拆箱类型是int,int类型会向范围比它大的double自动转换,所以该三元操作符的返回值是double类型,那么我们利用它的装箱类Double接收自然是可以的,JVM会帮我们装箱。如果非要用Integer接收也是可以的,只不过需要显式地强转。
最后一句的字节码如下:
18 if_icmple 29 (+11) //?前的表达式结果为假时跳转到29行
21 aload_1 //将第二个局部变量入栈(即变量a)
22 invokevirtual #24 <java/lang/Integer.intValue> //执行拆箱操作(即Integer类型的2转化为int类型)
25 i2d //将栈顶int类型的数据转换为double类型(即2转换为2.0)
26 goto 33 (+7) //跳转指令:跳转到33这个指令地址
29 aload_2 //将第三个引用类型本地变量(d)推送至栈顶
30 invokevirtual #30 <java/lang/Double.doubleValue>
33 invokestatic #18 <java/lang/Double.valueOf> //将栈顶元素装箱(即2.0装箱为Double类型)
36 astore_3 //将栈顶元素存入第4个局部变量中(即res)
37 return
这里的过程是这样的,先判断i<90的真假,此处为真,所以就需要执行下面的字节码:它会先将a(Integer)拆箱为int类型,再将int类型转换为double类型的(2.0),然后将这个double类型的2.0装箱为Double类型,最后将其赋值给res变量。在执行字节码的时候,如果i>90为假,那么JVM执行字节码时就不会执行Integer->Double类型的转化。只不过编译器不会去判断,所以需要用范围更大的Double接收。
2.避免带有变长参数的方法的重载、别让null威胁到变长方法
Java 5引入了变长参数,目的是为了让调用者“随心所欲”地传递实参数量,当然也需要遵守一定的规则,比如:
- 变长参数必须是方法中的最后一个参数
- 一个方法中不能定义多个变长参数
使用这个特性的时候也需要注意,比如我们定义三个方法如下:
public static void method(String str, int num) {
System.out.println("这是没有变长参数的方法");
}
public static void method(String str, int... nums) {
System.out.println("这是带有变长参数nums的方法");
}
public static void method(String str, Object... objs) {}
当调用不当时可能产生的问题如下:
public static void main(String[] args) {
/**
* 这里调用的是没有变长参数的方法。因为int是一个原生数据类型,而数组本身是一个对象。
* 编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。
*/
method("1",2);
/**
* 下面两个方法编译出错,如果没有method(String str, Object... objs)这个方法那是能通过编译的。
* 但是由于存在method(String str, int... nums)和method(String str, Object... objs)两个第一个参数类型相同,第二个都是变长参数
* 所以下面的调用就会让编译器不能理解到底是要调用哪个方法,所以编译不通过
*/
//method("1");
//method("1", null);
}
所以我们调用带有变长参数的方法时,如果不想传递变长参数时,不建议直接传递null,最好是传递一个空的对应类型的数组,这样就不会让编译器懵逼了(当然将参数们都装进数组中然后去传递数组这是极好的)。
3.重写变长方法也要循规蹈矩
首先,重写必须满足的条件:
- 参数列表必须与被重写方法相同。
- 返回类型必须与被重写方法的相同或是其子类。
- 重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。
我们来看一个示例:
父类:
public class Father {
public void method(String... strs) {
System.out.println("这是父类的方法");
};
}
子类:
public class Sub extends Father {
@Override
public void method(String[] strs) {
System.out.println("这是子类的方法");
}
}
调用时可能产生的问题:
public static void main(String[] args) {
//向上转型
Father father = new Sub();
//这里形参列表是由父类确定的,Java编译器会先将"1",编译成{“1”},然后由子类调用
father.method("1");
/*
* 这里由于没有做向上转型,所以形参列表是由子类Sub确定,而由于子类中明确要求要是String[]类型
* 而Java编译器由不会帮我们编译成{"1"},所以下面的调用编译不通过
*/
Sub sub = new Sub();
//sub.method("1"); //此处编译不通过
}
所以重写方法的时候一定要注意参数列表必须与被重写方法相同,这里的相同不仅仅是类型、数量,还包括显示形式。
4.警惕自增的陷阱
我们一定听到过一句话, i++是先赋值后加1,++i是先加1后赋值。那么我们通过一个例子来看下,Java中的i++到底是怎样一个流程。
public static void main(String[] args) {
int count = 0;
for(int index = 0; index < 10; index++) {
count = count++;
}
System.out.println("count的值为:" + count); //输出0
}
注意最后是输出0。因为它底层其实是这样做的:
- JVM把count值(其值是0)拷贝到临时变量区。
- count值加1,这时候count的值是1。
- 返回临时变量区的值,注意这个值是0,没修改过。
- 返回值赋值给count,此时count值被重置成0。
5.养成良好的习惯,显式声明UID
一个类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。若没有序列化,现在我们熟悉的远程调用、对象数据库都不可能存在。我们先来看一个简单的序列化类,如下:
public class Person implements Serializable{
private String name;
//省略getter和setter方法
}
这个类实现了Serializable接口,但是并没有显式声明UID。这么做会有什么问题?假如这是一个分布式部署的应用,消息生产者的Person类增加了一个age属性,而消费者没有增加,那么反序列化时就会抛出InvalidClassException异常,异常出现的原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转化为实例对象。而JVM就是通过SerialVersionUID来判断一个类的版本的。SerialVersionUID它有两种声明方式,一种是显式声明,一种是隐式声明(隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的。)所以才会抛出这个异常。
有时候我们需要一点特例场景,例如:我们的类改变不大,JVM是否可以把我以前的对象反序列化过来?就是依靠显式声明serialVersionUID,向JVM撒谎说“我的类版本没有变更”,如此,我们编写的类就实现了向上兼容。以上面例子来说就是这样:
public class Person implements Serializable{
//显式声明
private static final long serialVersionUID = -6648364975261402195L;
private String name;
}
这时面对生产者和消费者类版本不一致的情况,依然是可以反序列化出对象的,只不过消费者无法读取到新增的业务属性(age)而已。
注意:显式声明SerialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。
6.避免用序列化类在构造函数中为不变量赋值
Java中我们用final修饰 不变量,为final赋值的方式有:
- 直接赋值
- 在构造函数中赋值。
如下为第一种方式:
public class Student implements Serializable{
private static final long serialVersionUID = -1825641010498986620L;
public final String name = "李四";
}
此处做一个测试:
- 先将这个Student对象实例stu序列化到磁盘。
- 修改源代码中name属性的值为"张三"。
- 反序列化出对象实例。该对象实例的name值为“张三”。
注意这里反序列化得出的对象的name属性值更新了,这是反序列化的一个规则:如果final是一个直接量,则在反序列化时就会重新计算其值。
如下为第二种方式:
public class Student implements Serializable{
private static final long serialVersionUID = -1825641010498986620L;
public final String name;
public Student() {
name = "李四";
}
}
此处做跟示例一一样的测试,结果并没有输出“张三”,而是之前的“李四”。
这里触及了反序列化的另一个规则:反序列化时构造函数不会执行。反序列化的执行过程是这样的:JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,注意是类描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的name值,而此时JVM又发现name竟然没有赋值,不能引用,于是它很“聪明”地不再初始化,保持原值状态。所以依然是“李四”。
其实序列化保存到磁盘上(或网络传输)的对象文件包括两部分:
- 类描述信息:包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
- 非瞬态(transient关键字)和非静态(static关键字)的实例变量值。(持久化的是对象,而非类,static修饰的是属于类的)注意,这里的值如果是一个基本类型,好说,就是一个简单值保存下来;如果是复杂对象,也简单,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是说递归到最后,其实还是基本数据类型的保存。正是因为这两点原因,一个持久化后的对象文件会比一个class类文件大很多。
总结:反序列化时final变量在一下情况下不会被重新赋值:
- 通过构造函数为final变量赋值
- 通过方法返回值为final变量赋值
- final修饰的属性不是简单对象。(这里的简单对象包括:8个基本类型、数组、字符串(字符串情况复杂,不通过new关键字生成String对象的情况下,final变量的赋值与基本类型相同))
7.易变业务使用脚本语言编写
假如现在有一个算法来计算股票走势,该算法会根据市场环境逐渐调整,不断变化如果把这个算法写到某个类中(或者几个类中),就需要经常发布重启等操作。使用脚本语言可以很好的简化这一过程。Java 6开始正式支持脚本语言。但是因为脚本语言比较多,Java的开发者也很难确定该支持哪种语言,于是JCP(Java Community Process)很聪明地提出了JSR223规范,只要符合该规范的语言都可以在Java平台上运行(它对JavaScript是默认支持的)。
假设下面是利用JavaScript实现的该算法,如下:
function formula(num1, num2) {
//factor是从上下文中来的
return num1 + num2 * factor;
};
下面是利用Java调用执行该函数:
public static void main(String[] args) throws Exception {
//获得一个JavaScript的执行引擎
ScriptEngine engine = new ScriptEngineManager().getEngineByName("javascript");
//建立上下文变量
Bindings bind = engine.createBindings();
bind.put("factor", 1);
//绑定上下文,作用域是当前引擎范围
engine.setBindings(bind, ScriptContext.ENGINE_SCOPE);
Scanner scanner = new Scanner(System.in);
while(scanner.hasNextInt()) {
int num1 = scanner.nextInt();
int num2 = scanner.nextInt();
//执行JS代码
engine.eval(new FileReader("./src/model.js"));
if(engine instanceof Invocable) {
Invocable in = (Invocable)engine;
//执行JS中的函数
Double result = (Double) in.invokeFunction("formula", num1, num2);
System.out.println("计算结果为:" + result.intValue());
}
}
scanner.close();
}
在JVM没有重新启动的情况下,两次输出结果对比:
8.避免instanceof非预期结果
instanceof是一个简单的二元操作符,它是用来判断一个对象是否是一个类实例的。我们还是通过几个例子来看看instanceof呈现的各种结果:
public static void main(String[] args) {
boolean b1 = "name" instanceof Object; //String对象是否是Object类的实例,此处返回true
boolean b2 = new String() instanceof String;//String对象是否是否是String的实例,此处返回true
/**
* Object对象是否是String类的实例,此处返回false,Object是父类,其对象当然不是String类的实例
* 此处可以编译通过,只要instanceof左右两个操作数有继承或实现关系,就可以编译通过。
*/
boolean b3 = new Object() instanceof String;
/**
* 拆箱类是否是装箱类型的实例
* 编译不通过,因为‘A’是一个char类型,即基本类型,instanceof只能用于对象的判断,不能用于基本类型的判断
*/
// boolean b4 = 'A' instanceof Character;
/**
* 空对象是否是String类的实例
* 返回false。instanceof规则:若左操作数是null,结果直接返回false
*/
boolean b5 = null instanceof String;
boolean b6 = (String)null instanceof String;//类型转换后的空对象是否是String类的实例,此处返回false
// boolean b7 = new Date() instanceof String;//编译不通过。因为Date类和String类没有继承或实现关系
/**
* 在泛型类中判断String对象是否是String类的实例
* 此处返回false。泛型对象在编译时被擦除为原始类型Object类, Object instanceof Date ==> 返回false
*/
boolean b8 = new GenericClass<String>().isDateInstance("name");
}
static class GenericClass<T>{
public boolean isDateInstance(T t) {
return t instanceof Date;
}
}
9.边界,边界,还是边界(注意基本类型的边界)
假如现在有一个产品畅销的场景:它规定每个会员最多可以预定2000个产品,目的是防止囤货积压肆意加价,预先输入预定的数量,符合条件则成功。后台的处理逻辑模拟如下:
//一个会员拥有产品最多的数量
public static final int LIMIT = 2000;
public static void main(String[] args) {
//会员当前拥有的产品数量
int cur = 1000;
//准备订购的数量(假设这是用户输入)
int order = Integer.MAX_VALUE;
if(order > 0 && order + cur <= LIMIT) {
System.out.println("你已经成功预订了" + order + "个产品");
//……
}else {
System.out.println("超过限额,预定失败");
}
}
这段程序输出如下:
出现这个问题的根本原因是忽略了int类型的边界,Integer.MAX_VALUE是整型4字节表示的最大正数数值,在此基础上再加1000,那其结果值就是负数,肯定是小于LIMIT(2000)的,然后就错误地被预订了,当然解决该问题只需要修改if判断逻辑即可(order > 0 && order <= LIMIT - cur)这样一来,order的上下边界就被确定。虽然这是一个小例子但是细心真的很重要。
10.不要让四舍五入亏了一方
四舍五入是一种近似精确的计算方法,但也是有误差的,我们以舍入运用最频繁的银行利息计算为例来阐述该问题。四舍五入,小于5的数字被舍去,大于等于5的数字进位后舍去,由于所有位上的数字都是自然计算出来的,按照概率计算可知,被舍入的数字均匀分布在0到9之间,下面以10笔存款利息计算作为模型,以银行家的身份来思考这个算法:
- 四舍:舍弃的数值:0.000、0.001、0.002、0.003、0.004,因为是舍弃的,对银行家来说,就不用付款给储户了,那每舍弃一个数字就会赚取相应的金额:0.000、0.001、0.002、0.003、0.004。
- 五入:进位的数值:0.005、0.006、0.007、0.008、0.009,因为是进位,对银行家来说,每进一位就会多付款给储户,也就是亏损了,那亏损部分就是其对应的10进制补数:0.005、0.004、0.003、0.002、0.001。
因为舍弃和进位的数字是在0到9之间均匀分布的,所以对于银行家来说,每10笔存款的利息因采用四舍五入而获得的盈利是:0.000 + 0.001 + 0.002 + 0.003 + 0.004 - 0.005 - 0.004 - 0.003 - 0.002 - 0.001 = -0.005 也就是说每10笔利息计算中就损失0.005元,即每笔利息计算中损失0.0005元。如果存款的人很多的话那么损失是巨大的。
这个算法误差是由美国银行家发现的,并且对此提出了一个修正算法,叫做银行家舍入(Banker's Round)的近似算法,其规则如下:
- 舍去位的数值小于5时,直接舍去;
- 舍去位的数值大于等于6时,进位后舍去;
- 当舍去位的数值等于5时,分两种情况:5后面还有其他数字(非0),则进位后舍去;若5后面是0(即5是最后一个数字),则根据5前一位数的奇偶性来判断是否需要进位,奇数进位,偶数舍去。
以上规则汇总成一句话:四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。要在Java 5以上的版本中使用银行家的舍入法则非常简单,直接使用RoundingMode类提供的Round模式即可,示例代码如下:
public static void main(String[] args) {
//存款
BigDecimal b = new BigDecimal(888888);
//月利率 * 3 计算季利率
BigDecimal r = new BigDecimal(0.001875 * 3);
//计算利息
BigDecimal i = b.multiply(r).setScale(2, RoundingMode.HALF_EVEN);
System.out.println("季利息是:" + i); //输出:季利息是:4999.99
}
在上面的例子中,我们使用了BigDecimal类,并且采用setScale方法设置了精度,同时传递了一个RoundingMode.HALF_EVEN参数表示使用银行家舍入法则进行近似计算,BigDecimal和RoundingMode是一个绝配,想要采用什么舍入模式使用RoundingMode设置即可。目前Java支持以下七种舍入方式:
- ROUND_UP:远离零方向舍入。向远离0的方向舍入,也就是说,向绝对值最大的方向舍入,只要舍弃位非0即进位。
- ROUND_DOWN:趋向零方向舍入。向0方向靠拢,也就是说,向绝对值最小的方向输入,注意:所有的位都舍弃,不存在进位情况。
- ROUND_CEILING:向正无穷方向舍入。向正最大方向靠拢,如果是正数,舍入行为类似于ROUND_UP;如果为负数,则舍入行为类似于ROUND_DOWN。注意:Math.round方法使用的即为此模式。
- ROUND_FLOOR:向负无穷方向舍入。向负无穷方向靠拢,如果是正数,则舍入行为类似于 ROUND_DOWN;如果是负数,则舍入行为类似于ROUND_UP。
- HALF_UP:最近数字舍入(5进)。这就是我们最最经典的四舍五入模式。
- HALF_DOWN:最近数字舍入(5舍)。在四舍五入中,5是进位的,而在HALF_DOWN中却是舍弃不进位。
- HALF_EVEN:银行家舍入算法。
在普通的项目中舍入模式不会有太多影响,可以直接使用Math.round方法,但在大量与货币数字交互的项目中,一定要选择好近似的计算模式,尽量减少因算法不同而造成的损失。
11.提防包装类型的null值
我们知道Java引入包装类型(Wrapper Types)是为了解决基本类型的实例化问题,以便让一个基本类型也能参与到面向对象的编程世界中。而在Java 5中泛型更是对基本类型说了“不”,如想把一个整型放到List中,就必须使用Integer包装类型。我们来看一段代码:
public static void main(String[] args) {
List<Integer> numberList = new ArrayList<Integer>();
numberList.add(1);
numberList.add(null);
//计算集合中所有数据的和
int res = 0;
for(int i : numberList) {
res += i;
}
System.out.println(res);
}
这段代码最终会以空指针异常告终,因为我们向集合中添加了一个null值,而for循环时,又是以基本类型int来接收Integer变量的,所以这里隐含了一个自动拆箱的过程,Integer对象会调用它的intValue方法,而其真实值又是null的,所以才出现异常,我们只需要修改一个for循环即可,如下:
for(Integer i : numberList) {
res += (i == null) ? 0 : i;
}
对于包装类型,我们还需要注意它的大小比较(包装类是对象,如果对象直接使用“==”、“<”、“>”,那么其比较的其实的地址),对于Integer类型,它有一个整型池的概念,Integer对象中存在一个Integer元素的数组,他会将(-128到127中的Integer对象缓存起来)通过valueOf方法会优先从该数组中获取Integer对象。装箱动作也是通过valueOf方法实现的。在以后使用的过程中,可以考虑使用valueOf方法,优先使用整型池。
12.不要随便设置随机种子
随机数在太多的地方使用了,比如加密、混淆数据等,我们使用随机数是期望获得一个唯一的、不可仿造的数字,以避免产生相同的业务数据造成混乱。在Java项目中通常是通过Math.random方法和Random类来获得随机数的,我们来看一段生成随机数的代码:
public static void printRandom() {
Random r = new Random();
for(int i = 1; i < 4; i++) {
System.out.println("第" + i + "次:" + r.nextInt());
}
}
这样生成随机数每次执行都会输出不同的一组随机数。如果对上述代码稍作修改,那么情况就会发生变化,如下:
Random r = new Random(1000); //只修改这里即可
计算机不同输出的随机数也不同,但是有一点是相同的:在同一台机器上,甭管运行多少次,所打印的随机数都是相同的,也就是说第一次运行,会打印出这三个随机数,第二次运行还是打印出这三个随机数,只要是在同一台硬件机器上,就永远都会打印出相同的随机数。这是因为产生随机数的种子被固定了,在Java中,随机数的产生取决于种子,随机数和种子之间的关系遵从以下两个规则:
- 种子不同,产生不同的随机数。
- 种子相同,即使实例不同也产生相同的随机数。
Random类的默认种子(无参构造)是System.nanoTime()的返回值(JDK 1.5版本以前默认种子是System. currentTimeMillis()的返回值),注意这个值是距离某一个固定时间点的纳秒数,不同的操作系统和硬件有不同的固定时间点,也就是说不同的操作系统其纳秒值是不同的,而同一个操作系统纳秒值也会不同,随机数自然也就不同了。(顺便说下,System.nanoTime不能用于计算日期,那是因为“固定”的时间点是不确定的,纳秒值甚至可能是负值,这点与System. currentTimeMillis不同。)new Random(1000)显式地设置了随机种子为1000,运行多次,虽然实例不同,但都会获得相同的三个随机数。所以,除非必要,否则不要设置随机种子。
13.不要重写静态方法
我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明时的类型,实际类型是对象产生时的类型。对于非静态方法,它是根据对象的实际类型来执行的,而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名访问的;其次,可以通过对象访问静态方法(这并不是一个好习惯),如果是通过对象调用静态方法,JVM则会通过对象的表面类型查找到静态方法的入口,继而执行之。
在Java中静态方法可以被继承,但是不能被重写。在子类中构建与父类相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类、子类都是静态方法,此种行为叫做隐藏(Hide),它与重写有两点不同:
- 表现形式不同。隐藏用于静态方法,覆写用于非静态方法。在代码上的表现是:@Override注解可以用于重写,不能用于隐藏。
- 职责不同。隐藏的目的是为了抛弃(遮盖)继承自父类静态方法,重现子类方法,也就是期望父类的静态方法不要破坏子类的业务行为;而覆写则是将父类的行为增强或减弱,延续父类的职责。
其实重写是多态特性的体现,JVM通过判断对象实例的实际类型来调用相应的实例方法,所以重写是跟着对象实例走的,而静态方法是专属于某个类的,我们访问静态方法的时候一般也是通过类名来访问的,所以对于静态方法无法体现多态性。
14.构造函数尽量简化、避免在构造函数中初始化其他类
在构造函数中初始化其他类可能会出现的问题,其中之一:
public static void main(String[] args) {
Son s = new Son();
s.doSomething();
}
//父类
static class Father {
public Father() {
new Other();
}
}
//子类
static class Son extends Father{
public void doSomething() {
System.out.println("doSomething");
}
}
//相关类
static class Other{
public Other() {
new Son();
}
}
子类继承父类,初始化子类之前JVM会先初始化父类,在子类默认的构造方法中隐藏了super()这条语句,而父类构造方法中又去实例化Other对象,Other构造方法中又实例化Son对象,这就造成了三个构造方法的死递归,方法出不了栈,造成栈溢出。如果真的遇到这种情况,我觉得可以将Son对象定义为一个成员,使用懒加载的思想,在使用的时候再初始化。
15.使用构造代码块精炼程序
在Java中一共有四种类型的代码块:
- 普通代码块:就是在方法后面使用“{}”括起来的代码片段,它不能单独执行,必须通过方法名调用执行。
- 静态代码块:在类中使用static修饰,并使用“{}”括起来的代码片段,用于静态变量的初始化或对象创建前的环境初始化。
- 同步代码块:使用synchronized关键字修饰,并使用“{}”括起来的代码片段,它表示同一时间只能有一个线程进入到该方法块中,是一种多线程保护机制。
- 构造代码块:在类中没有任何的前缀或后缀,并使用“{}”括起来的代码片段。
Java中每个类至少会有一个构造方法,对于构造方法和构造代码块:编译器会把构造代码块插入到每个构造函数的最前端,也就是说:构造代码块会在每个构造函数内首先执行(需要注意的是:构造代码块不是在构造函数之前运行的,它依托于构造函数的执行)。需要注意的是:如果构造方法A中利用this关键字调用了构造方法B的话,利用构造方法A实例化对象时,会先执行构造代码块中的内容,完毕之后执行构造方法B(这个构造方法B中不插入构造代码块的内容),接着执行构造方法A的剩余内容。构造代码块是为了提取构造函数的共同量,减少各个构造函数的代码而产生的,而且要确保每个构造函数只执行一次构造代码块。
下面是一个测试示例:
public class Test {
{
System.out.println("构造代码块");
}
public Test(String str) {
this();
System.out.println("带参构造方法");
}
public Test() {
System.out.println("无参构造方法");
}
public static void main(String[] args) {
new Test("");
/**
* 输出如下:
构造代码块
无参构造方法
带参构造方法
*/
}
}
16.使用静态内部类提高封装性
Java中的嵌套类分为两种:静态内部类(也叫静态嵌套类)和内部类。只有在是静态内部类的情况下才能把static修饰符放在类前,其他任何时候static都是不能修饰类的。静态内部类有两个优点:加强了类的封装性和提高了代码的可读性。这和我们一般定义的类有什么区别呢?又有什么吸引人的地方呢?如下所示:
- 提高封装性。从代码位置上来讲,静态内部类放置在外部类内,其代码层意义就是:静态内部类是外部类的子行为或子属性,两者直接保持着一定的关系。
- 提高代码的可读性。相关联的代码放在一起,可读性当然提高了。
- 形似内部,神似外部。静态内部类虽然存在于外部类内,而且编译后的类文件名也包含外部类(格式是:外部类+$+内部类),但是它可以脱离外部类存在。即在需要的时候我们可以直接new静态内部类。而不需要实现声明外部类。
静态内部类与普通内部类区别如下:
- 静态内部类不持有外部类的引用:在普通内部类中,我们可以直接访问外部类的属性、方法,即使是private类型也可以访问,这是因为内部类持有一个外部类的引用,可以自由访问。而静态内部类,则只可以访问外部类的静态方法和静态属性(如果是private权限也能访问,这是由其代码位置所决定的),其他则不能访问。(静态内部类中没有隐含外部类的引用)。
- 静态内部类不依赖外部类:普通内部类与外部类之间是相互依赖的关系,内部类实例不能脱离外部类实例,在创建一个普通内部类对象时首先需要创建其外部类对象。因为内部类隐含对外部类的引用,所以外部类就不能被JVM的垃圾回收机制自动垃圾回收。(当其他类中没有引用外部类,而且内部类被垃圾回收时才会回收外部类)
- 普通内部类不能声明static的方法和变量:普通内部类不能声明static的方法和变量,注意这里说的是变量,常量(也就是final static修饰的属性)还是可以的,而静态内部类形似外部类,没有任何限制。
17.使用匿名类的构造函数
匿名类虽然没有名字,但也是可以有构造方法的,它的构造方法就是构造代码块。匿名类的构造方法比较特殊特殊,一般类(也就是具有显式名字的类)的所有构造方法默认都是调用父类的无参构造的,而匿名类因为没有名字,只能由构造代码块代替,也就无所谓的有参和无参构造方法了,它在初始化时直接调用了父类的同参数构造,然后再调用了自己的构造代码块。
18.让工具类不可实例化
Java项目中使用的工具类非常多,比如JDK自己的工具类java.lang.Math、java.util. Collections等都是我们经常用到的。工具类的方法和属性都是静态的,不需要生成实例即可访问,而且JDK也做了很好的处理,由于不希望被初始化,于是就设置构造方法为private访问权限,表示除了类本身外,谁都不能产生一个实例。由于Java反射机制的存在,修改构造方法的访问权限易如反掌,为了避免反射的操作,我们可以在修改构造方法为private的同时再抛出异常。如下:
public class MyUtils {
private MyUtils() {
throw new RuntimeException("不要实例化我");
}
}
19.避免对象的浅拷贝
我们知道所有类都继承自Object,Object提供了一个对象拷贝的默认方法,即clone方法,它是用protected修饰的只允许在同包和子类内部调用。我们如果想要让外部调用能够拷贝自己的对象实例,可以实现JDK提供的Cloneable接口(一个标记接口),并重写Object类的clone方法。如果不实现这个接口直接重写clone方法,则会抛出CloneNotSupportedException异常。Object类提供的clone方法是有缺陷的,它提供的是一种浅拷贝方式,也就是说它并不会把对象的所有属性全部拷贝一份,而是有选择性的拷贝,它的拷贝规则如下:
- 基本类型:如果变量是基本类型,则拷贝其值,比如int、float等。
- 对象:如果变量是一个实例对象,则拷贝地址引用,也就是说此时新拷贝出的对象与原有对象共享该实例变量,不受访问权限的限制。这在Java中是很疯狂的,因为它突破了访问权限的定义:一个private修饰的变量,竟然可以被两个不同的实例对象访问,这让Java的访问权限体系情何以堪!
- String字符串:这个比较特殊,拷贝的也是一个地址,是个引用,但是在修改时,它会从字符串池(String Pool)中重新生成新的字符串,原有的字符串对象保持不变,在此处我们可以认为String是一个基本类型。
20.推荐使用序列化方式实现对象的深拷贝
可以编写一个工具类来(CloneUtils)实现深拷贝,此工具类要求被拷贝的对象必须实现Serializable接口,否则是没办法拷贝的。用此方法进行对象拷贝时需要注意两点:
- 对象的内部属性都是可序列化的:如果有内部属性不可序列化,则会抛出序列化异常。
- 注意方法和属性的特殊修饰符:比如final、static变量的序列化问题会被引入到对象拷贝中来,这点需要特别注意。
- transient变量(瞬态变量,不进行序列化的变量)也会影响到拷贝的效果。
public class CloneUtils {
private CloneUtils() {}
//被克隆的对象需要实现Serializable接口
@SuppressWarnings("unchecked")
public static <T extends Serializable> T clone(T obj) {
T result = null;
ObjectOutputStream oos = null;
ObjectInputStream ois = null;
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(obj);
//分配内存空间
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ois = new ObjectInputStream(bis);
result = (T) ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}finally {
try {
oos.close();
ois.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return result;
}
}
当然,采用序列化方式拷贝时还有一个更简单的办法,即使用Apache下的commons工具包中的SerializationUtils类,直接使用更加简洁方便。
21.重写equals方法时要识别出自己
我们在写一个JavaBean时,经常会覆写equals方法,其目的是根据业务规则判断两个对象是否相等,比如我们写一个Person类,然后根据姓名判断两个实例对象是否相同,这在DAO(Data Access Objects)层是经常用到的。下面为Person类的equals方法:
@Override
public boolean equals(Object obj) {
//此处建议用getClass()方法判断,而不要使用instanceOf判断
if(obj != null && obj.getClass() == this.getClass()) {
Person p = (Person)obj;
//此处需要注意null的情况
if(p.getName() == null || name == null) {
return false;
}
//考虑到从Web上传递过来的对象有可能输入了前后空格,所以用trim方法剪切一下
return name.equalsIgnoreCase(p.getName().trim());
}
return false;
}
针对上面这种写法(注意有一个trim()),我们进行一个识别不出自己的示例:
public static void main(String[] args) {
Person p1 = new Person("张三");
Person p2 = new Person("张三 ");
List<Person> personList = new ArrayList<Person>();
personList.add(p1);
personList.add(p2);
System.out.println("集合中是否包含张三:" + personList.contains(p1)); //true
System.out.println("集合中是否包含张三:" + personList.contains(p2)); //false
}
这里将“张三”和“张三 ”一起添加进了集合,然后利用ArrayList的contains方法判断是否包含,第二个输出竟然是false。我们可以先从contains方法看起,该方法内部就是调用了一个indexOf(Object o)方法,如果返回-1就说明不包含。indexOf方法如下:
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
看了源码就比较清楚了,这里参数对象o逐个与集合中的元素做相等比较,如果相等则返回下标值。我们对Person的equals的处理是先获取到姓名,然后去掉姓名后的空格,在进行比较。所以:
集合中的元素0:"张三"; 1:"张三 "
所以第二个比较逻辑相当于:
"张三 " -->"张三" 与集合中第一个元素比较
"张三 " -->"张三" 与集合中第二个元素比较,在equals内,去除了集合中"张三 "后的空格--->"张三"
22.重写equals方法必须重写hashCode方法
我们都知道HashMap的底层处理机制是以数组的方式保存Map节点的,这其中的关键是这个数组下标的处理机制:依据传入元素hashCode方法的返回值决定其数组的下标,如果该数组位置上已经有了节点元素,且与传入的键值相等则不处理,若键相等值不相等则覆盖;如果数组位置没有节点元素,则插入。同理,检查键是否存在也是根据哈希码确定位置,然后遍历查找键值的。(不去重写hashCode方法,那么可能两个equals判断为相等的"张三"对象其哈希码不同。这是关键所在)
对于hashCode方法:它是一个对象的哈希码,是由Object类的本地方法生成的,确保每个对象有一个哈希码(这也是哈希算法的基本要求:任意输入k,通过一定算法f(k),将其转换为非可逆的输出,对于两个输入k1和k2,要求若k1=k2,则必须f(k1)=f(k2),但也允许k1≠k2,f(k1)=f(k2)的情况存在)。
23.不要主动进行垃圾回收
在Java里我们可以调用System.gc主动进行垃圾回收(当然JVM也不是看到就直接回收,这个操作相当于给JVM提建议),这样比较危险,是因为垃圾回收时要停止所有的响应(Stop the world),才能检查内存中是否有可回收的对象,这对一个应用系统来说风险极大,如果是一个Web应用,所有的请求都会暂停,等待垃圾回收器执行完毕,若此时堆内存(Heap)中的对象少的话则还可以接受,一旦对象较多(现在的Web项目是越做越大,框架、工具也越来越多,加载到内存中的对象当然也就更多了),那这个过程就非常耗时了,可能0.01秒,也可能是1秒,甚至是20秒,这就会严重影响到业务的正常运行。
24.不同的集合选择不同的遍历方式
在Java中我们可以通过foreach的方式(迭代器的变形用法)进行集合遍历,也可以利用下标索引的方式遍历。
- 对于ArrayList优先使用下标的方式去遍历。因为ArrayList实现了RandomAccess接口(随机存取接口),在Java中RandomAccess和Cloneable。Serializable接口一样都是标志性接口。对于ArrayList来说也就标志着其数据元素之间没有相互依赖和索引关系,可以随机访问和存储。 可是如果使用迭代器方式遍历,就需要强制建立一种互相“知晓”的关系,比如上一个元素可以判断是否有下一个元素以及下一个元素是什么等关系,所以相对下标方式遍历会比较耗时。
- 对于LinkedList它是双向链表的形式封装的集合类,它使用foreach遍历会比较关系,因为借助迭代器模式可以直接有上一个元素索引到下一个元素,如果用下标的方式,那么就需要借助“头、尾”节点来一个个地查找,比较耗时。
25.集合相等只需要关心元素数据(集合类的equals方法)
如果定义三个不同的集合类(ArrayList,Vector,LinkedList),都存入相同的元素(比如都存入“A”),然后利用他们的equals方法比较这三个集合是否相等,则都会返回true。这是因为这三个集合类都直接或间接的继承了AbstractList这个抽象类,该类中存在equals方法(这三个集合类都是用的这个equals方法)的实现如下:
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof List))
return false;
//获取两个集合对象的迭代器
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
//若相同位置处有且仅有一个元素为null则返回false
//若两个元素都不为null的情况下,利用元素的equals方法判断不相等则返回false
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
//如果两个集合类中的元素数量不相等则为false
return !(e1.hasNext() || e2.hasNext());
}
26.使用Comparator进行排序
在Java中,要想给数据排序,有两种实现方式,一种是实现Comparable接口,一种是实现Comparator接口。这两个接口是有区别的。
- Comparable接口里面的方法是 public int compareTo(T o); 在java.lang包下
- Comparator接口里面的方法是 int compare(T o1,T o2); 在java.util包下
实现了Comparable接口的类表明自身是可比较的,有了比较才能进行排序;而Comparator接口(比较器)是一个工具类接口,它与原有类的逻辑没有关系,只是实现两个对象的比较逻辑,从这方面来说,一个类可以有很多的比较器,只要有业务需求就可以产生比较器,有比较器就可以产生N多种排序,而Comparable接口的排序只能说是实现类的默认排序算法,一个类稳定、成熟后其compareTo方法基本不会改变,也就是说一个类只能有一个固定的、由compareTo方法提供的默认排序算法。如果一个类既实现Comparable接口也实现Comparator接口,会优先按照Comparator的规则排序。
先定义一个员工类,实现Comparable接口,其内部的compareTo方法是针对员工的实际年龄排序:
public class Employee implements Comparable<Employee>{
private int age;
private int workAge;
public Employee(int age, int workAge) {
this.age = age;
this.workAge = workAge;
}
@Override
public int compareTo(Employee o) {
int res = 0;
if(this.age < o.age) {
res = -1;
}else {
res = 1;
}
return res;
}
//或者 return this.age - o.age;
}
测试如下:
public static void main(String[] args) {
Employee e1 = new Employee(21, 1);
Employee e2 = new Employee(27, 1);
Employee e3 = new Employee(28, 6);
Employee e4 = new Employee(24, 4);
Employee e5 = new Employee(26, 2);
Employee[] emps = {e1,e2,e3,e4,e5};
Arrays.sort(emps);
System.out.println(Arrays.toString(emps));
//输出按照年龄排序为:21、24、26、27、28
}
从输出可以看出这是升序排列后的结果,但是compareTo方法只不过是返回一个int类型的数值,这跟升序降序有什么关系?
我之前也听到过一句话:对于compareTo方法返回值:
- 正数:当前对象大于参数对象
- 0:相等
- 负数:当前对象小于参数对象
这个其实只是一种“规则”,或者说大家的约定,当然具体怎么实现还是要看自己的compareTo逻辑,只不过升序降序不是靠这三个数这么单纯的。而是看具体的排序方法怎么实现,或者说怎么利用compareTo方法,JDK中有很多排序方法,或者有序的数据结构都是可以传递Comparable接口的。我们就针对Arrays.sort方法看(对于升降序来说其他的也是一样的),Arrays的sort方法有一处调用了ComparableTimSort类的countRunAndMakeAscending方法(这里就不贴源码了,可以自己看),从该方法中可以看出:
方法内部将数组中的 后一个元素 与 前一个元素比较(此处指相对位置),有两种情况:
- 数组后一个元素.compareTo(数组前一个元素) 返回负数。则两者相对位置调换。
- 数组后一个元素.compareTo(数组前一个元素) 返回0或正数。则两者相对位置不变。
依照这种形式可以看出:后一个元素为方法的调用者(当前对象)。前一个元素为参数对象。那么:
- 如果要升序排列,则后一个元素肯定是大值,前一个元素肯定是小值。由于返回正数相对位置不会变,那么需要 后面的值 - 前面的值为正数(很明显后面的值大,升序排列)。
- 如果要降序排列,则后一个元素肯定是小值,前一个元素肯定是大值。由于返回正数相对位置不会变,那么需要 前面的值-后面的值为正数。(很明显前面的值大,降序排列)。
方法再贴一下就不用往前翻了:
public int compareTo(Employee o) {
return this.age - o.age;
}
由于我们的方法是这样调用的:当前对象.compareTo(参数对象) 后面元素为方法调用者,而且我们比较的是各自的age成员。所以,当前对象的age即为后面的值,参数对象的age即为前面的值。所以基于此处的写法(升序写法),this.age会出现如下两种情况(这也是为什么会是升序排列的原因):
- this.age > o.age : 返回正数,则相对位置不变,this.age(后面的值较大)放在数组的后面。
- this.age < o.age : 返回负数, 则相对位置调换, this.age(后面的值较小)放在数组的前面。
当然降序排列的话就是return o.age - this.age。
为了方便记忆,我这样总结了一下(对JDK可以用Comparable接口的所有地方,比如Arrays.sort或者优先队列这些,下面两句话具有普适性):我们可以将当前对象看成前一个对象,将参数对象看成后一个对象
- 前一个对象 > 后一个对象时:返回正数为升序;返回负数为降序。(前大于后,正升负降)
- 前一个对象 < 后一个对象时:返回正数为降序;返回负数为升序。(前小于后,正降负升)
此时该类内部已经实现了基于年龄来排序了,但是假如现在我们需要利用工龄来排序,再来修改这个类的源码肯定是不合适的,所以这时就可以利用Comparator接口了。可以这样做:
Comparator<Employee> workAgeComparator = new Comparator<Employee>() {
//这里o1为前一个对象,o2为后一个对象,那个口诀也是适用的。
@Override
public int compare(Employee o1, Employee o2) {
return o1.workAge - o2.workAge;
}
};
特别的:在重写了compareTo方法后,我们还需要注意与equals方法同步,特别是使用一些检索类的API时,比如集合类的indexOf方法(内部会遍历集合用equals方法做相等比较)和Collections的binarySearch方法(使用二分查找的方式用compareTo方法来判断目标值与参数值在集合中的位置是否一致,注意:使用二分查找时集合必须是排好序的)。
27.集合运算使用更优雅的方式
我们就通过一个例子来看一下求两个集合的交集、并集、差集(由所有属于A但不属于B的元素组成的集合,叫做A与B的差集)。假如现在有两个集合arrayList1{A,B,C,D}和arrayList2{A,A,B,E,E}
//求交集,两个集合的交集存储进arrayList1中
arrayList1.retainAll(arrayList2); //arrayList1中的元素为:A B
//求并集,此时arrayList1中就是两个集合的并集元素了,这里是包含重复元素的
arrayList1.addAll(arrayList2); //arrayList1中的元素为:A B C D A A B E E
//求差集,即从arrayList1中删除出现在arrayList2中的元素
arrayList1.removeAll(arrayList2); //arrayList1中的元素为:C D
28.使用shuffle随机打乱集合元素
这里说的是集合工具类Collections的shuffle方法,我们就其源码看一下它是如何打乱集合元素的。如下:
public static void shuffle(List<?> list, Random rnd) {
int size = list.size();
//为了性能而做的if-else判断
if (size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) {
//从后往前遍历集合,取得[0,i)的随机数,将当前元素与这个随机位置处的元素交换位置
for (int i=size; i>1; i--)
swap(list, i-1, rnd.nextInt(i));
} else {
//这里先将集合数据转成数组,然后打乱数组元素,然后利用迭代器将这个打乱的数组的数据更新到集合中的节点上
Object arr[] = list.toArray();
for (int i=size; i>1; i--)
swap(arr, i-1, rnd.nextInt(i));
ListIterator it = list.listIterator();
for (int i=0; i<arr.length; i++) {
it.next();
it.set(arr[i]);
}
}
}
29.集合大家族
集合容器家族非常庞大。大致可以分为几类:
- List:实现List接口的集合主要有:ArrayList(动态数组)、LinkedList(双向链表)、Vector(线程安全的动态数组)、Stack(对象栈)
- Set:Set是不包含重复元素的集合,其主要的实现类有:EnumSet、HashSet、TreeSet,其中EnumSet是枚举类型的专用Set,所有元素都是枚举类型;HashSet是以哈希码决定其元素位置的Set,其原理与HashMap相似,它提供快速的插入和查找方法;TreeSet是一个自动排序的Set,它实现了SortedSet接口。
- Map:Map可以分为排序Map和非排序Map,排序Map主要是TreeMap类,它根据Key值进行自动排序(红黑树);非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子类,它的主要用途是从Property文件中加载数据,并提供方便的读写操作;EnumMap则是要求其Key必须是某一个枚举类型。Map中还有一个WeakHashMap类需要说明,它是一个采用弱键方式实现的Map类,它的特点是:WeakHashMap对象的存在并不会阻止垃圾回收器对键值对的回收,也就是说使用WeakHashMap装载数据不用担心内存溢出的问题,GC会自动删除不用的键值对,这是好事。但也存在一个严重问题:GC是静悄悄回收的,我们的程序无法知晓该动作,存在着重大的隐患。
- Queue:
- 阻塞式队列,队列满了以后再插入元素则会抛出异常,主要包括:ArrayBlockingQueue、PriorityBlockingQueue、LinkedBlockingQueue等(这些都是线程安全的)
- 非阻塞队列,无边界的,只要内存允许,都可以持续追加元素,我们最经常使用的是PriorityQueue类。
- 双端队列,支持在头、尾两端插入和移除元素,它的主要实现类是:ArrayDeque、LinkedBlockingDeque、LinkedList。
- 工具类:java.util.Arrays和java.util.Collections
30.推荐使用枚举定义常量
定义常量的方式多种,比如:类常量,接口常量,枚举常量。单说枚举常量,枚举的优点主要表现在以下几个方面。
- 枚举常量更简单:枚举常量只需要定义每个枚举项,不需要定义枚举值,而接口常量(或类常量)则必须定义值,否则编译通不过,即使我们不需要关注其值是多少也必须定义。
- 枚举具有内置方法:比如通过枚举基类的values()方法获得所有的枚举项,每个枚举都是java.lang.Enum的子类,该基类提供了诸如获得排序值的ordinal方法、compareTo比较方法等,大大简化了常量的访问。
- 枚举可以自定义方法:这一点似乎并不是枚举的优点,类常量也可以有自己的方法呀,但关键是枚举常量不仅可以定义静态方法,还可以定义非静态方法,而且还能够从根本上杜绝常量类被实例化。枚举中定义的静态方法既可以在类中引用,也可以在实例(枚举项)中引用。
虽然枚举常量在很多方面比接口常量和类常量好用,但是有一点它是比不上接口常量和类常量的,那就是继承,枚举类型是不能有继承的,但是,一般常量在项目构建时就定义完毕了,很少会出现必须通过扩展才能实现业务逻辑的场景。
在定义枚举的时候有一个建议:定义枚举时,枚举项数量不要超过64,否则建议拆分。
推荐在枚举定义中为每个枚举项定义描述,特别是在大规模的项目开发中,大量的常量项定义使用枚举项描述比在接口常量或类常量中增加注释的方式友好得多,简洁得多。可以使用构造函数来协助完成,如下:
public enum Season {
Spring("春"), Summer("夏"), Autumn("秋"), Winter("冬");
private String desc;
//枚举类的构造方法使用private,如果可以构造枚举值,那枚举类的存在价值就没了
private Season(String desc){
this.desc = desc;
}
public String getDesc() {
return desc;
}
}
31.小心switch带来的空值异常
使用枚举定义常量时,会伴有大量的switch语句判断,目的是为每个枚举项解释其行为,例如这样一个方法:传入null会导致空指针异常,方法如下:
public static void doSports(Season season) {
switch (season) {
case Spring: System.out.println("春天放风筝"); break;
case Summer: System.out.println("夏天游泳"); break;
case Autumn: System.out.println("秋天捉知了"); break;
case Winter: System.out.println("冬天滑雪"); break;
default: System.out.println("输入错误"); break; //其实这里也可以抛出一个异常,视情况而定
}
}
至于为什么会出现空指针异常,这跟Java中switch后可以跟的类型有关系:目前Java中的switch只能判断byte,short,char,int,String(JDK1.7新特性)类型,这是Java编译器的限制。为什么枚举类型也可以跟在switch后面呢?这也是异常出现的原因所在。因为编译时,编译器判断出switch语句后的参数是枚举类型,然后就会获取枚举的排序值(执行枚举项的ordinal()方法,这个方法返回枚举值在枚举类中的声明顺序,从0开始)进而匹配,所以会出现空指针异常。
32.枚举和注解结合使用威力更大
我们知道注解的写法和接口很类似,都采用了关键字interface,而且都不能有实现代码,常量定义默认都是public static final类型的等,它们的主要不同点是:注解要在interface前加上@字符,而且不能继承,不能实现,这经常会给我们的开发带来一些障碍。我们来分析一个ACL(Access Control List,访问控制列表)设计案例,看看如何避免这些障碍,ACL有三个重要元素:
- 资源,有哪些信息是要被控制起来的。
- 权限级别,不同的访问者规划在不同的级别中。
- 控制器(也叫鉴权人),控制不同的级别访问不同的资源。
首先鉴权人接口如下:
public interface Identifier {
//无权访问时的礼貌语
String REFUSE_WORD = "您无权访问";
//鉴权
public boolean identify();
}
用枚举类实现该鉴权接口:
public enum CommonIdentifier implements Identifier {
//权限级别
Reader("读者"), Author("作者"), Admin("管理员");
private String desc;
private CommonIdentifier(String desc) {
this.desc = desc;
}
@Override
public boolean identify() {
return false;
}
}
枚举结合注解实现权限级别控制
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface Access {
//默认是管理员级别
CommonIdentifier level() default CommonIdentifier.Admin;
}
资源类:
@Access(level = CommonIdentifier.Author)
public class Foo {}
ACL的模拟实现:
public static void main(String[] args) {
Foo f = new Foo();
Access access = f.getClass().getAnnotation(Access.class);
//鉴权失败
if(access == null || !access.level().identify()) {
System.out.println(Identifier.REFUSE_WORD);
}
}
注意上面模拟实现类中access.level().REFUSE_WORD其中access是一个注解,注解是不能继承的,而上述例子中通过结合枚举类,从而利用注解对象调用了鉴权方法,能调用该方法当然就可以通过该方法获取更多的信息。
33.不同的场景使用不同的泛型通配符
Java泛型支持通配符,可以单独使用一个“?”表示任意类,也可以使用extends关键字表示某一个类(接口)的子类型,还可以使用super关键字表示某一个类(接口)的父类型,但问题是什么时候该用extends,什么时候该用super呢?
- 泛型结构只参与“读”操作则限定上界(extends关键字)
- 泛型结构只参与“写”操作则限定下界(使用super关键字)
比如下面的例子:
public static <E> void read(List<? extends E> list) {
for(E e : list) {
//业务逻辑处理,比如这里我可以将list中的元素加入到另外的List<E>集合中,或者作为Map<E,V>的键等
}
//比如ArrayList的一个构造方法 public ArrayList(Collection<? extends E> c)
//它就是读取集合c中的元素,然后加到Object[]中去
}
public static void write(List<? super Number> list) {
list.add(123);
list.add(3.14);
}
对于read方法,如果参数改成List<? super E> 那将无法操作,因为我们不知道list到底存放的是什么元素,只能推断出是E类型的父类(当然,也可以是E类型),但问题是E类型的父类是什么呢?无法再推断,只有运行时才知道,那么编码期就完全无法操作了。所以这里for(E e : list)编译都不会通过(因为没办法接收list中的变量,按什么类型接收?编译器不知道啊,这里写的是按照E类型接收,不合适。因为?这个类型是E类型的父类,那么编译器怎么能将?类型自动转换为E类型呢?(自动向下转型,你手动转都可能出现ClassCastException异常,你不能把一个父类实例对象赋值给一个子类类型变量)),所以拒绝执行此操作。当然,你可以把它当作是Object类来处理,需要时再转换成E类型—这完全违背了泛型的初衷。
对于write方法,不管它是Integer类型的123,还是Double类型的3.14,都可以加入到list列表中,因为它们都是Number类型,这就保证了泛型类的可靠性。如果此处是List<? extends Number>则不可以,因为这样只是确定了泛型的上界(Number),具体是什么类型就无法确定,是Integer类型?是Double?还是Byte?这些都符合extends关键字的定义,由于无法确定实际的泛型类型,所以编译器拒绝了此类操作。(编译器:我都不知道list中具体存什么类型你就往里面加,你都没告诉我到底是Integer类型吗你就胡加八加的)。
34.警惕泛型是不能协变和逆变的
- 协变:用一个窄类型替换宽类型
- 逆变:用宽类型覆盖窄类型
其实,在Java中协变和逆变我们已经用了很久了,就像下面一样:
Base base = new Sub(); //Sub类继承Base类,base变量发生协变,声明为Base,实际却是Sub类型。
Number[] n = new Integer[10]; //数组支持协变
泛型不支持协变,下面这行代码编译不通过。原因就是Java为了保证运行期的安全性,必须保证泛型参数类型是固定的,所以它不允许一个泛型参数可以同时包含两种类型,即使是父子类关系也不行。
List<Number> list = new ArrayList<Integer>();
泛型不支持协变,但可以使用通配符模拟协变,如下:
List<? extends Number> arrayList = new ArrayList<Integer>();
Java虽然可以允许逆变存在,但在对类型赋值上是不允许逆变的,你不能把一个父类实例对象赋值给一个子类类型变量,泛型自然也不允许此种情况发生了,但是它可以使用super关键字来模拟实现,如下:
/*
* 这里所有Integer父类型(自身、父类或接口)作为泛型参数,这里看着就像是把一个Number类型的ArrayList赋值给了Integer类型的List,
* 其外观类似于使用一个宽类型覆盖一个窄类型,它模拟了逆变的实现。
*/
List<? super Integer> list = new ArrayList<Number>();
泛型既不支持协变也不支持逆变,带有泛型参数的子类型定义与我们经常使用的类类型也不相同,其基本的类型关系如下图所示。
35.建议采用的顺序是List<T>、List<?>、List<Object>
- List<T>是确定的某一个类型。List<T>表示的是List集合中的元素都为T类型,具体类型在运行期决定;List<?>表示的是任意类型,与List<T>类似,而List<Object>则表示List集合中的所有元素为Object类型,因为Object是所有类的父类,所以List<Object>也可以容纳所有的类类型,从这一字面意义上分析,List<T>更符合习惯:编码者知道它是某一个类型,只是在运行期才确定而已。
- List<T>可以进行读写操作。List<T>可以进行诸如add、remove等操作,因为它的类型是固定的T类型,在编码期不需要进行任何的转型操作。List<?>是只读类型的,不能进行增加、修改操作,因为编译器不知道List中容纳的是什么类型的元素,也就无法校验类型是否安全了,而且List<?>读取出的元素都是Object类型的,需要主动转型,所以它经常用于泛型方法的返回值。注意,List<?>虽然无法增加、修改元素,但是却可以删除元素,比如执行remove、clear等方法,那是因为它的删除动作与泛型类型无关。List<Object>也可以读写操作,但是它执行写入操作时需要向上转型(Up cast),在读取数据后需要向下转型(Downcast),而此时已经失去了泛型存在的意义了。
推而广之,Dao<T>应该比Dao<?>、Dao<Object>更先采用,Desc<Person>则比Desc<?>、Desc<Object>更优先采用。
36.严格限定泛型类型应采用多重界限
在Java的泛型中,可以使用“&”符号关联多个上界并实现多个边界限定,而且只有上界才有此限定,下界没有多重限定的情况。具体写法就像下面的例子一样。
public class Me implements Staff, Passenger{
//指定了泛型类型T必须是Staff和Passenger的共有子类型,
//此时变量t就具有了这两个接口(类)中限定的方法和属性
public static <T extends Staff & Passenger> void discount(T t) {
//这两个方法分别定义在Saff和Passenger中
if(t.getSalary() < 2500 && t.isStanding()) {
System.out.println("……");
}
}
//省略getSalary()和isStanding()的实现
}
37.适时选择getDeclared×××和get×××
Java的Class类提供了很多的getDeclared×××方法和get×××方法,例如getDeclaredMethod和getMethod等成对出现,这两者的区别如下:
- getMethod方法获得的是所有public访问级别的方法,包括从父类继承的方法。
- getDeclaredMethod获得是自身类的所有方法,包括公用(public)方法、私有(private)方法等,而且不受限于访问权限。
其他的getDeclaredConstructors和getConstructors、getDeclaredFields和getFields等与此相似。
Java之所以如此处理,是因为反射本意只是正常代码逻辑的一种补充,而不是让正常代码逻辑产生翻天覆地的变动,所以public的属性和方法最容易获取,私有属性和方法也可以获取,但要限定本类。如果需要列出所有继承自父类的方法,该如何实现呢?简单,先获得父类,然后使用getDeclaredMethods,之后持续递归即可。
38.反射访问属性或方法时将Accessible设置为true
我们知道,动态修改一个类或方法或执行方法时都会受Java安全体系的制约,而安全的处理是非常消耗资源的(性能非常低),因此对于运行期要执行的方法或要修改的属性就提供了Accessible可选项:由开发者决定是否要逃避安全体系的检查(这个值默认是false的)。Accessible属性只是用来判断是否需要进行安全检查的,如果不需要则直接执行,这就可以大幅度地提升系统性能(当然了,由于逃避了安全检查,也可以运行private方法、访问private私有属性了)。经过测试,在大量的反射情况下,设置Accessible为true可以提升性能20倍以上。我们在设置Field或执行Constructor时,务必要设置Accessible为true,这并不仅仅是因为操作习惯的问题,还是在为我们系统的性能考虑。
39.使用反射增加装饰模式的普适性
我们还是依靠一个例子来看。这个例子是将普通小动物包装一下,获得某些能力。
动物接口(被包装的动物接口):
public interface IAnimal {
public void doStuff();
}
能力接口(装饰器接口):
public interface IFeature {
//加载能力
public void load();
}
包装动作类:
public class DecorateAnimal implements IAnimal {
private IAnimal animal; //被包装的动物
private Class<? extends IFeature> klass; //使用哪个装饰器
public DecorateAnimal(IAnimal animal, Class<? extends IFeature> klass) {
this.animal = animal;
this.klass = klass;
}
@Override
public void doStuff() {
//装饰器类的代理。此处实质上是修改load()方法
InvocationHandler handler = new InvocationHandler() {
//具体的包装行为
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object obj = null;
//判断load方法的访问权限
if(Modifier.isPublic(method.getModifiers())) {
//反射执行load()方法
obj = method.invoke(klass.newInstance(), args);
}
//调用被包装类的doStuff方法
animal.doStuff();
return obj;
}
};
ClassLoader classLoader = getClass().getClassLoader();
//动态代理,装饰器类的代理对象
IFeature proxy = (IFeature)Proxy.newProxyInstance(classLoader, klass.getInterfaces(), handler);
//执行其代理对象的load()方法,也就是上面的invoke方法
proxy.load();
}
}
到此,核心东西已经构建完毕,现在定义两种能力:
public class FlyFeature implements IFeature {
@Override
public void load() {
System.out.println("增加飞行能力");
}
}
public class DigFeature implements IFeature {
@Override
public void load() {
System.out.println("增加钻地能力");
}
}
我们来写一个小示例:创建一个“猫”的类并装饰上两种飞行和钻地能力。
public static void main(String[] args) {
IAnimal cat = new IAnimal() {
@Override
public void doStuff() {
System.out.println("我天生就会捉老鼠");
}
};
cat = new DecorateAnimal(cat, FlyFeature.class);
cat = new DecorateAnimal(cat, DigFeature.class);
cat.doStuff();
}
就cat本身而言,本来只有捉老鼠的能力,但是我们用包装动作类在捉老鼠能力之前为其添加了飞行能力,又在飞行和捉老鼠两种能力之前为其添加了钻地能力。而这两种能力其实都是用相同的一段代码“动态代理”为其添加的,如果现在又需要第三种能力或者以后又需要第四种能力,那么我们只需要实现IFeature接口来实现load方法定义好能力就行,然后直接使用包装动作类(根本不需要改动),而且装饰类和被装饰类都是互相独立的,二者通过包装动作类组合到一起,实现了对装饰类和被装饰类的完全解耦,提高了系统的扩展性。
40.提倡封装异常
Java中的异常一次只能抛出一个,如果在第一个逻辑片段中抛出异常,则第二个逻辑片段就不再执行了,也就无法抛出第二个异常了,现在的问题是:如何才能一次抛出两个(或多个)异常呢?你可能会问,这种情况可能出现吗?怎么会要求一个方法抛出多个异常呢?绝对可能出现,例如Web界面注册时,展现层依次把User对象传递到逻辑层,Register方法需要对各个Field进行校验并注册,例如用户名不能重复,密码必须符合密码策略等,不要出现用户第一次提交时系统提示“用户名重复”,在用户修改用户名再次提交后,系统又提示“密码长度少于6位”的情况,这种操作模式下的用户体验非常糟糕,最好的解决办法就是封装异常,建立异常容器,一次性地对User对象进行校验,然后返回所有的异常。下面是一段示例代码,MyException类中建立集合用于存储各种异常,在业务中使用时,将所有异常存进MyException对象中,最后抛出异常对象就行,然后可以利用该异常对象获取所有异常,一次性提示。
public class MyException extends Exception{
private static final long serialVersionUID = -8823063849980176780L;
private List<Throwable> cacuses = new ArrayList<Throwable>();
public MyException(List<? extends Throwable> cacuses) {
this.cacuses.addAll(cacuses);
}
public List<Throwable> getExceptions(){
return this.cacuses;
}
public static void doStuff() throws MyException{
List<Throwable> list = new ArrayList<Throwable>();
try { //第一个逻辑片段
}catch(Exception e) {
list.add(e);
}
try { //第一个逻辑片段
}catch(Exception e) {
list.add(e);
}
if(list.size() > 0) {
throw new MyException(list);
}
}
}
异常仅仅封装还是不够的,还需要传递异常。
比如,我们的J2EE项目一般都有三层结构:持久层、逻辑层、展现层,持久层负责与数据库交互,逻辑层负责业务逻辑的实现,展现层负责UI数据的处理。
有这样一个模块:用户第一次访问的时候,需要持久层从user.xml中读取信息,如果该文件不存在则提示用户创建之。那问题来了:如果我们直接把持久层的异常FileNotFoundException抛弃掉,逻辑层根本无从得知发生了何事,也就不能为展现层提供一个友好的处理结果了,最终倒霉的就是展现层:没有办法提供异常信息,只能告诉用户说“出错了,我也不知道出什么错了”—毫无友好性可言。正确的做法是先封装,然后传递,过程如下:
- 把FileNotFoundException封装为MyException。
- 抛出到逻辑层,逻辑层根据异常代码(或者自定义的异常类型)确定后续处理逻辑,然后抛出到展现层。
- 展现层自行决定要展现什么,如果是管理员则可以展现低层级的异常,如果是普通用户则展示封装后的异常。
41.受检异常尽可能转化为非受检异常
受检异常不能全都转化为非受检异常,它有自己存在的合理性,但受检异常确实有不足的地方:
受检异常使接口声明脆弱。以下面的User接口为例:
public interface User {
//修改用户名密码,抛出安全异常
public void changePassword() throws MySecurityException;
}
随着系统的开发,User接口有了多个实现者,比如普通的用户UserImpl、模拟用户MockUserImpl、非实体用户NonUserImpl(如自动执行机、逻辑处理器等)等,此时如果发现changePassword方法可能还需要抛出RejectChangeException(拒绝修改异常,如自动执行机正在处理任务时不能修改其密码),那就需要修改User接口了:changePassword方法增加抛出RejectChangeException异常,这会导致所有的User调用者都要追加对RejectChangeException异常问题的处理。
为了改善上述情况,我们可以将受检异常转化为非受检异常,我们在声明接口时不再声明异常,而是在具体实现时根据不同的情况产生不同的非受检异常,这样持久层和逻辑层抛出的异常将会由展现层自行决定如何展示,不再受异常的规则约束了,大大简化开发工作,提高了代码的可读性。在开发中当受检异常威胁到了系统的安全性、稳定性、可靠性、正确性时,则必须处理,不能转化为非受检异常,其他情况则可以转换为非受检异常。
42.构造函数中尽量不要抛出异常
对于构造函数,错误只能抛出,这是程序人员无能为力的事情;非受检异常不要抛出,抛出了“对己对人”都是有害的;受检异常尽量不抛出,能用曲线的方式实现就用曲线方式实现,总之一句话:在构造函数中尽可能不出现异常。
43.提升Java性能的基本方法
提取书中几种提升Java性能的基本方法:
- 不要在循环条件中计算
- 缩小变量的作用范围
- 频繁字符串操作使用StringBuilder或StringBuffer
- 使用非线性检索:如果在ArrayList中存储了大量的数据,使用indexOf查找元素会比java.utils. Collections. binarySearch的效率低很多,原因是binarySearch是二分搜索法,而indexOf使用的是逐个元素比对的方法。这里要注意:使用binarySearch搜索时,元素必须进行排序,否则准确性就不可靠了。
- 覆写Exception的fillInStackTrace方法:fillInStackTrace方法是用来记录异常时的栈信息的,这是非常耗时的动作,如果我们在开发时不需要关注栈信息,则可以覆盖之。
- 不建立冗余对象
- 若非必要,不要克隆对象:通过clone方法生成一个对象时,就会不再执行构造函数了,只是在内存中进行数据块的拷贝,此方法看上去似乎应该比new方法的性能好很多,但是Java的缔造者们也认识到“二八原则”,80%(甚至更多)的对象是通过new关键字创建出来的,所以对new在生成对象(分配内存、初始化)时做了充分的性能优化,事实上,一般情况下new生成的对象比clone生成的性能方面要好很多。
- 调整JVM参数以提升性能:我们写的每一段Java程序都要在JVM中运行,如果程序已经优化到了极致,但还是觉得性能比较低,那JVM的优化就要提到日程上来了。不过,由于JVM又是系统运行的容器,所以稳定性也是必须考虑的,过度的优化可能就会导致系统故障频繁发生,致使系统质量大幅下降。下面提供了两个常用的JVM优化手段,供你在需要时参考。
- 调整堆内存大小:我们知道,在JVM中有两种内存:栈内存(Stack)和堆内存(Heap),栈内存的特点是空间比较小,速度快,用来存放对象的引用及程序中的基本类型;而堆内存的特点是空间比较大,速度慢,一般对象都会在这里生成、使用和消亡。栈空间是由线程开辟,线程结束,栈空间由JVM回收,因此它的大小一般不会对性能有太大的影响,但是它会影响系统的稳定性,在超过栈内存的容量时,系统会报StackOverflowError错误。可以通过“java-Xss <size>”设置栈内存大小来解决此类问题。堆内存的调整不能太随意,调整得太小,会导致Full GC频繁执行,轻则导致系统性能急速下降,重则导致系统根本无法使用;调整得太大,一则是浪费资源(当然,若设置了最小堆内存则可以避免此问题),二则是产生系统不稳定的情况,例如:在32位的机器上设置超过1.8GB的内存就有可能产生莫名其妙的错误。设置初始化堆内存为1GB(也就是最小堆内存),最大堆内存为1.5GB可以用如下的参数:java -Xmx1536m -Xms1024m
- 调整堆内存中各分区的比例:我们都知道堆内存分为年轻代、老年代、元空间。不同的内存区域满了会触发不同的GC,而且尤其是老年代Full GC极其影响性能,如有需要可以调节各分区比例,一般情况下,新生区和养老区的比例为1:3左右。具体设置命令示例:java -XX:NewSize=32m -XX:MaxNewSize=640m -XX:MaxPermSize=1280m -XX:NewRatio=5 该配置指定新生代初始化为32MB(也就是新生区最小内存为32M),最大不超过640MB,养老区最大不超过1280MB,新生区和养老区的比例为1:5。
我们知道运行一段程序需要三种资源:CPU、内存、I/O,提升CPU的处理速度可以加快代码的执行速度,直接表现就是返回时间缩短了效率提高了;内存是Java程序必须考虑的问题,在32位的机器上,一个JVM最多只能使用2GB的内存,而且程序占用的内存越大,寻址效率也就越低,这也是影响效率的一个因素。I/O是程序展示和存储数据的主要通道,如果它很缓慢就会影响正常的显示效果。所以我们在编码时需要从这三个方面入手接口(当然了,任何程序优化都是从这三方面入手的)。
44.让注释正确、清晰、简洁
我们先来看一些不好的注释习惯:
- 废话式注释:比如这样的注释(//该算法不如某某算法优秀,可以优化,时间太紧,以后再说)
- 故事式注释:比如写一个汉诺塔算法,没必要从汉诺塔的故事(包括最原始的版本和多个变形版本)到算法分析,最后到算法比较和实际应用,全部写出来只要写一个汉诺塔算法就行。
- 不必要的注释:有些注释相对于代码来说完全没有必要,算不上是废话,只能说是多余的注释。比如:
//自增
num++;
- 过时的注释:代码会一致升级,我们应该保持注释与代码同步。
- 大块注释代码:可能是为了考虑代码的再次利用,有些大块注释掉的代码仍然保留在生产代码中,这不是一个好的习惯,大块注释代码不仅仅影响代码的阅读性,而且也隔断了代码的连贯性,特别是在代码中的间隔性注释,更增加了阅读的难度,会使Bug隐藏得更深。此类注释代码完全可以使用版本管理来实现,而不是在生产代码中出现。这里要注意的是,如果代码临时不用(可能在下一版本中使用,或者在生产版本固化前可能会被使用),可以通过注释来解决,如果是废弃(在生产版本上肯定不用该代码),则应该完全删除掉。
- 流水账式的注释:比如下面注释。
/*
* 2010-09-16 张三 创建该类,实现XX算法
* 2010-10-28 李四 修正算法中的XXXX缺陷
* 2010-11-30 李四 重构了XXX方法
* 2011-02-06 王五 删除了XXXX无用方法
* 2011-04-08 马六 优化了XXX的性能
*/
好的注释首先要求正确,注释与代码意图吻合;其次要求清晰,格式统一,文字准确;最后要求简洁,说明该说明的,惜字如金,拒绝不必要的注释,如下类型的注释就是好的注释:
- 法律版权信息:这是我们在阅读源代码时经常看到的,一般都是指向同一个法律版权声明的。
- 解释意图的注释:说明为什么要这样做,而不是怎么做的,比如解决了哪个Bug,方法过时的原因是什么。
- 警示性注释:这类注释是我们经常缺乏的,或者是经常忽视的(即使有了,也常常是与代码版本不匹配),比如可以在注释中提示此代码的缺陷,或者它在不同操作系统上的表现,或者警告后续人员不要随意修改。
- TODO注释:对于一些未完成的任务,则增加上TODO提示,并标明是什么事情没有做完,以方便下次看到这个TODO标记时还能记忆起要做什么事情。
45.让接口的职责保持单一
什么是职责?职责是一个接口(或类)要承担的业务含义,或是接口(或类)表现出的意图,例如一个User类可以包含写入用户信息到数据库、删除用户、修改用户密码等职责,而一个密码工具类则可以包含解密职责和加密职责。单一职责有以下三个优点:
- 类的复杂性降低:职责单一,在实现什么职责时都有清晰明确的定义,那么接口(或类)的代码量就会减少,复杂度也就会减少。当然,接口(或类)的数量会增加上去,相互间的关系也会更复杂,这就需要适当把握了。
- 可读性和可维护性提高:职责单一,会让类中的代码量减少,我们可以一眼看穿该类的实现方式,有助于提供代码的可读性,这也间接提升了代码的可维护性。
- 降低变更风险:变更是必不可少的,如果接口(或类)的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,那就会对系统的扩展性、维护性都有非常大的帮助。
职责单一在设计和编码中如何应用呢?下面以电话通信为例子来说明如何实施单一职责原则:
- 分析职责:一次电话通信包含四个过程:拨号、通话、回应、挂机,我们来思考一下该如何划分职责,这四个过程包含了两个职责:一个是拨号和挂机表示的是通信协议的链接和关闭,另外一个是通话和回应所表示的数据交互。问题是我们依靠什么来划分职责呢?依靠变化因素,我们可以这样考虑该问题:通信协议的变化会引起数据交换的变化吗?会的!(所以,我觉得这两个定义两个接口是最好的,好扩展,好维护,更加灵活)你能在3G网络视频聊天,但你很难在2G网络上实现。数据交互的变化会引起通信协议的变化吗?会的!传输2KB的文件和20GB的文件需要的不可能是同一种网络,用56KB的“小猫”传输一个20GB的高清影视那是不可行的。
- 设计接口:职责分析确定了两个职责,首先不要考虑实现类是如何设计的,我们首先应该通过两个接口来实现这两个职责。接口的定义如下:
//通信协议
interface Connection {
// 拨通电话
public void dial();
// 通话完毕,挂电话
public void hangup();
}
//数据传输
interface Transfer {
// 通话
public void chat();
}
- 合并实现:接口定义了两个职责,难道实现类就一定要分别实现这两个接口吗?这样做确实完全满足了单一职责原则的要求:每个接口和类职责分明,结构清晰,但是我相信读者在设计的时候肯定不会采用这种方式,因为一个电话类要把ConnectionManager和DataTransfer的实现类组合起来才能使用。这增加了类的复杂性,多出了两个类。通常的做法是一个实现类实现多个职责,也就是实现多个接口。(代码就不贴了)
注意:接口职责一定要单一,实现类职责尽量单一。
噢,他明白了,河水既没有牛伯伯说的那么浅,也没有小松鼠说的那么深,只有自己亲自试过才知道。——寓言故事《小马过河》