12-面向对象5(多态)

看了视频,里面就只说了“编译看左边,运行看右边”,讲的跟玄学似的。于是看了很多博客,现摘下来做个整合。

1. 运行时数据区

Java 源代码被编译器编译成 class 文件(不是底层操作系统可以直接执行的二进制指令)。因此,我们需要一种平台可以解释 class 文件并运行它。而做到这一点的正是 JVM。实际上,JVM 是一种解释执行 class 文件的规范技术,各个提供商都可以根据规范,在不同的底层平台上实现不同的 JVM。JVM实现的基本结构图入下:

当 JVM 运行一个程序时,需要在内存存储许多东西。比如字节码、程序创建的对象、传递的方法参数、返回值、局部变量等等。JVM 会把这些东西都组织到几个"运行时数据区"中便于管理。

1.1 栈

栈中的数据是线程私有的,一个线程是无法访问另一个线程的栈的数据

1.2 方法区


1.3 堆

2. 多态引入

  • 【抽象理解】同一个对象,不同时刻表现出来的不同状态
    • 继承允许将对象视为他自己本身的类型或其基类型来加以处理。既然允许将多种类型(从同一基类导出的)视为同一类型来处理,那么同一份代码也就可以毫无差别的运行在这些不同类型之上了。
    • 多态机制使具有不同内部结构的对象可以共享相同的外部接口
  • 作用:消除类型之间的耦合关系
  • 体现:父类引用指向子类对象
  • 使用前提
    • 要有继承关系
    • 要有方法重写
    • 父类引用指向子类对象

3. 对象的转型

  • 向上转型 upcasting
    • 由于对象既可以当作它自己本身的类型使用,也可以当作它的基类类型使用,编译器是允许的;这个转换可以自动进行
    • 因此将某个对象的引用是其基类类型的引用的行为称为"向上转型",通俗来讲就是"父类引用指向子类对象"
  • 向下转型 downcasting(造型)
    • 如果父类引用的对象是父类本身,那么在向下转型的过程中是不安全的,必须进行造型(强制类型转换),才能够通过编译时的检查 // 强制类型转换要求双目必须有子父类关系,否则编译报错
    • 错误的类型转换,就算躲过编译器检查,运行时也会引发 java.lang.ClassCastException;可以使用 instanceof 来测试一个对象的类型
  • obj instanceof T
    • obj 为一个对象,T 表示一个类或者一个接口,当 obj 为 T 的对象,或者是其直接或间接子类,或者是其接口的实现类,结果result 都返回 true,否则返回 false
    • 无继承关系的引用类型间的转换是非法的!所以,obj 所属的类与类 T 必须是子类和父类的关系,否则 instanceof 编译报错!

4. 对象的类型

  • 编译时类型
    • 编译时类型由声明该变量时使用的类型决定
    • 一个引用类型变量如果声明为父类的类型,但实际引用的是子类对象,那么该变量就不能再访问在子类中添加的属性和方法
    • 属性也是在编译时确定的,编译时引用声明为 Person 类型,没有 Son 特有成员变量,因而编译错误
    • 方法同理,只能调用父类中的方法,不能调用子类特有方法,否则也会编译报错
  • 运行时类型
    • 运行时类型由实际赋给该变量的对象决定
    • 若编译时类型和运行时类型不一致,就出现了对象的多态性

5. 绑定

将 [一个方法调用] 与 [一个方法主体] 关联起来,被称为"绑定"。

5.1 静态绑定

若这个绑定时发生在程序执行之前(如:由编译器或链接程序实现的),则称为"前期绑定"/"静态绑定"。

如果一个方法有 static、private、final 修饰或者是构造方法,那就都是"前期绑定"。所有的静态方法都是"前期绑定",因为静态方法可以通过类名进行访问,而不会用到引用的对象的实际类型信息,因此在编译时就可以通过类型信息确定是哪一个具体的方法

这也就揭示了为什么静态方法不能重写了,因为重写的目的是为了实现多态。private 方法默认是 final 类型;构造方法其实是一种特殊的 static 方法。

成员变量也属于"前期绑定",调用到的成员变量为父类的属性!也就是说运行时(动态)绑定针对的范畴只是「对象的方法」。


总结调用一个方法的过程(摘自 Java 核心技术卷 1):

  • 编译器查看对象的声明类型和方法名。假设调用 x.f(param),且隐式参数 x 声明为 Father 类的对象。需要注意的是:有可能存在多个名字为 f,但参数类型不一样的方法。例如,可能存在方法 f(int) 和方法 f(String)。编译器将会一一列举所有 Father 类中名为 f 的方法和其超类中访问属性为 public 且名为 f 的方法(超类的私有方法不可访问)。至此,编译器已获得所有可能被调用的候选方法。
  • 接下来,编译器将查看调用方法时提供的参数类型。如果在所有名为 f 的方法中存在一个与提供类型完全匹配,就选择这个方法。这个过程被称为“重载解析”。例如:对于调用 x.f("Hello") 来说,编译器将挑选出 f(String),而不是 f(int)。由于允许类型转换(int 可以转换成 double,Manager 可以转换成 Employee,Circle 可以转换成 Object,等等)所以这个过程可能很复杂。如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,就会报告一个错误。至此,编译器已获得需要调用的方法名字和参数类型。
  • Java 中「重载方法」的选择是“静态绑定”,也就是一个方法的参数选择是“静态绑定”的。如调用了一个重载的方法,在编译时根据参数列表就可以确定方法;并且如果这个方法是非静态的,那么具体调用的是父类的方法还是子类的方法还需要通过“动态绑定”来确定。当程序运行并且采用“动态绑定”调用方法时,JVM 一定会调用与 x 所引用对象的实际类型最合适的那个类的方法。

5.2 动态绑定

这个绑定发生在程序运行之中,根据对象的具体类型进行绑定的,那么这种绑定称为“动态绑定”。

“动态绑定”是基于对象的「实际类型」而非对象的「引用类型」!

再提【方法区】

每次调用方法都要进行搜索,时间开销相当。因此会在 JVM 加载类的同时,在方法区中会为每个类存放很多信息。而存放的类的信息中有一个数据结构叫【方法表】。它以 {数组} 的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址。这样一来,在真正调用方法的时候,JVM 仅查找这个表就行了。

子类方法表中继承了父类的方法!相同的方法(相同的方法签名:方法名和参数列表相同。返回值不是签名的一部分,但在覆盖时要保证返回类型的兼容性,即:允许子类将覆盖方法的返回类型定义为原返回类型的子类型)在所有类的方法表中的索引相同。但如果调用的就是 super.f(),编译器将对隐式参数超类的方法表进行搜索。

根据 [测试类常量池中第 15 个常量表] 中记录的“f1() 信息的符号引用”来查找引用 father 所在对应类 Father,继而找到该类对应的方法表,将找到的 Father 方法表中“f1() 对应的索引项 11”再放置回 [测试类的常量池中的第 15 个常量表] 中 ,这个过程是用声明类型 Father 的方法表中的索引项 来代替 常量池中的符号引用

在编译阶段,最佳方法名依赖于这个父类引用;而决定方法是哪个类的版本,这通过由 JVM 推断出这个对象的运行时类型来完成。换言之,在编译阶段,该被调用的方法就已经确定好了(如上:父类方法表第 11 个方法)。

而具体调用谁的方法实现,这还要看具体引用所指向的对象是谁 → 该对象所属类的方法表中“同样是第 11 个方法数据的指针”所指向的方法字节码所在的存储空间,那里存放的才是真正要被执行的方法体!体现在代码上,就是:能够被调用的只有父类中声明的方法,子类特有方法不能通过父类引用来调用


对于如下的调用方法,JVM 是如何定位的呢?

问题就在于 Father 类型中并没有方法签名为 f1(char) 的方法呀。但打印结果显示 JVM 调用了 Father 类型中的 f1(int) 方法,并没有调用 Son 类型中的 f1(char)

根据上面详细阐述的调用过程,首先可以明确的是:JVM 首先是根据对象 father 声明的类型 Father 来解析常量池的(解析:用 Father 方法表中的索引项来代替常量池中的符号引用)。如果 Father 中没有匹配到"合适"的方法,就无法进行常量池解析,这程序在编译阶段就通过不了。那既然通过了,就说明还是"合适"滴 ~

那什么叫“合适”的方法呢?

  • 方法签名完全一样的方法自然是合适的。但如果方法中的参数类型在声明的类型中并不能找到呢?比如上面的代码调用 father.f1(),Father 类型并没有 f1(char) 的方法签名
  • 实际上,JVM 会找到一种"凑合"的办法,就是通过 [参数的自动转型] 来找到"合适"的办法。比如 char 可以通过自动转型成 int,那么 Father 类就可以匹配到这个方法了。

5.3 重载和重写

「匹配方法的签名」和「绑定方法的实现」是两个不同的问题。引用变量的声明类型决定了编译时匹配哪个方法。在编译时,编译器会根据参数类型、参数个数和参数顺序找到匹配的方法,一个方法可能在沿着继承链的多个类中实现。JVM 在运行时动态绑定方法的实现,这是由变量的实际类型决定的。—— 《Java语言程序设计(基础篇)》

6. 示例代码

向上转型 → 编译时类型 & 运行时类型 → 动态绑定

测试代码 1

class Animal {
    protected void eat() {
        System.out.println("animal eat food");
    }
}

class Cat extends Animal {
    protected void eat() {
        System.out.println("cat eat fish");
    }
}

class Dog extends Animal {
    public void eat() {
        System.out.println("Dog eat bone");
    }
}

class Sheep extends Animal  {
    public void eat() {
        System.out.println("Sheep eat grass");
    }
}

// 多态是编译时行为还是运行时行为?
public class Test {
    public static Animal getInstance(int key) {
        switch (key) {
            case 0:
                return new Cat ();
            case 1:
                return new Dog ();
            default:
                return new Sheep ();
    }
}

    public static void main(String[] args) {
        int key = new Random().nextInt(3);
        System.out.println(key);
        Animal animal = getInstance(key);
        animal.eat();
    }
}

测试代码 2

public class Test2 {
    public static void main(String[] args) {
        Base base = new Sub();
        base.add(1, 2, 3); // "sub_1"; 可变参数和数组不构成[重载]! → 它俩是[重写]!
        Sub s = (Sub)base;
        s.add(1,2,3); // "sub_2"
    }
}

class Base {
    public void add(int a, int... arr) {
        System.out.println("base");
    }
}

class Sub extends Base {
    public void add(int a, int[] arr) {
        System.out.println("sub_1");
    }

    public void add(int a, int b, int c) {
        System.out.println("sub_2");
    }
}
原文地址:https://www.cnblogs.com/liujiaqi1101/p/13045289.html