继承和多态
概念
继承
- 继承
extends
:Java 中的继承均为公有继承 - 子类和超类:即子类和父类(也称基类)
Java 的继承不用考虑诸如虚函数(虚表、虚指针)等底层细节,不需要额外的显式声明就可以实现动态多态。
也就是说,Java 的方法均为虚函数(并不严谨),如果不希望让一个方法是虚拟的,可以使用final
关键字修饰。或者,对于static
、private
方法,不涉及动态多态。
虚表、虚指针是 C/C++ 对多态的实现方案,Java 有自己的实现方案 --> 方法表。
不过从思路上讲,基本上是差不多的。
与 C/C++ 不同,Java 不允许多重继承(multiple inheritance),即一个类拥有多个父类,但允许多层继承(multi level inheritance)。
多重继承有一定的好处,但会带来相当的复杂性和性能开销,于是 Java 采用了一个被称为"接口"的概念来获得多重继承的大多数好处,同时也规避了一些设计复杂性。接口将在后面的部分讨论。
class Son extends Father {
// ...
}
多态
-
多态的抽象表述:接口与实现的分离。
-
具体一些可以称:父类指针(引用)指向子类对象。
为了访问安全性,Java 不允许父类对象(实际上的)向下转型。会抛出
ClassCastException
异常。
访问权限
涉及到继承,就一定要谈protected
权限修饰符。
一般情况下,我们希望隐藏域,开放方法,让开发者依赖接口而非实现,但private
的域是被完全隐藏的,即便是子类都无法访问。
但有时候我们希望子类能访问一些私有域(或私有方法),就需要使用protected
来代替private
修饰成员,子类将可以访问到该成员(但是访问不到其他实例的protected
成员)。
再放一次这个表,加深理解:
修饰符 | 访问范围 |
---|---|
public | 均可访问 |
protected | 包内和所有子类可访问 |
(缺省) | 包内可访问 |
private | 仅本类访问 |
访问父类super
由于子类不能直接访问父类的私有域,所以我们应该调用父类的构造。
在子类构造的开头,Java 为我们隐式调用了父类的默认构造super()
,但当不存在默认构造时,我们需要手动调用super()
,且必须为第一个语句:
class Son extends Father {
public Son (int param) {
super(param); // 手动调 super
// ...
}
}
super
还可以访问到所有父类允许访问的 constructor、method 及 field:
class Son extends Father {
public void method() {
super.field;
super.method();
// ...
}
}
不过,尽管这种方法令super
很像一个对象的引用(类似this
),但并非如此,super
仅仅是一个指示调用父类的关键字。
在 C/C++ 中,我们使用
父类::成员
的形式引用父类的内容。
另外地,也可以通过this
调用本类构造(委托构造):
用于为构造函数提供默认值(Java 中没有默认参数)。
class Type {
public Type() {
this(10);
}
public Type (int param) {
// ...
}
}
重写
重写父类方法,实现动态多态。也叫(覆盖,覆写)
!!! 注意:
-
为防止寻址超出范围,子类方法的返回值,若改变,则仅可为父类方法返回类型的子类,这被称为"协变返回类型"。
C++ 允许这样做,但不保证安全,所以 Java 禁止了。
-
子类方法不能低于父类方法的可见性,例如子类无法用
private
修饰符重写一个父类的public
方法。这会导致发生多态时的访问权限冲突。C++ 同样允许这样做,但当试图访问不可访问的方法时,编译出错。
Java 允许的是可见性较大的重写为可见性较小的,具体表现为:
public
>protected
>(默认)
>private
还要注意的是,父类的 private 方法无法被重写。在子类写同名方法不会出错,但这不是重写,而是一个新方法。
class Father {
public void method() {
// ...
}
}
class Son extends Father {
public void method() {
// ...
}
}
推荐使用注解:
@override
用于检测是否发生了正确的重写,是一种安全检测手段。
注解后若未正确重写会出现编译错误。
class Son extends Father {
@override
public void method() {
// ...
}
}
注解(Annotation)
作用在代码的注解:
@Override
- 检查该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。@Deprecated
- 标记过时方法。如果使用该方法,会报编译警告。@SuppressWarnings
- 指示编译器去忽略注解中声明的警告。
作用在其他注解的注解(或者说 元注解):
@Retention
- 标识这个注解怎么保存,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问。@Documented
- 标记这些注解是否包含在用户文档中。@Target
- 标记这个注解应该是哪种 Java 成员。@Inherited
- 标记这个注解是继承于哪个注解类(默认 注解并没有继承于任何子类)
从 Java 7 开始,额外添加了 3 个注解:
@SafeVarargs
- Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。@FunctionalInterface
- Java 8 开始支持,标识一个匿名函数或函数式接口。@Repeatable
- Java 8 开始支持,标识某注解可以在同一个声明上使用多次。
多态抑制
如果不希望一个方法被子类重写,可以使用final
修饰该方法:
final public int method() {
// ...
}
对于 private 方法,可以使用
final
修饰,但没什么用,因为子类根本无法重写它。
如果不想让某个类可以被继承,使用final
修饰该类:
final class Type {
// ...
}
可以将final
看作一个多态的抑制关键字,在 Java 中,如果不希望一个类(对象)展现出多态性,就要显式声明它为final
。换言之,任何未显式声明的 Java 类都具有多态性。
这与 C/C++ 的设计哲学不同,在 C/C++ 中,任何类都不具备多态性(这与 C/C++ 的对象内存结构有关),除非我们显式声明它(虚函数)。
关于 C/C++ 对象的内存结构,可见笔者的 C/C++ 笔记 --> C++ 面向对象#继承和多态
对象转型
转型安全
本节开头(继承的多态)提到过:Java 不允许不安全的向上转型(子类指针指向父类对象),因为这可能会导致内存的访问越界:
Father father = new Father(); // 父类对象
Son son1 = (Son)father; // 转型到子类
不过,发生了多态的对象,上下转型都是安全的:
Son son = new Son(); // 子类对象
Father father = son; // 转型到父类对象
Son son1 = (Son)father; // 再转回来
判断实例
为了保证转型安全,应提前判断实例的实际类型。
instanceof
的表达式返回一个 boolean
值,表示obj
是否是Type
的实例:
obj instanceof Type;
if (obj instanceof Son) {
((Son) obj).specificMethod();
}
注意,instanceof
认为子类对象是父类的实例,即允许通过较安全的转型检查,但对于其他应用场景,这可能带来一些麻烦(马上就要提到)。
抽象类
上层的类一般更抽象,有时候我们只希望将它作为其他派生类的基类,复用结构,而不是作为一个实例类。
使用abstract
修饰抽象类和抽象方法:
抽象类不允许实例化。
abstract class Father {
public abstract void method();
}
与 C++ 不同,Java 类必须使用
abstract
修饰后才允许定义抽象方法。不过,使用
abstract
修饰的类不一定必须存在抽象方法。
顶级类Object
Object
Object
是 Java 中所有类的父类,也叫根类、顶级类,甚至,数组类型都是Object
的扩展:
Object arr = new int[]{/* .. */}; // ok
下面介绍几个Object
中几个基本的方法。
equals 方法
用于检测一个对象是否与另一个对象相等:
obj1.equals(obj2);
在Object
的实现中,该方法仅比较了引用的地址,所以我们一般需要重写他。
一个常规的equals
实现如下:
@Override
public boolean equals(Object r) {
// 查看引用的是否是同一片内存
if (this == r) return true;
if (r == null || // 是否为 null(当使用 instanceof 时,不需要这一步判断)
// 类名是否不等,或进行实例判断
[ this.getClass() != r.getClass() | !(r instanceof Type) ])
return false;
// 此时 r 已经确定是本类的实例了,开始转型进行本类状态的判断
Type robj = (Type) r;
return this.basicType == robj.basicType &&
Object.equals(this.objType, robj.objType) &&
[ super.equals(r) && ] // 调用父类 equals
/* ... */;
}
我们首先进行了开销最小的快速判断——if (this == r) return true;
,然后判断r
是否为 null、类名是否不等getClass
。
注意,我标注出了
getClass
和instanceof
,选择两个其中之一,选择的依据和缘由如下。刚才我们提到过这一点:
注意,
instanceof
认为子类对象是父类的实例,即允许通过较安全的转型检查,但对于其他应用场景,这可能带来一些麻烦(马上就要提到)。
getClass
返回该实例的真实类型,不受引用类型的影响。而
子类 instanceof 父类
也返回 true,这违反了 Java 规范中对equals
的对称性要求。Java 规范要求一个
equals
方法应该具备以下几个性质:
自反性:对于任何非空引用 x, x.equals(x) 应该返回 true。
对称性:对于任何引用 x 和 y, 当且仅当 y.equals(x) 返回 true, x.equals(y) 也应该返回 true。
传递性:对于任何引用x、 y 和 z, 如果 x.equals(y) 返回 true, y.equals(z) 返回 true,x.equals(z) 也应该返回 true。
一致性:如果 x 和 y 引用的对象没有发生变化,反复调用 x.equals(y) 应该返回同样的结果。
对于任意非空引用 x, x.equals(null) 应该返回 false。
—— Java 核心技术 卷 I - P168
读者可能已经意识到了这个问题:如果发生了多态,我们该如何抉择,是否允许父子类实例的比较?
对于一些具体的业务场景,我们可能希望父类和子类去比较某一个共有部分,并返回正确的结果。
- 所以,对于这种多态的比较需求(即父类决定是否相等),我们应该仅在父类中重写
equals
方法,并使用instanceof
关键字,以便令各子类实例(及父类实例)能够自由比较而不违反对称性。- 如果子类决定是否相等,就应该使用
getClass
。
如果至此仍未返回,证明r
已经确定是本类的实例,接下来转型进行具体的状态判断。
对于基本类型,直接通过==
判断。
对于对象类型,应该调用Objects
的static
方法equals
,这是为了防止null
调用equals
方法(毕竟我们不一定能保证双方均不为null
),其实现如下:
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
然后,对于子类,应该调用父类 equals。
相关方法
判断两个数组相等:
static Boolean java.util.Arrays.equals(type[] a, type[] b)
刚才我们使用过的Objects.equals
:
static boolean equals(Object a, Object b)
注意,该方法传入两个null
时返回true
。
hashCode 方法
在顶级类Object
中,该方法被实现为当前对象的地址值。
重写equals
的同时,一定要同时重写hashCode
方法,并保持比较的一致性。
(先给出实例,稍后说明原理,继续阅读前应确保已经了解"哈希"(或称散列)的概念)
例如一个Person
类:
class Person{
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
Person
的equals
:
@Override
public boolean equals(Object r) {
if (this == r) return true;
if (r == null ||
this.getClass() != r.getClass()) return false;
Person person = (Person) r;
return age == person.age &&
name.equals(person.name);
}
Person
实例相等的语义为 —— 其姓名和年龄都相等,由此可以定义hashCode
:
@Override
public int hashCode() {
return Objects.hash(name, age);
}
Objects.hash
方法保证了,当参数列表一致时,返回值一定相同。即此处我们实现了这一点:当对象根据equals
语义判断为相等时,hashCode
也一定相等。
为什么呢?为什么要这样实现?
这跟散列表有关,散列表在 Java 的标准库中叫HashMap
(或LinkedHashMap
)。
散列时会调用对象的hashCode
方法,而顶级类Object
实现的hashCode
将返回地址,这意味着在equals
语义下相等的两个对象将散列为两个key
(因为内存地址不同),这也就无法正常使用散列表这种结构。
标准库中
HashMap.put
实现:static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
而当我们根据equals
语义重写了hashCode
后,就可以将地址不同但equals
语义相同的两个对象散列到同一个key
,从而在使用散列表时访问到正确的内存。
下面给出实例,演示两种情况下(重载或不重载hashCode
)使用散列表的情况:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person person = (Person) o;
return age == person.age &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
测试代码:
public class test {
public static void main(String[] args) {
HashMap<Person, Integer>mp = new HashMap<>();
mp.put(new Person("高厉害", 20),1);
mp.put(new Person("高厉害", 20),2);
mp.put(new Person("小明", 30),3);
out.println(mp.toString());
}
}
当我们如上重载了hashCode
:
输出(格式经过手动调整):
{
Person{name=‘小明’, age=30}=3,
Person{name=‘高厉害’, age=20}=2
}
若将重载的hashCode
删除:
输出:
{
Person{name=‘高厉害’, age=20}=2,
Person{name=‘高厉害’, age=20}=1
Person{name=‘小明’, age=30}=3
}
第一行和第二行,明明是语义上相同的对象,却在散列时被映射到了两片不同的内存空间,就是因为其hashCode
不同。未重载的hashCode
为根类的实现,返回地址值。
下面介绍几个工具,用于生成散列值:
java.util.Objects:
static int hash(Object...)
// 返回一个散列码,由提供的所有对象的散列码组合而得到。
static int hashCode(Object a)
// 如果 a 为 null 返回 0,否则返回 a.hashCode()
java.util.Arrays:
static int hashCode(type[] a)
// 返回数组 a 的散列码
java.lang.*:
static int hashCode((int|long|short|byte|double|f1oat|char|boolean) value)
// 返回给定值的散列码。
toString 方法
众所周知,Java 是强类型语言,会使用 C/C++ 的 Java 学习者,在刚上手时就能明显感受到类型检查的强度差异。
short a = 32768;
这将引发编译期错误,正确的做法是:
short a = (short)32768;
尽管我第一次碰到这样的错误时,惊叹 Java 的类型检查实在强得过分(强类型语言只接触过 Python),但在之后的学习中,却发现 Java 存在着各种与这种体验完全相反的隐式转换。
其中之一就是有关toString
的隐式转换了:
习惯了强得不要不要的 Java 类型系统之后,意外地发现print
方法居然可以这样使用:
Person gaolihai = new Person("高厉害", 20);
out.println(gaolihai);
尽管是弱类型的 C/C++,也不会允许这种代码成功跑起来,但是 Java 做到了,print
相关方法就是有这样的神奇功效,他会隐式调用参数的toString
方法。
不过,在我看了一下print
的实现后就不惊讶了,因为它实际上是显式调用了toString
:
public void println(Object x) {
String s = String.valueOf(x);
if (getClass() == PrintStream.class) {
// need to apply String.valueOf again since first invocation
// might return null
writeln(String.valueOf(s));
} else {
synchronized (this) {
print(s);
newLine();
}
}
}
到此为止问题还不大,这些都是源码级别的"隐式"。short a = (short)32768;
让我依然坚信 Java 的强类型检查,然而,当我偶然发现了以下代码可以成功编译的时候,不禁陷入了沉思:
"" + 1;
这我熟啊!上一次见到他是在 js 中。。用于方便地将某类型转换成字符串。
然后我颤颤巍巍地尝试了以下代码:
这用于确保某个变量非字符串,并进行数值运算
"1" - 0 + 1;
好消息,这是编译期错误,Java 不允许这么干(如果允许的话,我就彻底蒙了)。
以上现象确实涉及到 Java 的一个隐式调用约定,只要对象与一个字符串通过操作符+
连接起来,Java 编译就会自动地调用toString
方法来获得这个对象的字符串描述。
从而,在 Java 中进行各类型的字符串拼接非常方便:
使用空串""
表示与后面的变量相连接,不用显式地调用toString
"" + objType + basicType;
顶级类中的实现如下:
重写toString
,提供类的一些状态信息是个好习惯。
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
笔者认为,Java 砍掉了运算符重载,却加入了很多约定的隐式调用,这比 C/C++ 没有好到哪去。
例如,某个类的开发者认为,该类的字符串表示应该符合某种情况下的业务需要,从而被特别设计了,但是这一点并不会体现在上层代码的语义中,换句话说,这种设计与运算符重载类似,仍然对调用者是隐藏的(当然,我们不一定要在业务逻辑中依赖
toString
,事实上也很少有人这么做,toString
通常用于打日志)。不过,这些隐式调用相比复杂的运算符重载,确实是向简约迈进的一步。
以上事实告诉我们,Java 虽然是强类型语言,但存在很多约定的隐式调用 —— 这符合 Java 面向接口编程的设计哲学,实现某个接口,然后就能完美对接到一个模块里。
给出一些相关方法:
java.lang.Object
Class getClass( )
// 返回 Class 对象,Java 将类运行时的描述封装在 Class 类中
java.lang.Class
Class getSuperClass()
// 返回对象的父类 Class 对象
String getName()
// 返回这个类的名字
关于动态类型。
Java 隐式继承顶级类
Object
的行为,令它在很多情况下向动态类型靠拢(例如原始 ArrayList)。这种偏动态的现象来自根类引用时发生的多态,想到这里我不得不倒吸一口冷气,这难道就是动态类型的实现原理?
包装类
包装类是基本类型的包装,且其的实例是不可变的(避免引用传参)。
从 jdk9 起,包装类的构造方法被注解为废弃的,并推荐使用包装类的工厂方法。
It is rarely appropriate to use this constructor. The static factory is generally a better choice, as it is likely to yield significantly better space and time performance.
工厂方法:(以Integer
为例)
public static Integer valueOf(int i)
public static Integer valueOf(String s)
public static Integer valueOf(String s, int radix)
该工厂方法也是自动装箱时编译器的默认行为,例如:
list.add(3);
经过编译后相当于:
list.add(Integer.valueOf(3));
自动拆箱会隐式调用:
int intValue()
自动拆箱常发生在算数运算或赋值中:
int n = list.get(i);
相当于:
int n = list.get(i).intValue();
同时包装类也作为一个工具类,对应基础类型相关的工具被封装为静态方法。
static String toString(int i)
// 整数转换为字符串
static String toString(int i ,int radix)
// 整数转换为 radix 进制的字符串
static int parselnt(String s)
// 字符串转换为整数
static int parseInt(String s,int radix)
// 字符串转换为 radix 进制的整数