重拾JavaSE基础——多态及其实现方式

今天是比较抽象的多态,希望能给大家带来帮助

主要内容

  1. 多态

    1. 为什么使用多态
    2. 多态的形式
    3. 多态的概念
    4. 多态的劣势
    5. 多态存在的必然条件
    6. 类型转换
  2. 多态的实现原理

    1. 多态的分类
    2. 运行时多态的形式
    3. 实现原理

      1. 常量池
      2. 方法调用方式
      3. 动态绑定实现多态
  3. 写在最后

多态

先说好不钻牛角尖哈,多态Java的特性之一,先不着急说他的概念,先看看为什么要使用多态,多态给我们带来什么好处

为什么使用多态

举个例子吧,老奶奶喜欢养宠物,领养了一只加菲猫,加菲猫是只小动物,要吃饭,老奶奶每天负责喂它。Java翻译过来就是下面这样子的

// 老奶奶
public class Granny {
    
    public static void main(String[] args) {
        // 领养一只加菲猫,这里简单的new出来了
        Garfield garfield = new Garfield();
        // 抱起加菲猫给它喂食
        feed(garfield);
    }
    
    public static void feed(Garfield garfield) {
        // 加菲猫吃东西
        garfield.eat();
    }
}

class Garfield extends Animal{
    
    @Override
    public void eat() {
        System.out.println("加菲猫吃饱了");
    }
}

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

一切都很顺畅。但是这时候老奶奶又去领养了一只牧羊犬,牧羊犬也是小动物,也要吃饭,老奶奶也要给他喂食,这时候代码要添加一个牧羊犬类,老奶奶要添加一个给牧羊犬喂食的方法

public class Granny {
    
    public static void main(String[] args) {
        // 领养一只加菲猫,这里简单的new出来了
        Garfield garfield = new Garfield();
        // 抱起加菲猫给它喂食
        feed(garfield);
        
        // 领养一只牧羊犬
        Shepherd shepherd = new Shepherd();
        // 老奶奶给他喂食
        shepherd.eat();
    }
    
    public static void feed(Garfield garfield) {
        // 加菲猫吃东西
        garfield.eat();
    }
    
    public static void feed(Shepherd shepherd) {
        // 加菲猫吃东西
        shepherd.eat();
    }
}
class Shepherd extends Animal{
    
    @Override
    public void eat() {
        Systen.out.println("牧羊犬吃的很开心");
    }
}
// 加菲猫
class Garfield extends Animal{
    // ... 
}

如果老奶奶还想继续领养小动物,老奶奶又要给这只小动物创建一个新的喂食的方法。聪明的我给老奶奶指了条明路,只要把feed方法的参数范围扩大一点,不要指定是加菲猫还是牧羊犬z,只要是小动物都给他喂食,反正小动物都有吃的方法。

public class Granny {
    
    public static void main(String[] args) {
        // 领养一只加菲猫,这里简单的new出来了
        Garfield garfield = new Garfield();
        // 抱起加菲猫给它喂食
        feed(garfield);
        
        // 领养一只牧羊犬
        Shepherd shepherd = new Shepherd();
        // 老奶奶给他喂食
        shepherd.eat();
    }
    // 扩大了范围
    public static void feed(Animal animal) {
        // 给动物喂食
        animal.eat();
    }
}

这样老奶奶就舒服了,所以多态的好处之一就是方便传参

后来老奶奶发现自己家里的动物越来越多,受不了了决定只养一只其他的都卖了,于是老奶奶选择留下加菲猫又回到了最初的日子

public class Granny {
    
    public static void main(String[] args) {
        // 领养一只加菲猫,这里简单的new出来了
        Garfield garfield = new Garfield();
        // 抱起加菲猫给它喂食
        feed(garfield);
    }
    
    public static void feed(Garfield garfield) {
        // 加菲猫吃东西
        garfield.eat();
    }
}

但是养了一段时间老奶奶觉得加菲猫老在家躺着没什么意思,想念牧羊犬了,于是把加菲猫丢了换回牧羊犬,将原来Garfield garfield = new Garfield();改为

Shepherd shepherd = new Shepherd();

又过了一段时间老奶奶觉得不行,牧羊犬吃得太多了开销顶不住,还是加菲猫好,于是他又把代码改回来了,又将Shepherd shepherd = new Shepherd();改回

Garfield garfield = new Garfield();

我见老奶奶都一把年纪了,改来改去还挺麻烦的,就跟她说你要不定义一个Animal类的annimal变量代表你的宠物把,像这样

Animal animal = new Garfield();

这样换宠物只要改new后面的就行了,老奶奶一听觉得很有道理,所以多态的另一个好处就是右边的对象可以组件化切换,业务功能也会随之改变

在我们开发中也常常使用多态,大家回忆一下一个Service需要依赖其他Service,是不是这样写的

@Resource
private IUserServiceImpl userService;
总结:多态的优势可以总结成两个点: 方便入参实现组件化切换

多态的形式

  • 子类继承父类

    父类 变量名称 = new 子类构造器
  • 实现类实现接口

    接口 变量名称 = new 实现类构造器

多态的概念

看完上面的内容,会有一种感觉,多态的风格其实是定义变量的时候把类型范围扩大,如上面的例子,老奶奶以后都会把他的宠物们定义成这样

Animal garfield = new Garfield();
Animal shepherd = new Shepherd();

定义加菲猫和牧羊犬的时候声明的都是Animal类型,但他们的eat方法是不一样的。同一种类型的对象执行同一个行为(方法)会得到不同的结果,这个就是多态的概念

多态只是一种编程风格,没有要求一定要遵循,只是使用了多态会有他的好处,多态已经成为大家公认且遵守的Java特性,顺着趋势走就OK

多态的劣势

这里有个小插曲,为什么老奶奶一开始会放弃加菲猫选择牧羊犬,因为牧羊犬可以帮忙看家,这是他的独有功能

class Shepherd extends Animal{
    
    private Integer i = 0;
    
    @Override
    public void eat() {
        Systen.out.println("牧羊犬吃的很开心");
    }
    
    public void lookDDoor() {
        Systen.out.println("这是牧羊犬的超能力");
    }
}

但是他发现自从用了多态后,再也无法让牧羊犬去看门了

public class Granny {
    
    public static void main(String[] args) {
        
        // 领养一只牧羊犬
        Animal shepherd = new Shepherd();
        // 看门
        shepherd.lookDoor(); // 报错
    }
}

大家可以先认为Animal shepherd = new Shepherd();进行了自动转型,shepherd 已经没有看家的方法了,所以多态的劣势就是子类失去了独有的行为,而且连成员变量都不能直接访问(只能借助重写的方法去访问)

public static void main(String[] args) {
    // 领养一只加菲猫,这里简单的new出来了
    Garfield garfield = new Garfield();

    garfield.i;// 报错
}

这时候需要使用强制类型转换来解决问题,至于为什么不能调用子类的方法相信看完后面你就懂啦

多态存在的必然条件

  1. 必须存在继承关系
  2. 必须是父类/接口类型变量引用子类/实现类类型变量
  3. 必须存在重写方法

类型转换

大家可以先记住语法,回头就能理解转换到底是在干嘛了

  • 自动转换

    Animal garfield = new Garfield();

    子类类型会自动转换成父类类型,其实就是多态的默认写法

  • 强制类型转换

    子类 新变量名称 = (子类) 需要转换的变量名称

    Animal garfield = new Garfield();
    // garfield = (Garfield)garfield 必须用新的引用接收
    Garfield garfield2 =  (Garfield)garfield; 
    注意:必须使用新的变量去接收

强制类型转换的时候需要对类型进行判断

在老奶奶养加菲猫和牧羊犬的时候有一个小插曲,加菲猫很贪吃,一顿要吃多点,老奶奶也没办法,只能给他加餐,但是使用了多态,喂猫喂狗的方法都是同一个`feed`,有没有办法可以判断一下入参到底是加菲猫还是牧羊犬呢,那肯定是有的

public static void feed(Animal animal) {
    // 判断是不是加菲猫,是的话给他加餐
    if (garfield instanceof Garfield) {
        System.out.println("加餐");
        animal.eat();
    }
}

到底是加菲猫还是牧羊犬只有代码运行的时候才知道,intanceof可以判断运行引用animal的实际类型是否为Garfield

多态的实现原理

一个对象变量可以指向多种实际类型的现象成为多态,这导致一个对象变量调用同一个方法的时候得到了不同的结果。感觉非常抽象,看下面的例子

一只猫有两个个eat方法,一个无参一个有参

class Cat {
    
    public void eat() {
        System.out.println("猫会吃饭")
    }
    
    public void eat(Integer weight) {
        System.out.println("猫会吃饭,吃了" + weight)
    }
}

当主函数运行以下代码的时候

Cat cat = new Cat();
cat.eat();
cat.eat(10)

回想刚刚的概念,是不是同一个变量cat,调用同一个方法eat,但结果是不一样。这就是编译时多态,在编译成class文件的时候就可以确定,程序执行的eat方法是Cat类中的成员方法,而且根据形参也可以知道是哪个eat方法,

方法签名和返回参数相同看作同一个方法。这种形式成为 方法重载Overload

再看下一种情况,猫类继承了动物类,重写了动物类的eat方法

ublic class Animal {
    
    public void eat() {
        System.out.println("动物可以走路");
    }
}
class Cat {
    @Override
    public void eat() {
        System.out.println("猫会走路");
    }
}

现在有一个feed喂养的方法,需要传入一个动物类型

public void feed(Animal animal) {
    animal.eat();
}

在编译的时候不能确定animal到底是什么类型的,可能是加菲猫可能是牧羊犬,准确点应该是计算机不知道animal实际是什么类型的,但程序员知道。这种就是我们最常用的多态,叫运行时多态,由于不确定传入的参数是什么类型的,同一个变量animal调用同一个方法eat产生的结果是不一样的

多态的分类

根据上面的例子,多态可以分为

  • 编译时多态(静态多态)
  • 运行时多态(动态多态)
后面所提到的多态都是运行时多态

运行时多态的形式

就是上面提到过的那两种

  • 子类继承父类

    父类 变量名称 = new 子类构造器
  • 实现类实现接口

    接口 变量名称 = new 实现类构造器

实现原理

尽量用通俗的话去解释,如果理解有误麻烦评论区告诉我

常量值

大家肯定听过,编译器把源代码编译成class文件的时候,会把一些常量信息统一放在class文件的一块区域,大家可以用字节码分析工具随便打开一个class文件就能看到c常量池了,这种写在文件里面的常量信息被称为静态常量池,当class文件被加载到虚拟机的时候,会在方法区开辟一段空间存放这些常量信息,这个区域就叫做运行时常量池

常量池存了哪些信息

可以看下图,其实很像我们的数据库,

常量池.jpg

注意:因为! class文件还没被加载,所以现在用分析工具展示的是静态常量池,里面包含一些符号引用(就是一个名字),加载到方法区后会替换成直接引用(内存地址)
  • CONSTANT_utf8_info

    基本信息都存在CONSTANT_utf8_info,里面保存了这个类里面的成员方法的名字、我们定义的字符串常量(System.out.println(...)里面的字),引用类型类名(如CatAnimal),变量名(如cat)等等

    Length of bytes array; 6
    length of String: 6
    String: Animal
  • CONSTANT_Class_info

    保存对其他类的符号引用(Class_name)和在CONSTANT_utf8_info的引用

    Class_name:cp info #25
  • CONSTANT_NameAndType_info

    保存方法和字段的类型和名称,还有描述符信息(入参和返回值)

    Name: cp info #15 
    Descriptor: cp info #18 <(LAnimal;)V>
    Name: cp info #28 
    Descriptor: cp info #10 <()V>
    <(LAnimal;)V>里面的 V表示返回值为空
  • CONSTANT_Methodref_info

    保存方法的方法名称的索引和该方法所属的类名的索引,这个相当于中间表

    Class_name: cp info #22 
    Name_and_type: cp info #23 
  • CONSTANT_interfaceMethod_info

    CONSTANT_Methodref_info类似,保存了接口方法的名称和类型的索引和接口的索引

所有的表最终信息都保存在 CONSTANT_utf8_info种,看上去就像我们的数据库表设计一样

方法调用方式

Java的方法调用方式有两种,静态调用动态调用

  1. 静态调用

    顾名思义,就是A类调用B类的静态成员方法,也就是说调用的时候很明确,我要调用方法区里面那个叫B类的那个静态方法,最后会把B类的静态方法的字节码地址替换运行时常量池对应的表符号引用,替换的过程称为静态绑定,调用绑定后的方法称为静态调用

    StringUtils.isBlank();

    类调用(invokestatic)在编译的时候计算机已经很明确要调那个方法了,只要类被加载到方法区,一切都顺利

    注意:Java中只有被 privatestaticfinal修饰 的方法属于静态
  2. 动态调用

    如果要调用动态成员变量的方法就比较麻烦了,必须先去堆中找到对应的对象,然后根据对象的信息找到对应的方法的字节码地址,保存到堆中,对象中为什么会有方法的字节码地址呢,这是动态绑定完成的操作,具体后面再说,调用动态绑定后的方法被称为动态调用

    cat.eat();

    实例调用(invokevirtual)就需要等到对象被创建的时候才能指定调用哪个方法

JVM调用方法的指令:

  • 静态调用:invokestatic`invokespecial
  • 动态调用:invokeinterfaceinvokevirtual

实例化

这里需要说明的是,类如

Animal cat = new Cat();

这种形式对于cat来说他是Animal类型的,但在堆中开辟的是Cat类的对象空间,并由this指针指向Cat实例,所以cat的实际类型其实是Cat

动态绑定实现多态

子类继承父类

方法表是在方法区中有一个集合,专门存放方法名称和代码指针,代码指针指向存放方法体字节码的内存地址。这里需要强调的是,如果是子类重写了父类的方法或者实现类实现了接口的方法,指针是指向重写的方法的

如下面的代码

public class Main {
    public static void main(String[] args) {
        Animal cat = new Cat();
        cat.run();
    }
}
class Animal {
    public void play() {
        System.out.println("父类方法");
    }
    public void run() {
        System.out.println("父类方法");
    }
    
    public void eat() {
        System.out.println("父类方法");
    }
}
class Cat extends Animal {
    @Override
    public void run() {
        System.out.println("子类方法");
    }
}

对于AnimalCat类,方法表是这样的

类方法表.png

当调用Catrun方法的时候,字节码为invokevirtual #15JVM先在常量池查CONSTANT_Methodref_info -> CONSTANT_NameAndType_info -> CONSTANT_utf8_info,查出来现在需要调用的是Animal类中run方法,然后去Animal的方法表里面找run方法,记录以下偏移量offset,再调用invoke this,offset,这时候的this指针正指向的是堆中的Cat对象,Cat也有一张方法表,恰好数下来offset就是子类的run方法,于是找到Cat类的run方法的字节码地址,顺利调用。所以动态调用的核心就在于这个方法表和this指针的设计

实现类实现接口

接口可以多继承的,大家看下面的例子会发现用偏移量无法实现动态调用

interface A {
    
    public void a1();
    
    public void a2();
    
    public void a3();
}
interface B {
    
    public void b1();
}
class TestA implements A{
    // 重写三个方法
}
class TestAB implements A, B {
    // 从写四个方法
}
public class Main {
    
    B testAB = new TestAB();
    testAB.b1();
    
}

接口方法表.png

很明显接口Bb1方法的偏移量和实现类TestAB不一样,所以JVM提供了invokeinterface方法,它不再使用偏移量,而是使用搜索的方式寻找合适的方法,所以调用接口的方法会比调用子类的慢

为什么不能调用子类中非重写的方法

因为在父类的方法表压根就没与那个方法,例如上面的例子,如果runCat独有的方法,在父类Animal中就没有这个方法,就不能进行动态绑定了

不能找到子类独有方法.png

那大家可以想一下强制类型转换到底是在干嘛

写在最后

写这篇文章之前我是完全不知道多态是怎么实现的,我也是一边查资料一边研究,希望能帮助大家理解多态

你可能感兴趣的:(java,java-se,多态,语法,原理)