web-dev-qa-db-fra.com

Comportement générique différent lors de l'utilisation de lambda au lieu d'une classe interne anonyme explicite

Le contexte

Je travaille sur un projet qui dépend fortement des types génériques. Un de ses composants clés est le soi-disant TypeToken, qui fournit un moyen de représenter les types génériques au moment de l'exécution et de leur appliquer certaines fonctions utilitaires. Pour éviter l'effacement de type de Java, j'utilise la notation des accolades ({}) pour créer une sous-classe générée automatiquement car cela rend le type réifiable.

Ce que TypeToken fait essentiellement

Il s'agit d'une version fortement simplifiée de TypeToken qui est beaucoup plus clémente que l'implémentation d'origine. Cependant, j'utilise cette approche afin que je puisse m'assurer que le vrai problème ne réside pas dans l'une de ces fonctions utilitaires.

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];

        // ...
    } 

Quand ça marche

Fondamentalement, cette implémentation fonctionne parfaitement dans presque toutes les situations. Il n'a aucun problème avec la gestion de la plupart des types. Les exemples suivants fonctionnent parfaitement:

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

Comme elle ne vérifie pas les types, l'implémentation ci-dessus autorise tous les types autorisés par le compilateur, y compris TypeVariables.

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

Dans ce cas, type est un GenericArrayType contenant un TypeVariable comme type de composant. C'est parfaitement bien.

La situation étrange lors de l'utilisation de lambdas

Cependant, lorsque vous initialisez un TypeToken à l'intérieur d'une expression lambda, les choses commencent à changer. (La variable type provient de la fonction test ci-dessus)

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

Dans ce cas, type est toujours un GenericArrayType, mais il contient null comme type de composant.

Mais si vous créez une classe interne anonyme, les choses recommencent à changer:

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

Dans ce cas, le type de composant contient à nouveau la valeur correcte (TypeVariable)

Les questions qui en résultent

  1. Qu'advient-il de la TypeVariable dans l'exemple lambda? Pourquoi l'inférence de type ne respecte-t-elle pas le type générique?
  2. Quelle est la différence entre l'exemple déclaré explicitement et l'exemple implicitement déclaré? L'inférence de type est-elle la seule différence?
  3. Comment puis-je résoudre ce problème sans utiliser la déclaration explicite du passe-partout? Cela devient particulièrement important dans les tests unitaires car je veux vérifier si le constructeur lève des exceptions ou non.

Pour clarifier un peu: ce n'est pas un problème qui est "pertinent" pour le programme car je n'autorise PAS du tout les types non résolvables, mais c'est quand même un phénomène intéressant que j'aimerais comprendre.

Ma recherche

Mise à jour 1

En attendant, j'ai fait quelques recherches sur ce sujet. Dans la spécification du langage Java §15.12.2.2 , j'ai trouvé une expression qui pourrait avoir quelque chose à voir avec cela - "pertinente pour l'applicabilité", mentionnant "l'expression lambda typée implicitement" comme exception. Évidemment, c'est le chapitre incorrect, mais l'expression est utilisée à d'autres endroits, y compris le chapitre sur l'inférence de type.

Mais pour être honnête: je n'ai pas encore vraiment compris ce que tous ces opérateurs aiment := ou Fi0 signifie ce qui rend vraiment difficile de le comprendre en détail. Je serais heureux si quelqu'un pouvait clarifier un peu cela et si cela pouvait être l'explication du comportement étrange.

Update 2

J'ai repensé à cette approche et je suis arrivé à la conclusion que même si le compilateur supprimait le type car il n'est pas "pertinent pour l'applicabilité", il ne justifie pas de définir le type de composant sur null à la place du type le plus généreux, Object. Je ne peux pas penser à une seule raison pour laquelle les concepteurs de langage ont décidé de le faire.

Mise à jour 3

Je viens de retester le même code avec la dernière version de Java (J'ai utilisé 8u191 avant). À mon grand regret, cela n'a rien changé, bien que l'inférence de type de Java ait été améliorée ...

Mise à jour 4

J'ai demandé une entrée dans l'officiel Java Bug Database/Tracker il y a quelques jours et cela vient d'être accepté. Comme les développeurs qui ont examiné mon rapport ont attribué la priorité P4 au bogue, il se peut prenez un certain temps jusqu'à ce qu'il soit corrigé. Vous pouvez trouver le rapport ici .

Un grand merci à Tom Hawtin - une ligne de mire pour avoir mentionné que cela pourrait être un bogue essentiel dans le Java SE lui-même. Cependant, un rapport de Mike Strobel serait probablement beaucoup plus détaillé que le mien en raison de son Cependant, lorsque j'ai rédigé le rapport, la réponse de Strobel n'était pas encore disponible.

53
Quaffel

tldr:

  1. Il y a un bogue dans javac qui enregistre la mauvaise méthode de fermeture pour les classes internes intégrées à lambda. Par conséquent, les variables de type de la méthode englobante actual ne peuvent pas être résolues par ces classes internes.
  2. Il existe sans doute deux ensembles de bogues dans l'implémentation de l'API Java.lang.reflect:
    • Certaines méthodes sont documentées comme levant des exceptions lorsque des types inexistants sont rencontrés, mais elles ne le font jamais. Au lieu de cela, ils permettent aux références nulles de se propager.
    • Les diverses Type::toString() remplacent actuellement lancent ou propagent un NullPointerException lorsqu'un type ne peut pas être résolu.

La réponse a à voir avec les signatures génériques qui sont généralement émises dans les fichiers de classe qui utilisent des génériques.

En règle générale, lorsque vous écrivez une classe qui possède un ou plusieurs supertypes génériques, le compilateur Java émet un attribut Signature contenant la ou les signatures génériques entièrement paramétrées du supertype de la classe (s). J'ai écrit à ce sujet avant , mais la courte explication est la suivante: sans eux, il ne serait pas possible de consommer des types génériques en tant que types génériques sauf si vous aviez justement le code source. En raison de l'effacement de type, les informations sur les variables de type sont perdues au moment de la compilation. Si ces informations n'étaient pas incluses en tant que métadonnées supplémentaires, ni le IDE ni votre compilateur ne sauraient qu'un type était générique et vous ne pourriez pas l'utiliser comme tel. Le compilateur ne pourrait pas non plus émettre le runtime nécessaire vérifie pour assurer la sécurité du type.

javac émettra des métadonnées de signature générique pour tout type ou méthode dont la signature contient des variables de type ou un type paramétré, c'est pourquoi vous pouvez obtenir les informations génériques de supertype d'origine pour vos types anonymes. Par exemple, le type anonyme créé ici:

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

... contient ce Signature:

LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;

À partir de cela, les API Java.lang.reflection Peuvent analyser les informations génériques de supertype sur votre classe (anonyme).

Mais nous savons déjà que cela fonctionne très bien lorsque le TypeToken est paramétré avec des types concrets. Regardons un exemple plus pertinent, où son paramètre type inclut une variable type:

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

Ici, nous obtenons la signature suivante:

LTypeToken<[TF;>;

C'est logique, non? Voyons maintenant comment les API Java.lang.reflect Sont capables d'extraire des informations génériques de supertype de ces signatures. Si nous regardons dans Class::getGenericSuperclass(), nous voyons que la première chose qu'il fait est d'appeler getGenericInfo(). Si nous n'avons pas encore appelé cette méthode, un ClassRepository est instancié:

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;
}

La pièce critique ici est l'appel à getFactory(), qui se développe en:

CoreReflectionFactory.make(this, ClassScope.make(this))

ClassScope est le bit qui nous intéresse: cela fournit une portée de résolution pour les variables de type. Étant donné un nom de variable de type, la portée est recherchée pour une variable de type correspondante. Si aucun n'est trouvé, la portée 'externe' ou entourant est recherchée:

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

Et, enfin, la clé de tout (de 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);

    // ...
}

Si une variable de type (par exemple, F) n'est pas trouvée sur la classe elle-même (par exemple, l'anonyme TypeToken<F[]>), Alors l'étape suivante consiste à rechercher le méthode englobante . Si nous regardons la classe anonyme désassemblée, nous voyons cet attribut:

EnclosingMethod: LambdaTest.test()V

La présence de cet attribut signifie que computeEnclosingScope produira un MethodScope pour la méthode générique static <F> void test(). Puisque test déclare la variable de type W, nous la trouvons lorsque nous recherchons la portée englobante.

Alors, pourquoi ça ne marche pas à l'intérieur d'un lambda?

Pour répondre à cela, nous devons comprendre comment les lambdas sont compilés. Le corps du lambda se déplace dans une méthode statique synthétique. Au moment où nous déclarons notre lambda, une instruction invokedynamic est émise, ce qui provoque la génération d'une classe d'implémentation TypeToken la première fois que nous frappons cette instruction.

Dans cet exemple, la méthode statique générée pour le corps lambda ressemblerait à quelque chose comme ceci (si décompilée):

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

... où LambdaTest$1 est votre classe anonyme. Démontons cela et inspectons nos attributs:

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

Tout comme dans le cas où nous avons instancié un type anonyme extérieur d'un lambda, la signature contient la variable de type W. Mais EnclosingMethod fait référence à la méthode synthétique.

La méthode synthétique lambda$test$0() ne déclare pas la variable de type W. De plus, lambda$test$0() n'est pas entouré par test(), donc la déclaration de W n'est pas visible à l'intérieur. Votre classe anonyme a un sur-type contenant une variable de type que votre classe ne connaît pas car elle est hors de portée.

Lorsque nous appelons getGenericSuperclass(), la hiérarchie de portée pour LambdaTest$1 Ne contient pas W, donc l'analyseur ne peut pas le résoudre. En raison de la façon dont le code est écrit, cette variable de type non résolue entraîne le placement de null dans les paramètres de type du supertype générique.

Notez que si votre lambda avait instancié un type qui ne faisait référence à aucune variable de type (par exemple, TypeToken<String>), Vous ne le feriez pas rencontrer ce problème.

Conclusions

(i) Il y a un bogue dans javac. Le Java Virtual Machine Specification §4.7.7 ("L'attribut EnclosingMethod") indique:

Il est de la responsabilité d'un compilateur Java Java de s'assurer que la méthode identifiée via le method_index Est bien la plus proche lexicalement englobant méthode de la classe qui contient cet attribut EnclosingMethod. (c'est moi qui souligne)

Actuellement, javac semble déterminer la méthode englobante après le réécriveur lambda suit son cours, et par conséquent, l'attribut EnclosingMethod fait référence à une méthode qui ne existait même dans le champ lexical. Si EnclosingMethod a signalé la méthode réelle englobant lexicalement, les variables de type sur cette méthode pourraient être résolues par les classes intégrées à lambda, et votre code produirait les résultats attendus.

C'est sans doute aussi un bogue que l'analyseur/réificateur de signature permet à un argument de type null de se propager silencieusement dans un ParameterizedType (qui, comme le souligne @ tom-hawtin-tackline, a des effets auxiliaires comme toString() lancer un NPE).

Mon rapport de bogue pour le problème EnclosingMethod est maintenant en ligne.

(ii) Il existe sans doute plusieurs bogues dans Java.lang.reflect et ses API de prise en charge.

La méthode ParameterizedType::getActualTypeArguments() est documentée comme lançant un TypeNotPresentException lorsque "l'un des arguments de type réels fait référence à une déclaration de type inexistante". Cette description couvre sans doute le cas où une variable de type n'est pas dans la portée. GenericArrayType::getGenericComponentType() devrait lever une exception similaire lorsque "le type du type de tableau sous-jacent fait référence à une déclaration de type inexistante". Actuellement, aucun ne semble lancer un TypeNotPresentException en aucune circonstance.

Je dirais également que les différentes substitutions de Type::toString Devraient simplement remplir le nom canonique de tout type non résolu plutôt que de lancer un NPE ou toute autre exception.

J'ai soumis un rapport de bogue pour ces problèmes liés à la réflexion et je publierai le lien une fois qu'il sera publiquement visible.

Solutions de contournement?

Si vous devez pouvoir référencer une variable de type déclarée par la méthode englobante, vous ne pouvez pas le faire avec un lambda; vous devrez revenir à la syntaxe de type anonyme plus longue. Cependant, la version lambda devrait fonctionner dans la plupart des autres cas. Vous devriez même être en mesure de référencer les variables de type déclarées par le class. Par exemple, ceux-ci devraient toujours fonctionner:

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>>() {};
    }
}

Malheureusement, étant donné que ce bogue existe apparemment depuis la première introduction de lambdas et qu'il n'a pas été corrigé dans la version la plus récente de LTS, vous devrez peut-être supposer que le bogue reste dans les JDK de vos clients longtemps après qu'il a été corrigé, en supposant qu'il le soit fixe du tout.

13
Mike Strobel

Pour contourner ce problème, vous pouvez déplacer la création de TypeToken hors de lambda vers une méthode distincte, tout en utilisant lambda au lieu de la classe entièrement déclarée:

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

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

Je n'ai pas trouvé la partie pertinente de la spécification, mais voici une réponse partielle.

Il y a certainement un bogue avec le type de composant étant null. Pour être clair, c'est TypeToken.type de la conversion ci-dessus en GenericArrayType (beurk!) avec la méthode getGenericComponentType invoquée. Les documents API ne mentionnent pas explicitement si le null retourné est valide ou non. Cependant, la méthode toString lance NullPointerException, il y a donc certainement un bogue (au moins dans la version aléatoire de Java que j'utilise).

Je n'ai pas de compte bugs.Java.com, je ne peux donc pas le signaler. Quelqu'un devrait.

Jetons un coup d'œil aux fichiers de classe générés.

javap -private YourClass

Cela devrait produire une liste contenant quelque chose comme:

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

Notez que notre méthode explicite test a son paramètre type, mais pas la méthode lambda synthétique. Vous pourriez vous attendre à quelque chose comme:

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

Pourquoi cela ne se produit-il pas? Vraisemblablement parce que ce serait un paramètre de type de méthode dans un contexte où un paramètre de type de type est requis, et ce sont des choses étonnamment différentes. Il existe également une restriction sur les lambdas ne leur permettant pas d'avoir des paramètres de type de méthode, apparemment parce qu'il n'y a pas de notation explicite (certaines personnes peuvent suggérer que cela semble être une mauvaise excuse).

Conclusion: il y a au moins un bogue JDK non signalé ici. L'API reflect et cette partie lambda + génériques du langage ne sont pas à mon goût.

1