🚥

なぜメソッドのシグネチャにはジェネリクスが含まれないの?

2023/11/10に公開

はじめに

今回はメソッドのシグネチャ、特にジェネリクス周りの扱いについて考えていきたいと思っています。メソッドのオーバーロードの時にジェネリクスが違えば別メソッドとして定義してもいいんじゃない?と考えたことがこの記事を書こうと思ったきっかけになりますので、そのあたりについては重点的に扱っていくことになるかと想像しています。

シグネチャ

そもそもメソッドのシグネチャってなんぞや、というところから初めていきましょう。メソッドのシグネチャには メソッド名引数リスト の二つの要素が含まれています。戻り値 のデータ型までが含まれる場合もある気がしますが、公式ドキュメントには戻り値の記述はありませんので、一旦この二つをシグネチャとして扱うこととします。 public void method(int a, int b) { }というメソッドであれば、method(int a, int b)の部分がシグネチャであるということです。

オーバーライド

このシグネチャという表現ですが主にオーバーライドについて学習するときに見かけるかと思います。というのは、オーバーライドをする場合には、元となるメソッドからシグネチャの変更を認めないこととなっているからです。一応コード例を示しておきます。

Parent.java
class Parent {
  public void method(int i, int j){ }
}
Child1.java
class Child1 extends Parent {
  @Override
  public void method(int i, int j) { } // ①問題なし
}
Child2.java
class Child2 extends Parent {
  @Override
  public void method(double i, double j) { } // ②コンパイルエラー
}

オーバライド元となるParentクラスのmethodというメソッドのシグネチャはmethod(int i, int j)となります[1]ので、それを変更せずにオーバーライドを行なっている①は問題なくコンパイルでき、変更を加えている②はコンパイルができません。

ここまではおそらく簡単だと思いますので、次のParentクラスに定義されたmethodをオーバーライドする場合を考えてみて下さい。

Parent.java
class Parent {
  public void method(List<String> list){ }
}

ジェネリクスを含むメソッドのオーバーライド

では先程のParentクラスを継承し、methodをオーバーライドするChildクラスを作成してみます。

Child1.java
class Child1 extends Parent {
  @Override
  public void method(List<String> list){ } // ①問題なし
}
Child2.java
class Child2 extends Parent {
  @Override
  public void method(List<Integer> list) { } // ②コンパイルエラー
}

先ほどと同じように、引数のデータ型を変更するとコンパイルエラーとなりました。

ここであれ?と思った人。鋭いですね。これ本当に引数のデータ型を変更しているでしょうか。まあ、こんなこと書くということは変更していないということな訳ですが、実はList<String>List<Integer>はオーバーライドの際には同じデータ型として扱われることになっています。この仕様は先ほども参照した公式ドキュメントでは override-equivalent と表現されています。そのまんまオーバーライドでは同等ですよということです。ではなぜ②でのオーバーライドは認められないのでしょうか。

引数リスト

ここで最初に触れたシグネチャの構成要素を思い出してみましょう。シグネチャには メソッド名引数リスト の二つの要素が含まれていました。そのうち今問題となっているのは引数リストですから、こちらを詳しく見ていくことにします。

引数リストでは仮引数の宣言が行われている訳ですが、<T> void method(T t);のように型パラメータを利用することができます。この型パラメータにより、引数リストに柔軟性を持たせることが可能になっているのです。ではこのTというデータ型、実行時にはどのようなデータ型として扱われると思いますか?型パラメータに指定された値でしょうか。それとも型パラメータ専用の特殊なデータ型なのでしょうか。ここも少し考えてみて下さい。ヒントは型パラメータが参照型しか扱えないということです。

イレイジャ

ヒントでも触れたように、型パラメータでは参照型のみを扱うことができますね。そして、参照型のデータは全てObjectクラスのサブタイプです。そのため型パラメータで指定したデータ型が何であれ全てObject型で扱える訳です。ここまで話すと予想がついてしまったかもしれませんね、そうです、型パラメータを利用したデータ型は基本的に[2]実行時にはObject型として扱われます。<T> void method(T t);void method(Object t)となるということです。

じゃあ型パラメータはどう役立っているの?という疑問が生まれてきます。実は型パラメータというものはコンパイルのタイミングで型安全を検証するために使われています。検証し終わったら随時Objectに書き換えられコンパイルされていく。この仕組みを イレイジャ(Erasure) または 型消去 と呼びます。普段この仕組みを理解して開発を進める必要はほとんどないのですが、引数リストに何らかの形で型パラメータが利用されている場合には注意が必要です。次のコードを確認してみて下さい。

Sample.java
class Sample<T, S> {
  void method(T value) { }
  void method(S value) { }
}

このコードからは、「型パラメータに指定された異なるデータ型の引数をそれぞれ持つようなメソッドをオーバーロードによって定義したい」という意図が見て取れます。ですが、イレイジャによって各型パラメータはObjectに置換されますので、このメソッドはオーバーロードとして正しい定義にはなっていません。よってコンパイルエラーとなります。[3]

では先ほどのParentクラスを用いた例も考えてみましょう。コードを再掲しますので、もう一度確認してみて下さい。

Parent.java
class Parent {
  public void method(List<String> list){ }
}
Child1.java
class Child1 extends Parent {
  @Override
  public void method(List<String> list){ } // ①問題なし
}
Child2.java
class Child2 extends Parent {
  @Override
  public void method(List<Integer> list) { } // ②コンパイルエラー
}

この例では型パラメータに指定されたデータ型が一緒の場合はオーバーライドが成功して、そうでない場合は失敗しコンパイルエラーとなるのでした。この理屈をイレイジャを使って考えてみましょう。

イレイジャでは型パラメータに指定されたデータ型は型安全の検証の後Object型に置換されます。これは仮引数宣言時のList<String>にも適用されるため、[4]検証に成功すればList<Object>となるはずです。ただ、StringとIntegerの間には何の互換性もありませんので検証に失敗してしまします。そのためにコンパイルエラーが発生していたということです。

イレイジャ実装の経緯

イレイジャが実装された経緯についても少しだけ触れておきます。イレイジャ実装の主な要因としては、List等のさまざまなデータ型のインスタンスを扱いうるデータ型と、扱いうるデータ型の指定を目的としたジェネリクスの導入時期がずれていることにあります。もちろんジェネリクスの導入時期の方が後になるのですが、そのジェネリクス導入時期以前に作成されたライブラリには当然ジェネリクスが記述されていません。そして、そのライブラリを利用すしていたプロジェクトでもジェネリクスは記述されていません。ここで、ライブラリ側がJavaの仕様変更に合わせてジェネリクスを導入したという状況を考えてみましょう。コードで言うと以下のような状況になります。まずはジェネリクス導入前の状況からみていきます。

SampleProvider.java
public interface SampleProvider {
  void run(List list);
}
SampleUser.java
class SampleUser implements SampleProvider {
  @Override
  public void run(List list) {
    // do something
  }
}

SampleProviderがライブラリでSampleUserが利用者になっています。現状問題は発生していません。

では、ライブラリにジェネリクスを採用させてみましょう。

SampleProvider.java
public interface SampleProvider {
  <T> void run(List<T>  list);
}

ここでイレイジャがない場合、もともとはエラーなく利用できていたSampleUserクラスにコンパイルエラーが発生してしまいます。シグネチャが異なる形でオーバーライドしていることになるためです。この仕様はライブラリの開発者にジェネリクスの導入を躊躇わせることになってしまいます。ライブラリ開発者側からすれば型安全なことを保証できた方が良いにも関わらず、以前からの利用者に変更を強いることになるために、ジェネリクスの導入を見送ることになる可能性がある訳です。このような事象を考慮してイレイジャは導入されています。イレイジャを利用したメソッドシグネチャにおいてはListList<T>はoverride-equivalentなので、特に変更なくジェネリクス導入前の利用者はライブラリを使い続けることができるという訳です。

なぜメソッドのシグネチャにはジェネリクスが含まれないの?

ここまで読んでいただいた方はもうお分かりかと思いますが、この質問への回答は「ジェネリクス導入前、導入後それぞれのコードを共存させるため。」となります。その理由としては、公式ドキュメントで以下のように説明されています。

This is important so that library designers may freely generify methods independently of clients that define subclasses or subinterfaces of the library.
これは、ライブラリの設計者が、ライブラリのサブクラスやサブインタフェースを定義するクライアントと関係なく、自由にメソッドにジェネリクスを導入できるようにするために重要である。(著者訳)

ライブラリの設計者への配慮であると言うことですね。

最後に

ここまでお読みいただきありがとうございます。間違っている部分や抜けなどあるかと思いますので、気づいた方はコメント等で教えていただけると幸いです。

脚注
  1. 各引数名の変更は問題にならないことに注意して下さい。引数リストを構成する要素は種類順番です。 ↩︎

  2. ワイルドカードで境界を指定した際にはこの限りではありません。 ↩︎

  3. JAVA言語入門を参考にしている。より詳しく知りたい方は、ぜひ見に行ってみて下さい。 ↩︎

  4. Listインターフェースに検証用のデータを渡している箇所になるため、先ほどのSampleクラスの例とは少し違い<String>が完全に削除されます。正確にいえば置換ではない訳です。ただ、「List listのようにジェネリクスを記述せずに宣言されたListではObject型を扱う」ということは皆さんご存知かと思いますので、実質的に置換されたものと捉えても問題はないかと思っています。 ↩︎

Discussion