今日はJavaのメソッドバインディングに関して記事を書こうと思います。
普段Javaを勉強していてあまり意識していなかったのでこの際ちゃんと整理することにします。
#事前知識
事前知識としてキャストについて復習しておきましょう。キャストには明示的な型変換と暗黙的な型変換があります。詳しくは以下のURLを見てみてください。
https://techacademy.jp/magazine/28486
それとコンパイル時と実行時のタイミングについても知っておいた方がいいでしょう。コンパイル時とは自分が書いたJavaファイルがバイトコードに変換されるタイミング。実行時とはそのバイトコードをJVMが実際に実行するタイミングです。Eclipseで開発している場合は通常コードを書いたら勝手にコンパイルされ、実行ボタンを押したタイミングでコードが実行されます。ターミナルなどで開発してる時はjavacコマンドでコンパイルしてclassファイルを作ったらjavaコマンドで実際に実行しますね。
それと記事内で使用してる「結びつける」とか「バインドする」っていうのは全部同じ意味で使っています。(紛らわしくてごめんなさい)
#そもそもメソッドバインディングって?
僕自身この言葉の意味も知らなかったのですが簡単にいうと、Javaにおいてメソッドの呼び出しとその呼び出されたメソッドのシグニチャ、および実装部分を結びつける(バインディングする)仕組みのようなものです。シグニチャとはメソッド名+引数のことで、実装部分とは{}の中の処理のことです。メソッドはこの2つで一塊りとなっていますが今は分けて考えてください。これを理解していないと思わぬところでつまづく可能性があります。具体的に見ていきましょう。
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.0g食べました。」と出力されます。なぜでしょうか。自分は最初Humanが参照先として代入されているのだから「人間は500g食べました。」と出力されるはずだ、と考えました。Testクラスで引数に渡されているのもint型ですし。でも実際には答えは違いました。
何が起こったかを理解するためにJavaにおけるメソッドのバインディング(結び付け)が起こるタイミングを見ていきましょう。以下の表を見てください。
メソッドタイプ | コンパイル時にバインディング | 実行時にバインディング |
---|---|---|
non-staticメソッド | メソッドシグニチャ | メソッド実装 |
staticメソッド | メソッドシグニチャ、メソッド実装 |
今回はeatがnon-staticメソッドなのでこれを元に説明していこうと思います(staticメソッドでも考え方は同じです)。
#コンパイル時のバインディング
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によって処理の中身が決定されます。
#実行時のバインディング
あとはバイトコードの指示にしたがってanimal.eat(quantity)のメソッドを実行すれば良いだけです。
先ほど、コンパイラは変数animalの宣言タイプを見にいくと言いました。JVMは代入されるオブジェクトから捜索を開始します。つまり、
Animal animal = new Human();
コンパイラはこの式の左側(Animal)を参照して一連のバインディングを行うのに対して、JVMはまず右側(Human)のオブジェクトを見にいきます。
そうするとこれはHumanクラスのオブジェクトなのでHumanクラスの中で、eat:(D)Vのメソッドを探そうとします。一方で、Humanクラスの中にあるのは、eat:(I)Vなので(Iはint型のこと)、マッチするメソッドは見当たりません。そこでJVMはその継承関係から該当のメソッドを探しに行きます。つまり、そのクラスが継承している親クラスを見てそこにもなかったらさらにその親クラスを見にいくと言った具合でどんどん階層を上がりながらeat:(D)Vを探しに行くのです。今回だと一階層上がったAnimalクラスに該当のメソッドがあったのでこの中の実装部分を呼び出し元のメソッド(animal.eat(quantity))とバインディングして処理を実行しています。結果として、「動物は500.0g食べました。」と出力されたわけです。
#メソッドバインディングの例
最後にメソッドバインディングの一例を見てみましょう。
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)の違う型の引数を取るメソッドが2つオーバーロードされています(https://docs.oracle.com/javase/jp/8/docs/api/java/util/List.html )。ここではremoveにint型の引数が入れられているのでlist.remove(3)とList.remove(int index)がコンパイル時にバインディングされます。そして実行時にArrayList.remove(int index)の中の処理(実装部分)がlist.remove(3)とバインディングされます。結果として、このリストの3番目のindexにある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]と表示されました。indexの3番目ではなく、数値の3そのものが削除されたのです。なぜならCollectionにはCollection.remove(Object o)の1つしかremoveメソッドがないからです。コンパイラはこのObject型を引数に取るremoveメソッドとlist.remove(3)をバインディングします。これにより、list.remove(3)の引数の3というのはindexではなく、Objectとして扱われます。実際に先ほどと同様javapコマンドで確認してみると、Collection.remove:(Ljava/lang/Object;)Zと表示されます(ZはBoolean型のこと)。そしてバイトコードの指示に従って今度はArrayList.remove(int index)ではなく、ArrayList.remove(Object o)がJVMによって実行されます。結果として[1, 2, 4]が画面に表示されたわけです。もしメソッドバインディングについて知らなかったら普通にindexの3番目が削除される想定でプログラムを組んでいたかもしれません。そうなれば障害の原因になります。それを回避するためにもこのメカニズムを知っておくことは大きなメリットがあると思い、今回記事を書かせていただきました。
ここまで読んでいただきありがとうございました。