BT

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

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Programmation Orientée Données En Java

Programmation Orientée Données En Java

Points Clés

  • Le projet Amber a apporté un certain nombre de nouvelles fonctionnalités à Java ces dernières années. Bien que chacune de ces fonctionnalités soit autonomes, elles sont également conçues pour fonctionner ensemble. Plus précisément, les enregistrements, les classes scellées et la correspondance de modèles fonctionnent ensemble pour faciliter la programmation orientée données en Java.
  • La POO nous encourage à modéliser des entités et des processus complexes à l'aide d'objets, qui combinent état et comportement. La POO est à son meilleur lorsqu'elle définit et défend des limites.
  • Le typage statique puissant de Java et la modélisation basée sur les classes peuvent toujours être extrêmement utiles pour les programmes plus petits, mais de différentes manières.
  • La programmation orientée données nous encourage à modéliser les données comme des données (immuables) et à conserver séparément le code qui incarne la logique métier de la façon dont nous agissons sur ces données. Les records, les classes scellées et le pattern matching facilitent cela.
  • Lorsque nous modélisons des entités complexes, les techniques OO ont beaucoup à nous offrir. Mais lorsque nous modélisons des services simples qui traitent des données simples et ad hoc, les techniques de programmation orientée données peuvent nous offrir un chemin plus direct.
  • Les techniques de la POO et de la programmation orientée données ne sont pas en contradiction ; ce sont des outils différents pour différentes granularités et situations. Nous pouvons librement les mélanger et les assortir comme bon nous semble.

Le projet Amber a apporté un certain nombre de nouvelles fonctionnalités à Java ces dernières années :  l'inférence de type des variables localesles blocs de texte, les records, les classes scellées, le pattern matching, et plus encore. Bien que chacune de ces fonctionnalités soit autonomes, elles sont également conçues pour fonctionner ensemble. Plus précisément, les records, les classes scellées et le pattern maching fonctionnent ensemble pour faciliter la programmation orientée données en Java. Dans cet article, nous expliquerons ce que l'on entend par ce terme et comment cela pourrait affecter la façon dont nous programmons en Java.

Programmation orientée objet

Le but de tout paradigme de programmation est de gérer la complexité. Mais la complexité se présente sous de nombreuses formes, et tous les paradigmes ne gèrent pas toutes les formes de complexité de la même manière. La plupart des paradigmes de programmation ont un slogan d'une phrase de la forme "Tout est un..." ; pour la POO, c'est évidemment "tout est objet". La programmation fonctionnelle dit que "tout est une fonction" ; les systèmes basés sur les acteurs disent "tout est un acteur", etc. (Bien sûr, ce sont toutes des exagérations pour l'effet.)

La POO nous encourage à modéliser des entités et des processus complexes à l'aide d'objets, qui combinent état et comportement. La POO encourage l'encapsulation (le comportement de l'objet médiatise l'accès à l'état de l'objet) et le polymorphisme (il est possible d'interagir avec plusieurs types d'entités à l'aide d'une interface ou d'un vocabulaire commun), bien que les mécanismes permettant d'atteindre ces objectifs varient selon les langages OO. Lors de la modélisation du monde avec des objets, nous sommes encouragés à penser en termes de est-un (un compte d'épargne est un compte bancaire) et a-un (un compte d'épargne a-un propriétaire et un numéro de compte).

Alors que certains développeurs prennent plaisir à déclarer haut et fort que la programmation orientée objet est une expérience ratée, la vérité est plus subtile ; comme tous les outils, il convient bien à certaines choses et moins bien à d'autres. La POO mal faite peut être horrible, et beaucoup de gens ont été exposés aux principes de la POO poussés à des extrêmes ridicules. (Des diatribes comme le royaume des noms peuvent être amusantes et thérapeutiques, mais ils ne s'opposent pas vraiment à la POO, autant qu'à une exagération de dessin animé de la POO.) Mais si nous comprenons ce à quoi la POO est meilleure ou pire, nous pouvons l'utiliser là où elle offre plus de valeur et utiliser autre chose là où elle offre moins.

La POO est à son meilleur lorsqu'elle définit et défend des limites : les limites de maintenance, les limites de version, les limites d'encapsulation, les limites de compilation, les limites de compatibilité, les limites de sécurité, etc.
Des bibliothèques gérées de manière indépendante sont construites, maintenues et évoluent séparément des applications qui en dépendent (et les unes des autres), et si nous voulons pouvoir passer librement d'une version de la bibliothèque à la suivante, nous devons nous assurer que les frontières entre les bibliothèques et leurs clients sont claires, bien définies et délibérées. Les bibliothèques de plate-forme peuvent avoir un accès privilégié au système d'exploitation et au matériel sous-jacents, qui doivent être soigneusement contrôlés ; nous avons besoin d'une frontière solide entre les bibliothèques de la plate-forme et l'application pour préserver l'intégrité du système. Les langages OO nous fournissent des outils pour définir, naviguer et défendre précisément ces frontières.

La division d'un programme volumineux en parties plus petites avec des limites claires nous aide à gérer la complexité, car cela permet un raisonnement modulaire : la capacité d'analyser une partie du programme à la fois, tout en raisonnant sur l'ensemble. Dans un programme monolithique, placer des limites internes sensibles nous a aidés à créer des applications plus grandes qui s'étendaient sur plusieurs équipes. Ce n'est pas un hasard si Java a prospéré à l'ère des monolithes.

Depuis lors, les programmes sont devenus plus petits ; plutôt que de construire des monolithes, nous composons de plus grandes applications à partir de nombreux services plus petits. Au sein d'un petit service, il y a moins besoin de frontières internes ; des services suffisamment petits peuvent être maintenus par une seule équipe (ou même un seul développeur). De même, au sein de ces services plus petits, nous avons moins besoin de modéliser des processus avec état de longue durée.

Programmation orientée données

Le typage statique puissant de Java et la modélisation basée sur les classes peuvent toujours être extrêmement utiles pour les programmes plus petits, mais de différentes manières. Là où la POO nous encourage à utiliser des classes pour modéliser des entités et des processus métier, des bases de code plus petites avec moins de limites internes tireront souvent plus de profit de l'utilisation de classes pour modéliser les données. Nos services consomment des requêtes provenant du monde extérieur, telles que des requêtes HTTP avec des payloads JSON/XML/YAML non typées. Mais seuls les services les plus triviaux voudraient travailler directement avec des données sous cette forme ; nous aimerions représenter les nombres comme int ou long plutôt que comme des chaînes de chiffres, les dates comme des classes de type LocalDateTime, et les listes comme des collections plutôt que de longues chaînes délimitées par des virgules. (Et nous voulons valider ces données à la frontière, avant d'agir en conséquence.)

La programmation orientée données nous encourage à modéliser les données comme des données (immuables) et à conserver séparément le code qui incarne la logique métier de la façon dont nous agissons sur ces données. Au fur et à mesure que cette tendance vers des programmes plus petits a progressé, Java a acquis de nouveaux outils pour faciliter la modélisation des données en tant que données (records), pour modéliser directement des alternatives (classes scellées), et pour déstructurer de manière flexible des patterns de données polymorphes (pattern matching).

La programmation orientée données nous encourage à modéliser les données en tant que données. Les records, les classes scellées et le pattern matching fonctionnent ensemble pour rendre cela plus facile.

Programmer avec des données en tant que données ne signifie pas renoncer au typage statique. On pourrait faire de la programmation orientée données avec uniquement des maps et des listes non typées (on le fait souvent dans des langages comme Javascript), mais le typage statique a encore beaucoup à offrir en termes de sécurité, de lisibilité et de maintenabilité, même lorsque nous ne modélisons que des données simples. (Le code orienté données indiscipliné est souvent appelé "typé avec des chaînes de caractères (stringly typed)", car il utilise des chaînes pour modéliser des éléments qui ne devraient pas être modélisés en tant que chaînes, tels que des nombres, des dates et des listes.)

Programmation orientée données en Java

Les records, les classes scellées et le pattern matching sont conçus pour fonctionner ensemble afin de prendre en charge la programmation orientée données. Les records nous permettent de modéliser simplement les données à l'aide de classes ; les classes scellées modélisent les choix ; et le pattern matching nous fournit un moyen simple et sûr d'agir sur des données polymorphes. La prise en charge du pattern patching s'est faite en plusieurs incréments ; le premier ajoutait uniquement des modèles de test de type et ne les prenait en charge que dans instanceof ; les prochains modèles de test de type pris en charge dans switch également ; et plus récemment, des modèles de déconstruction pour les records ont été ajoutés dans Java 19. Les exemples de cet article utiliseront toutes ces fonctionnalités.

Bien que les records soient syntaxiquement concis, leur principale force est qu'ils nous permettent de modéliser proprement et simplement les agrégats. Comme pour toute modélisation de données, il y a des décisions créatives à prendre, et certaines modélisations sont meilleures que d'autres. L'utilisation de la combinaison des records et de classes scellées permet également de rendre les états illégaux non représentables plus facilement, améliorant encore la sécurité et la maintenabilité.

Exemple : options de ligne de commande

Comme premier exemple, considérons comment nous pourrions modéliser les options d'invocation dans un programme en ligne de commande. Certaines options prennent des arguments ; certains ne le font pas. Certains arguments sont des chaînes arbitraires, tandis que d'autres sont plus structurés, comme des nombres ou des dates. Le traitement des options de ligne de commande devrait rejeter les mauvaises options et les arguments malformés au début de l'exécution du programme. Une approche rapide et sale pourrait être de parcourir les arguments de la ligne de commande et pour chaque option connue que nous rencontrons, éliminer la présence ou l'absence de l'option, et éventuellement le paramètre de l'option, dans les variables. C'est simple, mais maintenant notre programme dépend d'un ensemble de variables globales de type chaîne. Si notre programme est petit, cela peut convenir, mais il ne s'adapte pas très bien. Non seulement cela risque d'entraver la maintenabilité à mesure que le programme grandit, mais cela rend notre programme moins testable - nous ne pouvons tester le programme dans son ensemble que via sa ligne de commande.

Une approche un peu moins quick-and-dirty pourrait consister à créer une seule classe représentant une option de ligne de commande et à analyser la ligne de commande dans une liste d'objets d'option. Si nous avions un programme de type cat qui copie les lignes d'un ou plusieurs fichiers vers un autre, on peut réduire les fichiers à un certain nombre de lignes et on peut éventuellement inclure des numéros de ligne, nous pourrions modéliser ces options à l'aide d'une enum et une classe Option :

enum MyOptions { INPUT_FILE, OUTPUT_FILE, MAX_LINES, PRINT_LINE_NUMBERS }
record OptionValue(MyOptions option, String optionValue) { }
static List<OptionValue> parseOptions(String[] args) { ... }

Il s'agit d'une amélioration par rapport à l'approche précédente ; au moins maintenant, il y a une séparation nette entre l'analyse des options de ligne de commande et leur consommation, ce qui signifie que nous pouvons tester notre logique métier séparément du shell de ligne de commande en lui fournissant des listes d'options. Mais ce n'est toujours pas très bon. Certaines options n'ont pas de paramètre, mais nous ne pouvons pas le voir en regardant l'énumération des options, et nous les modélisons toujours avec un objet OptionValue qui a un champ optionValue . Et même pour les options qui ont des paramètres, elles sont toujours typées en chaîne.

La meilleure façon de le faire est de modéliser directement chaque option. Historiquement, cela aurait pu être prohibitif, mais heureusement ce n'est plus le cas. Nous pouvons utiliser une classe scellée pour représenter une Option et disposer d'un record pour chaque type d'option :

sealed interface Option { 
    record InputFile(Path path) implements Option { }
    record OutputFile(Path path) implements Option { }
    record MaxLines(int maxLines) implements Option { }
    record PrintLineNumbers() implements Option { }
}

Les sous-classes Option sont des données pures. Les valeurs d'option ont de jolis noms et types propres ; les options qui ont des paramètres les représentent avec le type approprié ; les options sans paramètres n'ont pas de variables de paramètres inutiles qui pourraient être mal interprétées. De plus, il est facile de traiter les options avec du pattern matching dans un (généralement une ligne de code par type d'option.) Et comme Option est scellé, le compilateur peut vérifier qu'un commutateur gère tous les types d'options. (Si nous ajoutons plus de types d'options plus tard, le compilateur nous rappellera quels commutateurs doivent être étendus.)

Nous avons probablement tous écrit du code comme celui décrit dans les deux premières versions, même si nous en savons plus. Sans la capacité de modéliser proprement et avec concision les données, le faire "bien" est souvent trop de travail (ou trop de code).

Ce que nous avons fait ici est de prendre des données désordonnées et non typées de l'autre côté de la frontière d'invocation (arguments de ligne de commande) et de les transformer en données fortement typées, validées, sur lesquelles il est facile d'agir (en utilisant le pattern matching) et de créer de nombreux états illégaux (tels que comme spécifiant --input-file mais ne fournissant pas de chemin valide) non représentable. Le reste du programme peut simplement l'utiliser en toute confiance.

Types de données algébriques

Cette combinaison de records et de types scellés est un exemple de ce que l'on appelle les types de données algébriques (ADT : algebraic data types). Les records sont une forme de "types produits", ainsi appelés parce que leur espace d'état est le produit cartésien de celui de leurs composants. Les classes scellées sont une forme de "types somme", ainsi appelés parce que l'ensemble des valeurs possibles est la somme (union) des ensembles de valeurs des alternatives. Cette simple combinaison de mécanismes - agrégation et choix - est d'une puissance trompeuse et apparaît dans de nombreux langages de programmation. (Notre exemple ici était limité à un niveau de hiérarchie, mais cela n'a pas besoin d'être le cas en général ; l'un des sous-types autorisés d'une interface scellée pourrait être une autre interface scellée, permettant la modélisation de structures complexes.)

En Java, les types de données algébriques peuvent être modélisés précisément comme des hiérarchies scellées dont les feuilles sont des records. L'interprétation de Java des types de données algébriques a un certain nombre de propriétés souhaitables. Ils sont nominaux : les types et les composants ont des noms lisibles par l'homme. Ils sont immuables, ce qui les rend plus simples et plus sûrs et peut être partagé librement sans se soucier des interférences. Ils sont facilement testables, car ils ne contiennent rien d'autre que leurs données (éventuellement avec un comportement dérivé uniquement des données). Ils peuvent facilement être sérialisés sur disque ou via le réseau. Et ils sont expressifs -- ils peuvent modéliser un large éventail de domaines de données.

Application : types de retour complexes

L'une des applications les plus simples mais les plus fréquemment utilisées des types de données algébriques est les types de retour complexes. Étant donné qu'une méthode ne peut renvoyer qu'une seule valeur, il est souvent tentant de surcharger la représentation de la valeur de retour de manière douteuse ou complexe, par exemple en utilisant null pour signifier "introuvable", en encodant plusieurs valeurs dans une chaîne, ou en utilisant un type trop abstrait (tableaux, List ou Map) pour entasser tous les différents types d'informations qu'une méthode pourrait renvoyer dans un seul objet porteur. Les types de données algébriques rendent si facile de faire ce qu'il faut, que ces approches deviennent moins tentantes.

Dans Les classes scellées, nous avons donné un exemple de la façon dont cette technique pourrait être utilisée pour résumer à la fois le succès et conditions d'échec sans utiliser d'exceptions :

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> { }
}

L'avantage de cette approche est que le client peut gérer le succès et l'échec de manière uniforme via le pattern matching sur le résultat, plutôt que d'avoir à gérer le succès via la valeur de retour et les différents modes d'échec via des blocs catch séparés :

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

Un autre avantage des classes scellées est que si vous utilisez un switch sans default, le compilateur vous rappellera si vous avez oublié un cas. (Les checked exceptions le font aussi, mais de manière plus intrusive.)

Comme autre exemple, imaginez un service qui recherche des entités (utilisateurs, documents, groupes, etc.) par nom, et qui fait la distinction entre "aucune correspondance trouvée", "correspondance exacte trouvée" et "aucune correspondance exacte, mais il y avait des correspondances proches." Nous pouvons tous imaginer des moyens de regrouper cela dans une seule List ou tableau, et bien que cela puisse rendre l'API de recherche facile à écrire, cela la rend plus difficile à comprendre, à utiliser, ou tester. Les types de données algébriques facilitent les deux côtés de cette équation. Nous pouvons créer une API concise qui définit exactement ce que nous voulons dire :

sealed interface MatchResult<T> { 
    record NoMatch<T>() implements MatchResult<T> { }
    record ExactMatch<T>(T entity) implements MatchResult<T> { }
    record FuzzyMatches<T>(Collection<FuzzyMatch<T>> entities) 
        implements MatchResult<T> { }

    record FuzzyMatch<T>(T entity, int distance) { }
}

MatchResult<User> findUser(String userName) { ... }

Si nous rencontrons cette hiérarchie de retour en parcourant le code ou la Javadoc, il est immédiatement évident de savoir ce que cette méthode pourrait renvoyer et comment gérer son résultat :

Page userSearch(String user) { 
    return switch (findUser(user)) { 
        case NoMatch() -> noMatchPage(user);
        case ExactMatch(var u) -> userPage(u);
        case FuzzyMatches(var ms) -> disambiguationPage(ms.stream()
                                                          .sorted(FuzzyMatch::distance))
                                                          .limit(MAX_MATCHES)
                                                          .toList());
}

Bien qu'un encodage aussi clair de la valeur de retour soit bon pour la lisibilité de l'API et pour sa facilité d'utilisation, de tels encodages sont également souvent plus faciles à écrire , car le code s'écrit pratiquement à partir de besoins. D'un autre côté, essayer de trouver (et de documenter) des encodages "intelligents" qui entasser des résultats complexes dans des supports abstraits comme des tableaux ou des maps demande plus de travail.

Application : Structures de données ad-hoc

Les types de données algébriques sont également utiles pour modéliser des versions ad-hoc de structures de données à usage général. La classe populaire Optional pourrait être modélisée comme un type de données algébrique :

sealed interface Opt<T> { 
    record Some<T>(T value) implements Opt<T> { }
    record None<T>() implements Opt<T> { }
}

(C'est en fait ainsi que Optional est défini dans la plupart des langages fonctionnels.) Les opérations courantes sur Opt peuvent être mises en œuvre avec le pattern matching :

static<T, U> Opt<U> map(Opt<T> opt, Function<T, U> mapper) { 
    return switch (opt) { 
        case Some<T>(var v) -> new Some<>(mapper.apply(v));
        case None<T>() -> new None<>();
    }
}

De même, un arbre binaire peut être implémenté comme suit :

sealed interface Tree<T> { 
    record Nil<T>() implements Tree<T> { }
    record Node<T>(Node<T> left, T val, Node<T> right) implements Tree<T> { }
}

et nous pouvons implémenter les opérations habituelles avec le pattern matching :

static<T> boolean contains(Tree<T> tree, T target) { 
    return switch (tree) { 
        case Nil() -> false;
        case Node(var left, var val, var right) -> 
            target.equals(val) || left.contains(target) || right.contains(target);
    };
}

static<T> void inorder(Tree<T> t, Consumer<T> c) { 
    switch (tree) { 
        case Nil(): break;
        case Node(var left, var val, var right):
            left.inorder(c);
            c.accept(val);
            right.inorder(c);
    };
}

Il peut sembler étrange de voir ce comportement écrit en tant que méthodes statiques, alors que des comportements courants tels que la traversée doivent "évidemment" être implémentés en tant que méthodes abstraites sur l'interface de base. Et certainement, certaines méthodes peuvent avoir du sens à mettre dans l'interface. Mais la combinaison des records, des classes scellées et du pattern matching nous offre des alternatives que nous n'avions pas auparavant ; nous pourrions les implémenter à l'ancienne (avec une méthode abstraite dans la classe de base et des méthodes concrètes dans chaque sous-classe) ; comme méthodes par défaut dans la classe abstraite implémentée en un seul endroit avec le pattern matching ; comme méthodes statiques ; ou (lorsque la récursivité n'est pas nécessaire), en tant que traversées ad hoc en ligne au point d'utilisation.
Parce que le support de données est spécialement conçu pour la situation, nous pouvons choisir si nous voulons que le comportement voyage avec les données ou non. Cette approche n'est pas en contradiction avec l'orientation objet ; c'est un ajout utile à notre boîte à outils qui peut être utilisé avec OO, selon la situation.

Exemple : JSON

Si vous regardez d'assez près la spécification JSON, vous verrez qu'une valeur JSON est également un ADT :

sealed interface JsonValue { 
    record JsonString(String s) implements JsonValue { }
    record JsonNumber(double d) implements JsonValue { }
    record JsonNull() implements JsonValue { }
    record JsonBoolean(boolean b) implements JsonValue { }
    record JsonArray(List<JsonValue> values) implements JsonValue { }
    record JsonObject(Map<String, JsonValue> pairs) implements JsonValue { }
}

Lorsqu'il est présenté comme tel, le code permettant d'extraire les informations pertinentes d'un blob de JSON est assez simple ; si nous voulons faire correspondre le blob JSON { "name" :"John", "age" :30, "city" :"New York" } avec du pattern matching, voici ce que l'on pourrait avoir :

if (j instanceof JsonObject(var pairs)
    && pairs.get("name") instanceof JsonString(String name)
    && pairs.get("age") instanceof JsonNumber(double age)
    && pairs.get("city") instanceof JsonString(String city)) { 
    // use name, age, city
}

Lorsque nous modélisons des données en tant que données, la création d'agrégats et leur démontage pour extraire leur contenu (ou les reconditionner dans un autre formulaire) est simple, et parce que le pattern matching échoue gracieusement lorsque quelque chose ne correspond pas, le code pour décomposer ce blob JSON est relativement exempt de flux de contrôle complexes pour faire respecter les contraintes structurelles. (Bien que nous puissions être enclins à utiliser une bibliothèque JSON plus industrielle que cet exemple de jouet, nous pourrions en fait implémenter le jouet avec seulement quelques dizaines de lignes supplémentaires de code d'analyse qui suit les règles lexicales décrites dans la spécification JSON et les transforme en une JsonValue.)

Domaines plus complexes

Les domaines que nous avons examinés jusqu'à présent étaient soit des "jetables" (valeurs de retour utilisées à travers une limite d'appel), soit des domaines de modélisation généraux tels que des listes et des arbres. Mais la même approche est également utile pour des domaines spécifiques à une application plus complexes. Si nous voulions modéliser une expression arithmétique, nous pourrions le faire avec :

sealed interface Node { }
sealed interface BinaryNode extends Node { 
    Node left();
    Node right();
}

record AddNode(Node left, Node right) implements BinaryNode { }
record MulNode(Node left, Node right) implements BinaryNode { }
record ExpNode(Node left, int exp) implements Node { }
record NegNode(Node node) implements Node { }
record ConstNode(double val) implements Node { }
record VarNode(String name) implements Node { }

Avoir l'interface scellée intermédiaire BinaryNode qui fait abstraction de l'addition et de la multiplication nous donne le choix lors de la correspondance sur un Node ; nous pourrions gérer à la fois l'addition et la multiplication en faisant correspondre sur BinaryNode, ou les gérer individuellement, selon la situation. Le langage s'assurera toujours que nous couvrons tous les cas.

Écrire un évaluateur pour ces expressions est trivial. Puisque nous avons des variables dans nos expressions, nous aurons besoin d'un magasin pour celles-ci, que nous transmettons à l'évaluateur :

double eval(Node n, Function<String, Double> vars) { 
    return switch (n) { 
        case AddNode(var left, var right) -> eval(left, vars) + eval(right, vars);
        case MulNode(var left, var right) -> eval(left, vars) * eval(right, vars);
        case ExpNode(var node, int exp) -> Math.exp(eval(node, vars), exp);
        case NegNode(var node) -> -eval(node, vars);
        case ConstNode(double val) -> val;
        case VarNode(String name) -> vars.apply(name);
    }
}

Les records qui définissent les nœuds terminaux ont des implémentations toString raisonnables, mais la sortie est probablement plus détaillée que nous le souhaiterions. Nous pouvons facilement écrire un formateur pour produire une sortie qui ressemble plus à une expression mathématique :

String format(Node n) { 
    return switch (n) { 
        case AddNode(var left, var right) -> String.format("("%s + %s)", 
                                                           format(left), format(right));
        case MulNode(var left, var right) -> String.format("("%s * %s)", 
                                                           format(left), format(right));
        case ExpNode(var node, int exp) -> String.format("%s^%d", format(node), exp);
        case NegNode(var node) -> String.format("-%s", format(node));
        case ConstNode(double val) -> Double.toString(val);
        case VarNode(String name) -> name;
    }
}

Comme auparavant, nous pourrions les exprimer en tant que méthodes statiques, ou les implémenter dans la classe de base en tant que méthodes d'instance mais avec une seule implémentation, ou les implémenter en tant que méthodes d'instance ordinaires - nous sommes libres de choisir celle qui semble la plus lisible pour le domaine.

Après avoir défini notre domaine de manière abstraite, nous pouvons facilement y ajouter d'autres opérations. On peut différencier symboliquement par rapport à une seule variable facilement :

Node diff(Node n, String v) { 
    return switch (n) { 
        case AddNode(var left, var right) 
            -> new AddNode(diff(left, v), diff(right, v)); 
        case MulNode(var left, var right) 
            -> new AddNode(new MulNode(left, diff(right, v)), 
                           new MulNode(diff(left, v), right))); 
        case ExpNode(var node, int exp) 
            -> new MulNode(new ConstNode(exp), 
                           new MulNode(new ExpNode(node, exp-1), 
                                       diff(node, v)));
        case NegNode(var node) -> new NegNode(diff(node, var));
        case ConstNode(double val) -> new ConstNode(0);
        case VarNode(String name) -> name.equals(v) ? new ConstNode(1) : new ConstNode(0);
    }
}

Avant que nous ayons des records et et le pattern matching, l'approche standard pour écrire du code comme celui-ci était le pattern visiteur. Le pattern matching est clairement plus concit que les visiteurs, mais elle est également plus flexible et puissante. Les visiteurs exigent que le domaine soit construit pour la visite et impose des contraintes strictes; le pattern matching prend en charge beaucoup plus de polymorphisme ad hoc. Fondamentalement, le pattern matching compose mieux ; nous pouvons utiliser des patterns imbriqués pour exprimer des conditions complexes qui peuvent être beaucoup plus compliquées à exprimer avec les visiteurs. Par exemple, le code ci-dessus produira des arbres inutilement désordonnés lorsque, par exemple, nous avons un nœud de multiplication où un sous-nœud est une constante. Nous pouvons utiliser des patterns imbriqués pour gérer ces cas particuliers avec plus d'empressement :

Node diff(Node n, String v) { 
    return switch (n) { 
        case AddNode(var left, var right) 
            -> new AddNode(diff(left, v), diff(right, v)); 
        // special cases of k*node, or node*k
        case MulNode(var left, ConstNode(double val) k) 
            -> new MulNode(k, diff(left, v));
        case MulNode(ConstNode(double val) k, var right) 
            -> new MulNode(k, diff(right, v));
        case MulNode(var left, var right) 
            -> new AddNode(new MulNode(left, diff(right, v)), 
                           new MulNode(diff(left, v), right))); 
        case ExpNode(var node, int exp) 
            -> new MulNode(new ConstNode(exp), 
                           new MulNode(new ExpNode(node, exp-1), 
                                       diff(node, v)));
        case NegNode(var node) -> new NegNode(diff(node, var));
        case ConstNode(double val) -> new ConstNode(0);
        case VarNode(String name) -> name.equals(v) ? new ConstNode(1) : new ConstNode(0);
    }
}

Faire cela avec des visiteurs - en particulier à plusieurs niveaux d'imbrication - peut rapidement devenir assez désordonné et sujet aux erreurs.

Ce n'est ni l'un ni l'autre

Bon nombre des idées décrites ici peuvent sembler, au premier abord, quelque peu "non Java", car la plupart d'entre nous ont appris à commencer par modéliser des entités et des processus en tant qu'objets. Mais en réalité, nos programmes fonctionnent souvent avec des données relativement simples, qui proviennent souvent du "monde extérieur" où nous ne pouvons pas compter sur leur intégration propre dans le système de type Java. (Dans notre exemple JSON, nous avons modélisé les nombres comme double, mais en fait, la spécification JSON est silencieuse sur la plage de valeurs numériques ; le code à la limite d'un système va devoir prendre une décision de s'il faut tronquer ou rejeter les valeurs qui ne correspondent pas à la représentation locale.)

Lorsque nous modélisons des entités complexes ou écrivons des bibliothèques riches telles que java.util.stream, les techniques OO ont beaucoup à nous offrir. Mais lorsque nous construisons des services simples qui traitent des données simples et ad hoc, les techniques de programmation orientée données peuvent nous offrir un chemin plus direct. De même, lors de l'échange de résultats complexes à travers une limite d'API (comme notre exemple de résultat de correspondance), il est souvent plus simple et plus clair de définir un schéma de données ad hoc à l'aide d'ADT, que de compléter les résultats et le comportement dans un objet avec état (comme l'API Matcher en Java le fait.)

Les techniques de la POO et de la programmation orientée données ne sont pas en contradiction ; ce sont des outils différents pour différentes granularités et situations. Nous pouvons librement les mélanger et les assortir comme bon nous semble.

Suivez les données

Qu'il s'agisse de modéliser une valeur de retour simple ou un domaine plus complexe tel que JSON ou nos arborescences d'expressions, il existe des principes simples qui nous conduisent généralement à un code orienté données simple et fiable.

  • Modélisez les données, l'ensemble des données et rien que les données. Les records doivent modéliser les données. Faites en sorte que chaque record ne modélise qu'une seule chose, que ce que chaque record modélise soit clair, et choisissez des noms clairs pour ses composants. Lorsqu'il y a des choix à modéliser, comme "une déclaration de revenus est déposée soit par le contribuable, soit par un représentant légal", modélisez-les sous forme de classes scellées et modélisez chaque alternative avec un record. Le comportement dans les classes record doit se limiter à la mise en œuvre de quantités dérivées des données elles-mêmes, telles que le formatage.

  • Les données sont immuables. Un objet qui a un champ int mutable ne modélise pas un entier ; il modélise une relation variant dans le temps entre une identité d'objet spécifique et un entier. Si nous voulons modéliser des données, nous ne devrions pas avoir à nous soucier que nos données changent dans le temps. Les records nous aident ici, car ils sont superficiellement immuables, mais il faut quand même une certaine discipline pour éviter de laisser la mutabilité s'injecter dans nos modèles de données.

  • Valider à la limite. Avant d'injecter des données dans notre système, nous devons nous assurer qu'elles sont valides. Cela peut être fait dans le constructeur d'un record (si la validation s'applique universellement à toutes les instances), ou par le code à la limite qui a reçu les données d'une autre source.

  • Rendre les états illégaux non représentables. Les records et les types scellés facilitent la modélisation de nos domaines de manière à ce que les états erronés ne puissent tout simplement pas être représentés. C'est bien mieux que d'avoir à vérifier la validité tout le temps ! Tout comme l'immuabilité élimine de nombreuses sources courantes d'erreurs dans les programmes, il en va de même pour le fait d'éviter les techniques de modélisation qui nous permettent de modéliser des données non valides.

Un avantage caché de cette approche est la testabilité. Non seulement il est facile de tester le code lorsque ses entrées et ses sorties sont des données simples et bien définies, mais cela ouvre la porte à des tests génératifs plus faciles, qui sont souvent beaucoup plus efficaces pour trouver des bugs que la création manuelle de cas de test individuels.

La combinaison de records, de types scellés et du pattern matching facilite le respect de ces principes, ce qui donne des programmes plus concis, lisibles et plus fiables. Bien que la programmation avec des données en tant que données puisse être un peu inconnue compte tenu des fondements OO de Java, ces techniques valent la peine d'être ajoutées à notre boîte à outils.

 

Au sujet de l’Auteur

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT