8. Pattern matching
Exemples complets : lessons/l08.
Deux langages qui convergent
Section intitulée « Deux langages qui convergent »Le filtrage par motif (pattern matching) est arrivé par étapes dans les deux langages : en C# à partir de la version 7 (2017), en Java à partir de la 16 (2021). En Java 25, les fonctionnalités finalisées sont les motifs de type dans instanceof et switch, les motifs de record, les gardes avec when, case null et les variables anonymes _. Elles couvrent le même terrain que les motifs de type, positionnels et d’élimination (discard) de C#. C# va plus loin avec les motifs de propriété, relationnels, logiques et de liste, que Java exprime avec des gardes.
| C# | Java 25 |
|---|---|
o is string s |
o instanceof String s |
expression switch avec => |
expression switch avec -> |
motif positionnel Point(var x, var y) |
motif de record Point(var x, var y) |
case Circle c when c.Radius == 0 |
case Circle c when c.radius() == 0 |
élimination _ |
variable anonyme _ (Java 22) |
motif null |
case null |
motif de propriété { Total: > 1000 } |
aucun : motif de record plus garde |
relationnel < 0, logique and, or, not |
aucun : garde |
motif de liste [var first, .., var last] |
aucun |
| avertissement d’exhaustivité CS8509 | erreur d’exhaustivité pour les expressions switch |
instanceof avec une liaison
Section intitulée « instanceof avec une liaison »La première étape remplace la paire test-puis-cast, comme le is de C# avec une déclaration :
Object value = "matched";if (value instanceof String s && s.length() > 3) { System.out.println("long string: " + s);}long string: matchedLa variable du motif est accessible partout où le compilateur peut prouver que la correspondance a réussi, y compris après un test négatif qui sort de la méthode :
static int lengthOrZero(Object value) { // La variable du motif est accessible partout où la correspondance est certaine. if (!(value instanceof String text)) { return 0; } return text.length();}Là où la correspondance n’est pas certaine, la variable n’existe pas. Avec ||, s pourrait ne pas être liée :
class Scope { static int length(Object value) { if (value instanceof String s || value == null) { return s.length(); } return 0; }}PatternScope.java:4: error: cannot find symbol return s.length(); ^ symbol: variable s location: class Scope1 errorPour le même code, C# signale CS0165, « utilisation d’une variable locale non assignée » : la variable est déclarée mais pas assignée de façon certaine. La règle est la même ; seul le message diffère.
switch sur n’importe quel type
Section intitulée « switch sur n’importe quel type »Un switch à motifs accepte n’importe quel type référence, et les cas sont testés de haut en bas :
static String classify(Object value) { return switch (value) { case null -> "null"; case Integer i when i < 0 -> "negative int " + i; case Integer i -> "int " + i; case String s when s.isBlank() -> "blank string"; case String s -> "string of length " + s.length(); case int[] array -> "int array of length " + array.length; case List<?> list -> "list of " + list.size(); default -> "something else: " + value.getClass().getSimpleName(); };}nullnegative int -4int 42blank stringstring of length 7int array of length 3list of 2something else: DoubleTrois détails diffèrent de C# :
null. Unswitchsanscase nulllèveNullPointerExceptionsur une entréenull, même avec undefault. C’est le comportement d’avant les motifs, conservé pour la compatibilité. C# tombe simplement dans_.- Les gardes testent ce que C# met dans le motif.
Integer i when i < 0est leint i and < 0de C#. Il n’y a pas de motifs relationnels, donc la réponse est toujours une garde. - Les génériques sont effacés (leçon 4).
case List<?> listest permis, mais pascase List<String> listsur unObject: le type des éléments ne peut pas être vérifié.
Le compilateur rejette un cas qui ne peut jamais correspondre parce qu’un cas précédent intercepte déjà tout ce qu’il accepterait :
class Texts { static String kind(Object value) { return switch (value) { case CharSequence cs -> "text"; case String s -> "string"; default -> "other"; }; }}DominatedCase.java:5: error: this case label is dominated by a preceding case label case String s -> "string"; ^1 errorDans une expression switch, C# signale la même erreur sous le code CS8510, « Le modèle a déjà été géré par un bras précédent de l’expression switch ». Java refuse aussi un default à côté d’un motif qui correspond déjà à tout :
class Anything { static String describe(Object value) { return switch (value) { case String s -> "string"; case Object o -> "object"; default -> "unreachable"; }; }}DefaultAndUnconditional.java:6: error: switch has both an unconditional pattern and a default label default -> "unreachable"; ^1 errorLes switch sur des enums étaient déjà exhaustifs sans motifs, et plusieurs constantes peuvent partager un même cas :
static String color(Suit suit) { // Plusieurs constantes dans un même cas, et pas de default : le switch sur l'enum est exhaustif. return switch (suit) { case HEARTS, DIAMONDS -> "red"; case CLUBS, SPADES -> "black"; };}red blackHiérarchies scellées et motifs de record
Section intitulée « Hiérarchies scellées et motifs de record »Les motifs portent leurs fruits avec les interfaces scellées et les records de la leçon 3. Un motif de record déconstruit un record en ses composants et s’imbrique à n’importe quelle profondeur. Avec une interface scellée, le compilateur connaît tous les sous-types, donc le switch n’a pas besoin de default :
record Point(double x, double y) {}
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(Point center, double radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}
record Triangle(Point a, Point b, Point c) implements Shape {}
// Pas de branche default : le compilateur connaît les trois sous-types permis.static double area(Shape shape) { return switch (shape) { case Circle(Point _, double r) -> Math.PI * r * r; case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) -> Math.abs(x2 - x1) * Math.abs(y2 - y1); case Triangle(Point a, Point b, Point c) -> Math.abs((b.x() - a.x()) * (c.y() - a.y()) - (c.x() - a.x()) * (b.y() - a.y())) / 2; };}
// Les gardes affinent un cas avec une condition booléenne.static String describe(Shape shape) { return switch (shape) { case Circle c when c.radius() == 0 -> "a point"; case Circle c -> "a circle of radius " + c.radius(); case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) when x2 - x1 == y2 - y1 -> "a square"; case Rectangle r -> "a rectangle"; case Triangle t -> "a triangle"; };}a circle of radius 1.0 3.14a point 0.00a square 9.00a rectangle 8.00a triangle 6.00La déconstruction suit l’ordre des composants du record, et les types des composants peuvent être écrits en toutes lettres ou remplacés par var. Point _ ignore un composant. Seuls les records peuvent être déconstruits : Java n’a pas d’équivalent à la méthode Deconstruct de C# pour les classes ordinaires.
C’est là que Java est plus strict que C#. Ajoutez un sous-type et oubliez un cas : C# donne l’avertissement CS8509, puis lève SwitchExpressionException à l’exécution. Pour une expression switch, Java refuse de compiler :
sealed interface Payment permits Card, Transfer, Voucher {}
record Card(String number) implements Payment {}
record Transfer(String iban) implements Payment {}
record Voucher(String code) implements Payment {}
class Fees { static double fee(Payment payment) { return switch (payment) { case Card c -> 0.30; case Transfer t -> 0.0; }; }}MissingPermittedCase.java:11: error: the switch expression does not cover all possible input values return switch (payment) { ^1 errorLe message ne nomme pas le Voucher manquant ; le correctif rapide (quick fix) d’IntelliJ IDEA, si. C’est pourquoi un switch sur une hiérarchie scellée s’écrit de préférence sans default : ajouter un sous-type casse alors le build à chaque endroit qui doit le gérer. Les développeurs C# obtiennent le même effet en traitant CS8509 comme une erreur.
Ce que deviennent les motifs C# en Java
Section intitulée « Ce que deviennent les motifs C# en Java »Les motifs de propriété, relationnels et logiques de C# n’ont pas de syntaxe en Java. Les écrire provoque une erreur d’analyse syntaxique, et les messages n’expliquent pas pourquoi :
class Signs { static String sign(int n) { return switch (n) { case < 0 -> "negative"; case 0 -> "zero"; default -> "positive"; }; }}RelationalPattern.java:4: error: illegal start of type case < 0 -> "negative"; ^RelationalPattern.java:4: error: ';' expected case < 0 -> "negative"; ^2 errorsrecord Point(int x, int y) {}
class Origins { static boolean onXAxis(Object o) { return o instanceof Point { y: 0 }; }}PropertyPattern.java:5: error: ';' expected return o instanceof Point { y: 0 }; ^PropertyPattern.java:5: error: not a statement return o instanceof Point { y: 0 }; ^PropertyPattern.java:5: error: ';' expected return o instanceof Point { y: 0 }; ^3 errorsLa traduction est un motif de record (ou un motif de type) plus une garde : o instanceof Point(var _, var y) && y == 0. Pour les motifs de liste, testez la taille et indexez la liste. Le côté C# affiche le résultat des motifs dont traite cette section :
static string Size(Order order) => order switch{ { Items.Count: 0 } => "empty", { Total: > 1000 } => "large", { Customer: "ada" or "alan", Total: >= 100 and <= 1000 } => "regular customer", _ => "normal",};L’exercice 2 traduit cette méthode.
Les motifs primitifs sont encore en préversion
Section intitulée « Les motifs primitifs sont encore en préversion »Un switch sur un int accepte des constantes, mais pas des motifs de type comme byte b (« tient-il dans un byte ? »). La JEP 507 les ajoute, mais en Java 25 ils sont encore une fonctionnalité en préversion :
class Bytes { static String fits(int value) { return switch (value) { case byte b -> "fits in a byte"; default -> "needs an int"; }; }}PrimitivePattern.java:4: error: primitive patterns are a preview feature and are disabled by default. case byte b -> "fits in a byte"; ^ (use --enable-preview to enable primitive patterns)1 errorLes fonctionnalités en préversion exigent --enable-preview à la compilation comme à l’exécution, et elles peuvent changer avant d’être finalisées. Ce cours ne les utilise pas.
Variables anonymes
Section intitulée « Variables anonymes »Depuis Java 22, _ désigne une variable qu’il faut déclarer mais qu’on n’utilise pas : dans les motifs, les paramètres de lambda, les clauses catch et les boucles for. C’est l’élimination (discard) de C# :
// Variables anonymes (Java 22) : _ pour ce qu'on n'utilise pas.Map<String, Integer> scores = Map.of("ada", 3);scores.forEach((_, score) -> System.out.println("score " + score));try { Integer.parseInt("x");} catch (NumberFormatException _) { System.out.println("not a number");}score 3not a numberÀ retenir
Section intitulée « À retenir »instanceof T tet les motifs deswitchremplacent les casts ; les variables de motif n’existent que là où la correspondance est certaine.- Une expression
switchsur un type scellé ou un enum doit être exhaustive, et c’est une erreur de compilation, pas un avertissement. Omettezdefaultpour que les nouveaux sous-types cassent le build. - Les motifs de record déconstruisent et s’imbriquent ; seuls les records peuvent être déconstruits.
- Pas de motifs de propriété, relationnels, logiques ni de liste : utilisez une garde (
when). case nulldoit être explicite, sinon leswitchlève une exception. Les cas dominés et undefaultà côté d’un motif inconditionnel sont des erreurs.- Les motifs de type primitifs sont encore en préversion en Java 25.
Exercices
Section intitulée « Exercices »- Modélisez des expressions arithmétiques par une interface scellée
Expravec les recordsNum(int),Add(Expr, Expr),Mul(Expr, Expr)etNeg(Expr). Écrivezeval, puissimplify, qui supprime1 *,* 1,0 +et la double négation, récursivement.
Solution
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
static int eval(Expr expr) { return switch (expr) { case Num(int value) -> value; case Add(Expr l, Expr r) -> eval(l) + eval(r); case Mul(Expr l, Expr r) -> eval(l) * eval(r); case Neg(Expr e) -> -eval(e); };}
static Expr simplify(Expr expr) { return switch (expr) { case Mul(Num(int one), Expr e) when one == 1 -> simplify(e); case Mul(Expr e, Num(int one)) when one == 1 -> simplify(e); case Add(Num(int zero), Expr e) when zero == 0 -> simplify(e); case Neg(Neg(Expr e)) -> simplify(e); case Add(Expr l, Expr r) -> new Add(simplify(l), simplify(r)); case Mul(Expr l, Expr r) -> new Mul(simplify(l), simplify(r)); case Neg(Expr e) -> new Neg(simplify(e)); case Num n -> n; };}simplify(new Add(new Num(0), new Mul(new Num(1), new Neg(new Neg(new Num(7)))))) renvoie Num[value=7]. Java n’a pas de motifs constants à l’intérieur d’un motif de record (le Mul(Num(1), var e) de C#), donc le 1 devient une garde. Les cas spécifiques précèdent les cas généraux Add, Mul et Neg. Les records se comparent par valeur, le test peut donc vérifier le résultat avec equals.
- Traduisez la méthode C#
Sizede la section sur les motifs C#, avec des recordsOrder(String customer, double total, List<String> items).
Solution
static String size(Order order) { return switch (order) { case Order(var _, var _, var items) when items.isEmpty() -> "empty"; case Order(var _, var total, var _) when total > 1000 -> "large"; case Order(var customer, var total, var _) when (customer.equals("ada") || customer.equals("alan")) && total >= 100 && total <= 1000 -> "regular customer"; case Order _ -> "normal"; };}Chaque motif de propriété C# devient une déconstruction qui nomme les composants dont elle a besoin, plus une garde. case Order _ est le dernier cas, inconditionnel ; default fonctionnerait aussi. Une chaîne faite uniquement de gardes comme celle-ci est souvent plus claire sous forme d’instructions if, et la version C# est plus courte. C’est un des endroits où C# est plus expressif.
- Modélisez une valeur JSON par une interface scellée avec les records
JNull,JBool,JNumber,JString,JArray(List<Json>)etJObject(Map<String, Json>). Écrivezrender(Json), qui affiche les nombres entiers sans point décimal et échappe les guillemets dans les chaînes.
Solution
static String render(Json json) { return switch (json) { case JNull _ -> "null"; case JBool(boolean b) -> String.valueOf(b); case JNumber(double d) when d == Math.rint(d) -> String.valueOf((long) d); case JNumber(double d) -> String.valueOf(d); case JString(String s) -> '"' + s.replace("\"", "\\\"") + '"'; case JArray(List<Json> items) -> items.stream().map(SolutionsTest::render).collect(Collectors.joining(",", "[", "]")); case JObject(Map<String, Json> fields) -> fields.entrySet().stream() .map(e -> render(new JString(e.getKey())) + ":" + render(e.getValue())) .collect(Collectors.joining(",", "{", "}")); };}Un objet avec name = Ada "Countess" et tags = [1, 2.5, true, null] est rendu sous la forme {"name":"Ada \"Countess\"","tags":[1,2.5,true,null]} quand la map conserve l’ordre d’insertion (LinkedHashMap). Ajouter un record JDate à permits empêcherait ce switch de compiler tant qu’il ne gère pas le nouveau cas, et c’est tout l’intérêt de cette conception. L’échappement est volontairement minimal : un vrai sérialiseur échappe aussi les barres obliques inverses et les caractères de contrôle.