Points Clés
- Java SE 10 (mars 2018) a introduit l'inférence de type pour les variables locales, l'une des fonctionnalités les plus demandées récemment pour Java.
- L'inférence de type est une technique utilisée par les langages à typage statique, où les types de variables peuvent être déduits du contexte par le compilateur.
- L'inférence de type en Java est locale ; la portée sur les contraintes rassemblées et résolues est limitée à une partie étroite du programme, telle qu'une expression ou une déclaration unique.
- Stuart Marks, de l'équipe des bibliothèques Java, a compilé un guide de style et une FAQ utiles pour vous aider à comprendre les compromis entourant l'inférence de type pour les variables locles.
- Correctement utilisée, l'inférence de type peut rendre votre code plus concis et plus lisible.
Dans Java Futures à QCon New York, Brian Goetz, architecte du langage Java, nous a guidés dans une revue des fonctionnalités récentes et futures du langage Java. Dans cet article, il détaille l'inférence de type pour les variables locales.
Java SE 10 (mars 2018) a introduit l'inférence de type pour les variables locales. Auparavant, la déclaration d'une variable locale nécessitait une déclaration de type explicite. Maintenant, l'inférence de type permet au compilateur de choisir le type statique de la variable, en fonction du type de sa valeur d'initialisation :
var names = new ArrayList<String>();
Dans cet exemple simple, la variable names
aura le type ArrayList<String>
.
Malgré la similarité syntaxique avec une fonctionnalité similaire en JavaScript, il ne s'agit pas d'un typage dynamique : toutes les variables en Java ont toujours un type statique. L'inférence de type de variables locales nous permet simplement de demander au compilateur de déterminer ce type pour nous, plutôt que de nous obliger à le fournir explicitement.
Inférence de type en Java
L'inférence de type est une technique utilisée par les langages à typage statique, où les types de variables peuvent être déduits du contexte par le compilateur. Les langages varient dans leur utilisation et leur interprétation de l'inférence de type. L'inférence de type fournit généralement au programmeur une option et non une obligation; nous sommes libres de choisir entre les types explicites et inférés, et nous devons faire ce choix de manière responsable, en utilisant l'inférence de type lorsqu'il améliore la lisibilité et en évitant le risque de confusion.
Les noms de types en Java peuvent être longs, soit parce que le nom de la classe est lui-même long, qu'il a des paramètres de type génériques complexes, ou les deux. En général, dans les langages de programmation, plus vos types sont intéressants, moins ils sont amusants à écrire - c'est pourquoi les langages utilisant des systèmes de typage plus sophistiqués ont tendance à s'appuyer davantage sur l'inférence de type.
Java a commencé avec une forme limitée d'inférence de type dans Java 5, et sa portée s'est progressivement étendue au fil des ans. En Java 5, lorsque les méthodes génériques ont été introduites, nous avons également introduit la possibilité d'inférer les paramètres de type générique lors de l'utilisation; on écrit généralement:
List<String> list = Collection.emptyList();
plutôt que de fournir des types explicites :
List<String> list = Collection.<String>emptyList();
En fait, la forme inférée est si courante que certains développeurs Java n'ont même jamais vu la forme explicite !
En Java 7, nous avons étendu la portée de l'inférence de type aux paramètres de type d'invocations de constructeur génériques (également appelé "diamant"); nous pouvons écrire
List<String> list = new ArrayList<>();
comme un raccourci pour le plus explicite
List<String> list = new ArrayList<String>();
En Java 8, lorsque nous avons introduit les expressions lambda, nous avons également introduit la possibilité de déduire les types de paramètres formels des expressions lambda. Donc, on peut écrire :
list.forEach(s -> System.out.println(s))
comme un raccourci pour le plus explicite
list.forEach((String s) -> System.out.println(s))
Et, dans Java 10, nous avons étendu l'inférence de type à la déclaration de variables locales.
Certains développeurs pourraient penser qu'il est préférable d'utiliser de manière routinière les types inférés, car il en résulte un programme plus concis; d'autres pourraient penser que c'est pire car cela supprime les informations potentiellement utiles. Mais ces deux points de vue sont simplistes. Parfois, les informations qui seraient déduites sont simplement un fouillis qui autrement risquerait de gêner (personne ne se plaint que nous utilisions systématiquement l'inférence de type pour les paramètres de type génériques) et, dans ces cas, l'inférence de type rend notre code plus lisible. Dans d'autres cas, les informations de type fournissent des indications essentielles sur ce qui se passe ou reflètent les choix créatifs du développeur; dans ces cas, il est préférable de s'en tenir aux types explicites.
Bien que nous ayons étendu la portée de l'inférence de type au fil des ans, l'un des principes de conception que nous avons suivi consiste à utiliser uniquement l'inférence de type pour les détails d'implémentation, et non la déclaration d'éléments d'API; les types de champs, les paramètres de méthode et les retours de méthode doivent toujours être explicitement typés, car nous ne voulons pas que les contrats d'API changent subtilement en fonction des modifications apportées à l'implémentation. Mais, dans la mise en œuvre des corps de méthodes, il est raisonnable d'avoir plus de latitude pour faire des choix sur la base de ce qui est plus lisible.
Comment fonctionne l'inférence de type ?
L'inférence de type est souvent mal comprise souvent proche de la magie ou de la lecture de l'esprit; Les développeurs anthropomorphisent souvent le compilateur et demandent "pourquoi le compilateur ne pourrait-il pas comprendre ce que je voudrais". En réalité, l'inférence de type est quelque chose de beaucoup plus simple : la résolution de contraintes.
Différents langages utilisent l'inférence de type différemment, mais le concept de base est le même pour tous les langages : rassembler les contraintes sur les types inconnus et, à un moment donné, résolvez-les. Là où les concepteurs de langage ont la latitude, c'est là où l'inférence de type peut être utilisée, quelles contraintes sont rassemblées et sur quelle portée elles sont résolues.
L'inférence de type en Java est locale; la portée sur laquelle nous rassemblons les contraintes et lorsque nous les résolvons est limitée à une partie étroite du programme, telle qu'une expression ou une déclaration unique. Par exemple, pour les variables locales, la portée sur laquelle nous rassemblons les contraintes et résolvons est la déclaration locale de la variable elle-même - indépendamment des autres assignations à cette variable. D'autres langues poursuivent une approche plus globale de l'inférence de type, prenant en compte toutes les utilisations de la variable avant de tenter de résoudre ce type. Bien que, au début, cela puisse sembler préférable car plus précis, il est souvent plus difficile à utiliser. Si les types de variables peuvent être influencés par chacune de leurs utilisations, lorsque les choses tournent mal (comme par exemple lorsque le type est surcontraint en raison d'une erreur de programmation), les messages d'erreur sont souvent peu utiles, et peut apparaître loin de la déclaration de la variable dont le type est inféré ou de la localisation de son utilisation erronée. Ces choix illustrent l'un des compromis fondamentaux auxquels les concepteurs de langage sont confrontés lorsqu'ils utilisent l'inférence de type : nous négocions en permanence précision et puissance prédictive en termes de complexité et de prévisibilité. Nous pouvons modifier l'algorithme pour augmenter la prévalence du compilateur pour "bien faire les choses" (en rassemblant plus de contraintes ou en résolvant sur une plus grande étendue), mais la conséquence est presque toujours plus désagréable quand elle échoue.
Comme exemple simple, considérons l'utilisation de l'opérateur diamant :
List<String> list = new ArrayList<>();
Nous savons que le type de List
est List<String>
, car il a un type explicite. Nous essayons d'inférer le type de paramètre de ArrayList, que nous écrirons comme x
. Donc, le type du côté droit est ArrayList<x>
. Comme nous assignons la droite à gauche, le type de droite doit être un sous-type de gauche, alors nous rassemblons la contrainte :
ArrayList<x> <: List<String>
Où <:
signifie "sous-type de". (Nous recueillons également la borne triviale x <: Object
, du fait que x
est une variable de type générique, dont la borne implicite est Object
.) Nous savons également, de la déclaration de ArrayList
que List<x>
est un supertype de ArrayList<x>
. À partir de ceci, nous pouvons déduire la contrainte liée x <: String
( JLS 18.2.3 ) et, puisqu'il s'agit de notre seule contrainte x
, nous pouvons conclure x=String
.
Voici un exemple plus compliqué :
List<String> list = ...
Set<String> set = ...
var v = List.of(list, set);
Ici, le membre de droite est un appel de méthode générique. Nous déduisons donc le paramètre de type générique à partir de la méthode suivante dans List:
public static <X> List<X> of(X... values)
Ici, nous avons plus d'informations à utiliser que dans l'exemple précédent - les types de paramètres, qui sont List<String>
et Set<String>
. Nous pouvons donc rassembler les contraintes :
List<String> <: x
Set<String> <: x
Étant donné cet ensemble de contraintes, nous résolvons x
en calculant la plus petite limite supérieure (JLS 4.10.4) - le type le plus précis qui soit un super type des deux - ce qui dans ce cas est Collection<String>
. Donc, le type de v
est List<Collection<String>>
.
Quelles contraintes rassemblons-nous ?
Lors de la conception d'un algorithme d'inférence de type, un choix clé est la manière dont nous recueillons les contraintes du programme. Pour certaines constructions de programme, telles que les affectations, le type situé à droite doit être compatible avec le type situé à gauche, nous en tirerions donc sûrement des contraintes. De même, pour les paramètres de méthodes génériques, nous pouvons rassembler des contraintes à partir de leurs types. Mais il existe d'autres sources d'informations que nous pourrions choisir d'ignorer dans certaines circonstances.
Au début, cela semble surprenant; Rassembler plus de contraintes ne serait-il pas préférable, car cela conduit à une réponse plus précise ? Encore une fois, la précision n'est pas toujours l'objectif le plus important. Le regroupement de davantage de contraintes peut également augmenter le risque de voir une solution surchargée (auquel cas l'inférence échoue ou choisit une réponse de repli comme Object
), ainsi que l'instabilité accrue du programme (de petits changements dans la mise en œuvre peuvent entraîner des changements surprenants dans le typage ou résolution de la surcharge ailleurs.) Comme pour la portée que nous résolvons, nous échangeons précision et puissance prédictive contre complexité et prévisibilité, ce qui est une tâche subjective.
Comme exemple concret de l'ignorance d'une source possible de contraintes, prenons un exemple connexe : la résolution de la surcharge de la méthode lorsque des lambdas sont passés en tant que paramètres de méthode. Nous pourrions utiliser les exceptions émises par les corps lambda pour restreindre l'ensemble des méthodes applicables (plus de précision), mais cela permettrait également à de petits changements dans la mise en œuvre du corps lambda de modifier le résultat de la sélection de la surcharge, ce qui serait surprenant (prévisibilité réduite). Dans ce cas, la précision accrue ne payant pas pour une prévisibilité réduite, cette contrainte n'est donc pas prise en compte lors de la prise de décision en matière de résolution de la surcharge.
Les petites lignes
Maintenant que nous avons compris le fonctionnement général de l'inférence de type, examinons en détail comment elle s'applique aux déclarations de variables locales. Pour une variable locale déclarée avec var
, nous calculons d'abord le type standalone de l'initialiseur. (Le type standalone est le type obtenu en calculant le type d'une expression "de bas en haut", en ignorant la cible de l'affectation. Certaines expressions, telles que les lambdas et les références de méthode, n'ont pas de type standalone et ne peuvent donc pas être l'initialiseur d'une variable locale dont le type est inféré).
Pour la plupart des expressions, nous utilisons simplement le type standalone de l'initialiseur comme type de la variable locale. Cependant, dans plusieurs cas - notamment lorsque le type standalone est non dénotable - nous pouvons affiner ou rejeter ce type.
Un type non dénotable est un type que nous ne pouvons pas écrire dans la syntaxe du langage. Les types non dénotables en Java incluent les types d'intersection (Runnable & Serializable
), les types de capture (ceux qui dérivent de la conversion de capture générique), les types de classe anonymes (le type d'une expression de création de classe anonyme) et le type Null (le type du null
littéral). Au début, nous avons envisagé de rejeter l'inférence sur tous les types non dénotables, en vertu de la théorie que var
devrait simplement être un raccourci pour un type explicite. Mais il s'est avéré que les types non dénotables étaient si omniprésents dans les programmes réels qu'une telle restriction rendrait la fonctionnalité moins utile et plus frustrante. Cela signifie que les programmes utilisant var
ne sont pas nécessairement simplement un raccourci pour un programme qui utilise des types explicites - il existe certains programmes qui sont exprimables avec var
qui ne le sont pas directement.
A titre d'exemple d'un tel programme, considérons la déclaration de classe anonyme :
var v = new Runnable() {
void run() { … }
void runTwice() { run(); run(); }
};
v.runTwice();
Si nous devions fournir un type explicitement – le choix évident étant : Runnable
– la méthode runTwice()
ne serait pas accessible via la variable v car elle ne fait pas partie de Runnable. Mais avec un type inféré, nous pouvons déduire le type le plus précis de l'expression de création de classe anonyme et pouvons donc accéder à la méthode.
Chaque catégorie de type non dénotable possède sa propre histoire. Pour le type Null (ce que nous déduirions en var x = null
), nous rejetons simplement la déclaration. C'est parce que la seule valeur qui correspond au type Null est null
et il est très peu probable que ce qui était prévu était une variable qui ne peut que valoir null. Parce que nous ne voulons pas "deviner" l'intention en déduisant Object
ou en utilisant un autre type, nous rejetons ce cas afin que le développeur puisse fournir le type correct.
Pour les types de classes anonymes et les types d'intersection, nous utilisons simplement le type inféré; ces types sont étranges et nouveaux mais fondamentalement inoffensifs. Cela signifie que nous sommes maintenant plus largement exposés à certains types "étranges" qui étaient auparavant restés sous la ligne de flottaison. Par exemple, supposons que nous ayons :
var list = List.of(1, 3.14d);
Cela ressemble à l'exemple précédent, donc nous savons comment cela va se dérouler - nous allons prendre la plus petite limite supérieure de Integer
et Double
. Cela s'avère être un type un peu laid Number & Comparable<? extends Number & Comparable<?>>
. Ainsi, le type de list
est List<Number & Comparable<? extends Number & Comparable<?>>>
Comme vous pouvez le constater, même un exemple simple peut donner lieu à des types étonnamment compliqués, dont certains ne peuvent pas s'écrire explicitement.
Le cas le plus délicat est ce que nous faisons avec les types de capture génériques. Les types de capture proviennent du côté sombre des génériques; elles découlent du fait que chaque utilisation de ?
dans un programme correspond à un type différent. Considérons cette déclaration de méthode :
void m(List<?> a, List<?> b)
Même si les types de a
et b
sont textuellement identiques, ils ne sont en réalité pas du même type car nous n'avons aucune raison de croire que les deux listes sont du même type d'élément. (Si nous voulions que les deux listes soient du même type, nous créerions une méthode générique m()
avec un type T
et nous utiliserions List<T>
pour les deux.) Le compilateur invente donc un espace réservé, appelé "capture", pour chaque utilisation du ?
dans le programme, afin que nous puissions séparer les utilisations différentes des caractères génériques. Jusqu'à présent, les types de capture restaient dans l'obscurité à laquelle ils appartenaient, mais si nous leur permettions de s'échapper dans la nature, ils pourraient semer la confusion.
Par exemple, supposons que nous ayons ce code dans la classe MyClass
:
var c = getClass();
Nous pourrions nous attendre à ce que le type de c
soit Class<?>
, mais le type de l'expression à droite est en fait Class<capture<?>>
. Mettre ce type tel quel dans notre programme n'aiderait personne.
Interdire la déduction des types de capture semblait attrayant au début, mais encore une fois, il y avait trop de cas où ces types sont apparus. Nous avons donc choisi de les purifier à l'aide d'une transformation appelée projection ascendante (upward projection) (JLS 4.10.5), qui prend un type pouvant inclure des types de capture et génère un supertype de ce type sans types de capture. Dans le cas de l'exemple ci-dessus, la projection ascendante assainit le type de c
to Class<?>
, qui est un type plus raisonable.
La désinfection des types est une solution pragmatique, mais ce n'est pas sans compromis. En déduisant un type différent du type naturel de l'expression, cela signifie que si nous devions refactoriser une expression complexe f(e)
en var x = e; f(x)
utilisant un refactoring "extraire une variable", cela pourrait modifier les décisions d'inférence de type en aval ou de sélection de surcharge. La plupart du temps, ce n'est pas un problème, mais c'est un risque que nous prenons lorsque nous bricolons avec le type "naturel" d'une expression. Dans le cas des types de capture, les effets secondaires du traitement étaient meilleurs que ceux de la maladie.
Opinions divergentes
Comparée à quelque chose comme les lambdas ou les génériques, l'inférence de type pour les variables locales est une fonctionnalité assez petite (bien que, comme vous l'avez vu, les détails soient plus compliqués que la plupart des gens ne le reconnaissent). Mais la controverse autour de cette fonctionnalité a été tout sauf petite.
Pendant plusieurs années, cela a été l'une des fonctionnalités les plus demandées pour Java. Les développeurs se sont habitués à cette fonctionnalité depuis C#, ou Scala, ou plus tard, Kotlin, et leur a terriblement manquée en revenant à Java et ils en ont parlé assez fort. Nous avons décidé d'aller de l'avant sur la base de sa popularité, de l'efficacité démontrée dans d'autres langages similaires à Java et de ses possibilités d'interaction avec d'autres fonctionnalités du langage.
Etonnamment peut-être, dès que nous avons annoncé que nous allions de l'avant, d'autres voies se sont fait entendre : ceux qui pensaient clairement que c'était la plus stupide idée qu'ils aient jamais vue. Cela a été décrit comme "cédant à la mode" ou "encourageant la paresse" (et pire), et des prédictions terribles ont été faites sur un avenir dystopique de code illisible. Et les partisans comme les antagonistes ont justifié leur position en faisant appel à la même valeur fondamentale : la lisibilité.
Après avoir délivré la fonctionnalité, la réalité n'était pas si grave; Bien qu'il existe une courbe d'apprentissage initiale dans laquelle les développeurs doivent trouver le bon moyen d'utiliser la nouvelle fonctionnalité (comme avec toutes les autres fonctionnalités), la plupart des développeurs peuvent facilement internaliser des lignes directrices raisonnables sur les cas où la fonctionnalité ajoute de la valeur ou pas, et l'utiliser en conséquence.
Conseils de style
Stuart Marks, de l'équipe des bibliothèques Java d'Oracle, a compilé un guide de style utile pour aider à comprendre les compromis entourant l'inférence de type pour les variables locales.
Comme pour la plupart des guides de style sensibles, celui-ci vise à clarifier les compromis. L'explicite est un compromis; D'une part, un type explicite fournit une déclaration non ambiguë et précise le type d'une variable, mais d'autre part, le type est parfois évident ou sans importance, et le type explicite peut entrer en conflit avec des informations plus importantes à l'attention du lecteur.
Les principes généraux énoncés dans le guide de style incluent :
- Choisissez de bons noms de variables. Si nous choisissons des noms expressifs pour les variables locales, il est plus probable que la déclaration de type soit inutile, voire même gênante. Par contre, si nous choisissons des noms de variable tels que
x
eta3
, supprimer les informations de type risque de rendre le code plus difficile à comprendre. - Minimiser la portée des variables locales. Plus la distance entre la déclaration d'une variable et son utilisation est grande, plus nous avons de chances de raisonner de manière imprécise. Utiliser
var
pour déclarer des variables locales dont l'étendue couvre plusieurs lignes est plus susceptible d'entraîner des oublis que celles ayant une étendue plus petite ou des types explicites. - Considérez
var
quand l'initialiseur fournit suffisamment d'informations au lecteur. Pour de nombreuses déclarations de variables locales, l'expression de l'initialiseur rend tout à fait évident ce qui se passe (tel quevar names = new ArrayList<String>()
) et, par conséquent, la nécessité d'un type explicite est réduite. - Ne vous inquiétez pas trop de la "programmation via interface". Les développeurs s'inquiètent souvent du fait que nous sommes depuis longtemps encouragés à utiliser des types abstraits (comme
List
) pour les variables, plutôt que des types d'implémentation plus spécifiques (commeArrayList
), mais si nous permettons au compilateur d'inférer le type, il en déduira le type le plus spécifique. Mais nous ne devrions pas trop nous inquiéter à ce sujet, car ce conseil est beaucoup plus important pour les API (telles que les types de retour de méthode) que pour les variables locales dans la mise en œuvre, surtout si vous avez suivi le conseil précédent concernant la réduction des portées. - Méfiez-vous des interactions entre
var
et l'inférence avec l'opérateur diamant. Les deuxvar
et "diamant" demandent au compilateur d'inférer les types pour nous, et il est parfaitement correct de les utiliser ensemble, s'il y a suffisamment d'informations de type présentes pour déduire le type souhaité, comme dans les types d'arguments de constructeur. - Attention à la combinaison
var
avec les littéraux numériques. Les littéraux numériques sont des poly expressions, ce qui signifie que leur type peut dépendre du type auquel ils sont affectés. (Par exemple, nous pouvons écrireshort x = 0
, et le littéral 0 est compatible avecint
,long
,short
etbyte
.) Mais, sans un type cible, le type standalone d'un littéral numérique estint
, donc changershort s = 0
pourvar s = 0
se traduira par le changement du type des
. - Utilisez
var
pour séparer des expressions chaînées ou imbriquées. Déclarer une nouvelle variable locale pour une sous-expression est fastidieux, cela augmente la tentation de créer une expression complexe avec chaînage et / ou imbrication, parfois au détriment de la lisibilité. En abaissant le coût de la déclaration d'une sous-expression, l'inférence de type de variables locales permet de reduire cette tentation, ce qui améliore la lisibilité.
Le dernier élément illustre un point important qui manque souvent dans le débat sur les fonctionnalités d'un langage de programmation. Lors de l'évaluation des conséquences d'une nouvelle fonctionnalité, nous ne prenons souvent en compte que les manières les plus superficielles de l'utiliser. Dans le cas d'une inférence de type de variables locales, cela remplacerait les types explicites dans les programmes existants avec var
. Cependant, le style de programmation que nous adoptons est influencé par de nombreux facteurs, notamment le coût relatif de différentes constructions. Si nous réduisons le coût de déclaration des variables locales, il va sans dire que nous pourrions rééquilibrer dans un endroit où nous utilisons plus de variables locales, ce qui pourrait potentiellement rendre les programmes plus lisibles. Mais, de tels effets de second ordre sont rarement pris en compte dans le débat pour savoir si une fonctionnalité va aider ou nuire.
De toute façon, bon nombre de ces directives ne sont que de bons conseils de style. Choisir de bons noms de variables est l'un des moyens les plus efficaces de rendre le code plus lisible, avec ou sans inférence.
Conclusion
Comme nous l'avons vu, l'inférence de type pour les variables locales n'est pas aussi simple que le suggère sa syntaxe. Certains voudront peut-être ignorer les types, mais cela nécessite en réalité une meilleure compréhension du système de types de Java. Toutefois, si vous comprenez comment cela fonctionne et respectez certaines règles de style raisonnables, cela peut aider à rendre votre code plus concis et plus lisible.
A propos de l'auteur
Brian Goetz est architecte du langage Java chez Oracle. Il était le spec lead de la spécification pour JSR-335 (Lambda Expressions for the Java Programming Language). Il est l'auteur du best-seller Java Concurrency in Practice et il est passionné par la programmation depuis que Jimmy Carter a été président.