【Core Java】03 Java 继承和多态

继承和多态

概念

继承

  • 继承extends:Java 中的继承均为公有继承
  • 子类和超类:即子类和父类(也称基类)

Java 的继承不用考虑诸如虚函数(虚表、虚指针)等底层细节,不需要额外的显式声明就可以实现动态多态。

也就是说,Java 的方法均为虚函数(并不严谨),如果不希望让一个方法是虚拟的,可以使用final关键字修饰。或者,对于staticprivate方法,不涉及动态多态。

虚表、虚指针是 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

注意,我标注出了getClassinstanceof,选择两个其中之一,选择的依据和缘由如下。

刚才我们提到过这一点:

注意,instanceof认为子类对象是父类的实例,即允许通过较安全的转型检查,但对于其他应用场景,这可能带来一些麻烦(马上就要提到)。

getClass返回该实例的真实类型,不受引用类型的影响。

子类 instanceof 父类也返回 true,这违反了 Java 规范中对equals的对称性要求。

Java 规范要求一个equals方法应该具备以下几个性质:

  1. 自反性:对于任何非空引用 x, x.equals(x) 应该返回 true。

  2. 对称性:对于任何引用 x 和 y, 当且仅当 y.equals(x) 返回 true, x.equals(y) 也应该返回 true。

  3. 传递性:对于任何引用x、 y 和 z, 如果 x.equals(y) 返回 true, y.equals(z) 返回 true,x.equals(z) 也应该返回 true。

  4. 一致性:如果 x 和 y 引用的对象没有发生变化,反复调用 x.equals(y) 应该返回同样的结果。

  5. 对于任意非空引用 x, x.equals(null) 应该返回 false。

—— Java 核心技术 卷 I - P168

读者可能已经意识到了这个问题:如果发生了多态,我们该如何抉择,是否允许父子类实例的比较?

对于一些具体的业务场景,我们可能希望父类和子类去比较某一个共有部分,并返回正确的结果。

  • 所以,对于这种多态的比较需求(即父类决定是否相等),我们应该仅在父类中重写equals方法,并使用instanceof关键字,以便令各子类实例(及父类实例)能够自由比较而不违反对称性。
  • 如果子类决定是否相等,就应该使用getClass

如果至此仍未返回,证明r已经确定是本类的实例,接下来转型进行具体的状态判断。

对于基本类型,直接通过==判断。

对于对象类型,应该调用Objectsstatic方法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;
    }
}

Personequals

@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 进制的整数

猜你喜欢

转载自blog.csdn.net/qq_16181837/article/details/112294901