题目背景
前段时间有同学发了一道比较有意思的题。
这道题的来源似乎是CS61B 2021年春季课程。题目如下:
interface Animal {
default void greet(Animal a) {
System.out.println("Hello Animal");
}
default void sniff(Animal a) {
System.out.println("sniff animal");
}
default void praise(Animal a) {
System.out.println("u r cool animal");
}
}
class Dog implements Animal {
@Override
public void sniff(Animal a) {
System.out.println("dog sniff animal");
}
void praise(Dog d) {
System.out.println("u r cool dog");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog();
Dog d = new Dog();
// try to guess the output
a.greet(d);
a.sniff(d);
d.praise(d);
a.praise(d);
}
}
问题分析
先看Animal接口,其是一个具有默认实现的接口,由三个方法组成:
void greet(Animal a)
void sniff(Animal a)
void praise(Animal a)
再看Animal的实现类Dog,经过方法重写和方法重载,其一共有四个方法:
继承自Animal接口的void greet(Animal a)
重写了Animal接口的void sniff(Animal a)
继承自Animal接口的void praise(Animal a)
重载了praise方法的void praise(Dog d)
最后看main方法,其一共定义了两个对象:
a变量是类型为Animal的引用类型,引用的对象为Dog类实例化的对象。a的静态类型为Animal,动态类型为Dog。
d变量是类型为Dog的引用类型,引用的对象为Dog类实例化的对象。d的静态类型为Dog,动态类型为Dog。
基于如上分析,我们对代码结构有了基本的认识。为了确保文章完整性,下面的解答中,会先使用最终正确的思路回答问题,再给出我一开始所踩的坑(给我坑的一愣一愣的)。
代码输出
a.greet(d)
变量a在编译时,认定变量a是Animal对象,由因为Dog类实现了Animal接口,所以d instanceof Animal
恒为true
。基于Animal接口的方法签名,编译器会决定使用方法void greet(Animal a)
。
在实际执行过程中,a.greet(d)
会执行编译时已经决定要执行的方法void greet(Animal a)
,故输出结果为"Hello Animal"。
a.sniff(d)
类似于上一部分,变量a在编译时,基于Animal接口的方法签名,编译器会决定使用方法void sniff(Animal a)
。
但是!在实际执行过程中,由于变量a引用的是Dog类的实例,同时Dog类重写了Animal接口的void sniff(Animal a)
,这一操作覆盖掉了原先Animal接口的默认实现,故输出结果为"dog sniff animal"。
d.praise(d)
类似于上一部分,变量d在编译时,基于Dog类的方法签名,编译器会决定使用重载了praise方法的void praise(Dog d)
。
在实际执行过程中,由于变量d引用的是Dog类的实例,故输出结果为"u r cool dog"。
a.praise(d)
提前剧透一下,这个的输出正是本题卡住我的地方...
变量a在编译阶段,认定变量a是Animal对象,由因为Dog类实现了Animal接口,所以d instanceof Animal
恒为true
。基于Animal接口的方法签名,编译器会决定使用方法void praise(Animal a)
。
但是!在实际执行过程中,由于变量a引用的是Dog类的实例,不过Dog类却是重载了Animal接口的void praise(Animal a)
为void praise(Dog d)
,不同于前面,这一操作并不会覆盖掉原先的void praise(Animal a)
方法,实际执行的依然是void praise(Animal a)
方法,故最终输出结果为"u r cool animal"。
分析过程中可能犯的错
经过上面的分析后,似乎得到最终答案并不是很复杂的事情,然而实际上我前前后后被这个题折磨了两次......下面我简单复现并分享一下错误思路的全流程。
(1)动态类型检查和静态类型检查
先简要讲讲动态类型和静态类型,给我自己复习一下。
静态类型:在编译时由变量声明决定的类型。变量的静态类型在整个程序中不会改变。
动态类型:在运行时由实际赋值给变量的对象类型决定。变量的动态类型可以随赋值的不同而变化。
在第一次接触这个题时,我居然没有意识到动态类型和静态类型。(没错,这个编程语言最基本的特性我居然没了解过。这或许就是大学第一门编程语言课程是Python的劣势)在遇到Animal a = new Dog();
时,我第一反应是会发生隐式类型转换,new Dog()
所创建的实例将会被自动放大为Animal类型。这导致我误判a.sniff(d)
的输出为"sniff animal"。
而实际上,a变量的静态类型为Animal,动态类型为Dog,因此调用的方法会根据动态类型Dog来决定具体实现。
(2)方法签名的匹配和编译时方法的绑定
我们先撇开先前对a.praise(d)
输出的分析,尝试走另外一套逻辑。
现在变量a的数据类型为Dog,你甚至尝试着用a.getClass()去确定了一下数据类型。同时,Dog类重载了Animal接口praise方法为void praise(Dog d)
,这个函数的签名简直和a.praise(d)
完全匹配!那么其输出当然是"u r cool dog"!
然后就完美掉进坑了...
Java函数重载:它们的调用地址在编译时就绑定了。Java重载是可以包括父类和子类的,即子类可以重载父类的同名不同参数的方法。这可以视为一种静态绑定。
Java函数重写:只有等到方法调用的那一刻,解释运行器才会确认所要调用的具体方法。这可以视为一种动态绑定。
其实运行时执行什么方法,是在编译阶段就已经决定了的。在编译阶段,Java编译器会基于方法签名的匹配,在编译时进行方法的绑定,此时就已经决定了最终运行时会执行什么方法。动态类型只是在运行时决定执行方法的方式。而在本题中,出题人藏了一个方法重载而不是方法重写,这一个细微的区别,决定了a.praise(d)
并不会像a.sniff(d)
一样使用Dog类的方法,因为sniff
方法是方法重写,而praise
方法是方法重载。