关于Java方法绑定的内容
我今天打算写一篇关于Java方法绑定的文章。平时学习Java时没有特别注意,所以这次我决定好好整理一下。
先備知識
让我们提前复习一下有关于Cast的知识。Cast包括显示类型转换和隐式类型转换。请详细参阅以下URL。
https://techacademy.jp/magazine/28486
还有,了解一下编译时和运行时的时机也很重要。编译时是指我们写的Java文件转换为字节码的时机。运行时是指JVM实际执行该字节码的时机。如果你在Eclipse上开发,一般情况下,编写代码后会自动编译,在按下运行按钮的时候代码会被执行。而在终端等其他开发环境中,我们需要用javac命令进行编译生成class文件,然后再用java命令进行实际执行。
此外,文章中使用的“绑定”和“连接”等词语都是同一个意思。(很容易造成混淆,抱歉)
从根本上说,什么是方法绑定?
我自己并不知道这个词的意思,但简单来说,在Java中,绑定是指将方法调用和被调用方法的签名以及实现部分关联起来的机制。签名指的是方法名加参数,而实现部分指的是{}中的代码处理部分。方法由这两个部分组成,但现在请分别考虑它们。如果不理解这一点,可能会在意想不到的地方遇到问题。让我们具体来看。
public class Animal {
public void eat(double quantity) {
System.out.println("動物は" + quantity + "g食べました。");
}
}
public class Human extends Animal {
public void eat(int quantity) {
System.out.println("人間は" + quantity + "g食べました。");
}
}
public class Test {
public static void main(String[] args) {
Animal animal = new Human();
int quantity = 500;
animal.eat(quantity); // ?
}
}
假设所有的类都在同一个包中。如果执行Test.java文件,会输出什么?为什么会这样?
方法绑定
以下是“答案是“动物吃了500克””的输出结果。为什么会这样呢?我一开始认为应该输出“人类吃了500克”,因为我给他的引用对象是Human。而且在Test类中,传递的参数也是int类型。但实际上答案是不同的。
为了理解发生了什么,让我们来看看在Java中方法绑定发生的时机。请参考下表。
由于本例中的eat方法是非静态方法,我打算以此为基础进行解释(即使是静态方法,概念也相同)。
编译时绑定
在Java编译时,会将调用的方法和其签名关联起来。也就是说,可以说编译器每次都会确定调用的方法的签名。让我们根据当前所看的代码来考虑这个规则。Test.java的最后一行是这样的。
animal.eat(quantity);
首先,编译器会查看变量animal的声明类型。animal的类型是Animal类型。然后,编译器会说:“嗯,嗯。这个变量是Animal类的类型,那么我来看看这个类中是否有正在调用的方法。”编译器开始搜索。在此过程中,还会包括与正在调用的方法兼容的方法在搜索范围内。实际上,Animal类中确实有。
eat(double quantity)
在调用代码中,参数被传递为int类型,但是int转换为double的转换是隐式进行的(无需显式强制转换),编译器会判断”这是一个与当前被调用的方法兼容的方法”,将animal.eat(quantity)方法调用与接受double类型参数的eat方法(签名)关联起来。此时,编译器已确定eat的参数为double类型,并且无法在运行时更改。让我们实际检验一下。
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class Human
3: dup
4: invokespecial #3 // Method Human."<init>":()V
7: astore_1
8: sipush 500
11: istore_2
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method Animal.eat:(D)V
18: return
}
在使用终端中的javac命令分别编译每个文件之后,使用javap -c Test命令来查看编译后的代码内容。请注意以下行的内容。
15: invokevirtual #4 // Method Animal.eat:(D)V
这段代码是在Test类中的方法调用部分,animal.eat(quantity)与之对应。其中,(D)表示参数是double类型,而(V)表示返回值是void类型。invokevirtual表示实际的实现部分在运行时确定。根据这段字节码指令,代码会在运行时被Runtime执行。换句话说,编译器只是将“被调用的方法是Animal的eat方法”与JVM关联起来,具体的处理内容在运行时由JVM确定。
运行时绑定
只需按照字节码的指示执行animal.eat(quantity)方法。之前,编译器会查找animal变量的声明类型。JVM会从被赋值的对象开始搜索。也就是说,
Animal animal = new Human();
编译器参考了此表达式的左侧(动物)进行一系列绑定,而JVM首先查找右侧(人类)的对象。
然后,由于这是Human类的对象,因此JVM在Human类内部尝试查找eat:(D)V方法。另一方面,Human类中存在的是eat:(I)V(其中I代表int类型),因此找不到匹配的方法。因此,JVM会从继承关系中继续查找相应的方法。换句话说,它将查看该类继承的父类,如果在那里找不到,则进一步查找父类,依此类推,一直向上层查找eat:(D)V。在本例中,上一级是Animal类,并且该类有相应的方法实现,因此将其绑定到调用方的方法(animal.eat(quantity))并执行处理。结果就是输出了”动物吃了500.0克”。
方法绑定的例子
让我们看一个方法绑定的例子。
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
System.out.println(list); // [1, 2, 3, 4]
list.remove(3);
System.out.println(list); // [1, 2, 3]
在List类的remove方法中,存在两种不同类型的参数重载方法,即List.remove(int index)和List.remove(Object o)(https://docs.oracle.com/javase/jp/8/docs/api/java/util/List.html)。在这里,由于传入了一个int类型的参数,所以在编译时会绑定到list.remove(3)和List.remove(int index),然后在运行时会绑定到ArrayList.remove(int index)的具体实现部分。结果是,该列表中索引为3的位置上的元素4被删除,输出为[1, 2, 3]。
到目前为止,没有什么特别的改变。但是下一个例子呢?
Collection<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
System.out.println(list); // [1, 2, 3, 4]
list.remove(3);
System.out.println(list); // [1, 2, 4] ← ??
只有一个选项
只有一个东西变了,list的容器从List类型变成了Collection类型。结果显示为[1, 2, 4]。不是第三个索引被删除,而是数字3本身被删除。这是因为Collection只有一个remove方法,即Collection.remove(Object o)。编译器将此对象类型的remove方法与list.remove(3)绑定。因此,list.remove(3)的参数3将被视为对象而不是索引。实际上,如果使用javap命令验证,将显示Collection.remove:(Ljava/lang/Object;)Z(Z表示布尔类型)。然后,根据字节码指令,由JVM执行的不是ArrayList.remove(int index),而是ArrayList.remove(Object o)。结果,[1, 2, 4]被显示在屏幕上。如果不了解方法绑定,可能会编写程序假设第三个索引被删除。如果是这样,将会导致故障。为了避免这种情况,我认为了解这个机制有很大的好处,并写下了本文。非常感谢您阅读到这里。