《Java从入门到失业》第五章:继承与多态(5.8-5.10):多态与Object类
5.8多态
上面我们了解了向上转型,即一个对象变量可以引用本类及子类的对象实例,这种现象称为多态(polymorphism)。多态究竟有什么用呢?我们先学习一个知识点。
5.8.1方法重写
前面我们学习类与对象的时候,学习过方法重载(overload),方法重载指的是在同一个类,存在多个方法名相同,但是方法签名不同的现象。
方法重写是啥呢?我们先看一个问题。Gun类有单发的方法:
public void shoot() { System.out.println("单发"); }
对于狙击枪AWM来说,射击的子弹是7.62mm子弹,不想只输出“单发”两个字,而是想输出“发射7.62mm子弹”。那么怎么办呢?可以在AWM类中重新写定义一个一模一样的方法,然后重写方法体:
public void shoot() { System.out.println("发射7.62mm子弹"); }
这种在子类中重新定义一个和超类相同方法签名的操作,称为方法重写(override)。这里需要注意一个问题,就是构造方法是不能被重写的,因为构造方法不能被继承。另外子类的方法不能低于超类方法的可见性。
方法重写要求方法签名和返回值都完全一样,有时候,我们在进行方法重写的时候,经常会出现方法名相同、参数列表也相同,但是返回值不同的现象,这时候其实就不算方法重写。不过在Java5.0之后,返回值只要是原返回类型的子类也算方法重写。其实有个好办法帮我们检查方法重写时候正确,就是在子类的覆盖方法上加上一个注解:@Override,如果重写错误,编译器会报错。关于注解后面会详细讨论。加上注解的方法如下:
@Override public void shoot() { System.out.println("发射7.62mm子弹"); }
当然,这个注解并不是必须的。不过笔者建议在重写的时候最好是加上。
另外,如果超类中的方法是静态方法,子类想要重写该方法,也必须定义为静态的,如果定义为成员方法,则会编译报错。反之亦然。我们可以用一个表总结一下子类中定义了一个和超类方法签名一样并且返回值也一样的情况:
|
超类成员方法 |
超类静态方法 |
子类成员方法 |
重写 |
编译报错 |
子类静态方法 |
编译报错 |
重写 |
现在,我们来总结一下方法重写:
- 方法签名必须相同
- 方法返回值相同或者为其子类
- 可见性不能低于超类方法
- 可以使用注解@Override帮助检查重写是否正确
- 成员方法必须用成员方法重写,静态方法必须用静态方法重写
5.8.2动态绑定
在上面的例子中,AWM重写了单发方法,我们给AK47类也重写单发方法:
@Override public void shoot() { System.out.println("发射5.56mm子弹"); }
然后我们编写测试方法:
public class ExtendTest { public static void main(String[] args) { Gun gun1 = new AWM("awm", "绿色", "8倍镜"); Gun gun2 = new AK47("ak47", "黑色", "4倍镜"); gun1.shoot();// 输出结果为: 发射7.62mm子弹 gun2.shoot();// 输出结果为: 发射5.56mm子弹 } }
我们发现,gun1和gun2都是Gun类型变量,但是最终调用shoot()方法的结果不一样。当实际引用的是AWM类型对象,则调用AWM的shoot方法,实际引用的AK47类型对象,则调用AK47的shoot方法。这种在运行时能够自动选择调用哪个方法的现象称为动态绑定(dynamic binding)。
5.8.3多态有什么用
我们了解了方法重写和动态绑定,那么多态有什么用处呢?下面我们用实际例子来演示。我们现在拥有了4个枪类:Gun、AWM、AK47、Gatling。现在我们编写一个玩家类,我们假设玩家只能拿一把枪,但是玩家可以随时更换枪支。我们设计一个玩家类如下:
根据设计,我们编写代码如下:
public class Player { private Gun gun; public void changeGun(Gun gun) { this.gun = gun; } public void shoot() { this.gun.shoot(); } }
然后我们编写测试类:
public class ExtendTest { public static void main(String[] args) { Gun gun1 = new AWM("awm", "绿色", "8倍镜"); Gun gun2 = new AK47("ak47", "黑色", "4倍镜"); Player player = new Player(); player.changeGun(gun1); player.shoot();// 输出 发射7.62mm子弹 player.changeGun(gun2); player.shoot();// 输出 发射5.56mm子弹 } }
我们看到,对于玩家类,不需要和枪支具体的子类打交道,只需要持有超类Gun对象即可。假如新加了别的枪,只需要编写新的枪类继承Gun类,重写shoot方法,然后调用Player类的changeGun方法即可。新增枪完全不需要修改Player类的代码。
多态的强大之处就在此,可以很方便的扩展子类,而不需要修改基类代码和一些调用者的类。一般编写框架和一个项目的核心代码,经常会利用继承和多态的特性,这个等你们经验丰富了,有机会进入一个项目的框架小组,编写框架代码的时候就会充分体会到多态的强大。
5.9final阻止继承
我们又一次看到了final关键字。前面我们学习类和对象的时候,知道用final修饰的属性将不能被修改。当时我们提到过,如果用final修饰类类型的属性时,必须保证该类也是final的。
当我们用final来修饰一个类的时候,那么这个类就不能被继承了,不过该类是可以继承其他类的。例如java.lang.String类就是一个final类:
public final class String
另外,我们还可以用final修饰方法,用final修饰的方法则不能被重写了。例如我们把Gun类中的getColor()方法定义为final的:
public final String getColor() { return this.color; }
5.10Object类
前面介绍继承层次的时候,提到过顶级超类java.lang.Object。如果某个类没有显示的使用extends关键字,则该类是继承自Object。事实上,在Java中,除了基本数据类型不是对象,其他都是对象,包括数组。因此数组也是继承自Ojbect类的。这里需要注意的是,即使是基本数据类型的数组,也是继承自Object。因此我们可以把一个数组赋值给一个Object类型的变量:
Object obj; int[] a = new int[] { 1, 2, 3 }; obj = a;
Object类中定义了许多有用的方法,都会被我们继承下来,因此我们有必要熟悉一下。这里我们主要介绍3个方法:equals方法、hashCode方法和toString方法。还有一些其他方法我们留在后面讨论。下表列出这3个方法的说明:
方法 |
说明 |
equals |
比较两个对象是否相等,如果相等返回true,否则返回false |
hashCode |
返回该对象的hash值 |
toString |
以字符串形式返回该对象的有关信息 |
5.10.1equals方法
我们在学习String类的时候,就接触过equals方法了。equals方法是用来比较两个对象时候相等。不过String类的equals方法是重写过的。因为Object的equals方法很简单,仅仅判断两个对象的引用是否相等(即两个对象变量内存中的值),实际上和等号(==)没有区别。但是其实大多数情况下,这种判断没有意义。例如对于String类来说,如果仅仅判断对象引用是否相等,那么“Java大失叔”和“Java大失叔”很有可能将不相等。更有意义的判断可能是两个对象的状态完全一致(即所有属性值都一致)。这就要求我们如果有需要,一般都要重写equals方法。
Java语言规范对equals方法其实是有要求的,需要满足下面几个特性:
- 自反性:对于任何非空引用x,x.equals(x)应该返回true
- 对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true
- 传递性:对于任何引用x、y和z。如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true
- 一致性:如果x和y引用的对象没有发生改变,反复调用x.equals(y)返回值不变
- 对于任何非空引用x,xequals(null)应该返回false
根据上面的规范,假如我们认为2个Gun对象名字和颜色都一样,则2把枪是相等的,可以如下编写代码:
1 @Override 2 public boolean equals(Object otherObj) { 3 if (this == otherObj) { 4 return true; 5 } 6 if (otherObj == null) { 7 return false; 8 } 9 if (this.getClass() != otherObj.getClass()) { 10 return false; 11 } 12 Gun other = (Gun) otherObj; 13 return this.name.equals(other.name) && this.color.equals(other.color); 14 }
注意,第9行用到了Object类的getClass方法,这个方法返回的是一个对象所属的类,比如一个AWM对象调用getClass方法返回是AWM类,一个Gun对象调用getClass方法返回的是Gun类。因此下面代码返回的结果将是false:
Gun gun1 = new Gun("awm", "绿色"); Gun gun2 = new AWM("awm", "绿色", "4倍镜"); System.out.println(gun1.equals(gun2));
如果只在Gun中重写了equals方法,而AWM中不重写的话,那么对于2把同样颜色、但是倍镜不同的AWM来说,将是不合理的。因此我们还需要在AWM中重写equals方法:
@Override public boolean equals(Object otherObj) { if (!super.equals(otherObj)) { return false; } AWM other = (AWM) otherObj; return this.gunsight.equals(other.gunsight); }
注意第3句代码,首先调用超类的equals方法,如果检测失败,表示2个对象不可能相等了,直接返回false即可。如果超类equals方法通过,往下只需要比较AWM特有的属性倍镜即可。
上面的equals方法编写几乎很完美,可以作为equals方法编写的模板,但是稍微有一点点瑕疵,就是Gun类equals方法第9行使用getClass方法来判断2个对象是否属于同一个类,对于某些不需要区分的那么严格的情况下,稍显严格。假如我们不需要关心一把枪是狙击器还是步枪,只要看名字和颜色一样,就认为相等(这个例子不是很合理,纯粹为了演示),那么就可以用instanceof关键字来判断,这样Gun的equals方法可以修改如下:
@Override public boolean equals(Object otherObj) { if (this == otherObj) { return true; } if (otherObj == null) { return false; } // if (this.getClass() != otherObj.getClass()) { // return false; // } if (!(otherObj instanceof Gun)) { return false; } Gun other = (Gun) otherObj; return this.name.equals(other.name) && this.color.equals(other.color); }
当然这时候AWM就不需要重写equals方法了。这样修改以后,上面的测试代码将返回true。通过这个例子,大家应该看出getClass和instanceof的区别了:
- instanceof比较宽松,对于x instanceof y,只要x是y的类型,或者是y的子类型,就返回true。
- getClass是严格的判断,不考虑继承的类型。
最后,我们可以总结一下equals方法编写的一个相对完美的模板:
- 使用==检测this和otherObj是否引用同一个对象,如果是直接返回true。
- 检测otherObj是否为null,如果是直接返回false。
- 比较this和otherObj是否属于同一个类;这里要仔细思考一下是使用getClass方法还是instanceof。如果不需要区分子类,就使用instanceof,同时子类不要重写equals方法。如果子类需要重新定义,就使用getClass方法
- 把otherObj转换为本类的类型
- 一般情况下,进行属性状态的比较;使用==比较基本数据类型的属性,使用equals方法比较类类型的属性。
- 如果是子类,第一句话调用super.equals(otherObj)。
5.10.2hashCode方法
hash code叫做散列码,是由对象导出的一个整型值。Object类的的hashCode方法是一个本地方法:
public native int hashCode();
实际上返回的就是对象的内存地址。如果对象x和y是不同的对象,那么x.hashCode()和y.hashCode()基本是不相同的。
如果一个类重写了equals方法,一般情况下,必须重写hashCode方法,以便让equals与hashCode的定义是一致的:如果x.equas(y)返回true,那么x.hashCode()和y.hashCode()也要返回同样的值。比如String类重写了equals方法,那么它也重写了hashCode方法:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
我们可以看出,String的散列码是通过字符串中的字符加上一些算法导出的,我们看代码:
String s1 = "Java大失叔"; String s2 = "Java大失叔"; System.out.println(s1.equals(s2));// 返回true System.out.println(s1.hashCode() == s2.hashCode());// 返回true
只要2个字符串equals相等,那么hashCode方法返回值也是相等的。
另外需要注意的是,散列码可以返回负数,我们尽量要合理的组织散列码,以便让不同的对象产生的散列码相对均匀。这里给出一个编写hashCode方法的建议:
- 初始化一个整型变量,例如h=17
- 然后选取equals方法中用到的所有属性,针对每个属性计算一个hash值,乘以h
- 对于对象类型的属性x,直接调用x.hashCode()计算hash值
- 对于基本数据类型的属性y,可以用包装器包装成对应的对象类型Y,然后调用Y.hashCode()计算hash值
- 对于数组类型的属性,可以调用java.util.Arrays.hashCode()方法计算hash值
- 最后把各个属性计算后的值相加作为最后的hash值返回
上面提到包装器类,因为基本数据类型不是对象,为了面向对象,Java对每一个基本数据类型都提供了一个包装器类,具体我们在后面会介绍。我们按照这个建议,给Gun类重写hashCode方法,因为Gun类equals方法参与的属性都是String,因此比较简单:
@Override public int hashCode() { return 17 * this.name.hashCode() + 17 * this.color.hashCode(); }
对于Gun的子类AWM,重写hashCode方法可以如下:
@Override public int hashCode() { return super.hashCode() + 31 * this.gunsight.hashCode(); }
5.10.3toString方法
我们经常会用System.out.println()来进行打印。如果我们把一个对象x传入到该方法中,那么println方法就会直接调用x.toString()方法。例如:
Gun gun = new Gun("awm", "绿色"); System.out.println(gun);// 打印:com.javadss.javase.ch05.Gun@12742ea
另外我们用一个字符串通过操作符“+”和一个对象x连接起来,那么编译器也会自动调用x.toString()方法。例如:
Gun gun = new Gun("awm", "绿色"); System.out.println("这是大失叔儿子的枪:" + gun);// 打印:这是大失叔儿子的枪:com.javadss.javase.ch05.Gun@12742ea
我们看到,默认的Object类的toString()方法只返回对象所属类名和散列码,如果我们想用toString方法进行调试,就有必要重写toString方法,返回对象的状态的相关信息。一种比较常见的重写toString方法的格式为:类名[属性值列表],比如我们可以在Gun类中重写toString方法如下:
@Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(this.getClass().getName()); sb.append("[name=").append(this.name).append(","); sb.append("color=").append(this.color).append("]"); return sb.toString(); }
这样重写以后,对于下面代码,打印将会友好的多:
Gun gun = new Gun("awm", "绿色"); System.out.println(gun);// 打印:com.javadss.javase.ch05.Gun[name=awm,color=绿色]
我们注意重写的toString方法的第4句,我们使用了this.getClass().getName()方法,这样在子类中将会动态输出子类所属的类,这也是多态的一种应用。例如打印AWM:
AWM gun = new AWM("awm", "绿色", "4倍镜"); System.out.println(gun);// 打印:com.javadss.javase.ch05.AWM[name=awm,color=绿色]
当然,如果AWM不重写toString方法的话,那么输出将不会体现倍镜属性的状态,因此我们最好给子类也重写toString方法,子类重写的时候,可以充分利用超类中已经重写的部分:
@Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(super.toString()); sb.append("[gunsight=").append(this.gunsight).append("]"); return sb.toString(); }
我们可以直接调用super.toString(),然后加上自身特有的属性值即可,这样输出值将会变为:
com.javadss.javase.ch05.AWM[name=awm,color=绿色][gunsight=4倍镜]
事实上,类库中很多类都重写了toString方法,不过对于数组来说,却提供了一种不是很友好的输出,例如:
double[] ds = { 1.0d, 2.0d }; System.out.println(ds);// 打印:[D@15db9742
前缀[D表示这是一个double数组,后面是哈希值。如果我们想获得友好的输出,可以使用java.util.Arrays类的toString方法:
double[] ds = { 1.0d, 2.0d }; System.out.println(Arrays.toString(ds));// 打印:[1.0, 2.0]
如果是多维数组,可以调用Arrays.deepToString方法:
double[][] ds = { { 1.0d, 2.0d }, { 3.0d, 4.0d } }; System.out.println(Arrays.deepToString(ds));// 打印:[[1.0, 2.0], [3.0, 4.0]]
笔者非常建议如果时间、条件允许,最好对每一个重要的自定义类都重写toString方法,这将会对你和调用你代码的人有很大帮助。