编写高质量代码:改善Java程序的151个建议(第3章:类、对象及方法___建议41~46)
建议41:让多重继承成为现实
在Java中一个类可以多重实现,但不能多重继承,也就是说一个类能够同时实现多个接口,但不 能同时继承多个类。但有时候我们确实需要继承多个类,比如希望拥有多个类的行为功能,就很难使用单继承来解决问题了(当然,使用多继承是可以解决的)。幸 运的是Java中提供的内部类可以曲折的解决此问题,我们来看一个案例,定义一个父亲、母亲接口,描述父亲强壮、母亲温柔的理想情形,代码如下:
public interface Father {
public int strong();
}
interface Mother {
public int kind();
}
其中strong和kind的返回值表示强壮和温柔的指数,指数越高强壮和温柔也就越高,这与游戏中设置人物的属性是一样的,我们继续开看父亲、母亲这两个实现:
class FatherImpl implements Father {
// 父亲的强壮指数为8
@Override
public int strong() {
return 8;
}
}
class MotherImpl implements Mother {
// 母亲的温柔指数为8
@Override
public int kind() {
return 8;
}
}
父亲的强壮指数为8,母亲的温柔指数也为8,门当户对,那他们生的儿子和女儿一定更优秀了,我们看看儿子类,代码如下:
class Son extends FatherImpl implements Mother {
@Override
public int strong() {
// 儿子比父亲强壮
return super.strong() + 1;
}
@Override
public int kind() {
return new MotherSpecial().kind();
}
private class MotherSpecial extends MotherImpl {
@Override
public int kind() {
// 儿子的温柔指数降低了
return super.kind() - 1;
}
}
}
儿子继承自父亲,变得比父亲更强壮了(覆写父类strong方法),同时儿子也具有母亲的优点,只是 温柔指数降低了。注意看,这里构造了MotherSpecial类继承母亲类,也就是获得了母亲类的行为和方法,这也是内部类的一个重要特性:内部类可以 继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,多重继承才会成为可能。MotherSpecial的这种内部类叫做成员内部类(也叫 作实例内部类,Instance Inner Class),我们再来看看女儿类,代码如下:
class Daughter extends MotherImpl implements Father {
@Override
public int strong() {
return new FatherImpl() {
@Override
public int strong() {
//女儿的强壮指数降低了
return super.strong() - 2;
}
}.strong();
}
}
女儿继承了目前的温柔指数,同时又覆写了父亲的强壮指数,不多解释。注意看覆写的strong方法,这里是创建了一个匿名内部类(Anonymous Inner Class)来覆写父类的方法,以完成继承父亲行为的功能。
多重继承指的是一个类可以同时从多与一个的父亲那里继承行为与特征,按照这个定义,我们的儿子类、女儿类都实现了从父亲和母亲那里继承所有的功能,应该属于多重继承,这完全归功于内部类,大家在需要用到多重继承的时候,可以思考一下内部类。
在现实生活中,也确实存在多重继承的问题,上面的例子说后人继承了父亲也继承了母亲的行为和特征,再比如我国的特产动物"四不像"(学名麋鹿),其外 形" 似鹿非鹿,似马非马,似牛非牛,似驴非驴 ",这你想要用单继承实现就比较麻烦了,如果用多继承则可以很好地解决此问题:定义鹿、马、牛、驴 四个类,然后建立麋鹿类的多个内部类,继承他们即可。
建议42:让工具类不可实例化
Java项目中使用的工具类非常多,比如JDK自己的工具类 java.lang.Math、java.util.Collections等都是我们经常用到的。工具类的方法和属性都是静态的,不需要生成实例即可访 问,而且JDK也做了很好的处理,由于不希望被初始化,于是就设置了构造函数private的访问权限,表示出了类本身之外,谁都不能产生一个实例,我们 来看一下java.lang.Math代码:
public final class Math {
/**
* Don't let anyone instantiate this class.
*/
private Math() {}
}
之所以要将"Don't let anyone instantiate this class." 留下来,是因为Math的构造函数设置为了private:我就是一个工具类,我只想要其它类通过类名来访问,我不想你通过实例对象来访问。这在平台型或 框架项目中已经足够了。但是如果已经告诉你不要这么做了,你还要生成一个Math对象实例来访问静态方法和属性(Java的反射是如此的发达,修改个构造 函数的访问权限易如反掌),那我就不保证正确性了,隐藏问题随时都有可能爆发!那我们在项目中有没有更好地限制办法呢?有,即不仅仅设置成private 权限,还抛出异常,代码如下:
class UtilsClazz{
public UtilsClazz(){
throw new Error("Don't instantiate "+getClass());
}
}
如此,才能保证一个工具类不会实例化,并且保证了所有的访问都是通过类名来进行的。需要注意的一点是,此工具类最好不要做集成的打算,因为如果子类可以实例化的话,就要调用父类的构造函数,可是父类没有可以被访问的构造函数,于是问题就会出现。
注意:如果一个类不允许实例化,就要保证"平常" 渠道都不能实例它。
建议43:避免对象的浅拷贝
我们知道一个类实现了Cloneable接口就表示它具备了被拷贝的能力。如果在覆写clone()方法就会完全具备拷贝能力。拷贝是在内存中运行的, 所以在性能方面比直接通过new生成的对象要快很多,特别是在大对象的生成上,这会使得性能的提升非常显著。但是对象拷贝也有一个比较容易忽略的问题:浅 拷贝(Shadow Clone,也叫作影子拷贝)存在对象属性拷贝不彻底的问题。我们来看这样一段代码:
1 public class Person implements Cloneable {
2 public static void main(String[] args) {
3 // 定义父亲
4 Person f = new Person("父亲");
5 // 定义大儿子
6 Person s1 = new Person("大儿子", f);
7 // 小儿子的信息时通过大儿子拷贝过来的
8 Person s2 = s1.clone();
9 s2.setName("小儿子");
10 System.out.println(s1.getName() + " 的父亲是 " + s1.getFather().getName());
11 System.out.println(s2.getName() + " 的父亲是 " + s2.getFather().getName());
12 }
13 // 姓名
14 private String name;
15 // 父亲
16 private Person father;
17
18 public Person(String _name) {
19 name = _name;
20 }
21
22 public Person(String _name, Person _parent) {
23 name = _name;
24 father = _parent;
25 }
26
27 @Override
28 public Person clone() {
29 Person p = null;
30 try {
31 p = (Person) super.clone();
32 } catch (CloneNotSupportedException e) {
33 e.printStackTrace();
34 }
35 return p;
36
37 }
38
39 /*setter和getter方法略*/
40 }
程序中我们描述了这样一个场景:一个父亲,有两个儿子,大小儿子同根同种,所以小儿子的对象就通过拷贝大儿子的对象来生成,运行输出结果如下:
大儿子 的父亲是 父亲
小儿子 的父亲是 父亲
这很正确,没有问题。突然有一天,父亲心血来潮想让大儿子去认个干爹,也就是大儿子的父亲名称需要重新设置一下,代码如下:
public static void main(String[] args) {
// 定义父亲
Person f = new Person("父亲");
// 定义大儿子
Person s1 = new Person("大儿子", f);
// 小儿子的信息时通过大儿子拷贝过来的
Person s2 = s1.clone();
s2.setName("小儿子");
//认干爹
s1.getFather().setName("干爹");
System.out.println(s1.getName() + " 的父亲是 " + s1.getFather().getName());
System.out.println(s2.getName() + " 的父亲是 " + s2.getFather().getName());
}
大儿子重新设置了父亲名称,我们期望的输出结果是:将大儿子的父亲名称修改为干爹,小儿子的父亲名称保持不变。运行一下结果如下:
大儿子 的父亲是 干爹
小儿子 的父亲是 干爹
怎 么回事,小儿子的父亲也成了"干爹"?两个儿子都木有了,这老子估计被要被气死了!出现这个问题的原因就在于clone方法,我们知道所有类都继承自 Object,Object提供了一个对象拷贝的默认方法,即页面代码中 的super.clone()方法,但是该方法是有缺陷的,他提供的是一种浅拷贝,也就是说它并不会把对象的所有属性全部拷贝一份,而是有选择的拷贝,它 的拷贝规则如下:
- 基本类型:如果变量是基本类型,则拷贝其值。比如int、float等
- 对 象:如果变量是一个实例对象,则拷贝其地址引用,也就是说此时拷贝出的对象与原有对象共享该实例变量,不受访问权限的控制,这在Java中是很疯狂的,因 为它突破了访问权限的定义:一个private修饰的变量,竟然可以被两个不同的实例对象访问,这让java的访问权限体系情何以堪。
- String字符串:这个比较特殊,拷贝的也是一个地址,是个引用,但是在修改时,它会从字符串池(String pool)中重新生成新的字符串,原有的字符串对象保持不变,在此处我们可以认为String是一个基本类型。
明白了这三个原则,上面的例子就很清晰了。小儿子的对象是通过大儿子拷贝而来的,其父亲是同一个人,也就是同一个对象,大儿子修改了父亲的名称后,小儿子也很跟着修改了——于是,父亲的两个儿子都没了。其实要更正也很简单,clone方法的代码如下:
public Person clone() {
Person p = null;
try {
p = (Person) super.clone();
p.setFather(new Person(p.getFather().getName()));
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return p;
}
然后再运行,小儿子的父亲就不会是干爹了,如此就实现了对象的深拷贝(Deep Clone),保证拷贝出来的对象自成一体,不受"母体"影响,和new生成的对象没有什么区别。
注意:浅拷贝只是Java提供的一种简单拷贝机制,不便于直接使用。
建议44:推荐使用序列化对象的拷贝
上一建议说了对象的浅拷贝问题,试下Cloneable接口就具备了拷贝能力,那我们开思考这样一个问题:如果一个项目中有大量的对象是通过拷贝生成 的,那我们该如何处理呢?每个类都系而一个clone方法,并且还有深拷贝?想想这是何等巨大的工作量呀!是否有更好的方法呢?
其实,可以通过序列化方式来处理,在内存中通过字节流的拷贝来实现,也就是把母对象写到一个字节流中,再从字节流中将其读出来,这样就可以重建一个对象了,该新对象与母对象之间不存在引用共享的问题,也就相当于深拷贝了一个对象,代码如下:
1 import java.io.ByteArrayInputStream;
2 import java.io.ByteArrayOutputStream;
3 import java.io.IOException;
4 import java.io.ObjectInputStream;
5 import java.io.ObjectOutputStream;
6 import java.io.Serializable;
7
8 public final class CloneUtils {
9 private CloneUtils() {
10 throw new Error(CloneUtils.class + " cannot instance ");
11 }
12
13 // 拷贝一个对象
14 public static <T extends Serializable> T clone(T obj) {
15 // 拷贝产生的对象
16 T cloneObj = null;
17 try {
18 // 读取对象字节数据
19 ByteArrayOutputStream baos = new ByteArrayOutputStream();
20 ObjectOutputStream oos = new ObjectOutputStream(baos);
21 oos.writeObject(cloneObj);
22 oos.close();
23 // 分配内存空间,写入原始对象,生成新对象
24 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
25 ObjectInputStream ois = new ObjectInputStream(bais);
26 // 返回新对象, 并做类型转换
27 cloneObj = (T) ois.readObject();
28 ois.close();
29 } catch (ClassNotFoundException e) {
30 e.printStackTrace();
31 } catch (IOException e) {
32 e.printStackTrace();
33 }
34 return cloneObj;
35
36 }
37
38 }
此工具类要求被拷贝的对象实现了Serializable 接口,否则是没办法拷贝的(当然,使用反射是另一种技巧),上一建议中的例子只是稍微修改一下即可实现深拷贝,代码如下
public class Person implements Serializable{
private static final long serialVersionUID = 4989174799049521302L;
/*删除掉clone方法,其它代码保持不变*/
}
被拷贝的类只要实现Serializable这个标志性接口即可,不需要任何实现,当然serialVersionUID常量还是要加上去的,然后我们就可以通过CloneUtils工具进行对象的深拷贝了,用词方法进行对象拷贝时需要注意两点:
- 对象的内部属性都是可序列化的:如果有内部属性不可序列化,则会抛出序列化异常,这会让调试者很纳闷,生成一个对象怎么回出现序列化异常呢?从这一点考虑,也需要把CloneUtils工具的异常进行细化处理。
- 注 意方法和属性的特殊修饰符:比如final,static变量的序列化问题会被引入对象的拷贝中,这点需要特别注意,同时 transient变量(瞬态变量,不进行序列化的变量)也会影响到拷贝的效果。当然,采用序列化拷贝时还有一个更简单的方法,即使用Apache下的 commons工具包中SerializationUtils类,直接使用更加简洁.
建议45:覆写equals方法时不要识别不出自己
我们在写一个JavaBean时,经常会覆写equals方 法,其目的是根据业务规则判断两个对象是否相等,比如我们写一个Person类,然后根据姓名判断两个实例对象是否相同时,这在DAO(Data Access Objects)层是经常用到的。具体操作时先从数据库中获得两个DTO(Data Transfer Object,数据传输对象),然后判断他们是否相等的,代码如下:
public class Person {
private String name;
public Person(String _name) {
name = _name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public boolean equals(Object obj) {
if(obj instanceof Person){
Person p = (Person) obj;
return name.equalsIgnoreCase(p.getName().trim());
}
return false;
}
}
覆写的equals方法做了多个校验,考虑到Web上传递过来的对象有可能输入了前后空格,所以用trim方法剪切了一下,看看代码有没有问题,我们写一个main:
public static void main(String[] args) {
Person p1= new Person("张三");
Person p2= new Person("张三 ");
List<Person> list= new ArrayList<Person>();
list.add(p1);
list.add(p2);
System.out.println("列表中是否包含张三:"+list.contains(p1));
System.out.println("列表中是否包含张三:"+list.contains(p2));
}
上面的代码产生了两个Person对象(注意p2变量中的那个张三后面有一个空格),然后放到list中,最后判断list是否包含了这两个对象。看上去没有问题,应该打印出两个true才对,但是结果却是:
列表中是否包含张三:true
列表中是否包含张三:false
刚刚放到list中的对象竟然说没有,这太让人失望了,原因何在呢?list类检查是否包含元素时时通过调用对象的equals方法来判断的,也就是说 contains(p2)传递进去,会依次执行p2.equals(p1),p2.equals(p2),只有一个返回true,结果都是true,可惜 的是比较结果都是false,那问题出来了:难道
p2.equals(p2)因为false不成?
还真说对了,p2.equals(p2)确实是false,看看我们的equals方法,它把第二个参数进行了剪切!也就是说比较的如下等式:
"张三 ".equalsIgnoreCase("张三");
注意前面的那个张三,是有空格的,那结果肯定是false了,错误也就此产生了,这是一个想做好事却办成了 "坏事" 的典型案例,它违背了equlas方法的自反性原则:对于任何非空引用x,x.equals(x)应该返回true,问题直到了,解决非常简单,只要把trim()去掉即可。注意解决的只是当前问题,该equals方法还存在其它问题。
建议46:equals应该考虑null值情景
继续45建议的问题,我们解决了覆写equals的自反性问题,是不是就完美了呢?在把main方法重构一下:
public static void main(String[] args) {
Person p1= new Person("张三");
Person p2= new Person(null);
/*其它部分没有任何修改,不再赘述*/
}
很小的改动,大家肯定晓得了运行结果是包"空指针"异常。原因也很简单:null.equalsIgnoreCase方法自然报错,此处就是为了说明覆写equals方法遵循的一个原则---
对称性原则:对于任何引用x和y的情形,如果x.equals(y),把么y.equals(x)也应该返回true。
解决也很简单,前面加上非空判断即可,很简单,就不贴代码了。