BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Pleins Feux Sur Une Fonctionnalité De Java 14 : Les Records

Pleins Feux Sur Une Fonctionnalité De Java 14 : Les Records

Points Clés

  • Java SE 14 (mars 2020) présente les Records comme une fonctionalité en mode preview. Les Records visent à améliorer la capacité du langage à modéliser des agrégats de «données simples» avec moins de code.
  • Un record peut être considéré comme un tuple nominal; c'est un support transparent, peu profond et immuable pour une séquence d'éléments ordonnée spécifique.
  • Les records peuvent être utilisés dans une grande variété de situations pour modéliser des cas d'utilisation courants, tels que les retours multiples, les jointures de flux, les clés composées, les nœuds d'arborescence, les DTO, etc., et fournir des garanties sémantiques plus solides qui permettent aux développeurs et aux frameworks de raisonner de manière fiable sur leur état.
  • Comme les énumérations, les records sont soumis à certaines restrictions par rapport aux classes, et ne remplaceront donc pas toutes les classes de supports de données. Plus précisément, ils ne sont pas destinés à remplacer les classes JavaBean mutables.
  • Les classes existantes qui pourraient être considérées comme des records peuvent être migrées de manière compatible vers des records.

Les fonctionnalités en preview

Compte tenu de la portée mondiale et des engagements de compatibilité élevés de la plate-forme Java, le coût d'une erreur de conception dans une fonctionnalité du langage est très élevé. Dans le contexte d'une erreur dans le langage, l'engagement envers la compatibilité signifie non seulement qu'il est très difficile de supprimer ou de modifier considérablement la fonctionnalité, mais que les fonctionnalités existantes limitent également ce que les fonctionnalités futures peuvent faire - les nouvelles fonctionnalités brillantes d'aujourd'hui sont les contraintes de compatibilité de demain.

Le terrain d'essai ultime pour les fonctionnalités du langage est l'utilisation réelle; les commentaires des développeurs qui les ont réellement essayés sur de vraies bases de code sont essentiels pour s'assurer que la fonctionnalité fonctionne comme prévu. Lorsque Java avait des cycles de publication pluriannuels, il y avait beaucoup de temps pour l'expérimentation et la rétroaction. Pour garantir suffisamment de temps pour l'expérimentation et la rétroaction dans le cadre de la nouvelle cadence de publication rapide, les nouvelles fonctionnalités du langage passeront par une ou plusieurs séries de preview, où elles font partie de la plate-forme, mais doivent être activées séparément et qui ne sont pas encore permanentes -- de sorte que dans le cas où ils doivent être ajustés en fonction des commentaires des développeurs, cela est possible sans casser le code critique.

Dans Java Futures à QCon New York, Brian Goetz, architecte du langage Java, nous a porposé une visite éclair de certaines des fonctionnalités récentes et futures du langage Java. Dans le premier article de cette série, il a examiné l'inférence de type pour les variables locales. Dans cet article, il plonge dans les Records.

Java SE 14 (mars 2020) présente les records (jep359) en tant que fonctionnalité en preview. Les records visent à améliorer la capacité du langage à modéliser des agrégats de «données simples» avec moins de code. Nous pouvons déclarer une simple abstraction de points x-y comme suit :

record Point(int x, int y) { }

qui déclarera une classe finale appelée Point, avec des composants immuables pour x et y et les implémentations des accesseurs, des constructeurs, des méthodes equals, hashCode et toString.

Nous connaissons tous l'alternative - écrire (ou faire générer par l'EDI) des implémentations remplies de code souvent à faible valeur ajoutée pour les constructeurs, les méthodes d'Object et les accesseurs.

Celles-ci sont sûrement lourdes à écrire, mais plus important encore, ce sont plus de travail pour les lire; nous devons lire tout le code standard juste pour conclure que nous n'avions pas du tout besoin de le lire.

Que sont les records ?

Un record peut être considéré comme un tuple nominal; c'est un support transparent, shallowly immutable pour une séquence d'éléments ordonnée spécifique. Les noms et les types des éléments d'état sont déclarés dans l'en-tête du record et sont appelés description d'état. Nominal signifie que l'agrégat et ses composants ont des noms, plutôt que de simples index; transparent signifie que l'état est accessible aux clients (bien que la mise en œuvre puisse servir d'intermédiaire pour cet accès); shallowly immutable signifie que le tuple de valeurs représenté par un record ne change pas une fois instancié (bien que, si ces valeurs sont des références à des objets mutables, l'état des objets auxquels il est fait référence peut changer).

Les records, comme les énumérations, sont une forme restreinte de classes, optimisée pour certaines situations courantes. Les énumérations nous offrent une sorte d'aubaine; nous abandonnons le contrôle de l'instanciation et, en retour, nous obtenons certains avantages syntaxiques et sémantiques. Nous sommes ensuite libres de choisir des énumérations ou des classes régulières, selon que les avantages des énumérations l'emportent sur les coûts dans la situation spécifique en question.

Les records nous offrent quelque chode de similaire; ce qu'ils nous demandent d'abandonner, c'est la capacité de dissocier l'API de la représentation, ce qui permet au langage de dériver l'API et l'implémentation pour la construction, l'accès à l'état, la comparaison d'égalité et la représentation mécaniquement à partir de la description de l'état.

Lier l'API à la représentation peut sembler entrer en conflit avec un principe fondamental de l'orienté objet : l'encapsulation. Alors que l'encapsulation est une technique essentielle pour gérer la complexité, et la plupart du temps c'est le bon choix, parfois nos abstractions sont si simples - comme un point x-y - que les coûts d'encapsulation dépassent les avantages. Certains de ces coûts sont évidents - comme le code à faible valeur ajoutée requis pour créer une simple classe de domaine. Mais il y a aussi un autre coût, moins évident : les relations entre les éléments de l'API ne sont pas capturées par le langage, mais uniquement par convention. Cela sape la capacité de raisonner mécaniquement au sujet des abstractions, ce qui à son tour conduit à du code à faible valeur ajoutée supplémentaire.

La programmation avec des objets de données en Java a, historiquement, nécessité un acte de foi. Nous connaissons tous la technique suivante pour modéliser un support de données mutable :

class AnInt {
    private int val;

    public AnInt(int val) { this.val = val; }

    public int getVal() { return val; }

    public void setVal(int val) { this.val = val; }

    // More boilerplate for equals, hashCode, toString
}

Le nom val apparaît à trois endroits dans l'API publique - l'argument du constructeur et les deux méthodes d'accesseur. Il n'y a rien dans ce code, autre que la convention de nommage, pour capturer ou exiger que ces trois utilisations de val parlent de la même chose, ou que getVal() renvoie la valeur la plus récente définie par setVal() - au mieux, elle est capturée dans une spécification lisible par l'homme (mais en réalité, nous ne le faisons presque jamais.) Interagir avec une telle classe est purement un acte de foi.

Les records, en revanche, prennent un engagement plus fort - que l'accesseur x() et le paramètre du constructeur x parlent de la même quantité . En conséquence, non seulement le compilateur est capable de dériver des implémentations par défaut sensibles de ces membres, mais les frameworks peuvent mécaniquement raisonner sur la construction et les protocoles d'accès aux états - et leur interaction - pour dériver mécaniquement des comportements tels que le marshaling en JSON ou XML.

Les petits détails

Comme mentionné précédemment, les records comportent certaines restrictions. Leurs champs d'instance (qui correspondent aux composants déclarés dans l'en-tête du record) sont implicitement final; ils ne peuvent avoir aucun autre champ d'instance; la classe du record elle-même ne peut pas étendre d'autres classes; et les classes de record sont implicitement final. En dehors de cela, ils peuvent avoir à peu près tout ce que les autres classes peuvent : constructeurs, méthodes, champs statiques, variables de type, interfaces, etc.

En échange de ces restrictions, les records acquièrent automatiquement les implémentations implicites du constructeur canonique (celui dont la signature correspond à la description de l'état), les accesseurs pour chaque composant d'état (dont les noms sont les mêmes que le composant), les champs finaux privés pour chaque composant d'état et les implémentations basées sur l'état des méthodes Object equals(), hashCode() et toString(). (À l'avenir, lorsque le langage Java prend en charge le pattern matching, les records prennent automatiquement en charge les modèles de déconstruction également.) La déclaration d'un record peut "remplacer" les déclarations implicites du constructeur et des méthodes si celles-ci s'avèrent inadaptées (bien qu'il existe des contraintes, spécifiées dans la super classe implicite java.lang.Record, que ces implémentations doivent respecter) et peuvent déclarer des membres supplémentaires (sous réserve des restrictions).

Un exemple de cas où un record pourrait vouloir affiner l'implémentation du constructeur est de valider l'état dans le constructeur. Par exemple, dans une classe Range, nous voudrions vérifier que l'extrémité inférieure de la plage n'est pas supérieure à l'extrémité supérieure :

public record Range(int lo, int hi) {
    public Range(int lo, int hi) {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
        this.lo = lo;
        this.hi = hi;
    }
}

Bien que cette implémentation soit parfaitement correcte, il est quelque peu regrettable que, pour effectuer une simple vérification invariante, nous ayons dû utiliser les noms des composants cinq fois de plus. On pourrait facilement imaginer que les développeurs se convainquent qu'ils n'ont pas besoin de vérifier ces invariants, car ils ne veulent pas rajouter autant de code redondant qu'ils ont simplement épargné en utilisant un record.

Étant donné que cette situation devrait être si courante et que la vérification de la validité est si importante, les records permettent une formulation compacte spéciale pour déclarer explicitement le constructeur canonique. Sous cette forme, la liste des arguments peut être omise dans son intégralité (elle est supposée être la même que la description de l'état) et les arguments du constructeur sont implicitement validés dans les champs du records à la fin du constructeur. (Les paramètres du constructeur eux-mêmes sont mutables, ce qui signifie que les constructeurs qui veulent normaliser l'état - comme réduire un rationnel aux termes les plus bas - peuvent le faire simplement en mutant les paramètres du constructeur.) Ce qui suit est la version compacte de la déclaration du record ci-dessus :

public record Range(int lo, int hi) {
    public Range {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
    }
}

Cela conduit à un résultat agréable : le seul code que nous devons lire est le code qui n'est pas mécaniquement dérivable de la description de l'état.

Exemples de cas d'utilisation

Bien que toutes les classes - et même pas toutes les classes centrées sur les données - ne puissent pas devenir des records, les cas d'utilisation des records abondent.

Une fonctionnalité couramment demandée pour Java est le retour multiple - permettant à une méthode de renvoyer plusieurs éléments à la fois; l'incapacité de le faire nous conduit souvent à exposer des API sous-optimales. Considérez une paire de méthodes qui analysent une collection et renvoient les valeurs minimales ou maximales :

static<T> T min(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

static<T> T max(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

Ces méthodes sont faciles à écrire, mais elles ont quelque chose d'insatisfaisant; pour obtenir les deux valeurs limites, nous devons parcourir la liste deux fois. Ceci est moins efficace qu'une analyse unique et peut entraîner des valeurs incohérentes si les collections analysées peuvent être modifiées simultanément.

Bien qu'il soit possible que ce soit l'API que l'auteur voulait exposer, il est plus probable que c'est l'API que nous avons obtenue, car écrire une meilleure API nécessitait trop de travail. Plus précisément, pour renvoyer les deux valeurs limites en une seule passe, nous avons besoin d'un moyen de renvoyer les deux valeurs à la fois. Nous pourrions bien sûr le faire en déclarant une classe, mais la plupart des développeurs chercheraient immédiatement des moyens pour éviter de le faire - uniquement en raison de la surcharge syntaxique de déclaration de la classe d'assistance. En réduisant le coût de description d'un agrégat personnalisé, nous pouvons facilement le transformer en l'API que nous voulions probablement en premier lieu :

record MinMax<T>(T min, T max) { }

static<T> MinMax<T> minMax(Iterable<? extends T> elements,
                           Comparator<? super T> comparator) { ... }

Un autre exemple courant sont les clés composées de maps. Parfois, nous voulons une map basée sur la conjonction de deux valeurs distinctes, comme représenter la dernière fois qu'un utilisateur donné a utilisé une certaine fonctionnalité. Nous pouvons facilement le faire avec une HashMap dont la clé combine la personne et la fonctionnalité. Mais s'il n'y a pas de type PersonAndFeature pratique, nous devons en écrire un, avec tous les détails de construction, comparaison d'égalité, hachage, etc. Encore une fois, nous pouvons le faire, mais notre paresse pourrait bien s'imisser sur le chemin, et nous pourrions être tentés, par exemple, de concaténer le nom de la personne avec le nom de la fonctionnalité comme clé, ce qui se traduit par un code plus difficile à lire et plus sujet aux erreurs. Les records nous permettent de le faire directement :

record PersonAndFeature(Person p, Feature f) { }
Map<PersonAndFeature, LocalDateTime> lastUsed = new HashMap<>();

Le désir d'utiliser des composites vient souvent avec des streams, tout comme ils le pourraient avec des clés de maps - et nous rencontrons les mêmes problèmes accidentels qui nous font chercher des solutions sous-optimales. Supposons, par exemple, que nous souhaitons effectuer une opération d'un stream sur une quantité dérivée, telle que le classement des meilleurs joueurs. Nous pourrions écrire ceci comme :

List<Player> topN
        = players.stream()
             .sorted(Comparator.comparingInt(p -> getScore(p)))
             .limit(N)
             .collect(toList());

C'est assez facile, mais que se passe-t-il si la recherche du score nécessite des calculs ? Nous calculerions alors les scores O (n ^ 2), plutôt que O(n). Avec les records, nous pouvons facilement attacher temporairement certaines données dérivées au contenu du flux, opérer sur les données jointes, puis les projeter à nouveau comme nous le souhaitons :

record PlayerScore(Player player, Score score) {
    // convenience constructor for use by Stream::map
    PlayerScore(Player player) { this(player, getScore(player)); }
}

List<Player> topN
    = players.stream()
             .map(PlayerScore::new)
             .sorted(Comparator.comparingInt(PlayerScore::score))
             .limit(N)
             .map(PlayerScore::player)
             .collect(toList());

Si cette logique est à l'intérieur d'une méthode, le record peut même être déclaré local à la méthode.

Bien sûr, il existe de nombreux autres cas évidents pour les records : nœuds d'arborescence, objets de transfert de données (DTO), messages dans les systèmes d'acteurs, etc.

Embrasser notre fainéantise

Un thème commun dans les exemples jusqu'à présent est qu'il était possible d'obtenir le bon résultat sans records, mais nous aurions bien pu être tentés de prendre des raccourcis en raison de la surcharge syntaxique. Nous avons tous été tentés de réutiliser une abstraction existante de manière incorrecte plutôt que de coder l'abstraction correcte, ou de prendre des raccourcis en omettant les implémentations des méthodes d'Object (qui peuvent provoquer des bogues subtils lorsque ces objets sont utilisés comme clés de maps, ou rendre le débogage plus difficile lorsque la valeur toString() n'est pas utile).

Avoir une notation concise pour dire ce que nous voulons nous apporte deux sortes d'avantages. La plus évidente est que le code qui fait déjà la bonne chose bénéficie de la concision, mais plus subtilement, cela signifie également que nous obtiendrons plus du code qui fait la bonne chose - parce que nous avons réduit la quantité d'énergie d'activation pour faire la bonne chose, et donc réduit la tentation de prendre des raccourcis. Nous avons constaté un effet similaire avec l'inférence de type pour les variables locales; lorsque la surcharge de déclaration d'une variable est réduite, les développeurs sont plus susceptibles de factoriser des calculs complexes dans des calculs plus simples, ce qui donne un code plus lisible et moins sujet aux erreurs.

Les routes non empruntées

Tout le monde convient que la modélisation d'agrégats de données en Java - ce que nous faisons souvent - a besoin de trop de code. Malheureusement, ce consensus n'a qu'une profondeur syntaxique; les opinions variaient largement (et à haute voix) sur la souplesse des records, les restrictions raisonnables à accepter et les cas d'utilisation les plus importants.

La principale route non suivie consistait à étendre les records pour remplacer les classes JavaBean mutables. Bien que cela aurait des avantages évidents - en particulier, en élargissant le nombre de classes qui pourraient devenir des records - les coûts supplémentaires auraient également été importants. Les fonctionnalités complexes et ad hoc sont plus difficiles à raisonner et sont plus susceptibles d'interagir de manière surprenante avec d'autres fonctionnalités - c'est ce que nous obtiendrions si nous essayions de dériver la conception des fonctionnalités de la variété de modèles JavaBean couramment utilisés aujourd'hui ( sans parler des débats sur les cas d'utilisation suffisamment fréquents pour mériter un support linguistique).

Ainsi, alors qu'il est superficiellement tentant de penser que les records concernent principalement la réduction du code à faible plus value, nous avons préféré l'aborder comme un problème sémantique; comment pouvons-nous mieux modéliser les agrégats de données directement dans le langage et fournir une base sémantique solide pour de telles classes que les développeurs peuvent facilement raisonner dessus ? (L'approche consistant à traiter cela comme un problème sémantique plutôt que syntaxique fonctionnait assez bien pour les énumérations.) Et la réponse logique pour Java était : les records sont des tuples nominaux.

Pourquoi les restrictions ?

Les restrictions sur les records peuvent sembler arbitraires au premier abord, mais elles découlent toutes d'un objectif commun, que nous pouvons résumer comme «les records sont l'état, l'état tout entier et rien que l'état». Plus précisément, nous voulons l'égalité des records à être dérivé de l'intégralité de l'état déclaré dans la description de l'état, et rien d'autre. Si des champs modifiables, ou des champs supplémentaires ou des super classes étaient autorisés, ils introduiraient chacun des situations dans lesquelles l'égalité des records ignorerait certains composants d'état (il est douteux d'inclure des composants mutables dans les calculs d'égalité), ou dépendrait d'un état supplémentaire qui ne fait pas partie de la description de l'état (comme les champs d'instance supplémentaires ou l'état de super classe). Cela aurait grandement compliqué la fonctionnalité (car les développeurs exigeraient sûrement la possibilité de spécifier séparément les composants qui font partie du calcul d'égalité), et saperaient également les invariants sémantiques souhaitables (tels que : extraire l'état et construire un nouveau record à partir des valeurs résultantes devrait aboutir à un record égal à l'original).

Pourquoi pas des tuples structurels ?

Etant donné que le centre de conception des records est constitué de tuples nominaux, on peut se demander pourquoi nous n'avons pas choisi à la place des tuples structurels. Ici, la réponse est simple : les noms comptent. Un record Personne avec les composants prenom et nom est plus clair et plus sûr qu'un tuple de String et String. Les classes prennent en charge la validation d'état via leurs constructeurs; les tuples ne le font pas. Les classes peuvent avoir un comportement supplémentaire dérivé de leur état; les tuples ne le peuvent pas. Les classes appropriées peuvent être migrées de manière compatible vers et depuis les records sans casser leur code client; les tuples ne le peuvent pas. Et les tuples structurels ne font pas de distinction entre un Point et une Plage (les deux sont des paires d'entiers), même si les deux ont une sémantique complètement différente. (Nous avons fait face au choix entre la représentation nominale et la représentation structurelle en Java; auparavant dans Java 8, nous avons choisi les types de fonctions nominaux plutôt que structurels pour un certain nombre de raisons; plusieurs de ces mêmes raisons s'appliquent lors du choix des tuples nominaux plutôt que des tuples structurels.)

Futurs

JEP 355 spécifie les records comme une fonctionnalité autonome, mais la conception des records a été influencée par le désir que les records fonctionnent bien avec plusieurs autres fonctionnalités en cours de développement : sealed types, pattern matching et inline classes.

Les records sont une forme de product type, ainsi appelé parce que leur espace d'état est le (un sous-ensemble du) produit cartésien des espaces d'état de leurs composants, et forment la moitié de ce que l'on appelle communément les types de données algébriques. L'autre moitié est appelée sum type; un sum type est une union discriminée, telle que "une forme est un cercle ou un rectangle"; ce n'est pas quelque chose que nous pouvons actuellement exprimer en Java (sauf par le biais de ruse comme les constructeurs non publics). Les types scellés (Sealed types) corrigeront cette limitation, par laquelle les classes et les interfaces peuvent directement déclarer qu'elles ne peuvent être étendues que par un ensemble fixe de types. Les sommes de produits sont une technique très courante et très utile pour modéliser des domaines complexes de manière flexible mais sécurisée, comme les nœuds d'un document complexe.

Les langages avec des types de produits prennent souvent en charge la déstructuration des produits avec le pattern matching; les records ont été conçus de A à Z pour permettre une déstructuration facile (l'exigence de transparence des records découle en partie de cet objectif). La première étape du pattern matching ne prend en charge que les modèles de type (type pattern), mais les modèles de déconstruction des records suivront bientôt.

Enfin, les records correspondent souvent (mais pas toujours) aux types inline; les agrégats qui répondent aux exigences des records et des types inline (et nombre d'entre eux le feront) peuvent combiner le record et l'inline en inline records.

Conclusion

Les records réduisent la verbosité de nombreuses classes courantes en fournissant un moyen direct de modéliser des données en tant que données, plutôt que de simuler des données avec des classes. Les records peuvent être utilisés dans une grande variété de situations pour modéliser des cas d'utilisation courants, tels que les retours multiples, les jointures de stream, les clés composées, les nœuds d'arborescence, les DTO, etc. Bien que les records soient utiles en eux-mêmes, ils interagiront également positivement avec plusieurs fonctionnalités à venir, notamment les sealed types, le pattern matching et les inline classes.

A propos de l'auteur

Brian Goetz est l'architecte du langage Java chez Oracle et a été le responsable des spécifications pour la JSR-335 (Expressions Lambda pour le langage de programmation Java). Il est l'auteur du best-seller Java Concurrency in Practice, et a été fasciné par la programmation depuis que Jimmy Carter était président.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT