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 Les Fonctionnalités Java : Les Classes Scellées

Pleins Feux Sur Les Fonctionnalités Java : Les Classes Scellées

Points Clés

  • La sortie de Java SE 15 en septembre 2020 introduira les "classes scellées" (JEP 360) comme fonctionnalité en preview
  • Une classe scellée est une classe ou une interface qui restreint les autres classes ou interfaces qui peuvent l'étendre
  • Les classes scellées, comme les énumérations, capturent des alternatives dans les modèles de domaine, permettant aux programmeurs et aux compilateurs de raisonner sur l'exhaustivité
  • Les classes scellées sont également utiles pour créer des hiérarchies sécurisées en dissociant l'accessibilité de l'extensibilité, permettant aux développeurs de bibliothèques d'exposer des interfaces tout en contrôlant toutes les implémentations
  • Les classes scellées fonctionnent avec les records et le pattern matching pour prendre en charge une forme de programmation plus centrée sur les données

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.

Java SE 15 (septembre 2020) propose les classes scellées (sealed classes) en tant que fonctionnalité en preview. Le scellement permet aux classes et aux interfaces d'avoir plus de contrôle sur leurs sous-types autorisés. Ceci est utile à la fois pour la modélisation de domaine générale et pour la création de bibliothèques de plate-forme plus sécurisées.

Une classe ou une interface peut être déclarée sealed, ce qui signifie que seul un ensemble spécifique de classes ou d'interfaces peut directement l'étendre :


sealed interface Shape
    permits Circle, Rectangle { ... }

Cela déclare une interface scellée appelée Shape. La liste permits signifie que seuls Circle et Rectangle peuvent implémenter Shape. (Dans certains cas, le compilateur peut être en mesure de déduire la clause permits pour nous). Toute autre classe ou interface qui tente d'étendre Shape recevra une erreur de compilation (ou une erreur d'exécution, si vous essayez de tricher en générant un fichier .class off-label qui déclare Shape comme un supertype).

Nous connaissons déjà la notion de restriction de l'extension à travers les classes final. Le scellement peut être considéré comme une généralisation de la finalité. La restriction de l'ensemble des sous-types autorisés peut entraîner deux avantages : l'auteur d'un supertype peut mieux raisonner sur les implémentations possibles car il peut contrôler toutes les implémentations, et le compilateur peut mieux raisonner sur l'exhaustivité (comme dans des instructions switch ou des conversions avec cast.) Les classes scellées s'associent également bien avec les records.

Somme et types de produits

La déclaration de l'interface ci-dessus indique qu'une Shape peut être soit un Circle ou un Rectangle et rien d'autre. En d'autres termes, l'ensemble de tous les Shape est égal à l'ensemble de tous les Circle plus l'ensemble de tous les Rectangle. Pour cette raison, les classes scellées sont souvent appelées somme de types (sum types), car leur ensemble de valeurs est la somme des ensembles de valeurs d'une liste fixe d'autres types. Les sommes de types et les classes scellées ne sont pas une nouveauté. Par exemple, Scala possède également des classes scellées, Haskell et ML ont des primitives pour définir la somme de types (parfois appelés tagged unions ou discriminated unions.)

Les sommes de types se trouvent fréquemment aux côtés des types de produits (product types). Les records, récemment introduits dans Java, sont une forme de type de produits, ainsi appelée en raison de leur état est (un sous-ensemble de) le produit cartésien de l'état de leurs composants. (Si cela semble compliqué, considérez les types de produits comme des tuples et les records comme des tuples nominaux.) Terminons la déclaration de shape en utilisant des records pour déclarer les sous-types :

sealed interface Shape
    permits Circle, Rectangle {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Ici, nous voyons comment la somme des types et les types de produits vont ensemble. On peut dire "un cercle est défini par un centre et un rayon", "un rectangle est défini par deux points" et enfin "une forme est soit un cercle soit un rectangle". Parce que nous nous attendons à ce qu'il soit courant de co-déclarer le type de base avec ses implémentations de cette manière, lorsque tous les sous-types sont déclarés dans la même unité de compilation, nous autorisons la clause permits à être omise et nous la déduisons pour être l'ensemble des sous-types déclarés dans cette unité de compilation :

sealed interface Shape {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Attendez, n'est-ce pas violer l'encapsulation ?

Historiquement, la modélisation orientée objet nous a encouragés à masquer l'ensemble des implémentations d'un type abstrait. Nous avons été découragés de demander "quels sont les sous-types possibles de Shape", et nous avons également dit que la conversion vers une classe d'implémentation spécifique est un "code smell". Alors pourquoi ajoutons-nous soudainement des fonctionnalités linguistiques qui vont apparemment à l'encontre de ces principes de longue date ? (Nous pourrions également poser la même question à propos des Records : ne viole-t-il pas l'encapsulation pour mandater une relation spécifique entre une représentation de classes et son API ?)

La réponse est, bien sûr, "cela dépend". Lors de la modélisation d'un service abstrait, il est positif que les clients n'interagissent qu'avec le service via un type abstrait, car cela réduit le couplage et maximise la flexibilité pour faire évoluer le système. Cependant, lors de la modélisation d'un domaine spécifique où les caractéristiques de ce domaine sont déjà bien connues, l'encapsulation peut avoir moins à nous offrir. Comme nous l'avons vu avec les enregistrements, lors de la modélisation de quelque chose d'aussi prosaïque qu'un point XY ou une couleur RVB, l'utilisation de la totalité des objets pour modéliser les données nécessite à la fois beaucoup de travail de faible valeur et pire, peut souvent obscurcir ce qui se passe réellement. Dans de tels cas, l'encapsulation a des coûts non justifiés par son avantage; la modélisation des données en tant que données est plus simple et plus directe.

Les mêmes arguments s'appliquent aux classes scellées. Lors de la modélisation d'un domaine bien compris et stable, l'encapsulation de "je ne vais pas vous dire quels types de formes il y a" ne confère pas nécessairement les avantages que nous espérons obtenir de l'abstraction opaque, et peut même faire qu'il est plus difficile pour les clients de travailler avec ce qui est en fait un simple domaine.

Cela ne signifie pas que l'encapsulation est une erreur; cela signifie simplement que parfois l'équilibre des coûts et des avantages est hors de propos, et nous pouvons utiliser notre jugement pour déterminer quand cela aide et quand il fait obstacle. Lors du choix d'exposer ou de masquer l'implémentation, nous devons être clairs sur les avantages et les coûts de l'encapsulation. Cela nous donne-t-il de la flexibilité pour faire évoluer la mise en œuvre, ou s'agit-il simplement d'une barrière destructrice d'informations à la manière de quelque chose qui est déjà évident pour l'autre partie ? Souvent, les avantages de l'encapsulation sont substantiels, mais dans les cas de hiérarchies simples qui modélisent des domaines bien compris, la surcharge de déclaration d'abstractions à l'épreuve des balles peut parfois dépasser l'avantage.

Lorsqu'un type comme Shape s'engage non seulement dans son interface, mais dans les classes qui l'implémentent, nous pouvons nous sentir plus à l'aise de demander "êtes-vous un cercle" et de transtyper en Circle, puisque Shape a spécifiquement nommé Circle comme l'un de ses sous-types connus. Tout comme les records sont un type de classe plus transparent, les sommes sont un type de polymorphisme plus transparent. C'est pourquoi les sommes et les produits sont si fréquemment vus ensemble. Ils représentent tous deux une sorte de compromis similaire entre la transparence et l'abstraction, donc là où l'un a du sens, l'autre l'est probablement aussi. (Les sommes de produits sont souvent appelées types de données algébriques.)

L'exhaustivité

Les classes scellées comme Shape s'engagent dans une liste exhaustive de sous-types possibles, ce qui aide les programmeurs et les compilateurs à raisonner sur les formes d'une manière que nous ne pourrions pas faire sans ces informations. (D'autres outils peuvent également tirer parti de ces informations; l'outil Javadoc répertorie les sous-types autorisés dans la page de documentation générée pour une classe scellée.)

Java SE 14 introduit une forme limitée de pattern matching, qui sera étendue à l'avenir. La première version nous permet d'utiliser des type patterns dans instanceof :

if (shape instanceof Circle c) {
    // compiler has already cast shape to Circle for us, and bound it to c
    System.out.printf("Circle of radius %d%n", c.radius()); 
}

C'est un court saut à partir de là pour utiliser des type patterns dans switch. (Ceci n'est pas pris en charge dans Java SE 15, mais arrive bientôt.) Lorsque nous y arriverons, nous pouvons calculer la zone d'une forme à l'aide d'une expression switch dont les étiquettes case sont des type patterns, comme suit 1:

float area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())
                                 * (r.upperRight().x() - r.lowerLeft().x()));
    // no default needed!
}

La contribution du scellement ici est que nous n'avons pas besoin d'une clause default, car le compilateur savait de la déclaration de Shape que Circle et Rectangle couvre toutes les formes, et donc une clause default serait inaccessible dans le switch ci-dessus. (Le compilateur insère toujours silencieusement une clause default dans les expressions swtich, juste au cas où les sous-types autorisés de Shape ont changé entre la compilation et l'exécution, mais il n'est pas nécessaire d'insister pour que le programmeur écrive cette valeur par défaut "juste au cas où".) Ceci est similaire à la façon dont nous traitons une autre source d'exhaustivité - une expression switch sur une enum qui couvre toutes les constantes connues n'a pas non plus besoin d'une clause default (et elle est généralement une bonne idée de l'omettre dans ce cas, car cela est plus susceptible de nous alerter d'avoir manqué un cas.)

Une hiérarchie comme Shape donne à ses clients le choix : ils peuvent gérer les formes entièrement par le biais de leur interface abstraite, mais ils peuvent également "déplier" l'abstraction et interagir via des types plus clairs lorsque cela a du sens. Les fonctionnalités linguistiques telles que le pattern matching rendent ce type de déroulement plus agréable à lire et à écrire.

Des exemples de types de données algébriques

Le modèle de «somme des produits» peut être puissant. Pour qu'il soit approprié, il doit être extrêmement improbable que la liste des sous-types change, et nous prévoyons qu'il sera plus facile et plus utile pour les clients de discriminer directement les sous-types.

S'engager dans un ensemble fixe de sous-types et encourager les clients à utiliser ces sous-types directement est une forme de couplage étroit. Toutes choses étant égales par ailleurs, nous sommes encouragés à utiliser un couplage lâche dans nos conceptions pour maximiser la flexibilité de changer les choses à l'avenir, mais un tel couplage lâche a également un coût. Avoir à la fois des abstractions "opaques" et "transparentes" dans notre langage nous permet de choisir le bon outil selon la situation.

Un endroit où nous aurions pu utiliser une somme de produits (si cela avait été une option à l'époque) est dans l'API de java.util.concurrent.Future. Un Future représente un traitement qui peut s'exécuter simultanément avec son initiateur. Le traitement représenté par un Future n'a peut-être pas encore été démarré, a été démarré mais n'est pas encore terminé, déjà terminé avec succès ou à une exception, a expiré ou a été annulé par l'interruption. La méthode get() de Future reflète toutes ces possibilités :

interface Future<V> {
    ...
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

Si le calcul n'est pas encore terminé, get() se bloque jusqu'à ce que l'un des modes de complétion se produise et, en cas de succès, renvoie le résultat du calcul. Si le calcul s'est terminé en lançant une exception, cette exception est enveloppée dans une ExecutionException. Si le calcul a expiré ou a été interrompu, un autre type d'exception est levé. Cette API est assez précise, mais est quelque peu pénible à utiliser car il existe plusieurs chemins de contrôle, le chemin normal (où get() renvoie une valeur) et de nombreux chemins d'échec, chacun devant être traité dans un bloc catch :

try {
    V v = future.get();
    // handle normal completion
}
catch (TimeoutException e) {
    // handle timeout
}
catch (InterruptedException e) {
    // handle cancelation
}
catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // handle task failure
}

Si nous avions scellé les classes, les records et le pattern matching lorsque Future a été introduit dans Java 5, il aurait été possible que nous ayons défini le type de retour comme suit :

sealed interface AsyncReturn<V> {
    record Success<V>(V result) implements AsyncReturn<V> { }
    record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
    record Timeout<V>() implements AsyncReturn<V> { }
    record Interrupted<V>() implements AsyncReturn<V> { }
}

...

interface Future<V> {
    AsyncReturn<V> get();
}

Ici, nous disons qu'un résultat asynchrone est soit un succès (qui porte une valeur de retour), un échec (qui porte une exception), un délai d'expiration ou une annulation. Il s'agit d'une description plus uniforme des résultats possibles, plutôt que de décrire certains d'entre eux avec la valeur de retour et d'autres avec des exceptions. Les clients devraient toujours traiter tous les cas - il n'y a aucun moyen de contourner le fait que la tâche pourrait échouer - mais nous pouvons gérer les cas de manière uniforme (et plus compacte) 1 :

AsyncResult<V> r = future.get();
switch (r) {
    case Success(var result): ...
    case Failure(Throwable cause): ...
    case Timeout(), Interrupted(): ...
}

Les sommes des produits sont des énumérations généralisées

Une bonne façon de penser aux sommes de produits est qu'elles sont une généralisation des énumérations. Une déclaration enum déclare un type avec un ensemble exhaustif d'instances constantes :

enum Planet { MERCURY, VENUS, EARTH, ... }

Il est possible d'associer des données à chaque constante, comme la masse et le rayon de la planète :

enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS (4.869e+24, 6.0518e6),
    EARTH (5.976e+24, 6.37814e6),
    ...
}

En généralisant un peu, une classe scellée énumère non pas une liste fixe d'instances de la classe scellée, mais une liste fixe de types d'instances. Par exemple, cette interface scellée répertorie différents types de corps célestes et les données pertinentes pour chaque type :

sealed interface Celestial {
    record Planet(String name, double mass, double radius)
        implements Celestial {}
    record Star(String name, double mass, double temperature)
        implements Celestial {}
    record Comet(String name, double period, LocalDateTime lastSeen)
        implements Celestial {}
}

Tout comme vous pouvez basculer de manière exhaustive sur les constantes d'énumération, vous pourrez également basculer de manière exhaustive sur les différents types de corps célestes 1 :

switch (celestial) {
    case Planet(String name, double mass, double radius): ...
    case Star(String name, double mass, double temp): ...
    case Comet(String name, double period, LocalDateTime lastSeen): ...
}

Des exemples de ce modèle apparaissent partout : événements dans un système d'interface utilisateur, codes de retour dans un système orienté service, messages dans un protocole, etc.

Des hiérarchies plus sécurisées

Jusqu'à présent, nous avons parlé du moment où les classes scellées sont utiles pour incorporer des alternatives dans les modèles de domaine. Les classes scellées ont également une autre application, très différente : les hiérarchies sécurisées.

Java nous a toujours permis de dire "cette classe ne peut pas être étendue" en marquant la classe final. L'existence de final dans le langage reconnaît un fait fondamental sur les classes : parfois elles sont conçues pour être étendues, et parfois elles ne le sont pas, et nous aimerions prendre en charge ces deux modes. En effet, Effective Java nous recommande de "Concevoir et documenter pour l'extension, ou bien l'interdire". Il s'agit d'un excellent conseil, qui pourrait être utilisé plus souvent si la langue nous aidait à le faire.

Malheureusement, le langage ne nous aide pas ici de deux manières : la valeur par défaut pour les classes est extensible plutôt que finale, et le mécanisme final est en réalité assez faible, en ce sens qu'il oblige les auteurs à choisir entre contraindre l'extension et utiliser le polymorphisme comme technique d'implémentation. Un bon exemple où nous payons pour cette tension est String. Il est essentiel pour la sécurité de la plate-forme que les chaînes soient immuables et donc que String ne puisse pas être extensibles publiquement, mais il serait très pratique pour l'implémentation d'avoir plusieurs sous-types. ( Le coût de contourner ce problème est substantiel; les Compact strings ont permis d'améliorer considérablement l'empreinte mémoire et les performances en accordant un traitement spécial aux chaînes composées exclusivement de caractères Latin-1, mais il aurait été beaucoup plus facile et moins coûteux de le faire si String était une classe scellée au lieu d'une final).

C'est une astuce bien connue pour simuler l'effet des classes scellées (mais pas des interfaces) en utilisant un constructeur package-private et en plaçant toutes les implémentations dans le même package. Cela aide, mais il est encore quelque peu inconfortable d'exposer une classe abstraite publique qui n'est pas destinée à être étendue. Les auteurs de bibliothèques préféreraient utiliser des interfaces pour exposer des abstractions opaques. Les classes abstraites étaient censées être une aide à l'implémentation, pas un outil de modélisation. (Voir Effective Java , "Préférez les interfaces aux classes abstraites".)

Avec des interfaces scellées, les auteurs de bibliothèque n'ont plus à choisir entre utiliser le polymorphisme comme technique d'implémentation, permettre une extension incontrôlée ou exposer des abstractions comme interfaces. Ils peuvent avoir les trois. Dans une telle situation, l'auteur peut choisir de rendre les classes d'implémentation accessibles, mais plus probablement, les classes d'implémentation resteront encapsulées.

Les classes scellées permettent aux auteurs de bibliothèque de dissocier l'accessibilité de l'extensibilité. C'est plaisant d'avoir cette flexibilité, mais quand devrions-nous l'utiliser ? Nous ne voudrions certainement pas sceller des interfaces comme List. Il est totalement raisonnable et souhaitable pour les utilisateurs de créer de nouveaux types de List. Le scellement peut avoir des coûts (les utilisateurs ne peuvent pas créer de nouvelles implémentations) et des avantages (l'implémentation peut raisonner globalement sur toutes les implémentations); nous devons utiliser le scellement lorsque les avantages dépassent les coûts.

Les petits détails

Le modificateur sealed peut être appliqué aux classes ou aux interfaces. C'est une erreur de tenter de sceller une classe déjà finale, qu'elle soit explicitement déclarée avec le modificateur final, ou implicitement finale (comme les classes enum et record).

Une classe scellée a une liste permits, qui sont les seuls sous-types directs autorisés; ceux-ci doivent être disponibles au moment de la compilation de la classe scellée, doivent en fait être des sous-types de la classe scellée et doivent être dans le même module que la classe scellée (ou dans le même package, si dans l'unnamed module). Cette exigence signifie en fait qu'ils doivent être co-maintenus avec la classe scellée, ce qui est une exigence raisonnable pour un tel couplage.

Si les sous-types autorisés sont tous déclarés dans la même unité de compilation que la classe scellée, la clause permits peut être omise et sera déduite comme étant tous les sous-types dans la même unité de compilation. Une classe scellée ne peut pas être utilisée comme interface fonctionnelle pour une expression lambda, ou comme type de base pour une classe anonyme.

Les sous-types d'une classe scellée doivent être plus explicites quant à leur extensibilité;  un sous-type d'une classe scellée doit être fermée sealed, sealed, final, ou explicitement marqué non-sealed. (Les Records et les énumérations sont implicitement final, ils n'ont donc pas besoin d'être explicitement marqués comme tels.) C'est une erreur de marquer une classe ou une interface comme non-sealed si elle n'avait pas de super type direct sealed.

C'est une modification compatible au niveau binaire et source pour créer une classe final existante sealed. Il n'est ni compatible binairement ni au niveau source de sceller une classe non finale pour laquelle vous ne contrôlez pas déjà toutes les implémentations. Il est compatible binairement mais pas compatible au niveau source d'ajouter de nouveaux sous-types autorisés à une classe scellée (cela pourrait casser l'exhaustivité des expressions switch.)

Synthèse

Les classes scellées ont de multiples utilisations; ils sont utiles comme technique de modélisation de domaine lorsqu'il est logique de définir un ensemble exhaustif d'alternatives dans le modèle de domaine; ils sont également utiles comme technique de mise en œuvre lorsqu'il est souhaitable de dissocier l'accessibilité de l'extensibilité. Les types scellés sont un complément naturel aux records , car ensemble, ils forment un modèle commun connu sous le nom de types de données algébriques ; ils sont également un choix naturel pour le pattern matching , qui arriveront bientôt dans Java.

Notes de bas de page

1Cet exemple utilise une forme d'expression switch - qui utilise des patterns comme étiquettes de case - qui n'est pas encore pris en charge par le langage Java. Le modèle de release tous les six mois nous permet de co-concevoir des fonctionnalités mais de les fournir indépendamment; nous nous attendons à ce que le switch puisse utiliser des patterns comme étiquettes de case dans un avenir proche.

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