IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

"Java 21 me fait à nouveau aimer Java", il présente quelques points forts, notamment la prise en charge des motifs d'enregistrement
Par Raghav Shankar

Le , par Raghav Shankar

37PARTAGES

13  0 
Java 21 est déjà disponible et prend en charge les motifs d'enregistrement dans les blocs de commutation et les expressions. Une telle syntaxe est monumentale (du moins, au pays de Java). Elle marque le point où Java pourrait être considéré comme supportant correctement les modèles de programmation fonctionnelle de manière similaire à Kotlin, Rust, ou C#.

Un bref historique des versions récentes de Java.

Java a évolué rapidement au cours des dix dernières années. Java 9 a été la dernière version "lente", toutes les versions suivantes ayant été publiées à 6 mois d'intervalle. Voici un tableau montrant les mises à jour de Java au cours de la dernière décennie et les principaux changements syntaxiques/ajouts effectués dans chaque version (la plupart des changements sont omis pour rester dans le sujet).


Il y a quelques nouveautés notables.

Java 14 a stabilisé les expressions switch, 16 les enregistrements et la correspondance des motifs instanceof, 17 les classes scellées, et maintenant, 21 stabilisera les motifs d'enregistrement et la correspondance des motifs switch.

Cet ensemble de changements permet à Java d'exprimer l'un des fondements de la programmation fonctionnelle, ce que le langage n'avait jamais pu faire auparavant : les types de données algébriques, ainsi que les manières idiomatiques de les utiliser.

Les types de données algébriques sont un concept issu de la théorie des types, une branche de la théorie des ensembles qui se concentre spécifiquement sur des questions telles que "Une pomme est-elle un fruit ?" et d'autres énigmes fantaisistes que les professeurs de mathématiques aiment poser aux étudiants infortunés du monde entier.

Introduction minimale à certains termes de la théorie des types

La théorie des types est assez complexe, et la plupart d'entre elle n'est pas pertinente pour cet article.

Ainsi, au lieu d'expliquer la théorie des types, on va parler de quelques types spécifiques qu'il serait utile de connaître.

Bottom, ou type vide (⊥)

Ce type décrit l'ensemble des valeurs qui ne peuvent pas être calculées. Cet ensemble est généralement vide pour tout langage de programmation normal (Ø).

Aucun objet ne peut être coulé en bottom puisqu'il s'agit d'un ensemble vide.

Un exemple d'un tel type est le Nothing de Kotlin. L'existence d'une instance de Nothing est considérée comme une erreur. La façon dont Kotlin empêche l'instanciation de Nothing est de rendre son constructeur privé.

La version Java de ce type est Void, la classe enveloppante du type primitif Void. Là encore, il est impossible de construire une instance de Void car son constructeur est privé. Cependant, Void ne peut pas être considéré comme un véritable type inférieur car une variable Void peut toujours contenir null, ce qui signifie qu'il s'agit techniquement d'un type unitaire. En ce sens, la primitive Void est plus performante. Il n'y a absolument aucun moyen de l'utiliser comme type de variable, donc vous ne pouvez même pas avoir une variable void vide.

Top, ou type supérieur (⊤)

Ce type représente toutes les valeurs de tous les types - l'ensemble universel des valeurs, U. En Kotlin, ce type s'appelle Any. Il peut être tentant de penser qu'Object (l'équivalent Java de Any) est un type supérieur, si ce n'est que les primitives existent. Les primitives sont totalement distinctes du modèle d'objet de Java et interagissent avec les objets de manière assez peu standard. Pour cette raison, Java n'a techniquement pas de type supérieur comme d'autres langages.

Pendant ce temps, C fait ce qu'il fait et surcharge void en utilisant void * pour représenter un pointeur qui peut faire référence à une valeur de n'importe quel type à la place. Comme c'est drôle !

Tous les objets peuvent être castés en top puisque U contient toutes les valeurs qui existent.

Il n'y a pas grand chose à dire sur top, si ce n'est qu'une variable de ce type peut contenir n'importe quoi. Y compris une valeur de type bottom. Bonne chance pour trouver cette valeur, cependant.

Unit, ou le type unité (())

Ce type n'a qu'une seule valeur. Il n'existe qu'une seule instance de cette valeur, et il est impossible d'en créer d'autres.

La primitive void de Java fonctionne techniquement comme suit. Lorsqu'une méthode renvoie void, vous pouvez la traiter comme si elle renvoyait implicitement la seule instance du type void sous le capot (ce n'est pas ainsi que la JVM gère void). Java s'écarte de la norme théorique en ce sens que void ne peut jamais être transmis à une méthode en tant que paramètre.

Remarque
En réalité, Java ne fait que fusionner les types bottom et unit pour nous donner void.
Vous pouvez techniquement simuler le type unité en Java en déclarant une nouvelle classe qui est finale et n'a pas d'autres champs qu'une valeur d'instance statique. Vous pourriez alors traiter cette instance comme la seule valeur de type unité.

En fait, c'est précisément ainsi que Kotlin définit sa propre Unit. Si vous naviguez jusqu'à la définition de Unit, vous verrez à quel point elle est simple : il s'agit simplement d'un object !

Contrairement à Java, Kotlin permet d'utiliser Unit n'importe où, y compris en tant que paramètre d'une méthode. L'extrait suivant est donc légal :

Code : Sélectionner tout
1
2
3
fun identity(param1: Unit): Unit = param1

val result = identity(param1 = Unit) // just returns the Unit instance again.
Le type booléen

Le type booléen possède deux valeurs valides, true et false (ou tout autre nom que vous souhaitez utiliser). En fait, vous n'avez même pas besoin d'utiliser le type booléen natif de votre langage pour représenter ce type ; vous pouvez tout aussi bien utiliser une instance nullable d'un type d'unité. Si la variable est non nulle, elle est true, et si elle est null, elle est false. Il s'agit bien sûr d'une perte de temps inutile qui ne convient qu'à ceux qui souhaitent obscurcir leur code source.

Jusqu'à présent, nous avons examiné quelques exemples de base de "règles" utilisées pour définir les types dans la théorie des types. Entrons dans le vif du sujet et discutons des types somme et produit, et de la manière dont Java 21 nous permet de les représenter par le biais d'enregistrements et de classes scellées.

Produit de types

Les Produit de types sont composés de deux ou plusieurs types constitutifs. En général, un Produit de types est une liste de deux types ou plus regroupés. L'arité, ou le degré, d'un Produit de types est le nombre de types qui le composent.

Si vous souhaitez un exemple concret et agréable de Produit de types, ne cherchez pas plus loin que l'humble C struct:

Code : Sélectionner tout
1
2
3
4
5
6
struct some_type {
    int val1; // Type 1
    char *val2; // Type 2
    double val3; // Type 3
    int val4; // Type 4
};
Dans la structure ci-dessus, some_type est un Produit de types composé de quatre types différents : int, char *, double et int à nouveau. Remarquez que nous répétons int ici. Comment savoir de quel int il s'agit lorsque nous effectuons des opérations sur some_type ? Cela peut sembler évident, mais c'est un problème en mathématiques, parce qu'il faut construire tous les blocs de construction et les concepts que l'on utilise à partir de rien !

Dans le cas présent, nous disposons déjà des outils nécessaires. Nous associons chaque type au nom qui lui est donné dans la structure (Duh). D'un point de vue mathématique, un Produit de types n'est pas simplement une liste de types, mais une liste de paires ordonnées, où chaque paire ordonnée est constituée d'un type et d'un nom associé à ce type.

Par exemple, nous pouvons représenter la première valeur d'un some_type sous la forme d'une paire ordonnée (int, "val1". De cette façon, il est impossible de confondre les deux composants int ; ils ont des noms différents !

Mais qu'en est-il des tuples comme en Python ou en Rust ?

Vous pouvez les considérer comme des produits de types où le "nom" est l'index du type de composant dans le tuple.

Code : Sélectionner tout
1
2
3
4
5
6
some_tuple = (1, '2', True, 5)

int_1 = some_tuple[0] # (int, 0)
str_2 = some_tuple[1] # (str, 1)

...
Pourquoi les appelons-nous d'ailleurs "Produits de types" ?

En théorie des ensembles, le mot produit fait généralement référence au produit cartésien de deux ensembles.

Remarque
Le produit cartésien de deux ensembles est un ensemble de paires ordonnées constitué de toutes les combinaisons possibles de chaque élément des deux ensembles.

En termes mathématiques simples, le nombre d'éléments dans le produit cartésien C de deux ensembles A et B est le produit du nombre d'éléments dans A et du nombre d'éléments dans B.
Vous pouvez utiliser la notation de la théorie des ensembles pour exprimer le produit de deux types A et sous la forme C = A × B. Cette opération de produit n'est pas commutative ; A × B n'est pas la même chose que B × A. Si vous y réfléchissez un peu, vous comprendrez pourquoi : vous inverseriez l'ordre des composants déclarés ! L'exemple dont je viens de parler n'utilise que deux types de composants : A et . Comment représenter le type some_type, par exemple ? La réponse consiste à enchaîner plusieurs opérations de produit, comme suit :

Code : Sélectionner tout
some_type = int × char* × double × int
L'ensemble des valeurs à l'intérieur d'un Produit de types pourrait être exprimé comme suit :

Code : Sélectionner tout
C = A × B = {(a, b) | a ∈ A, b ∈ B}
Vous pourriez représenter l'ensemble de toutes les valeurs dans some_type comme suit :

Code : Sélectionner tout
some_type = { (val1, val2, val3, val4) | val1 ∈ int, val2 ∈ char*, val3 ∈ double, val4 ∈ int }
Très bien, nous avons établi ce que sont les Produits de types. Quel est le rapport avec Java ?

Lorsque Java 16 a été publié, la fonction de classe d'enregistrement a été stabilisée. Les classes d'enregistrement sont un excellent exemple de ce qu'est un Produit de types. Tous les champs sont finaux ; on ne peut pas non plus hériter de ces classes. Tout l'état d'un enregistrement est défini au moment de sa construction et une fois qu'un enregistrement est créé, c'est à cela qu'il ressemblera pour le reste de sa durée de vie, soit 200 millisecondes.

Cela contraste avec les classes Java normales, qui sont très hétérogènes. Vous pouvez avoir un état public et un état privé, il y a un état caché via l'héritage auquel vous ne pensez pas jusqu'à ce qu'il apparaisse comme un animatronic hanté sous caféine pour vous effrayer avec des bugs bizarres, et vous pouvez avoir des champs mutables, des champs statiques, et toutes sortes d'autres choses distrayantes qui yadda yadda yadda... (vous avez compris l'idée).

Le problème avec les types Java normaux est qu'il est impossible de généraliser les composants d'un type. Et c'est important quand tout ce que vous voulez, c'est traiter efficacement des données ; vous devez naviguer dans un labyrinthe de getters potentiellement non standard pour accéder à vos données en premier lieu, sans parler de les manipuler.

Java n'a jamais supporté la déstructuration comme le font JavaScript ou Rust. Mais même si Java l'avait supportée, la spécification aurait probablement limité cette fonctionnalité aux enregistrements. Posons-nous quelques questions pour mieux comprendre pourquoi.

Remarque
La déstructuration est une fonctionnalité que certains langages (très célèbre, JavaScript) possèdent et qui vous permet de prendre une valeur complexe, et, pour emprunter un PHPisme, de l'EXPLOITER en ses composants comme une liste de variables complètement indépendantes.


Comment déstructurer une classe Java normale ?

L'état interne d'une classe Java comprend tous ses champs, qu'ils soient publics ou privés. Cependant, permettre l'extraction de champs privés par la déstructuration ne semble pas être une bonne idée ; nous savons tous à quel point le vieil oncle Bob s'énerve lorsqu'il s'agit de rompre l'encapsulation. Nous savons tous à quel point le vieil oncle Bob s'énerve lorsqu'il s'agit de briser l'encapsulation.

Qu'en est-il alors de l'état public ?

Réfléchissons d'abord à cette question : Comment les objets Java exposent-ils l'état public ? Bien sûr, vous pouvez définir un champ comme public, et si vous voulez empêcher toute modification indue, rendez le champ final. Mais il existe également une autre approche extrêmement courante. La plupart des objets Java définissent chaque champ comme privé et rendent tous les champs accessibles uniquement par le biais de méthodes d'accès pour la lecture et l'écriture.

Malheureusement, il n'y a pas de conventions imposées par le langage pour définir les accesseurs ; vous pourriez donner à l'accesseur de foo le nom de getBar, et cela fonctionnerait toujours bien, à l'exception du fait qu'il confondrait toute personne essayant d'accéder à bar et non à `foo'.

Bien sûr, vous pouvez utiliser des frameworks comme Lombok pour éliminer la complexité et l'incertitude en plaçant quelques annotations sur vos classes POJO, mais cela ne change pas le fait sous-jacent que les classes normales en Java sont très difficiles à raisonner statiquement en raison du nombre de "variables" qui contribuent à définir l'état d'une classe.

C'est peut-être l'un des problèmes qui a empêché les auteurs de la spécification du langage Java d'ajouter d'emblée le filtrage à toutes les classes.

Pour résoudre ce problème, ils ont créé les enregistrements, une hiérarchie de classes entièrement différente. Il existe déjà un précédent : Java 5 a introduit les enums, qui héritent de java.lang.Enum. De même, tous les enregistrements héritent de java.lang.Record.

Alors comment les enregistrements font-ils ce que les classes normales ne font pas ?

Les enregistrements résolvent ce problème en restreignant la manière dont ils peuvent être définis et en définissant de manière rigide l'ensemble des propriétés qu'ils peuvent avoir.

Plus précisément :

  • Les enregistrements sont implicitement des classes finales et ne peuvent pas être hérités.
    • Il n'y a plus de classes enfants illégitimes nées d'une aventure avec une bibliothèque concubine entièrement différente.

  • Les enregistrements ne peuvent étendre aucune classe autre que java.lang.Record.
    • Cela permet d'éviter que l'état hérité ne pollue le code de l'enregistrement.

  • Les composants d'un enregistrement ne peuvent pas avoir de modificateurs de visibilité.
  • Les composants d'un enregistrement sont toujours finaux et immuables.
    • Toutefois, l'immuabilité ne s'étend pas au contenu de chaque composant de l'enregistrement.
    • Seules les références des composants d'un enregistrement sont considérées comme immuables.

  • Lorsque vous déclarez un enregistrement et que vous ne définissez pas de méthodes d'obtention, les méthodes d'obtention seront définies à l'aide d'une syntaxe très spécifique.
    • Cette syntaxe est très régulière ; Java utilise simplement le nom du champ comme nom du getter.
    • Pour le champ a, le getter sera a().
    • Java utilisera votre définition si vous définissez manuellement un getter qui correspond aux conventions de nommage. Dans le cas contraire, Java créera automatiquement une méthode getter qui respecte correctement les conventions. Le getter non standard ne fera pas une grande différence.

  • Les champs qui soutiennent les composants d'un enregistrement sont toujours implicitement privés et ne sont accessibles que par l'intermédiaire d'une méthode d'acquisition.


(Il y a un peu plus à faire, mais cela semble être un bon point d'arrêt).

Ces propriétés des enregistrements garantissent que toute nouvelle fonctionnalité du langage Java qui utilise les enregistrements, comme le pattern matching, fonctionnera toujours, parce que la spécification du langage elle-même garantit le comportement et la structure des enregistrements.

C'est bien. Dites-moi ce que je peux faire avec.

Correspondance de motifs.

Continue...

Il est assez pénible d'avoir à écrire du code extrêmement imbriqué lorsque vous avez beaucoup de conditions basées sur les types de vos données. Ce problème sera mis en perspective lorsque j'introduirai les types sum plus loin dans l'article. La correspondance de motifs est un moyen de vérifier statiquement (c'est-à-dire au moment de la compilation, lorsque vous écrivez le code) que certains motifs sont présents dans les données que vous traitez.

Regardez l'exemple ci-dessous. Notez que les données contenues dans A sont des instances Record et qu'elles peuvent contenir n'importe quel type d'enregistrement. Nous essayons d'abord d'imprimer le contenu de r à l'aide d'instructions Java if normales, avant de le faire à l'aide de la recherche de motifs par commutation.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
record A(Record inner) {}
record B(char b) {}
record SomeOtherRecord() {}

Record eitherAorB() {
  boolean cond1 = ((int)(Math.random() * 100) % 2 == 0);
  boolean cond2 = ((int)(Math.random() * 100) % 2 == 0);
    return cond1 ? new A(cond2 ? new A(null) : new B('e')) : new B('f'); // returns either A or B.
}

void main() {
  var r = eitherAorB();

  String oldJavaResult = "";

  if (r instanceof A) {
    var inner = ((A)r).inner(); // We have to cast it...
    if (inner instanceof B) {
      oldJavaResult = String.valueOf(((B)inner).b());
    } else if (inner instanceof SomeOtherRecord) {
      oldJavaResult = null;
    }
  } else if (r instanceof B) {
    oldJavaResult = String.valueOf((B)r.b());
  } else {
    oldJavaResult = "r does not match any pattern";
  }

  System.out.println("With the old method: \"" + oldJavaResult + "\"");

  // The type is Record.
  var result = switch (r) {
    case A(B(char a)) -> String.valueOf(a); // Destructuring!
    case A(SomeOtherRecord(/* ... */)) -> {
      // handle it.
      yield null;
    }
    case B(char b) -> String.valueOf(b);
    default -> "r does not match any pattern";
  };
  
  System.out.println(result.toString());
}
Le bloc switch est clairement mieux structuré que l'échelle if-else ci-dessus. Les modèles de commutation sont très puissants lorsque vous souhaitez extraire rapidement et facilement des données profondément imbriquées sans vous embarrasser de vérifications instanceof et de casts de type encombrants. Si vous avez l'occasion de travailler avec Java 21, je suis sûr que vous apprécierez cette fonctionnalité.

Si vous souhaitez l'essayer vous-même, voici comment procéder. Copiez ce code dans un main.java et exécutez-le avec :

Code : Sélectionner tout
java --enable-preview --source 21 main.java
Cet exemple met également en évidence une autre nouvelle fonctionnalité de prévisualisation, les méthodes principales non nommées.

Dans l'exemple précédent, nous avons pu utiliser la recherche de motifs pour passer d'un type d'enregistrement à l'autre. Mettons cela de côté une seconde et parlons de la façon dont nous gérons les choix en Java.

Choix, choix...

À quoi pensez-vous lorsque vous avez besoin d'un ensemble restreint d'alternatives parmi lesquelles choisir ? Les enums Java répondent à ce besoin ; ils sont composés d'un ensemble de variantes statiques et ne peuvent pas modifier les données qu'ils contiennent.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum Color {
  RED(255, 0, 0),
  GREEN(0, 255, 0),
  BLUE(0, 0, 255);

  public final int red;
  public final int green;
  public final int blue;

  Color(int red, int green, int blue) {
    this.red = red;
    this.green = green;
    this.blue = blue;
  }
}
L'enum ci-dessus définit trois couleurs, le rouge, le vert et le bleu, avec des valeurs définies pour les différents champs, et il n'est pas possible de modifier les valeurs de couleur à l'intérieur sans perturber tous les endroits où cet enum est utilisé (les valeurs sont finales dans le code ci-dessus, mais imaginez si ce n'était pas le cas).

Imaginons maintenant un problème différent. Vous voulez différentes représentations des couleurs telles que RGB, HSL et CMYK. Vous pourriez peut-être créer un enum pour cela ?

Code : Sélectionner tout
1
2
3
4
5
6
public enum ColorRepresentation {
  RGB,
  HSL,
  YUV,
  CMYK
}
Cela nous donne un bel ensemble restreint de valeurs parmi lesquelles nous pouvons choisir. Si vous souhaitez disposer de plusieurs valeurs de couleur pour différentes représentations, vous devez stocker les données de couleur séparément et conserver une valeur d'énumération ColorRepresentation à l'intérieur pour vous aider à comprendre ce qui se passe réellement...

Code : Sélectionner tout
1
2
3
4
5
6
class Color {
  public final ColourRepresentation repr;
  public final Number val1;
  public final Number val2;
  public final Number val3;
}
De toute évidence, ce n'est PAS ainsi qu'une personne connaissant Java concevrait la classe Color. Une bien meilleure façon d'implémenter des représentations de couleurs multiples sans sacrifier la lisibilité est d'utiliser le polymorphisme !

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public abstract class Color {}

public class RGB extends Color {
    private int red;
    private int green;
    private int blue;

    public RGB(int red, int green, int blue) {
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    @Override
    public String toString() {
        return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
    }
}

public class CMYK extends Color {
    private double cyan;
    private double magenta;
    private double yellow;
    private double black;

    public CMYK(double cyan, double magenta, double yellow, double black) {
        this.cyan = cyan;
        this.magenta = magenta;
        this.yellow = yellow;
        this.black = black;
    }

    @Override
    public String toString() {
        return "CMYK Color: (" + cyan + "%, " + magenta + "%, " + yellow + "%, " + black + "%)";
    }
}

public class YUV extends Color {
    private int y;
    private int u;
    private int v;

    public YUV(int y, int u, int v) {
        this.y = y;
        this.u = u;
        this.v = v;
    }

    @Override
    public String toString() {
        return "YUV Color: (Y=" + y + ", U=" + u + ", V=" + v + ")";
    }
}

public class HSL extends Color {
    private double hue;
    private double saturation;
    private double lightness;

    public HSL(double hue, double saturation, double lightness) {
        this.hue = hue;
        this.saturation = saturation;
        this.lightness = lightness;
    }

    @Override
    public String toString() {
        return "HSL Color: (H=" + hue + ", S=" + saturation + "%, L=" + lightness + "%)";
    }
}
Maintenant, étant donné une instance de Color, il vous suffit de vérifier s'il s'agit d'une instanceof de la représentation de couleur que vous souhaitez, et vous pouvez accéder aux données de cette représentation. Mais cette implémentation présente un défaut. Comment restreindre ce qu'est une couleur dans notre hiérarchie de classes ? Tout utilisateur de votre bibliothèque pourrait créer une nouvelle classe RYB qui hérite de Color, ou de RGB, par exemple. Cela devient un problème lorsque votre bibliothèque ne s'attend pas à ce qu'il existe de nouvelles variantes de Color ou si elle ne s'attend pas à ce que des variantes spécifiques de Color modifient leur comportement. À moins que l'API ne soit conçue pour être extensible, la création de nouvelles représentations pourrait provoquer des plantages dans le meilleur des cas (vous avez donc une chance de savoir ce qui a mal tourné) ou, dans le pire des cas, des bogues subtils qui affectent le code loin de la source du problème.

Pour résoudre ce problème, nous pourrions faire plusieurs choses :

  1. Rendre toutes les variantes final.
    • Bien que cela soit utile, cela n'empêche pas la création de nouvelles variantes directement à partir de Color, puisqu'il est impossible de rendre Color lui-même final.

  2. S'assurer que toute la logique interne a toujours un cas par défaut pour les variantes non reconnues.
    • Cela réduira l'extensibilité de la bibliothèque, mais si ce n'est pas un objectif, cela aidera.
    • Mais ce sera aussi beaucoup plus sujet aux erreurs ; si une seule partie de la logique oublie de prendre en compte le cas par défaut, il y aura des problèmes.

  3. On peut aussi utiliser des classes ou des interfaces scellées et faire d'une pierre deux coups.


Somme des types

Les classes scellées de Java 17 permettent de créer des modèles de conception basés sur le concept des Somme des types. Alors que la plage de valeurs d'un type produit est le produit des plages de valeurs des types qui le composent, la plage de valeurs d'un type somme est la somme. Mais que signifie le fait que la plage de valeurs soit une somme ?

Les Somme des types encodent le fait qu'un type peut être n'importe lequel de ses constituants à la fois. Ils sont également connus sous le nom de types d'union étiquetés car, dans la théorie des types, ils sont généralement représentés comme un type dont la plage de valeurs est l'ensemble d'union de ses composants, où chaque type composant est "étiqueté" avec une étiquette.

Vous pourriez exprimer un type somme de la manière suivante si vous utilisiez la notation pseudo-théorique des types :

Code : Sélectionner tout
T = A + B + C
L'ensemble des valeurs contenues dans T pourrait être exprimé par ce prédicat logique :

Code : Sélectionner tout
T = { x | x ∈ A ⋃ B ⋃ C }
L'expression "types d'union" vous rappelle peut-être unions du langage C.

Code : Sélectionner tout
1
2
3
4
5
union MyUnion {
    int intValue;
    double doubleValue;
    char charValue;
};
MyUnion est composé de trois types de composants, et C vous permet de traiter une valeur de MyUnion comme un conteneur pour n'importe lequel de ces trois types :

Code : Sélectionner tout
1
2
3
4
5
union MyUnion myUnion;
myUnion.intValue = 42;
printf("Integer value: %d\n", myUnion.intValue);
myUnion.doubleValue = 3.14159;
printf("Double value: %lf\n", myUnion.doubleValue);
Notez que la valeur de l'union est écrasée lors de la deuxième affectation de doubleValue. Si vous deviez imprimer myUnion.intValue après la deuxième affectation, vous verriez du charabia ; il s'agit en fait des octets de doubleValue coupés en deux et interprétés comme un entier.

Ceci met en évidence le défaut le plus important des unions en C ; il n'y a pas de moyen intégré de savoir quelle variante est contenue dans une valeur d'union sans information externe. Nous avons donc besoin d'un discriminant externe pour déterminer ce qui se trouve à l'intérieur. Le polymorphisme de Java peut le faire pour nous avec instanceof. Mais la hiérarchie des classes de Java est trop ouverte ; il n'y a aucun moyen de restreindre le nombre de variantes d'une "union" Java.

Ce dont nous avons besoin, ce sont des unions balisées, et c'est exactement ce que les types scellés nous permettent de représenter. Le modificateur sealed existe pour indiquer clairement que vous ne pouvez pas étendre une classe sealed au-delà des classes autorisées à en hériter. Cela permet au développeur de contrôler la manière dont les utilisateurs interagissent avec l'API de sa bibliothèque.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public sealed class Color permits RGB, CMYK, YUV, HSL {
    // Common properties or methods for all color representations
}

final class RGB extends Color {
    private final int red;
    private final int green;
    private final int blue;

    public RGB(int red, int green, int blue) {
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    // Additional methods or properties specific to RGB
}

final class CMYK extends Color {
    private final double cyan;
    private final double magenta;
    private final double yellow;
    private final double black;

    public CMYK(double cyan, double magenta, double yellow, double black) {
        this.cyan = cyan;
        this.magenta = magenta;
        this.yellow = yellow;
        this.black = black;
    }

    // Additional methods or properties specific to CMYK
}

final class YUV extends Color {
    private final int y;
    private final int u;
    private final int v;

    public YUV(int y, int u, int v) {
        this.y = y;
        this.u = u;
        this.v = v;
    }

    // Additional methods or properties specific to YUV
}

final class HSL extends Color {
    private final double hue;
    private final double saturation;
    private final double lightness;

    public HSL(double hue, double saturation, double lightness) {
        this.hue = hue;
        this.saturation = saturation;
        this.lightness = lightness;
    }

    // Additional methods or properties specific to HSL
}
Notez la syntaxe de la classe sealed Color. Il y a un modificateur sealed et une clause permits avec les noms de toutes les sous-classes de Color. permits est utilisé pour spécifier quelles classes peuvent hériter d'une classe particulière et est utilisé pour empêcher tout héritage non désiré. Notez également que chaque implémentation de Color est marquée comme final, de sorte que vous n'obtenez que les quatre représentations de couleurs que vous voyez ici ; vous ne pouvez pas créer les vôtres.

Cette hiérarchie de classes est verrouillée. Aucune nouvelle classe ne peut hériter de Color, qu'elle soit située dans le même paquetage ou non.

Si votre hiérarchie de classes commence par une classe sealed, vous devez marquer tous les héritiers (directs ou indirects) comme sealed, non-sealed ou final. Si une classe qui hérite n'a pas ces modificateurs, il s'agit d'une erreur de compilation.

Voici la signification de chacun de ces modificateurs :

  • sealed - La classe ne peut pas être héritée à moins que le nom de l'héritier ne soit mentionné après permits.
  • non-sealed - La classe peut être héritée normalement. Utile pour contrôler la portée des comportements personnalisés.
  • final - La classe est une "feuille" dans l'arbre d'héritage ; vous ne pouvez plus l'étendre.


Ces modificateurs peuvent être utilisés pour contrôler exactement comment les classes d'une API peuvent se comporter et comment elles sont utilisées. Cela permet d'éviter les situations où la seule chose qui empêche tout d'exploser sont des règles qui ne peuvent être appliquées que par les développeurs qui acceptent de ne pas faire la mauvaise chose.

Imaginons que nous écrivions un code pour gérer les couleurs en fonction de leur format. Nous disposons déjà d'un moyen agréable et limité de garantir qu'il n'y aura pas d'irrégularités. Mais comment structurer le code qui gère les instances de classe scellées ?

Les versions antérieures de Java ne nous laissaient pas beaucoup de choix ; le mieux que nous puissions faire est une échelle if-else :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Color color = new RGB(255, 0, 0);

if (color instanceof RGB) {
  RGB rgb = (RGB) color;
  // ...
} else if (color instanceof CMYK) {
  CMYK cmyk = (CMYK) color;
  // ...
} else if (color instanceof YUV) {
  YUV yuv = (YUV) color;
  // ...
} else if (color instanceof HSL) {
  HSL hsl = (HSL) color;
  // ...
} else {
  System.out.println("Unknown color type");
}
Nous pourrions faire un peu mieux si nous étions sous Java 16+ en utilisant la correspondance de motifs if :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
if (color instanceof RGB rgb) {
  // ...
} else if (color instanceof CMYK cmyk) {
  // ...
} else if (color instanceof YUV yuv) {
  // ...
} else if (color instanceof HSL hsl) {
  // ...
} else {
  System.out.println("Unknown color type");
}
Mais ne serait-il pas agréable d'activer une variable de couleur et d'en extraire le contenu en même temps, comme le fait Rust ?

Rust vous permettrait de faire cela, par exemple :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let color = Color::RGB(255, 0, 0);

match color {
  Color::RGB(red, green, blue) => {
    // ...
  }
  Color::CMYK(cyan, magenta, yellow, black) => {
    // ...
  }
  Color::YUV(y, u, v) => {
    // ...
  }
  Color::HSL(hue, saturation, lightness) => {
    // ...
  }
}
Notez que ces valeurs de couleur sont déstructurées dans le bloc match ci-dessus. Comment pourrions-nous faire cela avec des classes scellées ? Le switch pattern matching avec déstructuration ne fonctionne que sur les enregistrements, et les enregistrements ne peuvent hériter d'aucune autre classe que Record...

La solution consiste à utiliser des interfaces scellées. Elles fonctionnent exactement de la même manière que les classes scellées, sauf que même les enregistrements et les enums peuvent les implémenter.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public sealed interface Color permits RGB, CMYK, YUV, HSL {
    // Common properties or methods for all color representations
    String getDescription();
}

record RGB(int red, int green, int blue) implements Color {
    public String getDescription() {
        return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
    }
}

record CMYK(double cyan, double magenta, double yellow, double black) implements Color {
    public String getDescription() {
        return "CMYK Color: (" + cyan + "%, " + magenta + "%, " + yellow + "%, " + black + "%)";
    }
}

record YUV(int y, int u, int v) implements Color {
    public String getDescription() {
        return "YUV Color: (Y=" + y + ", U=" + u + ", V=" + v + ")";
    }
}

record HSL(double hue, double saturation, double lightness) implements Color {
    public String getDescription() {
        return "HSL Color: (H=" + hue + ", S=" + saturation + "%, L=" + lightness + "%)";
    }
}
Et voici comment vous pouvez proprement faire du pattern-match et extraire les données d'une instance de Color, ce qui est pratique si tout ce que vous voulez faire est d'extraire les données de la classe sans appeler aucune méthode sur elle :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Color color = new RGB(255, 0, 0);

switch (color) {
  case RGB(int red, int green, int blue) -> {
    // red, green and blue are in scope here.
  }
  case CMYK(double cyan, double magenta, double yellow, double black) -> {
    // ...
  }
  case YUV(int y, int u, int v) -> {
    // ...
  }
  case HSL hsl -> {
    // you can also leave the value intact and directly use the pattern matched value.
    System.out.println(hsl.getDescription());
  }
  case null -> {
    System.out.println("How did color become null?!");
  }
}
Remarque
Java 21 vous permet désormais d'attraper le cas null à l'intérieur des blocs et des expressions switch, de sorte que vous n'avez pas besoin de vérifier la présence de null avant d'arriver à une commutation.
Vous remarquerez peut-être que nous n'utilisons pas de cas par défaut ici. Java aurait normalement soulevé une erreur indiquant que tous les cas n'ont pas été couverts. Cependant, comme Color est une classe scellée, Java peut dire que tous les cas ont été traités.

Gardes ! Gardes !

On a des clauses de garde ! Vous pouvez attacher des conditions supplémentaires aux bras de commutation qui doivent être vraies pour que le bras soit exécuté ! Les clauses de garde permettent d'exprimer succinctement des conditions plus complexes dans les instructions et expressions de commutation.

Prenons l'exemple d'une situation dans laquelle nous devons appliquer un cas spécial aux couleurs RVB pour lesquelles rouge > 200 est vrai. Auparavant, nous devions placer cette condition à l'intérieur d'une condition if dans le corps du cas correspondant :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
switch (color) {
  case RGB(int red, int green, int blue) -> {
    if (red > 200) {
      System.out.println("Very red.");
    } else {
      System.out.println("Not that red...");  
    }
  }
  // ...
}
Ce n'est pas la chose la plus moche qui soit, mais cela signifie tout de même que vous finissez par imbriquer votre code un peu plus à long terme. Il est généralement plus facile d'analyser un code qui s'étend verticalement qu'horizontalement, car il y a moins de champs d'application à suivre.

Java 21 nous permet d'intégrer cette condition dans l'étiquette case avec le mot-clé when.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch (color) {
  // A guarded destructuring case.
  case RGB(int red, int green, int blue) when red > 200  -> {
    System.out.println("Very red.");
  }
  // You can use guards with direct pattern matching as well.
  case RGB rgb when rgb.green > 100 -> {
    System.out.println("Sort of green...");
    // ...  
  }
  // this case is needed to preserve exhaustivity.
  case RGB rgb -> {
    System.out.println("Not that red...");
    // ...  
  }
  // ...
}
Remarque
Java continuera à faire correspondre avec empressement le cas évalué comme vrai en premier, donc assurez-vous de placer les cas les plus spécifiques (gardés ou non) en premier, suivis par les cas moins spécifiques.
Ugh, exceptions (ft. un exemple de JEP 441)

Nous avons maintenant une nouvelle classe d'exceptions à gérer. Plus précisément, java.lang.MatchException

Que se passe-t-il lorsqu'une correspondance de motifs ne fonctionne pas correctement ? Prenons le cas d'une mauvaise implémentation d'un getter d'enregistrement :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
record R(int i) {
    public int i() {    // bad (but legal) accessor method for i
        return i / 0;
    }
}

static void exampleAnR(R r) {
    switch(r) {
        case R(var i): System.out.println(i); // i's accessor always throws!
    }
}
Le bloc d'interrupteurs ci-dessus lèvera une MatchException parce que lorsque le getter de i est appelé, une ArithmeticException est levée.

La JEP 441 stipule ce qui suit :

(Une méthode d'accès à un enregistrement qui lève toujours une exception est très irrégulière, et un commutateur de modèle exhaustif qui lève une MatchException est très inhabituel).
Les blocs de commutation exhaustive lèvent une exception si aucune des variantes spécifiées ne peut correspondre au sélecteur. À cet égard, le JEP stipule ce qui suit :

(Une commutation exhaustive sur une énumération n'échoue que si la classe de l'énumération est modifiée après la compilation de la commutation, ce qui est très inhabituel).
Une MatchException sera également levée si une clause de garde soulève une exception lors de son exécution.

Code : Sélectionner tout
1
2
3
4
5
6
static void example(Object obj) {
    switch (obj) {
        case R r when (r.i / 0 == 1): System.out. println("It's an R!");
        default: break;
    }
}
L'exemple ici est très facile à repérer ; nous pouvons facilement déterminer statiquement qu'il y a une erreur de division par zéro ici. Cependant, les eaux seraient plus troubles si le dividende était une valeur dynamique, éventuellement égale à 0.

Conclusion

Dans cet article, on a vu un certain nombre de choses que Java 21 nous permet de faire.

Traduit par Jade Emy avec l'aimable autorisation de l'auteur Raghav Shankar : Java 21's pattern matching could actually convince me to touch Java again.

Et vous ?

Quel est votre avis sur le sujet ?

Voir aussi :

Java 21 devrait inclure des collections séquencées et des modèles de chaînes de caractères. Le framework des collections de Java manque d'un type de collection qui représente une séquence

JDK 21 : la First Release Candidate est disponible, la version finale est annoncée pour le 19/09/2023

Java conserve sa popularité dans un paysage qui change, soutient New Relic à travers son récent rapport qui se penche sur l'état de l'écosystème du langage

Une erreur dans cette actualité ? Signalez-nous-la !