JAVA 七月 09, 2021

8.面向对象之组合与继承

文章字数 23k 阅读约需 20 mins. 阅读次数 1000000

一:继承的本质是避免重复

继承是面向对象软件技术当中的一个概念,与封装,多态共同并列为面向对象的三大基本特征。在我们介绍完多态以后,我们会对这三个基本特征进行归纳性的总结。

继承是让子类继承父类(基类,超类)的特征和行为,使得子类对象具有父类的属性和方法,使得子类具有和父类相同的行为,这样做的本质是为了避免重复的代码。

我们来看一个示例:

Cat

public class Cat {
    private String name;

    public String getName() {
        return name;
    }

    public void meow() {
        System.out.println("喵~");
    }
}

Dog

public class Dog {
    private String name;

    public String getName() {
        return name;
    }

    public void wang() {
        System.out.println("汪~");
    }
}

Rat

public class Rat {
    private String name;

    public String getName() {
        return name;
    }

    public void zhihzhi() {
        System.out.println("吱吱~");
    }
}

我们看到 CatDogRat 类都有 name 属性和 getName 方法,并且对于这三个类也有属于自己的方法。

试想一下,如果我们在未来需要添加更多的动物类,我们是否要将 name 字段和 getName 方法一遍一遍地拷贝?并且如果我们的 getName 方法内部发生了改动,我们就需要对所有拥有 getName 方法的类进行改动。

CatDogRat 都属于 Animal ,我们可以让这些类 继承(extends) 一个基类 Animal,将共有的属性和方法封装到基类中,这样做的好处就是减少了重复代码,增强了可维护性。

Animal

public class Animal {
    private String name;

    public String getName() {
        return name;
    }
}

Cat

public class Cat extends Animal {
    public void meow() {
        System.out.println("喵~");
    }
}

二:Java 的继承体系与 Object 中的常用方法

Java的继承体系

Java 语言中,一个类只能继承一个父类,也就是说在 Java 语言里,继承都是 单根继承,与单根继承相对应的就是多重继承。

比较经典的面试题:JavaC++ 的不同点?

JavaC++ 的不同之处中其中很重要的一点就是:

Java 语言不能多重继承,其继承体系是单根继承,但是可以实现多个接口;而 C++ 则可以实现多重继承。

多继承最大的缺点就是容易出现二义性。如果派生类所继承的多个父类具有相同的父类(如上图所示的菱形结构),而派生类对象需要调用祖先类的方法,就会产生二义性。

多继承也不是没有好处,一个类继承了多个父类,那么这个类就可以调用多个父类中的方法,而单根继承则不具备这样的特性。为了解决这一点,Java 语言提供了接口(Interface)这一概念,我们在后面会详细讲到。

Object 中的常用方法

equals()

equals() 方法用来判断两个对象是否相等。

Object 类的 equals 方法:

public boolean equals(Object obj) {
    return (this == obj);
}

可以通过Object 类的源代码看到,Object 类的 equals 方法直接用来判断两个对象是否是同一个对象,是同一个对象则返回 true,否则返回 *false;我们知道,*Object 类是所有类的基类,继承了 Object 类的子类也是具有 equals 方法的,我们可以通过重写 equals 方法来既定自己的用来判断两个对象是否 “相等” 的准则。

例如,我有一个订单类:Order

public class Order {
    Integer id;
    String name;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

对于这个订单类我规定,当两个订单的 id 相等时,就认为两个订单是同一个订单。我们需要重写 equals() 方法,在 IDEA 中,使用快捷键 control + Enter 可以快速生成 equalshashCode 方法。

代码如下:

import java.util.Objects;

public class Order {
    Integer id;
    String name;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Order order = (Order) o;
        return Objects.equals(id, order.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
hashCode()

hashCode() 方法是什么?

首先,hashCode() 方法是一个本地方法,其在 Object 类中的定义如下:

@HotSpotIntrinsicCandidate
public native int hashCode();

在解释 hashCode() 方法之前,我们可以提前先来了解下 Java 集合中的 SetSet 中添加的元素是无序且不可重复的,我们向 Set 中添加元素就需要判断原来的集合中是否包含这个元素,此时就要用到 equals() 方法对集合的每个元素和新添加的元素进行比较,来判断元素是否重复。但是集合中的元素如果非常多,那么效率就会低下。于是,有人就发明出了哈希算法来提高从集合中查找元素的效率。hashCode() 方法可以根据对象的内存地址经过哈希算法得到一个哈希值,我们通过取模运算对哈希值进行分组,将存储空间分成若干个区域,每组对应某个存储区域。

这样一来,当集合要添加新的元素时,先调用这个元素的 hashCode() 方法,就可以直接定位到它所在的存储区域,如果这个区域内没有其他的元素,它就可以直接存储在这个位置上,不用再进行任何比较;如果这个区域内已经有元素,就调用它的 equals() 方法与区域内的元素进行比较,这样一来调用 equals() 方法的次数被大大降低,算法得到了优化。至于Set 内部的数据结构究竟是怎样的,这部分内容我们会在集合章节中重点介绍。

为什么重写 equals 方法时,也要重写 hashCode 方法

首先,如果不重写 Object 类的 equals() 方法和 hashCode() 方法的话:

  • hashCode() 方法会根据对象存储的内存地址经过哈希算法得到一个哈希值

  • equals() 方法会严格判断,两个对象是否是同一个对象

我们之所以要重写 equals() 方法的原因是因为,在我们处理的业务系统中,绝大部分判断对象是否相等都不是一种严格意义上的相等,而是业务上的相等,就譬如我们上面举例的订单类 Order

那么为什么再重写 equals 方法时,也需要重写 hashCode 方法呢?

我们来看一下 hashCode 约定(出自《Effective Java》):

  • 在一个应用程序执行期间,如果一个对象的 equals 方法做比较所用到的信息没有被修改的话,那么,对该对象调用 hashCode 方法多次,它必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不同,即这个应用程序这次执行返回的整数与下一次执行返回的整数可以不一致。

  • 如果两个对象根据 equals 方法是相等的,那么调用这两个对象中任一个对象的 hashCode 方法必须产生同样的整数结果。

  • 如果两个对象根据 equals 方法是不相等的,那么调用这两个对象中任一个对象的 hashCode 方法,不要求必须产生不同的整数结果。

后两条翻译成人话:

  1. 如果两个对象相等,那么它们的 hashCode 值一定要相同

  2. 如果两个对象的 hashCode 相同,它们并不一定相等

所以,如果我们重写 equals 方法,但是并没有重写 hashCode 方法,有可能会违反 hashCode 约定的第二条:相等的对象必须具有相等的散列码(hashCode*)!所以,当我们所写的类用于存放在 *Hash 相关的集合类中时,如果只是重写了 equals 但是没有重写 hashCode 就有可能出现与预期不符的结果(因为 Hash 相关的集合都是先去计算 hashCode,再去判断对象是否相等)。

toString()

我们知道如果使用 System.out.println(x) 打印 x ,那么就会自动调用 xtoString 方法。

toString 也是会被经常重写的一个方法,当我们期望打印结果能够按照我们制定某种格式进行输出,就需要重写这个类的 toString 方法

三:继承中的类结构与初始化顺序

在第六章节《Java 对象系统基础》的对象初始化顺序这一小节,我们已经介绍过这部分内容了,现在我们回过头详细讲解。

对于有继承关系,实例化子类对象的整个初始化顺序如下所示:

  1. 父类静态成员的初始化

  2. 父类静态代码块初始化

  3. 子类静态成员的初始化

  4. 子类静态代码块初始化

  5. 父类成员的初始化

  6. 父类初始化块

  7. 父类构造器

  8. 子类成员的初始化

  9. 子类初始化块

  10. 子类构造器

我们再来回顾下这道题目:

请说出该程序的输出结果?

class A {
    public A() {
        System.out.println("class A constructor");
    }

    {
        System.out.println("class A block");
    }

    static {
        System.out.println("class A static block");
    }
}

public class B extends A {
    public B() {
        System.out.println("class B constructor");
    }

    {
        System.out.println("class B block");
    }

    static {
        System.out.println("class B static block");
    }

    public static void main(String[] args) {
        new B();
    }
}

答案:

class A static block
class B static block
class A block
class A constructor
class B block
class B constructor

学习过继承后,我们就明白,子类拥有父类的属性和方法,所以在初始化顺序上,父类也要先于子类进行初始化。

四:实例方法的覆盖

覆盖又称为 重写

我们可以在子类中去覆盖(重写)父类的方法,让子类表现出与父类不同的特点。

在我们重写父类的方法时,推荐使用 @Override 注解来防止“手残”。

如示例:

Cat

public class Cat {
    String name;

    public void meow() {
        System.out.println("喵~,我的名字叫" + name);
    }
}

WhiteCat

public class WhiteCat extends Cat {

    @Override
    public void meow() {
        System.out.println("喵~,我是一只白色的猫,我的名字叫" + name);
    }
}

和重写比较相近的一个词是重载,在之前我们已经介绍过二者的区别,这里就不再赘述了。

五:设计模式实战:模版方法

模版方法模式即:Template Method

组成模版的框架被定义在父类中,而在子类中实现父类中的定义的这些抽象方法。虽然子类中实现了父类的抽象方法,都有各自的实现,但是在父类定义了处理流程的框架,无论子类中的具体实现如何,处理的流程都会按照父类中所定义的那样进行,这种设计模式就是 Template Method 模式。

示例程序如下:

AbstractDisplay
public abstract class AbstractDisplay{
    public abstract void open();
    public abstract void print();
    public abstract void close();
    public final void display(){
        open();
        for(int i = 0; i < 5; i++){
            print();
        }
        close();
    }
}

AbstractDisplay 类是一个抽象类,如果不知道抽象类这个概念,可以等到我们介绍抽象类之后,在回过头加深理解。该类中定义了三个抽象方法,由子类继承实现。而 display 方法则是处理流程的框架。display 方法中,首先调用了 open 方法,然后调用了五次 print 方法,最后调用了close 方法。至于open,print,close 方法的具体实现交给继承 AbstractDisplay 的子类,而整体的框架流程,则是由父类的模板所定义出来的。

CharDisplay
public class CharDisplay extends AbstractDisplay{
    private char ch;
    public CharDisplay(char ch){
        this.ch = ch;
    }
    public void open(){
        System.out.print("<<");
    }
    public void print(){
        System.out.print(ch);
    }
    public void close(){
        System.out.println(">>");
    }
}

当向 CharDisplay 类的构造函数中传入 A 这个字符串,那么最终显示的结果为:

<<AAAAA>>
StringDisplay
public class StringDisplay extends AbstractDisplay{
    private String string;
    private int length;
    public StringDisplay(String string){
        this.string = string;
        this.length = string.length();
    }

    public void open(){
        printLine();
    }
    public void print(){
        System.out.println("|" + string + "|");
    }
    public void close(){
        printLine();
    }
    private void printLine(){
        System.out.print("+");
        for(int i = 0;i < length;i++){
            System.out.print("-");
        }
        System.out.println("+");
    }
}

当向 StringDisplay 类的构造器中传入字符串 Dobby 时,输出如下:

+-----+
|Dobby|
|Dobby|
|Dobby|
|Dobby|
|Dobby|
+-----+
Main
public class Main{
    public static void main(String[] args){
        AbstractDisplay d1 = new CharDisplay('H');
        AbstractDisplay d2 = new StringDisplay("Dobby");
        d1.display();
        d2.display();
    }
}

程序显示结果如下:

<<HHHHH>>
+-----+
|Dobby|
|Dobby|
|Dobby|
|Dobby|
|Dobby|
+-----+
UML

模版方法模式中的各个角色:

AbstractClass

AbstractClass 负责实现模板方法的框架,并且声明模板方法中所使用的抽象方法,这些抽象方法由继承它的子类来负责具体的实现。示例程序中由 AbstractDisplay 扮演这个角色。

ConcreteClass

负责实现 AbstractClass 中的抽象方法,示例程序中 CharDisplayStringDisplay 扮演此角色。

对模版方式的思考

使用模板方法模式的好处就在于,我们将算法框架流程编写到了父类中,无需再在子类里面定义算法的流程了。拿另外一个例子说明,比如做菜;做菜这件事,将基本的流程定义好:第一步,洗锅;第二步,放油;第三部,放菜;第四部,放调味料,第五步,炒菜……当做菜的流程被定义好,那么无论是西红柿炒鸡蛋,还是尖椒炒牛肉,都会按照做菜这个模板方法来进行,当然具体的步骤如放多少盐,倒多少油是做什么菜来决定的。试想一下没有模板方法,那么菜谱中每个菜的做法都被独立开,就会有大量的重复流程说明,模板方法的意义不仅仅在于定义算法的流程,更在于体现出了继承的优势,大大减少了重复的步骤,缩减了代码量。

关于代码中,为什么 display 这个方法要使用final 关键字呢?在本章的第七节中,我们会讲解 final 关键字;在这里先解释下原因,使用 final 关键字就是让继承 AbstractClass 的子类无法重写 display 这个模板流程方法。

六:向上/向下转型

instanceof

instanceofJava 中的二元运算符,左边是对象,右边是类;当左侧是右侧类或子类的对象时,返回 true;否则,返回 false

关于 instanceof 的用法上,有几点需要注意:

  1. 左边的对象实例不能是基础数据类型

  2. null 使用 instanceof 跟任何类型进行比较时,都会返回 false

instanceof 的应用场景一般都是用于对象 向上/向下 转型时,进行预先的判断处理。

向上转型

当你需要一个父类型时,总可以传递一个子类型的对象给它。

使用格式:

父类类型 变量名 = 子类变量名;

向下转型

父类类型向子类类型转换则是向下转型,向下转型是具有风险的,我们可以使用强制类型转换的方式进行向下转型。

使用格式:

子类类型 变量名 = (子类类型)父类变量名;

示例

Animal

public abstract class Animal {
    public abstract void eat();
}

Cat

public class Cat extends Animal {
    @Override
    public void eat() {
        System.out.println("吃鱼");
    }

    public void catchMouse() {
        System.out.println("抓老鼠");
    }
}

Dog

public class Dog extends Animal {

    @Override
    public void eat() {
        System.out.println("吃骨头");
    }

    public void watchHouse() {
        System.out.println("看家");
    }
}

Test

public class Test {
    public static void main(String[] args) {
        // 向上转型
        Animal animal = new Cat();
        animal.eat();

        // 向下转型
        Cat cat = (Cat) animal;
        cat.catchMouse();

        // 向下转型,编译不会报错,运行时会报 ClassCastException
        Dog dog = (Dog) animal;
        dog.watchHouse();
    }
}

代码运行结果:

吃鱼
抓老鼠
Exception in thread "main" java.lang.ClassCastException: class com.github.hcspTest.Cat cannot be cast to class com.github.hcspTest.Dog (com.github.hcspTest.Cat and com.github.hcspTest.Dog are in unnamed module of loader 'app')
    at com.github.hcspTest.Test.main(Test.java:14)

如我们所见,“吃鱼”,“抓老鼠” 是可以正常执行的,因为 animal 本身就属于 Cat 类的对象;而第三段向下转型的代码虽然可以通过编译,不过在运行时却报了 ClassCastException 异常。

ClassCastException:试图将对象强制转换为不是实例的子类时,抛出该异常;

上面的测试代码中创建了 Cat 类型对象,运行时不能转换成 Dog 类型的对象,这两个类型并没有任何继承关系,不符合类型转换的定义 ,因此会抛出 ClassCastException 异常。所以,向下转型是存在风险的,解决方法就是使用 instanceof 来判断。

修正代码如下:

public class Test {
    public static void main(String[] args) {
        // 向上转型
        Animal animal = new Cat();
        animal.eat();

        // 向下转型
        if (animal instanceof Cat) {
            Cat cat = (Cat) animal;
            cat.catchMouse();
        }
        if (animal instanceof Dog) {
            Dog dog = (Dog) animal;
            dog.watchHouse();
        }
    }
}

该代码运行结果:

吃鱼
抓老鼠

因为使用了 instanceof 进行了判断,所以当代码执行到 animal instanceof Dog 时,发现 animal 不是 Dog 的对象后就不会执行。

七:final 关键字与单例模式

final 关键字

  • final 声明变量,变量成为不可变的(必须初始化)

    final int a = 1;  // 必须初始化,否则不能通过编译
  • 我们无法对 final 修饰的变量再次赋值。

  • final 修饰的对象是线程安全的。

  • Java 中通常用static final 表示一个属于类的常量,这个常量的变量名一般会大写表示。

    public static final double PI = 3.1415926;
  • final 在方法上的声明:禁止继承/覆盖/重写此方法

  • final 在类上的声明:禁止继承此类

单例模式

单例模式(Singleton Pattern)顾名思义,单例模式会确保任何情况下都绝对只有一个实例,当我们想在程序中表示某个东西只会存在一个的时候,那么就可以使用单例模式。

示例程序:

Singleton

public class Singleton {
    private static final Singleton singleton = new Singleton();

    // 构造器私有化,无法使用 new 来生成实例
    private Singleton() {
    }

      // 静态工厂方法,返回独有的实例
    public static Singleton getInstance() {
        return singleton;
    }
}

Main

public class Main {
    public static void main(String[] args){
        Singleton obj1 = Singleton.getInstance();
        Singleton obj2 = Singleton.getInstance();
        if(obj1 == obj2){
            System.out.println("obj1 与 obj2 是相同的实例");
        }else{
            System.out.println("obj1 与 obj2 是不同的实例");
        }
    }
}

该程序运行结果如下:

obj1 与 obj2 是相同的实例

对单例模式的思考

单例模式的作用其一是为了解决多线程并发的问题。

示例程序:

TicketMaker

public class TicketMaker {
    private int ticket = 1000;
    private static final TicketMaker ticketMaker = new TicketMaker();
    private TicketMaker(){

    }
    public static TicketMaker getInstance(){
        return ticketMaker;
    }

    public synchronized int getNextTicketNumber(){
        return ticket++;
    }
}

我们只希望有一个 TicketMaker 实例,并且我们希望可以让 getNextTicketNumber 在多线程的环境下正常工作,所以我们需要将其定义为synchronized 方法。关于 synchronized 关键字,我们会在多线程的部分讲到。

单例模式的作用其二是为了节约系统的内存,提高系统运行的效率,如果对于一种工具类,你需要频繁调用,如果每次调用都要产生新的实例,那么就会在浪费内存的空间,所以单例模式的一个优点就是减少资源消耗,并且能提高程序的运行速度。

在本章节中,我们介绍的单例模式又称为饿汉式单例模式,饿汉式为线程安全,并且调用效率高,但是缺点是不能延时加载;除了饿汉式单例模式之外还有很多种单例模式。我们会在后面的内容对单例模式进行一个整体的介绍。

八:组合与继承

组合与继承是面向对象中两种代码复用的方式。

继承:is-a

组合:has-a

《Effective Java》中,说明了组合的使用是优先于继承的,原因如下:

  • 在包中使用继承是安全的,其中子类和父类都在同一个程序员的控制下,但是跨越包级边界的继承是危险的。
  • 继承打破了封装。一个子类依赖于其父类的实现细节来保证其正确的功能。父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。

程序示例:请修复 CountingSet 类中的 Bug

import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;

public class CountingSet extends HashSet<Object> {
    /** 统计"有史以来"向该集合中添加过的元素个数 */
    private int count = 0;

    @Override
    public boolean add(Object obj) {
        count++;
        return super.add(obj);
    }

    @Override
    public boolean addAll(Collection c) {
        count += c.size();
        return super.addAll(c);
    }

    public int getCount() {
        return count;
    }

    // 我们希望创建一个Set,能够统计"有史以来"添加到其中去的元素个数
    // 但是,现在结果明显不对
    // 请尝试修复此问题
    public static void main(String[] args) {
        CountingSet countingSet = new CountingSet();
        countingSet.add(new Object());
        countingSet.addAll(Arrays.asList(1, 2, 3));

        System.out.println(countingSet.getCount());
    }
}

这个示例是根据 《Effective Java》Iterm18:组合优先于继承提供的示例进行修改的,该程序无法正常工作;CountingSet 用于统计“有史以来”向集合中添加过的元素的个数,我们期望返回的结果为 4,但是该程序实际的运行结果为 7。

原因就出现在 HashSet 的内部,addAll 方法是基于 add 方法来实现的。

我们在 countingSet 中添加了一个对象之后,count 值为 1;然后我们又调用了 allAll 方法向 countingSet 中添加了三个元素,此时 count 值变为了 4,并且调用了 super.addAll ;因为 HashSetaddAll 是基于 add 实现的,所以我们又会反过来调用在 CountingSet 类中重写的 add 方法,并且调用 3 次,最终导致 count 值为 7

该问题导致的原因就是,我们重写了父类的方法,破坏了父类方法的约定,致使程序出现漏洞。解决问题的方法就是,不要继承一个现有的类,而是应该给你的新类增加一个私有属性,该属性是现有类的实例引用,这种设计被称为组合(composition)。

弃用继承,使用组合,上面出现的 Bug 即可修复,程序如下:

import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;

public class CountingSet {
    private HashSet<Object> hashSet = new HashSet<>();
    /**
     * 统计"有史以来"向该集合中添加过的元素个数
     */
    private int count = 0;

    public boolean add(Object obj) {
        count++;
        return hashSet.add(obj);
    }

    public boolean addAll(Collection c) {
        count += c.size();
        return hashSet.addAll(c);
    }

    public boolean remove(Object obj) {
        return hashSet.remove(obj);
    }

    public int size() {
        return hashSet.size();
    }

    public int getCount() {
        return count;
    }

    public boolean removeAll(Collection c) {
        return hashSet.removeAll(c);
    }

    public static void main(String[] args) {
        CountingSet countingSet = new CountingSet();
        countingSet.add(new Object());
        countingSet.addAll(Arrays.asList(1, 2, 3));

        System.out.println(countingSet.getCount());
    }
}

上一篇:
下一篇:
0%