web-dev-qa-db-ja.com

明示的な匿名内部クラスの代わりにラムダを使用する場合の異なる一般的な動作

コンテキスト

私は、ジェネリック型に大きく依存するプロジェクトに取り組んでいます。その主要コンポーネントの1つは、いわゆるTypeTokenです。これは、実行時にジェネリック型を表し、それらにいくつかのユーティリティ関数を適用する方法を提供します。 JavaのType Erasureを回避するために、中括弧表記({})を使用して、自動生成されたサブクラスを作成しています。これにより、型が再定義可能になります。

TypeTokenが基本的に行うこと

これはTypeTokenの非常に単純化されたバージョンで、元の実装よりもはるかに寛容です。ただし、このアプローチを使用しているので、実際の問題がこれらのユーティリティ関数のいずれかにないことを確認できます。

public class TypeToken<T> {

    private final Type type;
    private final Class<T> rawType;

    private final int hashCode;


    /* ==== Constructor ==== */

    @SuppressWarnings("unchecked")
    protected TypeToken() {
        ParameterizedType paramType = (ParameterizedType) this.getClass().getGenericSuperclass();
        this.type = paramType.getActualTypeArguments()[0];

        // ...
    } 

うまくいくとき

基本的に、この実装はほぼすべての状況で完全に機能します。ほとんどの型の処理に問題はありません。次の例は完全に機能します。

TypeToken<List<String>> token = new TypeToken<List<String>>() {};
TypeToken<List<? extends CharSequence>> token = new TypeToken<List<? extends CharSequence>>() {};

型をチェックしないため、上記の実装では、TypeVariablesを含め、コンパイラが許可するすべての型が許可されます。

<T> void test() {
    TypeToken<T[]> token = new TypeToken<T[]>() {};
}

この場合、typeGenericArrayTypeをコンポーネントタイプとして保持するTypeVariableです。これはまったく問題ありません。

ラムダを使用するときの奇妙な状況

ただし、ラムダ式内でTypeTokenを初期化すると、状況が変わり始めます。 (型変数は上記のtest関数から取得されます)

Supplier<TypeToken<T[]>> sup = () -> new TypeToken<T[]>() {};

この場合、typeGenericArrayTypeのままですが、コンポーネントタイプとしてnullを保持します。

しかし、匿名の内部クラスを作成している場合、状況は再び変化し始めます。

Supplier<TypeToken<T[]>> sup = new Supplier<TypeToken<T[]>>() {
        @Override
        public TypeToken<T[]> get() {
            return new TypeToken<T[]>() {};
        }
    };

この場合、コンポーネントタイプは再び正しい値(TypeVariable)を保持します

結果の質問

  1. Lambda-exampleのTypeVariableはどうなりますか?型推論がジェネリック型を尊重しないのはなぜですか?
  2. 明示的に宣言された例と暗黙的に宣言された例の違いは何ですか?型推論が唯一の違いですか?
  3. 定型的な明示的な宣言を使用せずにこれを修正するにはどうすればよいですか?これは、コンストラクターが例外をスローするかどうかを確認したいので、ユニットテストでは特に重要になります。

少し明確にするために:これはプログラムにとって「関連する」問題ではありません。なぜなら、私は解決不可能な型をまったく許可しないからです。しかし、それはまだ理解したい興味深い現象です。

私の研究

アップデート1

一方、私はこのトピックに関するいくつかの研究を行ってきました。 Java Language Specification§15.12.2.2 で、それと何か関係があるかもしれない式を見つけました-「適用可能性に関連する」、例外として「暗黙的に型付けされたラムダ式」に言及。明らかに、これは間違った章ですが、式は型推論に関する章を含む他の場所で使用されます。

しかし、正直に言うと、:=Fi0のようなこれらの演算子のすべてが、詳細を理解するのを本当に難しくしているのはどういう意味なのか、まだわかりません。誰かがこれを少し明確にできて、これが奇妙な振る舞いの説明かもしれないなら、私はうれしいです。

更新2

私はそのアプローチをもう一度考え、結論に至りました。「適用性に関係ない」ためにコンパイラが型を削除しても、代わりにコンポーネント型をnullに設定することは正当化されません最も寛大なタイプのオブジェクト。言語デザイナーがそうすることを決めた単一の理由を考えることはできません。

アップデート3

最新バージョンのJava(以前8u191を使用しました)で同じコードを再テストしました。残念なことに、Javaの型推論は変更されていませんが、改善されました...

更新4

公式のJava Bug Database/Trackerのエントリを数日前にリクエストしましたが、受け入れられました。レポートをレビューした開発者が優先度P4をバグに割り当てたため、修正されるまでしばらく時間がかかります。レポートは here で確認できます。

Tom Hawtinへの大きな叫び-これはJava SE自体の本質的なバグかもしれないと言及するためのタックライン。しかし、Mike Strobelによる報告はおそらく彼のおかげで私よりもずっと詳細になるでしょう。しかし、レポートを書いたとき、Strobelの答えはまだ得られていませんでした。

53
Quaffel

tldr:

  1. javacには、ラムダが埋め込まれた内部クラスの間違った囲い込みメソッドを記録するバグがあります。その結果、actualを囲むメソッドの型変数は、それらの内部クラスによって解決できません。
  2. _Java.lang.reflect_ API実装には、おそらく2組のバグがあります。
    • 一部のメソッドは、存在しない型が検出されたときに例外をスローするものとして文書化されていますが、決して実行されません。代わりに、null参照を伝播できます。
    • さまざまなType::toString()は、タイプを解決できない場合に、現在NullPointerExceptionをスローまたは伝播します。

答えは、ジェネリックを使用するクラスファイルで通常発行されるジェネリックシグネチャに関するものです。

通常、1つ以上のジェネリックスーパータイプを持つクラスを作成すると、Javaコンパイラは、クラスのスーパータイプの完全にパラメーター化されたジェネリックシグネチャを含むSignature属性を発行します。 。私は これらについて前に書いた ですが、短い説明はこれです:それらなしでは、たまたまジェネリック型を消費することはできませんジェネリック型としてソースコード。型の消去により、型変数に関する情報はコンパイル時に失われます。その情報が追加のメタデータとして含まれていない場合、IDEもコンパイラも型がジェネリックであることを認識せず、そのように使用できません。また、コンパイラは、タイプセーフを実施するために必要なランタイムチェックを発行することもできません。

javacは、型変数またはパラメーター化された型を含む署名を持つ型またはメソッドの汎用署名メタデータを出力します。これが、匿名型の元の汎用スーパータイプ情報を取得できる理由です。たとえば、ここで作成された匿名タイプ:

_TypeToken<?> token = new TypeToken<List<? extends CharSequence>>() {};
_

...このSignatureを含む:

_LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;
_

このことから、_Java.lang.reflection_ APIは(匿名の​​)クラスに関する一般的なスーパータイプ情報を解析できます。

しかし、TypeTokenが具象型でパラメーター化されている場合、これがうまく機能することは既にわかっています。 typeパラメーターにtype variableが含まれる、より関連性の高い例を見てみましょう。

_static <F> void test() {
    TypeToken sup = new TypeToken<F[]>() {};
}
_

ここで、次の署名を取得します。

_LTypeToken<[TF;>;
_

理にかなっていますよね?ここで、_Java.lang.reflect_ APIがこれらの署名から一般的なスーパータイプ情報を抽出する方法を見てみましょう。 Class::getGenericSuperclass()をじっと見ると、最初に行うことはgetGenericInfo()の呼び出しであることがわかります。以前にこのメソッドを呼び出さなかった場合、ClassRepositoryがインスタンス化されます:

_private ClassRepository getGenericInfo() {
    ClassRepository genericInfo = this.genericInfo;
    if (genericInfo == null) {
        String signature = getGenericSignature0();
        if (signature == null) {
            genericInfo = ClassRepository.NONE;
        } else {
            // !!!  RELEVANT LINE HERE:  !!!
            genericInfo = ClassRepository.make(signature, getFactory());
        }
        this.genericInfo = genericInfo;
    }
    return (genericInfo != ClassRepository.NONE) ? genericInfo : null;
}
_

ここで重要なのは、getFactory()の呼び出しです。

_CoreReflectionFactory.make(this, ClassScope.make(this))
_

ClassScopeは重要なビットです。これは、型変数の解決スコープを提供します。型変数名を指定すると、スコープは一致する型変数を検索します。見つからない場合は、 'outer'またはenclosingスコープが検索されます:

_public TypeVariable<?> lookup(String name) {
    TypeVariable<?>[] tas = getRecvr().getTypeParameters();
    for (TypeVariable<?> tv : tas) {
        if (tv.getName().equals(name)) {return tv;}
    }
    return getEnclosingScope().lookup(name);
}
_

そして最後に、すべての鍵(ClassScopeから):

_protected Scope computeEnclosingScope() {
    Class<?> receiver = getRecvr();

    Method m = receiver.getEnclosingMethod();
    if (m != null)
        // Receiver is a local or anonymous class enclosed in a method.
        return MethodScope.make(m);

    // ...
}
_

型変数(たとえば、F)がクラス自体(たとえば、匿名の_TypeToken<F[]>_)に見つからない場合、次のステップはenclosing method。逆アセンブルされた匿名クラスを見ると、次の属性があります。

_EnclosingMethod: LambdaTest.test()V
_

この属性が存在するということは、computeEnclosingScopeがジェネリックメソッドstatic <F> void test()に対してMethodScopeを生成することを意味します。 testは型変数Wを宣言しているため、外側のスコープを検索するとそれが見つかります。

では、なぜラムダ内で動作しないのですか?

これに答えるには、ラムダがどのようにコンパイルされるかを理解する必要があります。ラムダの本体 移動 合成静的メソッドへ。ラムダを宣言した時点で、invokedynamic命令が発行され、その命令に最初にヒットしたときにTypeToken実装クラスが生成されます。

この例では、ラムダ本体用に生成された静的メソッドは次のようになります(逆コンパイルされた場合)。

_private static /* synthetic */ Object lambda$test$0() {
    return new LambdaTest$1();
}
_

...ここで_LambdaTest$1_は匿名クラスです。それを分解して、属性を調べてみましょう。

_Signature: LTypeToken<TW;>;
EnclosingMethod: LambdaTest.lambda$test$0()Ljava/lang/Object;
_

ラムダの匿名型outsideをインスタンス化した場合と同様に、署名には型変数Wが含まれます。 しかしEnclosingMethod合成法を指します。

合成メソッドlambda$test$0()は、型変数Wを宣言しません。さらに、lambda$test$0()test()で囲まれていないため、Wの宣言は内部に表示されません。匿名クラスには、スコープ外にあるためクラスが知らない型変数を含むスーパータイプがあります。

getGenericSuperclass()を呼び出すと、_LambdaTest$1_のスコープ階層にWが含まれないため、パーサーはそれを解決できません。コードの記述方法により、この未解決の型変数により、nullが汎用スーパータイプの型パラメーターに配置されます。

ラムダが型変数をインスタンス化したnot型変数(たとえば、_TypeToken<String>_)をインスタンス化した場合、この問題は発生しません。

結論

(i)javacにバグがありますJava仮想マシン仕様 §4.7.7 ( " EnclosingMethod属性」)状態:

Javaコンパイラーの責任は、_method_index_で識別されるメソッドが、これを含むクラスの最も近い字句的に囲むメソッドであることを保証することですEnclosingMethod属性。 (エンファシス鉱山)

現在、javacは囲むメソッドを決定しているようですafterラムダリライタはそのコースを実行し、その結果、EnclosingMethod属性は存在しなかったメソッドを参照します字句スコープ内。 EnclosingMethodactual語彙的に囲むメソッドを報告した場合、そのメソッドの型変数はラムダ埋め込みクラスによって解決でき、コードは期待される結果を生成します。

間違いなく、署名パーサー/リファイアーがnull型の引数をParameterizedType(@ tom-hawtin-tacklineが指摘しているように、補助的な効果を持つ) toString()がNPEをスローするなど)。

私の バグレポートEnclosingMethodの問題がオンラインになりました。

(ii)_Java.lang.reflect_とそのサポートAPIには間違いなく複数のバグがあります。

メソッドParameterizedType::getActualTypeArguments()は、「実際の型引数のいずれかが存在しない型宣言を参照している場合」TypeNotPresentExceptionをスローするものとして文書化されています。この説明は、型変数がスコープ内にない場合をほぼカバーしています。 GenericArrayType::getGenericComponentType()は、「基礎となる配列型の型が存在しない型宣言を参照している」場合に同様の例外をスローする必要があります。現在、いずれの状況でも、どちらもTypeNotPresentExceptionをスローしないようです。

また、さまざまな_Type::toString_オーバーライドは、NPEまたはその他の例外をスローするのではなく、未解決の型の正規名を入力するだけでよいと主張します。

これらのリフレクション関連の問題に関するバグレポートを提出しました。リンクが公開されたら投稿します。

回避策は?

囲んでいるメソッドで宣言された型変数を参照できるようにする必要がある場合、ラムダでそれを行うことはできません。より長い匿名型構文にフォールバックする必要があります。ただし、ラムダバージョンは他のほとんどの場合に機能するはずです。囲んでいるclassで宣言された型変数を参照することもできるはずです。たとえば、これらは常に機能するはずです。

_class Test<X> {
    void test() {
        Supplier<TypeToken<X>> s1 = () -> new TypeToken<X>() {};
        Supplier<TypeToken<String>> s2 = () -> new TypeToken<String>() {};
        Supplier<TypeToken<List<String>>> s3 = () -> new TypeToken<List<String>>() {};
    }
}
_

残念ながら、ラムダが最初に導入されてから明らかにこのバグが存在し、最新のLTSリリースでは修正されていないことを考えると、バグが修正された後も、クライアントのJDKにバグが残っていると仮定する必要がありますまったく修正されました。

13
Mike Strobel

回避策として、TypeTokenの作成をラムダから別のメソッドに移動し、完全に宣言されたクラスの代わりにラムダを使用できます。

static<T> TypeToken<T[]> createTypeToken() {
    return new TypeToken<T[]>() {};
}

Supplier<TypeToken<T[]>> sup = () -> createTypeToken();
1

仕様の関連部分は見つかりませんでしたが、ここに部分的な答えがあります。

確かにnullであるコンポーネントタイプにはバグがあります。明確にするために、これはTypeToken.type上記のメソッドからGenericArrayTypeにキャストし、getGenericComponentTypeメソッドを呼び出します(うん!)。 APIドキュメントでは、返されたnullが有効かどうかを明示的に言及していません。ただし、toStringメソッドはNullPointerExceptionをスローするため、間違いなくバグがあります(少なくともJava私が使用しているバージョン)では)。

bugs.Java.comアカウントを持っていないので、報告できません。誰かがすべきです。

生成されたクラスファイルを見てみましょう。

javap -private YourClass

これにより、次のようなリストが作成されます。

static <T> void test();
private static TypeToken lambda$test$0();

明示的なtestメソッドには型パラメーターがありますが、合成ラムダメソッドにはないことに注意してください。あなたは次のようなものを期待するかもしれません:

static <T> void test();
private static <T> TypeToken<T[]> lambda$test$0(); /*** DOES NOT HAPPEN ***/
             // ^ name copied from `test`
                          // ^^^ `Object[]` would not make sense

なぜそうならないのですか。おそらく、これは型型パラメーターが必要なコンテキストのメソッド型パラメーターであり、驚くほど異なるものだからです。また、明らかに明示的な表記法がないため、ラムダにメソッド型パラメーターを許可しないという制限もあります(一部の人々は、これが貧弱な言い訳のように思われるかもしれません)。

結論:少なくとも報告されていないJDKバグが1つあります。 reflect AP​​Iおよび言語のこのラムダ+ジェネリック部分は、私の好みではありません。