小插曲:Java的绑定机制

383 阅读7分钟

Java 有一个比较容易忽略的小知识点,就是其动态绑定/静态绑定机制。如果不能理解这个机制,就有可能在执行上转型对象的重写方法时得到预料之外的结果。

首先了解上转型对象

上转型对象,至少和两个类有关,并且这两个类之间存在着继承关系。

比如说,苹果 Apple 和水果 Fruit 就是两个具备继承关系的类:

class Fruit {}
class Apple extends Fruit {}

此时,当声明了一个 Fruit 类的引用,该引用却指向了一个 Apple 类的实例时:

Fruit apple = new Apple();

此时的这个apple对象,我们就称之为上转型对象。大意为:一个子类实例被赋给了父类的引用。

Java中的属性是静态绑定的

我们用一个例子来说明这个问题,先声明两个具备继承关系的类,且它们内部有重名但不同值的属性value

class Parent {
    public int value = 10;
}

class Son extends Parent {
    public int value = 20;
}

我们在主函数中声明两个 Parent 引用,但是它们分别指向 Son 实例和 Parent 实例:

Parent son = new Son();
System.out.println(son.value);

Parent parent = new Parent();
System.out.println(parent.value);

执行程序,我们可以发现,这两行输出的值一样,而不是10与20。

这是Java的静态绑定机制决定的。静态绑定的内容指在编译过程中就已经确定了的内容。

一句话来概括被静态绑定规则,就是这个引用本身是什么类型,访问的就是谁的内容,程序不会去考虑重写机制可能带来的歧义问题。

带入到这个例子中就是:指向了一个 Son 类实例的引用 son "理应" 得到 Son 类定义的默认 value 值,但该引用本身是一个 Person 类型,因此静态绑定机制使得程序只会去访问 Person 类中定义的value 值,而不是 Son 类的。(重写只是个比喻,对属性重写在 Java 中本身就属于伪命题)

既然属性无法重写,那么父类的同名属性何去何从?

class Super {
    public int val = 200;
}

class Sub extends Super {
    public int val = 300;

    public void printThisValueAndSuperValue() {
        System.out.println("this.value=" + this.val);
        System.out.println("super.value=" + super.val);
    }
}

如题。我们给出两种猜想(实际答案很明显):

  • 第一种猜想,父类的同名成员因被覆盖而不再存在
  • 第二种猜想,父类的同名成员隐藏了起来,需要使用super关键字来访问,但仍然存在。

实践出真知。我们在主函数中声明一个 Sub 类的实例,并调用printThisValueAndSuperValue方法,查看能否同时打印出子类和父类的两个同名属性:

 new Sub().printThisValueAndSuperValue();

运行程序,没有任何的问题,并正确地打印了两个值(200,300)。这说明:当子类声明了与父类重名的属性时,父类的同名属性仍然被保留了下来,并可以使用super关键字将来访问。

话虽如此,在开发中,为了避免不必要的麻烦,不会在子类中声明与父类同名的属性。

有以下细节需要注意:

  • 在 Java 中,对重写和重载的判定均不把 "访问修饰符是否一致" 考虑在内。
  • 子类的重写方法的访问修饰符不可以比父类的更加严格。
  • 子类不可以去重写在父类中被声明为 private 的方法。

观察 Java 对方法的绑定机制

设现在有存在继承关系的两个类:Person 和 Son 类。它们满足以下条件:

  • 具备相同的getValue方法:都是返回本类的value值。
  • 在父类声明了 valueAdd10() 方法,方法内部通过 getValue 方法获取其 value 属性。
  • Person类的方法都是公开(表示允许被Son类继承)的。
class Parent {

    private int value = 10;

    public void valueAdd10() {
        System.out.println("parent:" + (getValue() + 10));
    }

    public int getValue() {
        return value;
    }
}

class Son extends Parent {

    private int value = 20;
    
    public int getValue() {
        return value;
    }

}

在主程序中创建一个上转型对象,运行valueAdd10方法。

Parent son = new Son();
son.valueAdd10();

由于valueAdd10是声明在父类的方法,所以它可能会造成一种错觉:其内部的getValue返回的也是父类的值。但实际上,屏幕打印的内容却是:parent:30(这说明执行的是子类的getValue方法)。

这是 Java 的动态绑定机制导致的现象:在运行到valueAdd10方法内的getValue方法时,由于son是一个上转型对象,并且它重写了getValue方法,因此程序在此处优先选择了子类的getValue方法。

我们随后进行一些改动,来让屏幕输出:parent:20

  1. valueAdd10做如下改造。
public void valueAdd10() {
    System.out.println("parent:" + (value + 10));
}

刚才已经介绍了,Java中的属性是不可重写的。这个value不存在任何歧义:它就是指代父类的属性。

  1. 或者只将主函数的引用指向Person类的实例:
Parent parent = new Parent();
parent.valueAdd10();

这就是声明一个普通的Person对象,它执行的当然就都是属于自己的属性和方法了。

并不是所有的方法都是动态绑定

动态绑定,即运行时绑定,只有在运行时程序才可以确认究竟要执行那个方法。首先要知道何时Java会选择动态绑定:

  1. 存在向上转型的情况。
  2. 这个方法被允许被子类重写。

第二条的言外之意就是:如果一个方法不允许被重写,那就谈不上动态绑定。

private修饰的方法

接着用上一节的例子做一点文章:我们将父类的getValue方法使用private关键字来修饰,其它部分不动,然后运行程序。结果是在屏幕中打印出来:parent:20

若尝试在子类的getValue方法上加上一个@Override注解,则会提示编译错误。这说明了编译器认为目前子类的public getValue()方法和父类的private getValue()是两码事。

本质原因是因为:父类被private修饰的getValue()方法被保护了起来,它没有被子类所继承,那就更谈不上重写。所以即便声明了一个上转型对象,由于只有父类内部可以调用这个方法(private方法不对外公开),VM 在执行代码时就不会考虑再动态地在子类中寻找重写的方法,因为此时子类的同名方法跟父类的同名方法并没有关系。

final修饰的方法

我们将父类的getValue修改回private或者protected,随后在前面加上final关键字来限定:

protected final int getValue() {return value;}

原因是final关键字禁止子类重写该方法,程序也就不会再考虑是否会有额外的重写方法了。

static修饰的方法

假如父类和子类都声明一个同名的静态getVal方法:

class Parent {
    public static int getVal() {return 100;}
}

class Son extends Parent {
    public static int getVal() {return 200;}
}

在该情况下,也不会发生动态绑定。因为:Java 规定子类可以继承父类的静态方法,但是不能重写父类的静态方法。而没有重写,就谈不上动态绑定。

绑定机制总结

综上所述,我们可以总结出两点:

  • 凡是可被重写的方法,都是动态绑定的。在程序处理上转型对象时,总是会从子类开始,按照继承顺序向上级寻找可调用的方法。
  • 而被声明(private, static, final)的方法,构造器方法和属性全都是静态绑定的。这个引用本身是什么类型的,程序则就访问该类下的方法/属性。