5. Classes Java d'usage courant
Nous présentons ici quelques classes de la plate-forme Java fréquemment utilisées : chaînes de caractères, tableaux, collections, fichiers, expressions régulières. Nous y ajoutons l'API Stream (Java 8), qui est aujourd'hui la façon la plus courante de rechercher, trier, regrouper ou transformer les éléments d'une collection en Java. Auparavant, nous montrons comment obtenir des renseignements sur les milliers de classes disponibles. Cette aide est indispensable au développeur Java, même confirmé.
Les exemples de ce chapitre sont, sauf deux (l'exemple JSON et l'application exemple de la fin), des fichiers source compacts exécutés par le lanceur de code source : [java Fichier.java]. Ils se trouvent dans le dossier [05-classes-java] du code du cours et s'exécutent, comme tout le cours, en langue française (locale fr-FR). Rappelons qu'un fichier source compact importe automatiquement toutes les classes publiques du module [java.base] : [java.util], [java.io], [java.nio.file], [java.util.regex], [java.util.stream], [java.time]… C'est pourquoi les programmes qui suivent n'ont aucune instruction [import].
5.1. Chercher de l'aide sur les classes Java
La bibliothèque standard de Java (l'API Java SE) comprend plus de 4 000 classes publiques. Personne ne les connaît toutes : le développeur Java consulte sans cesse leur documentation. Celle-ci est en ligne, gratuite, et publiée pour chaque version de Java.
5.1.1. La documentation en ligne
Le point d'entrée est le site docs.oracle.com/en/java/javase/25 d'Oracle. On y trouve :
- la référence de l'API Java SE 25 : https://docs.oracle.com/en/java/javase/25/docs/api/. Elle décrit, module par module et paquetage par paquetage, toutes les classes et interfaces de Java : leurs constructeurs, champs et méthodes ;
- une zone de recherche, en haut de chaque page de la référence, dans laquelle on tape un nom de classe ou de méthode ([StringBuilder], [Matcher.find]…) : la liste des réponses se complète au fur et à mesure de la frappe ;
- la spécification du langage Java (JLS) et des guides thématiques (outils du JDK, sécurité, expressions régulières…) ;
- une page « New features » qui décrit les nouveautés de chaque version. Les nouveautés elles-mêmes sont décrites par des JEP (JDK Enhancement Proposals), consultables sur openjdk.org/jeps : la JEP 512 décrit par exemple les fichiers source compacts utilisés dans ce chapitre.
La page d'une classe, par exemple java.lang.String, donne :
- le module et le paquetage de la classe ;
- sa déclaration, ses classes parentes et les interfaces qu'elle implémente ;
- une description générale, souvent longue et précise, avec des exemples ;
- la liste résumée de ses champs, constructeurs et méthodes, puis le détail de chacun : paramètres, valeur rendue, exceptions possibles, et la version de Java qui l'a introduit (rubrique « Since »).
La documentation est en anglais. Une recherche sur un moteur de recherche avec le nom complet de la classe et la version (« java.lang.StringBuilder 25 ») mène en général directement à la bonne page.
5.1.2. La Javadoc et l'aide de l'éditeur
Cette documentation est produite par l'outil [javadoc] du JDK à partir des commentaires de documentation écrits dans le code source des classes. Un tel commentaire commence par [/**] et se place juste avant une classe, un champ ou une méthode. Voici par exemple la méthode de calcul de l'impôt de l'application exemple de ce chapitre, documentée ainsi :
Le commentaire commence par une phrase de résumé ; les balises [@param] décrivent les paramètres et [@return] la valeur rendue ([@throws] décrirait une exception). On peut documenter ainsi son propre code ; la commande [javadoc] (ou [mvn javadoc:javadoc] dans un projet Maven) en produit des pages HTML semblables à celles de l'API Java. Depuis Java 23, les commentaires de documentation peuvent aussi s'écrire en Markdown, avec des lignes commençant par [///].
L'éditeur de code (IntelliJ IDEA, Visual Studio Code avec l'Extension Pack for Java) exploite ces commentaires et fournit une aide immédiate, sans quitter le code :
- l'autocomplétion : après avoir tapé [uneChaine.], l'éditeur propose la liste des méthodes de l'objet, avec leurs paramètres et leur type de résultat ;
- la documentation rapide : placer la souris sur un nom de classe ou de méthode (ou taper Ctrl+Q dans IntelliJ IDEA) affiche sa Javadoc ;
- l'aide sur les paramètres : en tapant la parenthèse ouvrante d'un appel (ou Ctrl+P dans IntelliJ IDEA), l'éditeur affiche les différentes surcharges de la méthode et le rôle de chaque paramètre ;
- atteindre la définition (Ctrl+B dans IntelliJ IDEA, F12 dans VS Code) : affiche le code source de la classe ou de la méthode. Le JDK est livré avec le code source de toute sa bibliothèque (fichier [lib/src.zip]) : on peut ainsi lire le code de [ArrayList] ou de [HashMap], écrit par les meilleurs spécialistes de Java, ce qui est très formateur.
Enfin, l'outil [jshell] du JDK permet d'essayer une méthode sans écrire de programme : on tape une expression, il affiche sa valeur. Ainsi ["abc".repeat(3)] affiche ["abcabcabc"]. La commande [/help] de [jshell] liste ses possibilités.
5.2. Les chaînes de caractères
5.2.1. La classe String
Le type [String] (paquetage [java.lang]) représente les chaînes de caractères. Il présente de nombreuses méthodes. En voici quelques-unes :
méthode | rôle |
[int length()] | nombre de caractères de la chaîne |
[char charAt(int index)] | le caractère n° [index] ; le premier a le n° 0 |
[boolean startsWith(String prefix)] | rend [true] si la chaîne commence par [prefix] |
[boolean endsWith(String suffix)] | rend [true] si la chaîne se termine par [suffix] |
[boolean contains(CharSequence s)] | rend [true] si la chaîne contient [s] |
[boolean equals(Object autre)] | rend [true] si la chaîne a le même contenu que [autre] ; [equalsIgnoreCase] ne tient pas compte de la casse |
[int indexOf(String str)] | première position de [str] dans la chaîne, ou -1. Une autre surcharge commence la recherche à la position [fromIndex] |
[int indexOf(int ch)] | idem pour le caractère [ch] |
[int lastIndexOf(int ch)] | idem [indexOf] mais rend la dernière position au lieu de la première |
[static String join(CharSequence delimiter, CharSequence... elements)] | méthode de classe : concatène les éléments en les séparant par [delimiter] |
[String replace(char oldChar, char newChar)] | rend une copie de la chaîne où le caractère [oldChar] a été remplacé par [newChar] ; une surcharge remplace une chaîne par une autre |
[String[] split(String regex)] | la chaîne est vue comme une suite de champs séparés par un séparateur décrit par l'expression régulière [regex] ; le résultat est le tableau de ces champs |
[String substring(int beginIndex, int endIndex)] | sous-chaîne formée des caractères de la position [beginIndex] (incluse) à la position [endIndex] (exclue) |
[String toLowerCase()], [String toUpperCase()] | rend la chaîne en minuscules, en majuscules |
[String strip()] | rend la chaîne débarrassée de ses espaces de début et de fin (Java 11 ; [stripLeading], [stripTrailing] : d'un seul côté) |
[String repeat(int count)] | rend la chaîne répétée [count] fois (Java 11) |
[boolean isEmpty()], [boolean isBlank()] | rend [true] si la chaîne est vide, si elle est vide ou ne contient que des espaces (Java 11) |
[Stream<String> lines()] | rend les lignes de la chaîne sous la forme d'un flux (Java 11, voir l'API Stream plus loin) |
[String formatted(Object... args)] | rend la chaîne, utilisée comme format, dans laquelle les emplacements [%s], [%d]… ont été remplacés par les arguments (Java 15) |
On notera un point important : une chaîne de type [String] est immuable, elle ne peut pas être modifiée après sa création. Lorsqu'une méthode rend une chaîne de caractères, celle-ci est une chaîne différente de la chaîne sur laquelle a été appliquée la méthode. Ainsi [s1.strip()] rend une chaîne [s2], et [s1] et [s2] sont deux chaînes différentes : [s1] n'a pas changé.
Contrairement à d'autres langages, une chaîne Java n'est pas un tableau : on n'écrit pas [c[i]] mais [c.charAt(i)], et le nombre de caractères est obtenu par la méthode [c.length()] (avec des parenthèses, alors que la longueur d'un tableau est le champ [tableau.length], sans parenthèses).
Le programme [Chaines01.java] est le suivant :
Résultats :
Commentaires :
- ligne 6 : le caractère n° 10, donc le 11e caractère de la chaîne ;
- lignes 8, 10 : une recherche infructueuse rend -1 ;
- ligne 11 : les deux paramètres de [substring] sont les positions du premier caractère gardé et du premier caractère non gardé : on obtient les 7 caractères des positions 4 à 10. C'est la convention générale de Java pour désigner une plage d'indices (début inclus, fin exclue) : on la retrouvera dans de nombreuses méthodes ;
- lignes 16-17 : la classe [String] n'a pas de méthode d'insertion : on recolle le début de la chaîne, le texte inséré et la fin de la chaîne. Nous verrons que la classe [StringBuilder] a une méthode [insert] ;
- ligne 19 : [split] découpe la chaîne en champs. Son paramètre n'est pas un simple caractère mais une expression régulière, que nous étudierons à la fin du chapitre : ["\\s+"] désigne une suite d'un ou plusieurs espaces (espace, tabulation, saut de ligne…). Le caractère [\] doit être doublé dans une chaîne Java, d'où l'écriture [\\s] ;
- ligne 23 : la méthode statique [join] fait l'inverse de [split] ;
- ligne 24 : [strip] (Java 11) supprime les espaces de début et de fin. On rencontre aussi la méthode [trim], plus ancienne, qui ne reconnaît que les espaces du code ASCII ; [strip] reconnaît tous les espaces d'Unicode et doit lui être préférée ;
- ligne 26 : toutes les méthodes précédentes ont rendu de nouvelles chaînes : [uneChaine] n'a pas été modifiée (ligne 21 des résultats) ;
- lignes 30-32 : une méthode du fichier source compact, appelée par la méthode [main] comme au chapitre 3.
5.2.2. Les méthodes split et join
Le programme [Chaines02.java] approfondit le découpage d'une chaîne en champs :
Résultats :
Commentaires :
- ligne 5 : les champs sont séparés par le caractère [:]. Deux [:] consécutifs délimitent un champ vide (ligne 4 des résultats). Mais attention : [split] supprime les champs vides de la fin. Le [:] final de la chaîne délimite un cinquième champ, vide, qui a disparu : on n'obtient que 4 champs, et [join] ne reconstitue pas la chaîne de départ (ligne 6 des résultats). C'est un piège classique ;
- ligne 13 : le second paramètre de [split] est une limite. Une limite négative demande de garder tous les champs, y compris les champs vides de la fin : on obtient bien 5 champs, et [join] reconstitue la chaîne de départ ;
- ligne 17 : le séparateur étant une expression régulière, on peut décrire des séparateurs élaborés : [":+"] désigne un ou plusieurs caractères [:] consécutifs. Les deux [:] consécutifs forment un seul séparateur et le champ vide disparaît ;
- ligne 22 : le modèle [\\s*[,;]\\s*] désigne une virgule ou un point-virgule, précédé et suivi d'un nombre quelconque d'espaces : les espaces qui entourent les séparateurs disparaissent avec eux. Ceux du début et de la fin de la chaîne sont supprimés auparavant par [strip]. On obtient en une instruction les trois champs « nettoyés » ;
- lignes 27-35 : pour supprimer à la fois les espaces autour des champs et les champs vides, on peut aussi traiter les champs un par un : le champ formé de deux espaces entre deux virgules devient vide après [strip] et n'est pas gardé. La liste [ArrayList] utilisée ici pour mémoriser les champs gardés sera présentée un peu plus loin. Nous verrons aussi que l'API Stream permet d'écrire ce traitement en une instruction ;
- ligne 39 : le séparateur [" | "] contient le caractère [|], qui a une signification particulière dans une expression régulière (« ou »). La méthode [Pattern.quote] transforme une chaîne quelconque en une expression régulière qui la désigne littéralement. On l'utilisera chaque fois qu'un séparateur contient l'un des caractères [\ ^ $ . | ? * + ( ) [ ] { }] ;
- lignes 43-48 : [join] n'accepte que des chaînes (plus exactement des objets [CharSequence], interface implémentée par [String] et [StringBuilder]) : un tableau d'entiers doit d'abord être converti en tableau de chaînes. Nous verrons qu'avec l'API Stream, [Arrays.stream(nombres).mapToObj(String::valueOf).collect(Collectors.joining(" + "))] fait la même chose en une instruction.
5.2.3. Rechercher et comparer des chaînes
La comparaison de chaînes est plus subtile qu'il n'y paraît : faut-il distinguer les majuscules des minuscules ? Comment classer « élève » par rapport à « essai » ? Et surtout, en Java, que compare l'opérateur [==] ? Le programme [Chaines03.java] présente les méthodes de recherche et de comparaison :
Résultats :
Commentaires :
- lignes 6-9 : [contains], [startsWith] et [endsWith] font une comparaison exacte, caractère par caractère : « LANGAGE » n'est pas trouvé ;
- lignes 12-14 : ces méthodes n'ont pas de variante qui ignore la casse. La façon la plus simple de faire une recherche sans tenir compte de la casse est de mettre les deux chaînes en minuscules ;
- lignes 16-17 : la méthode [regionMatches] compare une partie de la chaîne à une partie d'une autre chaîne. Son premier paramètre indique s'il faut ignorer la casse ; les suivants sont la position de départ dans la chaîne, l'autre chaîne, la position de départ dans l'autre chaîne et le nombre de caractères à comparer ;
- lignes 20-25 : le point le plus important de ce programme. Une variable de type [String] contient une référence vers un objet chaîne (chapitre 4). L'opérateur [] compare les références : il rend [true] si les deux variables désignent le même objet, et non deux objets de même contenu. La variable [oui] (ligne 21) désigne une chaîne « oui » construite à l'exécution : c'est un objet différent de la chaîne littérale « oui » du programme, et [oui == "oui"] vaut [false] (ligne 9 des résultats) alors que les deux chaînes ont le même contenu. Pour comparer des contenus, on utilise toujours la méthode [equals], ou [equalsIgnoreCase] pour ignorer la casse, ce qui est la bonne façon de tester une réponse « oui » saisie au clavier en majuscules ou en minuscules. La comparaison avec [] « marche » parfois, lorsque les deux chaînes sont des littéraux du programme, que Java ne stocke qu'en un exemplaire : c'est ce qui rend l'erreur difficile à détecter ;
- lignes 28-31 : la méthode [compareTo] rend un entier négatif, nul ou positif selon que la chaîne précède, est égale ou suit la chaîne paramètre. Elle compare les codes des caractères : « élève » suit « essai » car le code du caractère é (233) est supérieur à celui du caractère e (101) ; elle rend ici la différence des deux codes, 132. Pour classer des mots selon les règles d'une langue, on utilise un objet [java.text.Collator] : pour le français, le é est classé avec le e, et « élève » précède « essai » (ligne 13 des résultats). On utilisera l'ordre d'un [Collator] pour trier des mots destinés à un utilisateur, et [compareTo] pour comparer des identifiants techniques (noms de fichiers, clés…) ;
- lignes 34-40 : [isEmpty] teste une chaîne vide, [isBlank] (Java 11) une chaîne vide ou ne contenant que des espaces. Une variable de type [String] peut aussi valoir [null] (aucune chaîne) : appeler une méthode sur [null] lance une exception [NullPointerException]. On teste donc d'abord [absente == null] ; grâce au court-circuit de l'opérateur [||], [absente.isBlank()] n'est pas évalué lorsque [absente] est [null] ;
- lignes 43-44 : [String.format("%8s", ...)] complète une chaîne par des espaces jusqu'à une longueur de 8 caractères, à gauche, ou à droite avec le format [%-8s] ; [repeat] répète une chaîne.
5.2.4. Formats et blocs de texte
Jusqu'ici, nous avons construit les chaînes à afficher par concaténation, avec l'opérateur [+]. Pour aligner des valeurs en colonnes, fixer le nombre de décimales d'un nombre ou afficher une date, on utilise la méthode [String.format], ou la méthode d'instance [formatted] (Java 15), qui fait la même chose. Le premier paramètre est un format : un texte dans lequel des spécificateurs [%[-][largeur][.précision]conversion] indiquent où et comment insérer les paramètres suivants. Le programme [Chaines04.java] en donne des exemples, ainsi que les blocs de texte de Java 15 :
Résultats :
Commentaires :
- ligne 12 : une largeur cadre la valeur à droite dans un champ de 10 caractères ; précédée du signe [-], elle la cadre à gauche ;
- lignes 12-14 : les conversions les plus courantes :
spécificateur | signification | exemple (fr-FR) |
[%s] | chaîne ; tout objet est converti par sa méthode [toString] | dupont |
[%d], [%5d] | entier, éventuellement sur 5 caractères | 45 |
[%05d] | entier sur 5 chiffres au moins, complété par des zéros | 00045 |
[%X] | entier en hexadécimal ([%x] : en minuscules) | 2D |
[%.2f], [%12.2f] | nombre réel avec 2 décimales, éventuellement sur 12 caractères | 2345,68 |
[%,.2f], [%,d] | idem avec séparateur des milliers | 2 345,68 |
[%e] | nombre réel en notation scientifique | 2,345678e+03 |
[%n] | fin de ligne du système | |
[%%] | le caractère % | % |
- les nombres sont mis en forme selon la langue courante, ici le français : virgule décimale, espace comme séparateur des milliers. Il s'agit d'une espace fine insécable (caractère U+202F), qui ressemble à une espace ordinaire mais empêche une coupure de ligne au milieu d'un nombre ; de même, une espace insécable (U+00A0) précède le symbole € et le symbole %. On en tiendra compte si l'on compare ces textes à des chaînes écrites avec des espaces ordinaires ;
- ligne 16 : ["...".formatted(...)] est l'équivalent de [String.format("...", ...)]. Une expression conditionnelle peut fournir un paramètre ;
- lignes 19-22 : les montants monétaires et les pourcentages sont mis en forme par des objets [java.text.NumberFormat], obtenus pour une langue et un pays donnés ([Locale.FRANCE]). Le format pourcentage multiplie la valeur par 100 ;
- lignes 25-27 : les dates de l'API [java.time] (Java 8) sont mises en forme par un objet [DateTimeFormatter] construit à partir d'un modèle : [dd] jour, [MM] mois, [yyyy] année, [HH] heure, [mm] minutes, [ss] secondes, [EEEE] jour de la semaine en toutes lettres, [MMMM] mois en toutes lettres. Attention à la casse : [MM] est le mois, [mm] les minutes. Les noms des jours et des mois sont ceux de la langue courante ;
- lignes 30-34 : les largeurs permettent d'afficher des données en colonnes ;
- ligne 37 : dans une chaîne, le caractère [\] introduit une séquence d'échappement ([\n], [\t], [\"]…) ; pour obtenir le caractère [\] lui-même, il faut le doubler. Java n'a pas de chaînes « brutes » où [\] serait un caractère ordinaire, ce qui rend un peu lourde l'écriture des chemins Windows et des expressions régulières ;
- lignes 41-46 : un bloc de texte (Java 15) est délimité par trois guillemets ; le texte commence à la ligne qui suit les guillemets ouvrants. Il peut s'étendre sur plusieurs lignes et contenir des guillemets sans échappement. L'indentation commune à toutes les lignes (celle de la ligne la moins indentée, en comptant les guillemets fermants) est retirée : le texte JSON s'affiche sans la marge du code. Les séquences d'échappement y restent actives : [\] doit toujours être doublé (ligne 44). Les guillemets fermants étant sur leur propre ligne, le texte se termine par une fin de ligne : d'où l'utilisation de [IO.print] à la ligne 47 ([IO.println] ajouterait une ligne vide après l'accolade fermante) ;
- lignes 49-50 : Java n'a pas d'interpolation de chaînes, c'est-à-dire d'expressions écrites directement dans le texte, comme [${nom}] dans d'autres langages (une proposition, les « String Templates », a été essayée en aperçu dans Java 21 et 22, puis retirée). Avec un bloc de texte, on utilise des emplacements [%s], [%d]… remplis par [formatted]. Placer les guillemets fermants à la fin du texte évite la fin de ligne finale.
On rencontre aussi la classe [java.text.MessageFormat], dont les formats désignent les paramètres par leur numéro : [MessageFormat.format("{0} a {1} ans", nom, age)]. Elle est utilisée pour les messages traduits en plusieurs langues, où l'ordre des paramètres peut changer d'une langue à l'autre.
5.2.5. La classe StringBuilder
Nous avons dit que les méthodes de la classe [String] qui s'appliquent à une chaîne [s1] rendent une autre chaîne [s2]. La classe [java.lang.StringBuilder] permet de modifier une chaîne sans créer de nouvelle chaîne à chaque opération. Cela améliore les performances lorsqu'on construit une chaîne par de nombreux ajouts successifs, en évitant la multiplication de chaînes à durée de vie très limitée.
La classe admet divers constructeurs :
constructeur | rôle |
[StringBuilder()] | construction d'un objet vide, de capacité initiale 16 caractères |
[StringBuilder(String str)] | construction et initialisation avec [str] ; la capacité initiale est la longueur de [str] plus 16 |
[StringBuilder(int capacity)] | construction d'un objet vide, de capacité initiale [capacity] caractères |
Un objet [StringBuilder] stocke ses caractères dans un tableau dont la taille, sa capacité, est ajustée automatiquement lorsque le texte grandit. Le programme [Chaines05.java] illustre cette notion de capacité :
Résultats :
Commentaires :
- ligne 3 : création d'un objet [StringBuilder] contenant « test » : sa capacité initiale est de 4 + 16 = 20 caractères ;
- ligne 4 : [length] est le nombre actuel de caractères ; [capacity] le nombre de caractères que l'objet peut contenir avant de devoir agrandir son tableau ;
- ligne 6 : [append] ajoute une chaîne à la fin du texte ;
- ligne 10 : création d'un objet [StringBuilder] vide de capacité initiale 10, auquel on ajoute aussitôt « test ». La méthode [append] rend l'objet lui-même : on peut l'appeler directement sur le résultat du constructeur.
Les résultats montrent que lorsque la capacité est insuffisante, elle est un peu plus que doublée : la nouvelle capacité est l'ancienne multipliée par 2, plus 2 (20, 42, 86 pour le premier objet ; 10, 22, 46 pour le second). Chaque agrandissement alloue un nouveau tableau et y recopie les caractères. Si l'on connaît à l'avance la taille approximative du texte, la fixer dès la construction évite ces agrandissements successifs.
Voici quelques-unes des méthodes de la classe :
méthode | rôle |
[StringBuilder append(String str)] | ajoute [str] à la fin du texte. Surchargée pour accepter les types [int], [double], [char], [boolean], [Object]… |
[StringBuilder insert(int offset, String str)] | insère [str] à la position [offset] ; surchargée comme [append] |
[StringBuilder delete(int start, int end)] | supprime les caractères des positions [start] (incluse) à [end] (exclue) ; [deleteCharAt] supprime un caractère |
[StringBuilder replace(int start, int end, String str)] | remplace les caractères des positions [start] (incluse) à [end] (exclue) par [str] |
[StringBuilder reverse()] | inverse l'ordre des caractères |
[void setCharAt(int index, char ch)] | remplace le caractère n° [index] par [ch] |
[int indexOf(String str)] | position de [str] dans le texte, ou -1 |
[String toString()] | rend le texte sous la forme d'une chaîne [String] |
Le programme [Chaines06.java] les utilise :
Résultats :
Commentaires :
- ligne 4 : les méthodes de modification rendent l'objet [StringBuilder] lui-même, ce qui permet de les enchaîner. Suivons les étapes : « test » devient « testabCD » ([append]), puis « texyZTstabCD » ([insert] en position 2), puis « xyZTstabCD » ([delete] des caractères 0 et 1), enfin « XYZTstabCD » ([replace] des caractères 0 et 1 par « XY »). On notera que, contrairement à la méthode [replace] de [String], celle de [StringBuilder] ne recherche pas un texte : elle remplace une plage de positions. [IO.println] affiche l'objet en appelant sa méthode [toString] ;
- ligne 6 : [reverse] inverse le texte. [String] n'ayant pas de méthode [reverse], c'est la façon habituelle d'inverser une chaîne ;
- lignes 9-12 : la construction d'un texte de plusieurs lignes par ajouts successifs : c'est l'usage typique de [StringBuilder]. Les ajouts s'enchaînent sur une même ligne de code ;
- ligne 14 : [StringBuilder] n'a pas d'équivalent de [join] : on ajoute le résultat de [String.join] ;
- ligne 17 : on ajoute une chaîne formatée. La conversion [%f] exige un nombre réel : passer l'entier [total] lancerait une exception [IllegalFormatConversionException] à l'exécution, d'où la conversion [(double) total] ;
- ligne 19 : le texte final est obtenu sous la forme d'une chaîne [String] ;
- lignes 24-28 : la classe [java.util.StringJoiner] construit une chaîne formée d'éléments séparés par un séparateur, avec un préfixe et un suffixe facultatifs. [String.join] rend le même service, sans préfixe ni suffixe.
Pour quelques concaténations, l'opérateur [+] suffit : le compilateur les optimise. [StringBuilder] s'impose lorsque le nombre d'ajouts est grand ou inconnu, typiquement dans une boucle : la concaténation [s = s + ...] dans une boucle de n tours crée n chaînes et recopie à chaque tour toute la chaîne déjà construite. On rencontre dans le code ancien la classe [StringBuffer], qui a les mêmes méthodes que [StringBuilder] mais les rend utilisables simultanément par plusieurs threads, au prix d'une certaine lenteur : on lui préfère aujourd'hui [StringBuilder].
5.2.6. Analyser une chaîne sans la copier
Chaque appel de [substring] crée une nouvelle chaîne en mémoire. Dans le code dont les performances sont critiques (analyse de fichiers volumineux, de requêtes réseau…), on peut vouloir éviter ces créations. Java n'a pas de type « vue sur une partie de chaîne » intégré au langage, mais plusieurs méthodes travaillent sur une partie d'une chaîne sans la copier. Le programme [Chaines07.java] compare les deux façons d'analyser une ligne « nom:prénom:âge » :
Résultats :
Commentaires :
- lignes 8-9 : chaque appel de [substring] crée une nouvelle chaîne, même si, comme pour l'âge, elle n'est utilisée qu'un instant ;
- ligne 13 : une surcharge de [Integer.parseInt] (Java 9) analyse les caractères compris entre deux positions de la chaîne, dans la base indiquée (ici 10), sans créer de sous-chaîne ;
- ligne 15 : [regionMatches], déjà rencontrée, compare une partie de la chaîne à une autre chaîne sans copie ;
- ligne 19 : [CharBuffer.wrap] (paquetage [java.nio]) rend une vue sur les caractères 0 à [p1] - 1 de la chaîne : un objet qui ne contient pas de copie des caractères, mais un moyen d'y accéder. Il implémente l'interface [CharSequence], comme [String] et [StringBuilder] : de nombreuses méthodes de Java acceptent un [CharSequence] ([String.join], [contains], [append] et les expressions régulières, par exemple) ;
- ligne 24 : une surcharge de [indexOf] (Java 21) limite la recherche à une plage de positions ;
- ligne 27 : on ne crée une chaîne que si on en a réellement besoin.
Ces techniques sont des outils d'optimisation : pour un programme ordinaire, [substring] convient parfaitement. Depuis Java 9, la machine virtuelle stocke d'ailleurs les chaînes qui ne contiennent que des caractères du jeu Latin-1 (c'est le cas des textes français) sur un octet par caractère au lieu de deux (« chaînes compactes »), ce qui divise par deux la mémoire qu'elles occupent. On ne cherchera à éviter les copies que dans le code dont on a mesuré qu'il est critique pour les performances.
5.3. Les tableaux
5.3.1. La classe Arrays
Les tableaux de Java sont des objets, mais ils n'ont pas de méthodes utiles : en dehors du champ [length], toutes les opérations sur les tableaux sont des méthodes statiques de la classe utilitaire [java.util.Arrays]. Elles permettent de trier un tableau, d'y rechercher un élément, de le copier, de le remplir… Elles sont quasiment toutes surchargées : il en existe une version pour chaque type primitif ([int[]], [double[]], [char[]]…) et une version pour les tableaux d'objets.
méthode | rôle |
[static int binarySearch(int[] a, int key)] | rend la position de [key] dans [a], qui doit être trié ; un nombre négatif si [key] est absent |
[static int[] copyOf(int[] original, int newLength)] | rend un nouveau tableau de [newLength] éléments contenant les premiers éléments de [original], complété si besoin par des 0 ([false] pour les booléens, [null] pour les références) |
[static int[] copyOfRange(int[] original, int from, int to)] | rend un nouveau tableau formé des éléments de [from] (inclus) à [to] (exclu) |
[static boolean equals(int[] a, int[] a2)] | rend [true] si les deux tableaux ont les mêmes éléments, dans le même ordre |
[static void fill(int[] a, int val)] | affecte [val] à tous les éléments de [a] ; une surcharge ne remplit qu'une plage d'indices |
[static void sort(int[] a)] | trie [a] selon l'ordre naturel de ses éléments ; une surcharge ne trie qu'une plage d'indices ; pour un tableau d'objets, une surcharge reçoit un [Comparator] qui définit l'ordre (chapitre 4) |
[static String toString(int[] a)] | rend les éléments du tableau sous la forme d'une chaîne [[e1, e2, ...]] ; [deepToString] fait de même pour un tableau de tableaux |
[static <T> List<T> asList(T... a)] | rend une liste qui est une vue sur le tableau d'objets [a] |
[static IntStream stream(int[] array)] | rend un flux des éléments du tableau (voir l'API Stream plus loin) |
S'y ajoute la méthode [System.arraycopy(source, debutSource, destination, debutDestination, nombre)], qui copie des éléments d'un tableau dans un autre tableau existant.
Le programme [Tableaux01.java] illustre l'utilisation de certaines méthodes de la classe [Arrays] :
Commentaires :
- lignes 18-42 : la méthode [saisie] saisit les éléments d'un tableau tapés au clavier. Comme on ne connaît pas à l'avance le nombre d'éléments, on part d'un tableau vide (ligne 20) et on l'agrandit d'une case à chaque nouvel élément (ligne 37). La taille d'un tableau Java est fixée à sa création : [Arrays.copyOf] crée en fait un nouveau tableau, plus grand d'une case, et y recopie les éléments de l'ancien. Un algorithme plus efficace allouerait la place par groupes de N éléments. Un tableau n'est pas fait pour être agrandi : ce cas est mieux traité avec une liste [ArrayList], comme nous le verrons bientôt ;
- ligne 39 : le dernier élément d'un tableau a l'indice [length - 1] ;
- lignes 30-35 : la conversion de la chaîne saisie en nombre réel par [Double.parseDouble], qui lance une exception [NumberFormatException] si la chaîne n'est pas un nombre. [Double.parseDouble] ne dépend pas de la langue de la machine : elle attend toujours un point décimal, et refuse la saisie « 3,6 » (ligne 5 des résultats) ;
- lignes 53-75 : la méthode [recherche] cherche dans le tableau des éléments tapés au clavier. Pour un tableau non trié, on fait une recherche linéaire (ligne 71), qui examine les éléments un par un. La classe [Arrays] n'a pas de méthode [indexOf] : nous l'écrivons (lignes 78-85). Pour un tableau trié, on fait une recherche dichotomique avec [Arrays.binarySearch] (ligne 70), qui divise à chaque étape par deux la zone de recherche : elle trouve un élément parmi un million en une vingtaine de comparaisons ;
- ligne 10 : on trie le tableau. On utilise ici une variante de [sort] qui n'a qu'un paramètre, le tableau à trier. La relation d'ordre utilisée pour comparer les éléments du tableau est alors celle, implicite, de ces éléments : ici, l'ordre naturel des nombres ;
- lignes 45-50 : l'affichage d'un tableau. [IO.println] affiche un [double] avec un point décimal, quelle que soit la langue de la machine, et toujours avec au moins une décimale : -7 est affiché -7.0 ;
- lignes 88-91 : [IO.readln] (Java 25) affiche une invite, puis lit une ligne tapée au clavier ; elle rend [null] si l'entrée standard est épuisée, par exemple lorsqu'elle provient d'un fichier ;
- ligne 94 : une énumération, déclarée dans le fichier source compact comme les méthodes.
Résultats :
Lorsque l'élément cherché est absent, la méthode [indexOf] rend -1 et [binarySearch] un nombre négatif (ligne 27) : c'est [-(p + 1)], où p est la position où il faudrait insérer l'élément pour garder le tableau trié. Ici, -3 = -(2 + 1) : le nombre 0 devrait être inséré en position 2, entre -1.5 et 3.6. Attention : [binarySearch] sur un tableau non trié rend un résultat sans signification.
5.3.2. D'autres méthodes de la classe Arrays
Le programme [Tableaux02.java] présente d'autres méthodes de la classe [Arrays] :
Résultats :
Commentaires :
- ligne 4 : [Arrays.toString] permet d'afficher un tableau sur une ligne. Afficher directement un tableau avec [IO.println(nombres)] donnerait un texte comme [[I@1b6d3586]] : le type ([[I]], tableau d'entiers) et un identifiant de l'objet, sans ses éléments ;
- ligne 7 : on ne trie que les éléments d'indices 1 à 3, le second indice étant exclu : 10, 30, 20 deviennent 10, 20, 30 ;
- ligne 12 : copie des 3 premiers éléments de [nombres] au début de [copie], dont les autres éléments gardent leur valeur initiale 0 ;
- lignes 15-16 : [copyOf] et [copyOfRange] créent un nouveau tableau. Un tableau plus long que l'original est complété par des 0 ;
- lignes 19-22 : [fill] remplit le tableau, puis remet à 0 les éléments d'indices 1 et 2 (3 exclu) ;
- lignes 25-27 : comme pour les chaînes, l'opérateur [==] compare les références de deux tableaux, pas leurs contenus : il faut utiliser [Arrays.equals] ;
- ligne 31 : [Arrays.asList] rend une [List] (présentée dans la section suivante) qui est une vue sur un tableau d'objets : elle ne copie pas le tableau mais y donne accès, avec les méthodes des listes. On obtient ainsi [indexOf] et [lastIndexOf] (ligne 32) ;
- ligne 35 : la méthode [Collections.reverse] inverse l'ordre des éléments d'une liste. La liste étant une vue sur le tableau, c'est le tableau lui-même qui est inversé (ligne 11 des résultats). La classe [Arrays] n'a pas de méthode d'inversion ; ce procédé ne s'applique qu'aux tableaux d'objets, pas aux tableaux de types primitifs comme [int[]] ;
- ligne 40 : un tableau de chaînes est trié selon l'ordre de la méthode [compareTo] vue plus haut, c'est-à-dire des codes des caractères : les majuscules (codes 65 à 90) précèdent les minuscules (97 à 122), qui précèdent les lettres accentuées (é : 233). « Zoé » est le premier mot et « élève » le dernier ;
- ligne 43 : pour trier selon les règles du français, on passe à [sort] un objet [Collator], qui est un [Comparator] de chaînes ;
- lignes 47-50 : Java n'a pas de tableaux rectangulaires à deux dimensions : [new int[2][3]] crée un tableau de 2 éléments, dont chacun est un tableau de 3 entiers. [matrice.length] est le nombre de lignes, [matrice[0].length] le nombre d'éléments de la ligne 0. [Arrays.deepToString] affiche un tableau de tableaux.
L'API Stream, que nous présenterons plus loin, offre d'autres façons de chercher, trier ou transformer les éléments d'un tableau, par exemple [Arrays.stream(nombres).sum()].
5.4. Les collections
Outre le tableau, Java propose diverses classes pour stocker des collections d'éléments. Elles forment le framework des collections du paquetage [java.util], apparu avec Java 1.2 et rendu générique par Java 5. Il est organisé autour d'interfaces, qui décrivent ce que fait une collection, et de classes qui les implémentent, chacune à sa façon :
interface | rôle | implémentations principales |
[List<E>] | une suite ordonnée d'éléments, accessibles par leur position | [ArrayList], [LinkedList] |
[Set<E>] | un ensemble : pas de doublons | [HashSet], [LinkedHashSet], [TreeSet] |
[Map<K,V>] | un dictionnaire : des couples (clé, valeur), les clés étant uniques | [HashMap], [LinkedHashMap], [TreeMap] |
[Deque<E>] | une file à double entrée, utilisable comme file ou comme pile | [ArrayDeque], [LinkedList] |
[Queue<E>] | une file | [ArrayDeque], [PriorityQueue] |
Les interfaces [List], [Set] et [Queue] dérivent de l'interface [Collection<E>] ([size], [isEmpty], [contains], [add], [remove], [clear]…), qui dérive elle-même de [Iterable<E>], l'interface des objets que l'on peut parcourir avec une boucle [for]. La bonne pratique est de déclarer les variables et les paramètres avec le type de l'interface et de n'utiliser le nom de la classe qu'à la création : [List<String> villes = new ArrayList<>();]. On peut ainsi changer d'implémentation sans modifier le reste du code.
Une collection Java ne contient que des objets. Les génériques de Java n'acceptent pas les types primitifs : on n'écrit pas [List<int>] mais [List<Integer>], [Integer] étant la classe enveloppe (« wrapper ») du type [int] (de même [Double] pour [double], [Boolean] pour [boolean]…). Le compilateur convertit automatiquement un [int] en [Integer] lors d'un ajout (« autoboxing ») et un [Integer] en [int] lors d'une lecture (« unboxing »). Cette conversion a un coût en mémoire et en temps : une liste d'un million d'entiers contient un million d'objets [Integer]. Pour de gros volumes de nombres, on préférera un tableau [int[]] ou les flux de primitifs ([IntStream]) présentés avec l'API Stream.
Le paquetage [java.util] contient aussi les collections de Java 1.0 : [Vector] (une liste), [Hashtable] (un dictionnaire), [Stack] (une pile). Elles sont aujourd'hui obsolètes : synchronisées sur chaque opération, elles sont plus lentes que leurs remplaçantes [ArrayList], [HashMap] et [ArrayDeque]. On ne doit plus les utiliser dans du code nouveau. On évitera de même les types bruts (« raw types »), c'est-à-dire les collections génériques utilisées sans paramètre de type ([List liste = new ArrayList();]), comme on l'écrivait avant Java 5 : le compilateur ne peut plus vérifier le type des éléments ajoutés et signale chaque utilisation par un avertissement.
5.4.1. L'interface List et la classe ArrayList
La classe [java.util.ArrayList<E>] implémente une liste d'objets de type [E] dont la taille varie au cours de l'exécution du programme. Elle range ses éléments dans un tableau interne qu'elle agrandit automatiquement, comme [StringBuilder]. L'élément i d'une liste [l] est obtenu par [l.get(i)] et modifié par [l.set(i, valeur)].
Voici quelques-unes des méthodes de l'interface [List<E>] :
méthode | rôle |
[int size()] | nombre d'éléments de la liste |
[boolean add(E e)] | ajoute [e] à la fin de la liste |
[void add(int index, E element)] | insère [element] à la position [index] de la liste |
[boolean addAll(Collection<? extends E> c)] | ajoute les éléments de [c] à la fin de la liste |
[E get(int index)], [E set(int index, E element)] | lit, remplace l'élément n° [index] |
[E getFirst()], [E getLast()] | le premier, le dernier élément (Java 21) |
[void clear()] | supprime tous les éléments de la liste |
[boolean contains(Object o)] | rend [true] si [o] est dans la liste, [false] sinon |
[int indexOf(Object o)] | rend la position de [o] dans la liste, ou -1 si [o] n'est pas trouvé ; [lastIndexOf] rend la dernière position |
[boolean remove(Object o)] | supprime la première occurrence de [o] ; rend [true] si l'opération réussit, [false] sinon |
[E remove(int index)] | supprime l'élément n° [index] et le rend |
[List<E> subList(int from, int to)] | rend une vue sur les éléments de [from] (inclus) à [to] (exclu) |
[void sort(Comparator<? super E> c)] | trie la liste selon l'ordre défini par [c] ; [sort(null)] utilise l'ordre naturel des éléments |
[<T> T[] toArray(IntFunction<T[]> generator)] | rend les éléments de la liste sous forme de tableau (Java 11) |
[static <E> List<E> of(E... elements)] | crée une liste non modifiable contenant les éléments donnés (Java 9) |
La classe utilitaire [java.util.Collections] complète ces méthodes, comme [Arrays] pour les tableaux : [sort], [binarySearch], [reverse], [shuffle], [max], [min]…
Reprenons l'exemple traité précédemment avec un tableau et traitons-le maintenant avec un objet de type [ArrayList<Double>]. Parce que la liste est un objet proche du tableau, le code change peu. C'est le programme [Collections01.java] :
Commentaires :
- ligne 22 : la liste est créée vide. L'opérateur diamant [<>] (Java 7) évite de répéter le type des éléments : le compilateur le déduit du type de la variable ;
- ligne 39 : il n'y a plus d'agrandissement à gérer : la liste s'agrandit d'elle-même. La valeur [element], de type [double], est convertie automatiquement en un objet [Double] ;
- ligne 6 : le nombre d'éléments de la liste. Contrairement à [StringBuilder], [ArrayList] ne permet pas de connaître sa capacité ; la méthode [ensureCapacity] permet seulement de la fixer à l'avance ;
- lignes 12, 70, 71 : le tri et la recherche dichotomique sont faits par les méthodes statiques [Collections.sort] et [Collections.binarySearch] ; la recherche linéaire par la méthode [indexOf] de la liste, que les tableaux n'avaient pas ;
- lignes 45-50 : la méthode générique [affiche] admet deux paramètres :
- le premier est un texte à écrire ;
- le second est un objet implémentant l'interface générique [java.lang.Iterable<T>], dont la seule méthode abstraite est [Iterator<T> iterator()].
La boucle [for (T element : elements)] est valide pour tout objet [elements] implémentant l'interface [Iterable<T>], et pour les tableaux. Toutes les collections implémentent [Iterable] ; les tableaux, eux, ne l'implémentent pas, et la méthode [affiche] ne convient pas pour un tableau : c'est pourquoi nous ne l'avons pas utilisée dans [Tableaux01.java]. Nous reviendrons sur cette interface avec l'API Stream.
Résultats :
Les résultats sont les mêmes qu'avec le tableau.
5.4.2. Les méthodes de l'interface List
Le programme [Collections02.java] utilise les autres méthodes de l'interface [List] :
Résultats :
Commentaires :
- ligne 4 : [List.of(...)] (Java 9) crée une liste non modifiable (voir plus loin) ; on la copie dans une [ArrayList], modifiable. C'est la façon la plus courte d'initialiser une liste avec des valeurs ;
- lignes 8-10 : trois façons d'ajouter des éléments ;
- ligne 14 : l'élément n° 0 est remplacé par [set] ;
- ligne 15 : [get] lit un élément ; [getLast] (Java 21) rend le dernier élément, qu'il fallait écrire auparavant [villes.get(villes.size() - 1)] ;
- ligne 22 : [remove] rend [true] si l'élément a été trouvé et supprimé ;
- ligne 27 : les chaînes sont triées selon leur ordre naturel, celui de [compareTo] ;
- ligne 31 : [subList] rend une vue sur les éléments n° 1 et 2 (3 exclu), et non une copie : modifier la vue modifierait la liste ;
- lignes 35-36 : passage d'une liste à un tableau, et d'un tableau à une liste. Le paramètre de [toArray] est une référence de constructeur [String[]::new], que la liste utilise pour créer un tableau de chaînes de la bonne taille. Les références de méthodes et de constructeurs seront étudiées au chapitre 7 ;
- lignes 41-43 : un piège des listes d'entiers. L'interface [List] a deux méthodes [remove] : [remove(int index)] supprime par position, [remove(Object o)] par valeur. Pour une [List<Integer>], [nombres.remove(1)] appelle la première et supprime l'élément n° 1 (la valeur 20). Pour supprimer la valeur 1, il faut passer un objet [Integer] : [nombres.remove(Integer.valueOf(1))] ;
- lignes 47-48 : [clear] vide la liste, [isEmpty] teste si elle est vide ;
- ligne 51 : le type [List<?>] désigne une liste d'éléments de type quelconque (« joker », chapitre 4) : la méthode [affiche] accepte une [List<String>] comme une [List<Integer>]. Une collection s'affiche directement : sa méthode [toString] rend ses éléments entre crochets.
5.4.3. L'interface Map et la classe HashMap
La classe [java.util.HashMap<K,V>] permet d'implémenter un dictionnaire. On peut voir un dictionnaire comme un tableau à deux colonnes :
clé | valeur |
clé1 | valeur1 |
clé2 | valeur2 |
... | ... |
Dans l'interface [Map<K,V>], les clés sont de type [K], les valeurs de type [V]. Les clés sont uniques : il ne peut y avoir deux clés identiques (au sens de la méthode [equals]). Un tel dictionnaire pourrait ressembler à ceci si les types [K] et [V] désignaient des classes :

Une [HashMap] range ses couples dans une table de hachage : la position d'un couple dans la table est calculée à partir du code de hachage de sa clé (méthode [hashCode], chapitre 4). Retrouver la valeur associée à une clé est ainsi très rapide, quelle que soit la taille du dictionnaire. En contrepartie, l'ordre des couples dans la table n'a aucun rapport avec l'ordre de leur ajout.
Les principales méthodes de l'interface [Map<K,V>] sont les suivantes :
méthode | rôle |
[int size()] | nombre de couples (clé, valeur) du dictionnaire |
[V put(K key, V value)] | associe [value] à [key] : ajoute le couple si la clé est absente, remplace la valeur sinon ; rend l'ancienne valeur, ou [null] |
[V putIfAbsent(K key, V value)] | n'ajoute le couple que si la clé est absente ; rend la valeur existante, ou [null] |
[V get(Object key)] | rend la valeur associée à [key], ou [null] si la clé est absente |
[V getOrDefault(Object key, V defaultValue)] | idem, mais rend [defaultValue] si la clé est absente |
[boolean containsKey(Object key)] | rend [true] si [key] est une clé du dictionnaire ; [containsValue] teste une valeur |
[V remove(Object key)] | supprime le couple de clé [key] et rend sa valeur, ou [null] |
[Set<K> keySet()] | l'ensemble des clés du dictionnaire |
[Collection<V> values()] | la collection des valeurs du dictionnaire |
[Set<Map.Entry<K,V>> entrySet()] | l'ensemble des couples du dictionnaire ; un couple [Map.Entry] a les méthodes [getKey] et [getValue] |
[V merge(K key, V value, BiFunction<...> remappingFunction)] | ajoute le couple si la clé est absente ; sinon, remplace la valeur par la combinaison de l'ancienne valeur et de [value] |
[void clear()] | supprime tous les couples du dictionnaire |
Considérons le programme exemple [Collections03.java] :
Commentaires :
- ligne 4 : un tableau de chaînes qui va servir à initialiser le dictionnaire <String,Integer> ;
- ligne 5 : le dictionnaire, vide ;
- lignes 6-9 : son initialisation à partir du tableau de chaînes ;
- ligne 11 : le nombre de couples du dictionnaire ;
- lignes 13-15 : les clés et les valeurs du dictionnaire. [dico.keySet()] et [dico.values()] sont des collections, donc des [Iterable] : la méthode [affiche] déjà rencontrée convient ;
- ligne 23 : suppression d'un couple du dictionnaire ;
- lignes 26-28 : un dictionnaire n'est pas lui-même une collection, mais [entrySet] rend l'ensemble de ses couples, de type [Map.Entry<K,V>]. Ce parcours est plus efficace que celui des lignes 18-20, qui recherche la valeur de chaque clé avec [get] ;
- lignes 30-31 : [put] ajoute un couple ou remplace la valeur d'une clé existante, sans exception ni avertissement ; la valeur rendue, l'ancienne valeur ou [null], permet de savoir ce qui s'est passé ;
- lignes 33-34 : [putIfAbsent] ne remplace pas la valeur d'une clé existante ;
- lignes 36-37 : la lecture d'une clé absente ne provoque pas d'erreur : [get] rend [null]. [getOrDefault] permet de choisir la valeur rendue dans ce cas, et [containsKey] de tester l'existence d'une clé ;
- lignes 39-40 : [merge] ajoute la clé « léa » avec la valeur 1, et ajoute 1 à la valeur de la clé « zoé », qui existe déjà. Le troisième paramètre est la fonction qui combine l'ancienne et la nouvelle valeur, ici la somme de deux entiers, passée sous la forme d'une référence de méthode [Integer::sum] (chapitre 7). C'est la façon habituelle de compter des occurrences : [compteurs.merge(mot, 1, Integer::sum)] ;
- lignes 51-56 : recherche d'une clé dans le dictionnaire. [get] rend un [Integer], et non un [int], car il doit pouvoir rendre [null] : on teste ce cas avant d'utiliser la valeur. Écrire [int age = dico.get(nomCherche);] lancerait une [NullPointerException] pour une clé absente, lors de la conversion automatique de [null] en [int].
Résultats :
Les clés ne sont pas énumérées dans leur ordre d'ajout, mais dans l'ordre de la table de hachage, qui dépend des codes de hachage des clés : une [HashMap] ne garantit aucun ordre. Si l'ordre des clés importe, on utilisera une [LinkedHashMap], qui garde l'ordre d'ajout, ou une [TreeMap], qui maintient les clés triées : elles sont présentées ci-dessous.
5.4.4. D'autres collections
Le paquetage [java.util] contient d'autres collections, chacune adaptée à un usage particulier :
classe | rôle |
[HashSet<E>] | un ensemble : pas de doublons, pas d'ordre, test d'appartenance très rapide ; opérations ensemblistes (union [addAll], intersection [retainAll], différence [removeAll]) |
[LinkedHashSet<E>] | un ensemble qui garde l'ordre d'ajout de ses éléments |
[TreeSet<E>] | un ensemble dont les éléments sont maintenus triés |
[ArrayDeque<E>] | une file à double entrée : utilisée comme file, les éléments sortent dans l'ordre où ils sont entrés (FIFO, first in first out) ; utilisée comme pile, le dernier élément entré est le premier sorti (LIFO, last in first out) |
[LinkedHashMap<K,V>] | un dictionnaire qui garde l'ordre d'ajout de ses clés |
[TreeMap<K,V>] | un dictionnaire dont les clés sont maintenues triées |
[PriorityQueue<E>] | une file de priorité : l'élément qui sort est le plus petit selon l'ordre de la file |
[LinkedList<E>] | une liste doublement chaînée : insertions et suppressions rapides aux extrémités et au cours d'un parcours |
Le programme [Collections04.java] en fait un rapide survol :
Résultats :
Commentaires :
- ligne 4 : un ensemble initialisé avec les éléments d'une liste ;
- ligne 6 : [add] rend [false] si l'élément est déjà dans l'ensemble, qui reste inchangé ;
- lignes 9-14 : [retainAll] ne garde que les éléments présents dans l'autre collection (intersection), [addAll] ajoute ceux de l'autre collection (union). Ces méthodes modifient l'ensemble : on travaille donc sur une copie de [langages]. [Set.of] (Java 9) crée un ensemble non modifiable. L'ordre d'affichage des éléments d'un [HashSet] n'a pas de signification ;
- ligne 16 : un [TreeSet] construit à partir de l'ensemble présente ses éléments triés ;
- lignes 19-24 : [ArrayDeque] utilisée comme une file : [offer] fait entrer un élément à la fin de la file, [poll] fait sortir le plus ancien, [peek] le consulte sans le faire sortir. Les opérandes de la concaténation passée à [IO.println] sont évalués de gauche à droite : [peek], puis les deux [poll], puis [size] ;
- lignes 27-32 : [ArrayDeque] utilisée comme une pile : [push] empile un élément, [pop] dépile le plus récent. La classe [Stack] de Java 1.0 ne doit plus être utilisée ;
- lignes 34-40 : sur une pile vide, [pop] lance une exception [NoSuchElementException], alors que [poll] rend [null]. La plupart des opérations de [Deque] existent ainsi en deux versions : l'une lance une exception ([push], [pop], [getFirst]…), l'autre rend une valeur spéciale ([offer], [poll], [peek]…) ;
- lignes 43-48 : les couples d'une [TreeMap] sont énumérés dans l'ordre des clés ;
- lignes 50-54 : ceux d'une [LinkedHashMap] dans l'ordre de leur ajout ;
- lignes 57-67 : une [PriorityQueue] a besoin de savoir comparer ses éléments. On lui passe ici un [Comparator] qui compare les priorités : [Comparator.comparingInt(Urgence::priorite)] se lit « comparer les urgences selon la valeur entière rendue par leur méthode [priorite] ». Les patients sortent par ordre de priorité croissante, quel que soit leur ordre d'arrivée. La boucle s'arrête quand [poll] rend [null], la file étant vide ;
- lignes 71-72 : un patient est représenté par un [record] (chapitre 4).
5.4.5. Les collections non modifiables
Une classe qui gère une collection doit souvent la montrer à l'extérieur sans permettre qu'on la modifie. Java n'a pas d'interface « en lecture seule » distincte de [List] : une collection non modifiable implémente l'interface [List] complète, mais ses méthodes de modification ([add], [remove], [set]…) lancent une exception [UnsupportedOperationException]. Il en existe deux sortes :
- les vues non modifiables, obtenues par [Collections.unmodifiableList(liste)] ([unmodifiableSet], [unmodifiableMap]…) : elles ne copient pas la collection mais en interdisent la modification à ceux qui la reçoivent ;
- les collections non modifiables créées par [List.of(...)], [Set.of(...)], [Map.of(...)] (Java 9) ou copiées par [List.copyOf(collection)] (Java 10) : leur contenu est fixé une fois pour toutes.
Le programme [Collections05.java] les utilise :
Résultats :
Commentaires :
- lignes 44-61 : la classe [Classe] gère une liste d'élèves. La liste elle-même est privée ; seule la méthode [inscrire] permet de la modifier ;
- lignes 49-51 : la méthode [getEleves] rend une vue non modifiable sur la liste. Rendre la liste [eleves] elle-même permettrait au code extérieur de la modifier à l'insu de la classe ;
- lignes 10-14 : la vue a toutes les méthodes de [List], et [eleves.add("intrus")] est accepté par le compilateur ; c'est à l'exécution que l'exception [UnsupportedOperationException] est lancée ;
- ligne 15 : la classe réelle de la vue est une classe interne de [Collections], que l'on ne connaît que par l'interface [List] ;
- lignes 17-18 : la vue ne copie pas la liste : elle voit les modifications faites par la classe propriétaire ;
- lignes 54-56 et 21-23 : [List.copyOf] rend au contraire une copie non modifiable : la liste [copie] ne voit pas l'inscription de « petit » ;
- lignes 26-33 : [Map.of] crée un dictionnaire non modifiable à partir de couples clé, valeur ;
- lignes 35-40 : les collections créées par [of] et [copyOf] refusent la valeur [null], qui lance une [NullPointerException] dès leur création.
Le type d'une variable [List<String>] ne dit donc pas si la liste est modifiable : c'est à la documentation de la méthode qui la rend de le préciser. Une bonne pratique répandue est de rendre des collections non modifiables et de créer une nouvelle collection chaque fois qu'on veut en modifier une. Les [record], dont les champs sont non modifiables, s'accordent bien avec cette pratique : [record Classe(String nom, List<String> eleves)] gagne à copier sa liste dans son constructeur avec [List.copyOf].
5.4.6. Trier, parcourir, collections séquencées
Le programme [Collections06.java] présente trois points utiles de l'utilisation des collections : l'écriture d'un [Comparator], les collections séquencées de Java 21, et la suppression d'éléments pendant un parcours :
Résultats :
Commentaires :
- ligne 8 : la méthode [sort] d'une liste reçoit un [Comparator], objet qui compare deux éléments (chapitre 4). La méthode statique [Comparator.comparing] construit un comparateur à partir d'une fonction qui extrait une clé de chaque élément, ici le nom ([Eleve::nom] désigne la méthode [nom] du record). Les noms sont comparés selon l'ordre naturel des chaînes : « Durand », qui commence par une majuscule, est le premier et « élise » le dernier ;
- ligne 10 : un second paramètre précise comment comparer les clés, ici avec un [Collator] : l'ordre est celui d'un dictionnaire français ;
- lignes 12-13 : les comparateurs se composent : [comparingInt] compare les notes, [reversed] inverse l'ordre (notes décroissantes) et [thenComparing] ajoute un critère secondaire, utilisé pour départager les élèves de même note : les noms, sans tenir compte de la casse ([String.CASE_INSENSITIVE_ORDER]). Durand et martin, qui ont tous deux 15, sont classés par nom ;
- lignes 17-25 : Java 21 a introduit les collections séquencées : les interfaces [SequencedCollection], [SequencedSet] et [SequencedMap], implémentées par les collections dont les éléments ont un ordre défini ([List], [Deque], [LinkedHashSet], [TreeSet], [LinkedHashMap], [TreeMap]…). Elles ont des méthodes communes pour accéder aux deux extrémités : [getFirst], [getLast], [addFirst], [addLast], [removeFirst], [removeLast] ([firstEntry], [lastEntry]… pour un dictionnaire), et la méthode [reversed], qui rend une vue de la collection dans l'ordre inverse, sans la copier. Auparavant, obtenir le dernier élément d'un [LinkedHashSet] demandait de le parcourir entièrement ;
- lignes 28-37 : on ne doit pas modifier une collection pendant qu'on la parcourt avec une boucle [for] : l'itérateur utilisé par la boucle détecte la modification et lance une exception [ConcurrentModificationException] à l'élément suivant. L'essai est fait sur une copie de la liste ;
- lignes 39-44 : la bonne façon est d'écrire explicitement la boucle avec un [Iterator] (présenté avec l'API Stream) et de supprimer l'élément courant par la méthode [remove] de l'itérateur ;
- ligne 47 : plus simplement, la méthode [removeIf] (Java 8) supprime les éléments qui vérifient une condition, exprimée par une fonction anonyme (lambda) [eleve -> eleve.note() < 15], qui se lit « la fonction qui, à un élève, associe le booléen [eleve.note() < 15] ». Les lambdas seront étudiées en détail au chapitre 7 ; il nous suffit ici de savoir les lire. Nous les retrouverons à chaque ligne de la section suivante ;
- ligne 50 : la méthode [forEach] de toute collection exécute une lambda pour chaque élément ;
- lignes 63-64 : l'affichage d'un [record] par sa méthode [toString] donne le nom du record et les valeurs de ses composants : [Eleve[nom=Durand, note=15]].
5.5. L'API Stream
L'API Stream (paquetage [java.util.stream], Java 8) est un ensemble de méthodes qui permettent de traiter les éléments d'une collection comme on interroge une base de données : filtrer, trier, regrouper, calculer des totaux… Un flux (« stream ») [Stream<T>] est une suite d'éléments de type [T] que l'on fait passer dans une chaîne d'opérations. On l'obtient à partir d'une collection ([liste.stream()]), d'un tableau ([Arrays.stream(tableau)]), de valeurs ([Stream.of(1, 2, 3)]), des lignes d'un fichier ([Files.lines(chemin)]), des occurrences d'une expression régulière, et, avec Hibernate (chapitre 11), des lignes d'une table d'une base de données.
Il ne faut pas confondre ces flux avec les flux d'entrée-sortie ([InputStream], [OutputStream]) que nous rencontrerons avec les fichiers : ce sont deux notions sans rapport.
5.5.1. Les interfaces Iterable et Iterator
Avant de présenter les flux, revenons sur l'interface [Iterable<T>], celle des objets que l'on parcourt avec une boucle [for]. Elle ne contient qu'une méthode abstraite, [iterator], qui rend un itérateur [Iterator<T>] : un objet qui indique s'il reste des éléments ([hasNext]) et rend l'élément suivant ([next]).
Un objet [Iterable] n'a pas besoin de contenir ses éléments : son itérateur peut les produire un par un, à la demande. Le programme [Streams01.java] en donne un exemple, et présente un flux infini :
Résultats :
Commentaires :
- lignes 34-64 : le record [Pairs] implémente l'interface [Iterable<Integer>] : sa méthode [iterator] rend un itérateur qui produit les nombres pairs de 0 à [max]. Il ne construit aucune liste : il mémorise seulement le prochain nombre à produire (ligne 41) ;
- ligne 39 : l'itérateur est une classe anonyme : une classe sans nom, déclarée et instanciée en une seule expression [new Iterator<>() { ... }], qui implémente les deux méthodes de l'interface. Elle a accès au composant [max] du record ;
- lignes 44-50 : [hasNext] indique s'il reste un élément à produire ;
- lignes 53-61 : [next] produit l'élément suivant. Appelée alors qu'il n'y a plus d'élément, elle doit lancer une [NoSuchElementException] ;
- lignes 5-7 : les résultats (lignes 3-12) montrent que la production d'un élément par l'itérateur et sa consommation par la boucle [for] alternent ;
- lignes 11-15 : ce que fait réellement la boucle [for] : elle demande un itérateur à l'objet, appelle [hasNext] tant que cette méthode rend [true], et obtient chaque élément par [next] ;
- lignes 19-22 : un flux infini, la suite de Fibonacci. [Stream.iterate(graine, fonction)] produit la graine, puis le résultat de la fonction appliquée à la graine, puis le résultat de la fonction appliquée à ce résultat, etc. Chaque élément est ici un tableau de deux nombres consécutifs de la suite ; [map] n'en garde que le premier, [takeWhile] arrête le flux au premier nombre supérieur à 100, et [forEach] affiche chaque nombre. Le flux n'est jamais calculé en entier : seuls les éléments demandés le sont ;
- lignes 26-30 : une liste est [Iterable]. Une chaîne ne l'est pas : on ne peut pas la parcourir directement avec [for]. Sa méthode [chars] rend le flux de ses caractères, sous forme de codes entiers que l'on reconvertit en chaînes d'un caractère, puis [Collectors.joining("-")] les concatène avec un séparateur. On peut aussi parcourir le tableau rendu par ["abc".toCharArray()].
Java n'a pas d'instruction qui permette d'écrire un itérateur sous la forme d'une simple méthode produisant ses éléments un par un ([yield return] dans d'autres langages) : il faut écrire la classe de l'itérateur, comme ici, ou utiliser les méthodes de création de flux ([Stream.iterate], [Stream.generate]), qui produisent elles aussi leurs éléments à la demande. C'est ce mécanisme de production à la demande qu'utilise toute l'API Stream.
5.5.2. Filtrer, projeter, trier
Les exemples qui suivent travaillent sur une petite liste de personnes. Une personne est représentée par un [record] (Java 16), présenté au chapitre 4 : en une ligne, on obtient une classe avec un constructeur, des méthodes d'accès aux composants ([p.nom()], [p.age()]…), une méthode [toString] qui affiche les composants, et une égalité qui compare leurs valeurs.
Le programme [Streams02.java] présente les opérations de base de l'API Stream :
Résultats :
Commentaires :
- lignes 4-11 : la liste des personnes, non modifiable, créée par [List.of] ;
- ligne 55 : le record [Personne]. Un fichier source compact place ses déclarations de types à côté de ses méthodes ;
- ligne 14 : [personnes.stream()] rend le flux des éléments de la liste. La méthode [filter] filtre le flux : elle ne garde que les éléments qui vérifient une condition. Cette condition lui est passée sous la forme d'une lambda [p -> p.age() >= 18], « la fonction qui, à une personne p, associe le booléen [p.age() >= 18] ». [filter] appelle cette fonction pour chaque personne ;
- le résultat de [filter] est un nouveau flux [Stream<Personne>] : la liste [personnes] n'est pas modifiée. C'est vrai de toutes les opérations de l'API Stream ;
- lignes 49-52 : la méthode [affiche] parcourt le flux avec l'opération [forEach] et affiche chaque personne avec la méthode [toString] du record : [Personne[nom=dupont, ...]] ;
- ligne 18 : la méthode [map] projette chaque élément du flux sur une nouvelle valeur, ici une chaîne. Le résultat est un flux [Stream<String>] ;
- lignes 22-26 : les opérations rendent des flux, auxquels on peut appliquer d'autres opérations. On écrit souvent une opération par ligne : cette chaîne d'appels se lit de haut en bas comme une recette. [Personne::nom] est une référence de méthode, écriture abrégée de la lambda [p -> p.nom()] ;
- ligne 25 : [sorted] sans paramètre trie le flux selon l'ordre naturel de ses éléments, ici des chaînes ;
- ligne 26 : [toList] (Java 16) range les éléments du flux dans une liste non modifiable. On rencontre aussi l'écriture plus ancienne [collect(Collectors.toList())], qui rend en pratique une liste modifiable (une [ArrayList]), sans que la documentation le garantisse ;
- lignes 30-31 : [sorted] accepte un [Comparator], construit comme dans [Collections06.java] : âge décroissant, puis nom croissant pour départager les personnes de même âge. durand et laurent, qui ont tous deux 32 ans, sont classés par nom (lignes 23-24 des résultats) ;
- ligne 35 : les flux ne donnent pas la position de leurs éléments. Pour numéroter, on parcourt les indices avec un flux d'entiers [IntStream.range(0, 3)] (0, 1, 2 ; 3 exclu) et on transforme chaque indice en chaîne avec [mapToObj] ;
- lignes 39-45 : un flux est à usage unique : une fois parcouru par une opération terminale (ici [count], qui compte les éléments), il ne peut plus être utilisé et lance une [IllegalStateException]. Il faut redemander un flux à la collection.
Les opérations d'un flux sont de deux sortes : les opérations intermédiaires ([filter], [map], [sorted]…) rendent un nouveau flux ; les opérations terminales ([forEach], [toList], [count], [collect]…) parcourent le flux et produisent un résultat. Une chaîne d'opérations se termine toujours par une opération terminale. Contrairement à d'autres langages, Java n'a pas de syntaxe de requête proche de SQL : les traitements s'écrivent toujours sous forme de chaînes d'appels de méthodes.
5.5.3. Quantificateurs, agrégats, choix d'éléments
Le programme [Streams03.java] présente des opérations qui rendent une seule valeur, et des opérations qui sélectionnent une partie du flux :
Résultats :
Commentaires :
- lignes 13-15 : [anyMatch] rend [true] si au moins un élément vérifie la condition, [allMatch] si tous la vérifient, [noneMatch] si aucun ne la vérifie ;
- lignes 18-19 : [count] compte les éléments du flux ; pour compter ceux qui vérifient une condition, on filtre d'abord le flux. Pour une collection, la méthode [size] est évidemment préférable à [stream().count()] ;
- lignes 20-22 : [mapToInt(Personne::age)] transforme le flux de personnes en un [IntStream], flux de valeurs [int], qui a des méthodes de calcul : [sum], [average], [min], [max]. [average] rend un [OptionalDouble], car la moyenne d'un flux vide n'existe pas ; [orElse(0)] donne la valeur à utiliser dans ce cas ;
- lignes 23-24 : [summaryStatistics] calcule en un seul parcours le nombre, la somme, le minimum, la moyenne et le maximum ;
- lignes 26-28 : [min] et [max] d'un [Stream] reçoivent un [Comparator] et rendent l'élément minimal ou maximal (ici la personne), et non la valeur de la clé. Le résultat est un [Optional<Personne>] : une boîte qui contient une personne, ou qui est vide si le flux l'était. [get] rend le contenu de la boîte ;
- lignes 31-34 : [findFirst] rend le premier élément du flux, dans un [Optional] ; filtré, le flux donne le premier élément qui vérifie une condition. Pour le dernier élément d'une liste, la méthode [getLast] de la liste suffit ;
- lignes 35-37 : il n'y a personne à Brest : l'[Optional] est vide ([isPresent] rend [false]). Plutôt que de le tester, on le transforme : [map(Personne::nom)] transforme son contenu s'il y en a un, [orElse("personne")] fournit la valeur à utiliser s'il est vide ;
- lignes 38-43 : [orElseThrow] rend le contenu de l'[Optional], ou lance une [NoSuchElementException] s'il est vide. On l'utilise quand l'absence de valeur est une erreur. [get], utilisé plus haut, fait la même chose, mais son nom ne dit pas qu'il peut lancer une exception : on lui préfère [orElseThrow]. Il n'y a pas d'opération qui exige qu'un flux ait exactement un élément ; on peut vérifier la taille de la liste rendue par [toList] ;
- ligne 46 : [distinct] élimine les doublons (au sens de la méthode [equals]) ;
- lignes 49-50 : pour garder une personne par ville, il n'existe pas d'opération toute faite. On utilise ici un ensemble des villes déjà vues : [add] rend [true] pour une ville nouvelle (la personne est gardée) et [false] pour une ville déjà vue. Une lambda qui modifie une variable extérieure, comme celle-ci, est à réserver aux flux parcourus séquentiellement, ce qui est le cas par défaut ;
- lignes 54-58 : [limit(n)] garde les n premiers éléments, [skip(n)] les saute. Combinées, elles extraient une « page » de résultats. [takeWhile] (Java 9) garde les éléments tant qu'une condition est vérifiée et s'arrête au premier élément qui ne la vérifie pas ; [dropWhile] fait l'inverse : elle saute ces éléments et garde tous les suivants ;
- ligne 61 : l'opération [gather] (Java 24) applique au flux un collecteur intermédiaire (« gatherer »), qui peut regrouper, accumuler ou filtrer les éléments en tenant compte des éléments précédents. La classe [Gatherers] en fournit plusieurs ; [Gatherers.windowFixed(3)] découpe le flux en listes de 3 éléments, la dernière pouvant être plus courte. Il en existe d'autres, par exemple [windowSliding(n)] (fenêtres glissantes), et l'on peut écrire les siens.
5.5.4. Records locaux, regroupements, jointures
Le programme [Streams04.java] présente des opérations plus élaborées, réalisées pour la plupart avec l'opération terminale [collect] et les collecteurs de la classe [Collectors] :
Résultats :
Commentaires :
- lignes 18-19 : un record local (Java 16), déclaré dans la méthode, là où il sert. Il permet de donner un nom et un type aux résultats intermédiaires d'un traitement, sans avoir à déclarer une classe dans le programme : ligne 23, chaque personne de plus de 40 ans est transformée en un [Resume] qui ne garde que les données utiles ;
- lignes 28-29 : le collecteur [groupingBy] regroupe les éléments ayant la même clé, ici la ville. Le résultat est un dictionnaire [Map<String, List<Personne>>] : à chaque ville est associée la liste de ses habitants. Sans autre paramètre, [groupingBy] crée une [HashMap], dont l'ordre des clés est imprévisible ; on lui demande ici de créer une [LinkedHashMap] ([LinkedHashMap::new], référence de constructeur), qui garde les villes dans l'ordre de leur première apparition. Le troisième paramètre est le collecteur appliqué aux éléments de chaque groupe, ici [toList] ;
- lignes 30-32 : la méthode [forEach] d'un dictionnaire reçoit une lambda à deux paramètres, la clé et la valeur. Pour chaque groupe, on affiche sa taille, l'âge moyen et les noms ; le collecteur [Collectors.joining(", ")] concatène des chaînes avec un séparateur ;
- lignes 36-40 : l'API Stream n'a pas d'opération de jointure. On l'obtient avec [flatMap], qui remplace chaque élément par un flux, puis met bout à bout tous ces flux : chaque personne est remplacée par le flux des villes de même nom qu'elle (une ou aucune), transformées en chaînes. Comme dans une jointure SQL interne, on ne garde que les couples qui se correspondent : laurent, qui habite Lyon, absente de la liste des villes, n'apparaît pas, ni Brest, qui n'a pas d'habitant. Cette écriture parcourt toute la liste des villes pour chaque personne ;
- lignes 42-47 : la même jointure, plus efficace : le collecteur [toMap] construit un dictionnaire des villes, dont la clé est le nom de la ville ([Ville::nom]) et la valeur la ville elle-même ([v -> v]) ; on y cherche ensuite la ville de chaque personne majeure. Une clé en double ferait lancer une exception [IllegalStateException] à [toMap] ;
- lignes 50-54 : [toList] et [toMap] exécutent le traitement et rangent ses résultats dans une liste, un dictionnaire ;
- lignes 55-59 : [partitioningBy] partage les éléments en deux groupes selon une condition : le résultat est un dictionnaire dont les clés sont [true] et [false]. Le collecteur [mapping(Personne::nom, toList())] transforme chaque personne en son nom avant de la ranger dans la liste de son groupe ;
- lignes 62-68 : le troisième paramètre de [groupingBy] peut être n'importe quel collecteur, appliqué à chaque groupe : [counting] compte les éléments du groupe, [summingInt] additionne une valeur entière ([averagingInt] en calcule la moyenne, [maxBy] en cherche le maximum…). Avec [TreeMap::new], les villes sont triées ;
- lignes 72-73 : la position de chaque élément d'un flux filtré : on range le flux dans une liste, puis on en parcourt les indices.
5.5.5. L'évaluation paresseuse
Les opérations intermédiaires d'un flux ne sont pas exécutées au moment où on les écrit, mais au moment où une opération terminale parcourt le flux : c'est l'évaluation paresseuse (« lazy evaluation »). Le programme [Streams05.java] le démontre avec des lambdas qui affichent ce qu'elles font :
Résultats :
Commentaires :
- lignes 7-15 : un flux qui garde les nombres pairs puis calcule leurs carrés. Les lambdas sont ici écrites avec un corps entre accolades, qui affiche un message avant de rendre sa valeur avec [return] ;
- ligne 3 des résultats : après la définition du flux, rien n'a été affiché : ni [filter] ni [map] n'ont encore examiné un seul élément. [carresPairs] ne contient pas de résultats, mais la description d'un calcul ;
- lignes 4-16 des résultats : le calcul s'exécute pendant l'opération terminale [forEach], élément par élément : chaque nombre traverse [filter], puis [map] s'il est pair, puis arrive dans [forEach] avant que le nombre suivant ne soit examiné. Aucune liste intermédiaire n'est construite ;
- lignes 23-26 : un flux ne pouvant être parcouru qu'une fois, on mémorise plutôt le moyen de le créer : une lambda sans paramètre [() -> nombres.stream().filter(...)], de type [Supplier<Stream<Integer>>], dont la méthode [get] crée un nouveau flux à chaque appel. Le flux créé après l'ajout de 10 tient compte de ce nouvel élément ;
- lignes 29-31 : [toList] exécute le traitement et mémorise ses résultats : [instantane] est une liste, qui ne voit pas l'ajout de 20 ;
- lignes 35-42 : [findFirst] arrête le parcours dès qu'il a obtenu un élément : les nombres qui suivent 3 ne sont jamais examinés. [anyMatch], [limit], [takeWhile]… arrêtent de même le parcours au plus tôt ; c'est ce qui permet de traiter des flux infinis ;
- lignes 46-49 : certaines opérations intermédiaires ne peuvent pas traiter les éléments un par un : [sorted] doit connaître tous les éléments pour transmettre le plus petit. L'opération [peek], qui exécute une lambda sur chaque élément au passage sans le modifier, montre que les trois éléments sont reçus par [sorted] avant qu'il n'en transmette un seul ;
- lignes 52-55 : les flux de primitifs [IntStream], [LongStream] et [DoubleStream] évitent la création d'objets [Integer], [Long], [Double]. [IntStream.rangeClosed(1, 10)] produit les entiers de 1 à 10 (10 inclus). [boxed] transforme un [IntStream] en [Stream<Integer>], [asDoubleStream] en [DoubleStream]. Un [double] concaténé à une chaîne s'affiche avec toutes ses décimales et un point décimal ; pour en limiter le nombre, on utiliserait [formatted].
L'évaluation paresseuse a deux conséquences pratiques :
- un flux ne peut être parcouru qu'une fois. Si l'on a besoin plusieurs fois du résultat d'un calcul, on le range dans une liste avec [toList] ;
- les opérations se composent sans coût : enchaîner [filter], [map] et [limit] ne crée aucune collection intermédiaire, et seuls les éléments nécessaires sont calculés.
Signalons enfin que la méthode [parallelStream] d'une collection rend un flux dont les éléments sont traités simultanément par plusieurs threads (chapitre 8). Elle n'accélère que les traitements longs sur de gros volumes de données et exige des lambdas sans effet de bord : on ne l'utilisera qu'après avoir mesuré son intérêt. Nous retrouverons l'API Stream tout au long du cours, et nous verrons au chapitre 7 comment sont construites les interfaces fonctionnelles qu'elle utilise.
5.6. Les fichiers texte
Java a deux API pour les fichiers : le paquetage [java.io], présent depuis Java 1.0 ([File], [FileReader], [FileInputStream]…), et le paquetage [java.nio.file], apparu avec Java 7 ([Path], [Files]…). Le second est plus complet et signale mieux les erreurs : c'est lui qu'il faut utiliser aujourd'hui. Il s'appuie sur les classes de lecture et d'écriture de [java.io] ([BufferedReader], [BufferedWriter]…), que l'on obtient par des méthodes de la classe [Files].
Un fichier est désigné par un objet [java.nio.file.Path], créé par [Path.of("infos.txt")] (Java 11). Un chemin relatif, comme celui-ci, est complété par le dossier courant du programme, celui d'où il a été lancé.
Depuis Java 18 (JEP 400), l'encodage par défaut des fichiers texte est UTF-8, quel que soit le système d'exploitation. Auparavant, il dépendait du système (souvent Windows-1252 sous Windows en français), et un même programme pouvait lire différemment un même fichier selon la machine. Les méthodes de la classe [Files] utilisaient déjà UTF-8 par défaut depuis Java 8.
5.6.1. La classe BufferedReader
La classe [java.io.BufferedReader] permet de lire le contenu d'un fichier texte, ligne par ligne. Elle est en fait capable d'exploiter des flux qui ne sont pas des fichiers (une connexion réseau, par exemple). Elle lit le fichier par blocs, qu'elle garde dans une mémoire tampon (« buffer ») : la plupart des lectures se font en mémoire, sans accès au disque.
méthode | rôle |
[static BufferedReader Files.newBufferedReader(Path path)] | ouvre le fichier [path] en lecture, en UTF-8 ; une surcharge permet de préciser l'encodage : [Files.newBufferedReader(path, StandardCharsets.ISO_8859_1)] |
[String readLine()] | rend la ligne suivante du flux, sans la marque de fin de ligne, ou [null] si on était à la fin du flux |
[int read()] | rend le caractère suivant du flux, ou -1 à la fin du flux |
[Stream<String> lines()] | rend les lignes restantes sous la forme d'un flux de l'API Stream |
[void close()] | ferme le flux et libère les ressources allouées pour sa gestion. À faire obligatoirement après exploitation du flux, ce dont se charge l'instruction [try] avec ressources |
Le programme [Fichiers01.java] est le suivant :
Commentaires :
- ligne 2 : la méthode [main] reçoit ici les arguments de la ligne de commande, dans le tableau [args] ;
- ligne 4 : affiche le nom du dossier courant, celui à partir duquel on a lancé la commande [java]. [Path.of("")] désigne le dossier courant ; [toAbsolutePath] le transforme en chemin complet, dont [getFileName] ne garde que le dernier élément, pour ne pas encombrer les résultats d'un chemin complet ;
- ligne 6 : le fichier lu est [infos.txt] ou celui passé en argument. Comme c'est un nom relatif, il est cherché dans le dossier courant ;
- ligne 10 : l'instruction [try (déclaration de ressource) { ... }], dite try avec ressources (« try-with-resources », Java 7), garantit la fermeture du flux à la sortie du bloc, même si une exception se produit dans le bloc. Elle évite d'avoir à appeler explicitement la méthode [close]. Elle s'applique à tout objet implémentant l'interface [AutoCloseable], c'est-à-dire possédant une méthode [close] qui libère des ressources (fichier, connexion réseau, connexion à une base de données…). Avant Java 7, il fallait fermer le flux dans une clause [finally], après avoir testé qu'il avait bien été ouvert ;
- lignes 13-15 : le fichier est lu ligne par ligne. L'affectation [ligne = lecteur.readLine()] est une expression dont la valeur est celle de [ligne] : on la compare à [null], qui signale la fin du fichier ;
- ligne 20 : [Files.readString] (Java 11) lit tout le fichier d'un coup et le ferme ;
- lignes 21-27 : les erreurs d'entrée-sortie sont signalées par des exceptions dérivées de [java.io.IOException]. Ce sont des exceptions contrôlées (chapitre 4) : le compilateur exige qu'elles soient interceptées ou déclarées. Un fichier absent provoque une [NoSuchFileException], qui en dérive : elle doit donc être interceptée avant. Sa méthode [getFile] rend le chemin du fichier cherché. Les anciennes classes de [java.io] signalent la même erreur par une [FileNotFoundException].
Le fichier [infos.txt] est le suivant :
Il est placé dans le dossier [05-classes-java], d'où l'on exécute le programme :
Avec un fichier inexistant :
Avec un dossier au lieu d'un fichier, la lecture provoque une autre exception [IOException], dont le message est fourni par le système d'exploitation :
Le choix du dossier courant pour trouver les fichiers de données est naturel pour un fichier source compact, que l'on exécute depuis son dossier. Pour un projet Maven, la commande [mvn exec:java] s'exécute dans le dossier du projet : c'est là que nous placerons les fichiers de données de l'application exemple.
5.6.2. La classe BufferedWriter
La classe [java.io.BufferedWriter] permet d'écrire dans un fichier texte. Comme [BufferedReader], elle utilise une mémoire tampon et est capable d'exploiter des flux qui ne sont pas des fichiers.
méthode | rôle |
[static BufferedWriter Files.newBufferedWriter(Path path)] | ouvre le fichier [path] en écriture, en UTF-8. Le fichier est créé, ou vidé s'il existe. Des paramètres facultatifs permettent de préciser l'encodage et le mode d'ouverture : [Files.newBufferedWriter(path, StandardOpenOption.APPEND)] ajoute les écritures à la fin du fichier existant |
[void write(String str)] | écrit [str] dans le fichier |
[void newLine()] | écrit une marque de fin de ligne : celle du système, "\r\n" sous Windows, "\n" sous Linux et macOS |
[void flush()] | écrit dans le fichier le contenu du tampon, sans attendre qu'il soit plein |
[void close()] | ferme le flux, après avoir écrit dans le fichier le contenu du tampon. À faire obligatoirement après exploitation du flux |
Les écritures dans un [BufferedWriter] ne sont pas immédiates : le texte est d'abord écrit dans le tampon, puis dans le fichier lorsque le tampon est plein, lorsqu'on le vide explicitement par la méthode [flush] ou lorsqu'on ferme le flux. C'est le mode le plus efficace pour les fichiers, parce qu'il limite les accès au disque. Il ne convient pas à tous les flux, notamment aux flux réseau : dans un dialogue entre deux partenaires, ce qui est écrit par l'un doit être immédiatement lu par l'autre, et il faut vider le tampon après chaque message. La classe [java.io.PrintWriter], que nous utiliserons plus loin, ajoute les méthodes [print], [println] et [printf], et peut vider son tampon automatiquement après chaque [println] (constructeur [new PrintWriter(writer, true)]).
Le programme [Fichiers02.java] est le suivant :
Résultats :
Commentaires :
- ligne 6 : de nouveau, nous utilisons l'instruction [try] avec ressources afin de ne pas avoir à fermer explicitement le flux. Cette fermeture est faite automatiquement à la sortie du bloc (accolade fermante de la ligne 19). C'est elle qui écrit dans le fichier le texte resté dans le tampon : sans elle, le fichier pourrait rester incomplet ;
- ligne 7 : l'encodage par défaut, UTF-8 ;
- lignes 16-17 : écriture d'une ligne dans le fichier texte, puis d'une fin de ligne ;
- ligne 22 : la méthode [Files.size] donne la taille d'un fichier en octets. Les 25 octets se décomposent ainsi : 21 caractères, plus 1 octet supplémentaire pour chacun des deux [è] (qui occupent 2 octets en UTF-8), plus deux fins de ligne d'un octet ("\n" sous Linux, où ont été faites les exécutions ; sous Windows, chaque fin de ligne "\r\n" occuperait 2 octets) ;
- ligne 24 : pourquoi un [try / catch] ? Nous pourrions donner un nom de fichier de la forme [rep1/rep2/fichier] avec un dossier [rep1/rep2] qui n'existe pas, rendant impossible la création du fichier. Une exception [NoSuchFileException], dérivée de [IOException], serait alors lancée. Il existe d'autres cas d'exception possibles (disque plein, droits insuffisants...). De toute façon, le compilateur exige que les exceptions contrôlées [IOException] lancées par [newBufferedWriter], [write] ou [size] soient traitées.
5.6.3. Les classes Files et Path
Pour les opérations courantes, la classe utilitaire [Files] évite de manipuler des flux : une seule méthode ouvre le fichier, le lit ou l'écrit entièrement, puis le ferme. Elle permet aussi de créer, copier, déplacer, supprimer des fichiers et des dossiers. L'interface [Path] manipule les chemins.
méthode | rôle |
[Files.readString(path)] | rend le contenu du fichier sous la forme d'une chaîne (Java 11) |
[Files.readAllLines(path)] | rend les lignes du fichier dans une liste [List<String>] |
[Files.lines(path)] | rend les lignes du fichier sous la forme d'un flux [Stream<String>] lu à la demande, à fermer après usage |
[Files.writeString(path, texte, options...)] | crée (ou écrase) le fichier et y écrit [texte] (Java 11) ; avec l'option [StandardOpenOption.APPEND], ajoute le texte à la fin du fichier |
[Files.write(path, lignes, options...)] | écrit une collection de lignes, chacune suivie d'une fin de ligne |
[Files.readAllBytes(path)], [Files.write(path, octets)] | lit, écrit le fichier sous la forme d'un tableau d'octets |
[Files.exists(path)], [Files.size(path)], [Files.delete(path)], [Files.copy(source, cible)], [Files.move(source, cible)] | teste l'existence d'un fichier, donne sa taille, le supprime, le copie, le déplace |
[Files.createDirectories(path)] | crée le dossier et ses dossiers parents s'ils n'existent pas |
[Files.list(dossier)], [Files.walk(dossier)] | les éléments du dossier, de toute son arborescence, sous la forme d'un flux [Stream<Path>] |
[path.resolve(autre)] | construit un chemin en ajoutant [autre] à [path], avec le séparateur du système |
[path.getFileName()], [path.getParent()], [path.toAbsolutePath()] | le nom du fichier, son dossier, son chemin complet |
Le programme [Fichiers03.java] les utilise :
Résultats :
Commentaires :
- ligne 2 : les méthodes de [Files] lancent des [IOException]. Pour ne pas alourdir ce programme de démonstration, la méthode [main] les déclare avec la clause [throws IOException] au lieu de les intercepter : une erreur arrêterait le programme avec l'affichage de l'exception ;
- lignes 4-8 : [resolve] utilise le séparateur de chemins du système. Les exécutions du cours ont été faites sous Linux : le chemin obtenu est [donnees/villes.txt] ; sous Windows, il serait [donnees\villes.txt]. Construire les chemins avec [resolve] (ou [Path.of("donnees", "villes.txt")]) plutôt qu'en concaténant des chaînes rend le code portable. [Path] n'ayant pas de méthode qui rende l'extension d'un fichier, on la sépare du nom avec [lastIndexOf] ;
- ligne 11 : la création du dossier [donnees] ne provoque pas d'erreur s'il existe déjà ;
- lignes 14-21 : écriture, ajout et lecture en une instruction chacune ;
- lignes 23-26 : [Files.lines] rend un flux : les lignes sont lues une à une, au fur et à mesure du parcours. On peut y appliquer l'API Stream, et traiter ainsi un fichier de plusieurs gigaoctets sans le charger en mémoire, ce que [readAllLines] ferait. Le flux garde le fichier ouvert : il doit être fermé, d'où l'instruction [try] avec ressources ([Stream] implémente [AutoCloseable]) ;
- lignes 29-37 : le même texte « élève » est écrit avec trois encodages, puis les octets des fichiers sont affichés en hexadécimal avec la classe [HexFormat] (Java 17) :
- en UTF-8, encodage par défaut de Java, les caractères accentués occupent 2 octets (é = C3 A9) et les autres 1 octet : 7 octets pour 5 caractères ;
- en UTF-8 avec BOM (Byte Order Mark), le fichier commence par les 3 octets EF BB BF, qui signalent aux logiciels que le fichier est encodé en UTF-8. Java n'écrit jamais de BOM de lui-même : on l'obtient en écrivant le caractère U+FEFF en tête du texte, noté ["\uFEFF"] ;
- en Latin-1 (ISO-8859-1), encodage traditionnel des textes d'Europe occidentale, chaque caractère occupe un octet (é = E9) ;
- lignes 39-44 : relire un fichier avec un autre encodage que celui de son écriture altère les caractères accentués : la suite d'octets E9 6C E8 n'est pas valide en UTF-8. [Files.readString] et [Files.readAllLines] refusent un tel fichier en lançant une exception [MalformedInputException] (dérivée de [IOException]) ;
- lignes 46-47 : la conversion d'un tableau d'octets en chaîne par le constructeur [new String(octets, encodage)] est moins stricte : chaque octet invalide est remplacé par le caractère �. Un [InputStreamReader] se comporte de même ; en revanche, le [BufferedReader] rendu par [Files.newBufferedReader] est strict, comme [Files.readString], et lance lui aussi une [MalformedInputException] ;
- ligne 48 : relu avec le bon encodage, le fichier est correct ;
- ligne 50 : à la lecture, Java ne reconnaît pas le BOM : il est lu comme un caractère invisible au début du texte, qui compte 6 caractères au lieu de 5. Si l'on doit lire des fichiers UTF-8 produits avec un BOM (par certains logiciels Windows), il faut supprimer ce caractère soi-même. Aujourd'hui, la règle est simple : on écrit et on lit les fichiers texte en UTF-8, sans BOM ;
- lignes 53-59 : les fichiers du dossier. [Files.list] rend un flux, à fermer comme celui de [Files.lines], et ne garantit pas l'ordre des noms : [sorted] les trie ;
- lignes 62-67 : suppression du dossier. [Files.delete] refuse de supprimer un dossier non vide : on parcourt toute l'arborescence avec [Files.walk], et on trie les chemins dans l'ordre inverse ([Comparator.reverseOrder()]) pour supprimer le contenu d'un dossier avant le dossier lui-même.
Les méthodes de [Files] bloquent le thread qui les appelle jusqu'à la fin de l'opération. Nous verrons au chapitre 9, consacré à la programmation asynchrone, comment éviter de bloquer un programme pendant une entrée-sortie longue.
5.7. Les fichiers binaires
5.7.1. Les classes DataOutputStream et DataInputStream
Les classes [java.io.DataOutputStream] et [java.io.DataInputStream] servent à écrire et lire des fichiers binaires. Dans un tel fichier, les données ne sont pas écrites sous forme de texte mais sous leur forme interne : un [int] occupe toujours 4 octets, quelle que soit sa valeur. Ce sont des flux d'octets : elles dérivent des classes [OutputStream] et [InputStream], alors que les flux de texte dérivent de [Writer] et [Reader].
Considérons l'application suivante :
Le fichier texte [personnes1.txt] a le contenu suivant :
Le programme [Fichiers04.java] est le suivant :
Attardons-nous sur les opérations concernant la classe [DataOutputStream] :
- lignes 13-14 : l'objet [DataOutputStream] est construit autour d'un autre flux d'octets, selon le principe des flux emboîtés (le patron de conception « décorateur ») :
- [Files.newOutputStream(Path.of(args[1]))] rend un flux d'octets vers le fichier, créé ou vidé s'il existe ;
- [BufferedOutputStream] lui ajoute une mémoire tampon, pour ne pas accéder au disque à chaque écriture ;
- [DataOutputStream] lui ajoute des méthodes d'écriture des types de base ;
- lignes 50-51 : les opérations d'écriture. La classe [DataOutputStream] dispose d'une méthode par type de données simple : [writeInt], [writeDouble], [writeLong], [writeBoolean], [writeUTF] pour une chaîne… ;
- lignes 12-15 : les trois flux sont ouverts dans une même instruction [try] avec ressources, leurs déclarations étant séparées par des points-virgules. Ils seront fermés à la fin du bloc [try], dans l'ordre inverse de leur ouverture. Fermer le flux extérieur ([DataOutputStream]) ferme les flux qu'il contient. Le fichier des logs est écrit au travers d'un [PrintWriter], pour disposer de la méthode [println] ;
- ligne 27 : le séparateur est le caractère [:], entouré d'espaces éventuels, qui disparaissent avec lui ;
- lignes 42-47 : l'âge doit être un entier positif ou nul. Java n'a pas de méthode qui convertisse une chaîne en entier sans lancer d'exception en cas d'erreur : la méthode [entier] (lignes 67-73) intercepte l'exception [NumberFormatException] et rend -1, valeur impossible pour un âge ;
- lignes 9, 59, 62 : [System.exit(n)] arrête le programme avec le code de sortie n, qu'un script peut tester (0 : succès ; c'est le code de sortie d'un programme dont la méthode [main] se termine normalement).
Les trois arguments du programme sont passés après le nom du fichier source dans la commande [java]. Les résultats sont les suivants :
Le fichier de logs [logs.txt] a le contenu suivant :
Le contenu du fichier binaire [personnes1.bin] peut être affiché en hexadécimal avec la commande Linux [od] (sous Windows, la commande PowerShell [Format-Hex] donne un affichage analogue) :
On y lit la façon dont [DataOutputStream] écrit les données :
- une chaîne est écrite par [writeUTF] précédée de sa longueur en octets, sur 2 octets : 00 04 puis les 4 octets de « paul » (70 61 75 6c). Les caractères sont codés dans une variante d'UTF-8 ;
- un entier [int] est écrit sur 4 octets, l'octet de poids fort en premier (ordre dit « big-endian », celui des protocoles réseau) : 10 s'écrit 00 00 00 0a. Ce format est le même sur toutes les machines : un fichier écrit par [DataOutputStream] sous Windows se relit sous Linux ;
- les données sont écrites les unes à la suite des autres, sans séparateur ni fin de ligne : le fichier fait 48 octets (ligne 5 : avec l'option [-A d], [od] affiche les positions en décimal).
Pour relire ce fichier, il faut donc connaître exactement l'ordre et le type des données écrites. La classe [DataInputStream] a, pour chaque méthode [writeXX] de [DataOutputStream], une méthode [readXX] correspondante : [readUTF], [readInt], [readDouble]… Elle ne sait pas, en revanche, indiquer combien d'octets il reste à lire : un fichier lu par [DataInputStream] doit commencer par le nombre d'enregistrements qu'il contient, ou être lu jusqu'à l'exception [EOFException] qui signale la fin du flux. Or une exception doit rester exceptionnelle, et la fin d'un fichier ne l'est pas.
5.7.2. La classe RandomAccessFile
La classe [java.io.RandomAccessFile] lit et écrit un fichier en accès direct : on peut se placer à n'importe quelle position du fichier avant de lire ou d'écrire. Elle a les mêmes méthodes de lecture et d'écriture que [DataInputStream] et [DataOutputStream] (elle implémente les mêmes interfaces [DataInput] et [DataOutput]), et en plus :
méthode | rôle |
[RandomAccessFile(String name, String mode)] | ouvre le fichier en lecture ([mode] "r") ou en lecture et écriture ("rw") |
[long length()] | la taille du fichier, en octets |
[long getFilePointer()] | la position courante dans le fichier |
[void seek(long pos)] | se place à la position [pos] du fichier |
Le programme [Fichiers05.java] réalise l'opération inverse du programme précédent : il lit le fichier binaire pour créer un fichier texte. Si le fichier texte produit contient les données correctes du fichier d'origine, on saura que la conversion texte → binaire → texte s'est bien passée.
Attardons-nous sur les opérations concernant la classe [RandomAccessFile] :
- ligne 12 : l'objet [RandomAccessFile] est ouvert en lecture seule (mode "r") ;
- lignes 18-19 : les opérations de lecture, [readUTF] et [readInt]. Elles doivent être appelées dans l'ordre des écritures ;
- ligne 16 : on lit tant que la position courante dans le fichier n'a pas atteint sa taille ;
- lignes 26-27 : l'accès direct : [seek] ramène la position au début du fichier, et l'on relit la première personne. Si les enregistrements avaient tous la même taille, on pourrait lire directement le n-ième en se plaçant à la position n × taille : c'est le principe des fichiers à accès direct, ancêtres des bases de données ;
- ligne 28 : l'exception [EOFException] signale maintenant une vraie anomalie : un fichier qui s'arrête au milieu d'un enregistrement ;
- ligne 32 : [RandomAccessFile], classe de [java.io], signale un fichier absent par une [FileNotFoundException], dont le message, fourni par le système, contient le nom du fichier.
Exécutons le programme sur le fichier [personnes1.bin] produit précédemment :
Le contenu de [personnes2.txt] est le suivant :
On retrouve les 4 personnes valides du fichier [personnes1.txt]. Avec un fichier [tronque.bin] formé des 33 premiers octets de [personnes1.bin], la lecture de l'âge de jacques échoue :
Et avec un fichier inexistant :
Java a aussi un mécanisme de sérialisation binaire des objets : les classes [ObjectOutputStream] et [ObjectInputStream] écrivent et relisent des objets entiers, à condition que leur classe implémente l'interface [java.io.Serializable]. Ce mécanisme est aujourd'hui déconseillé : désérialiser des données provenant d'une source non sûre a permis de nombreuses attaques, car la désérialisation peut exécuter du code choisi par l'attaquant. On lui préfère un format texte comme JSON. On rencontre cependant encore l'interface [Serializable] dans de nombreuses classes, en particulier les exceptions, qui en héritent par la classe [Throwable] : c'est pourquoi le compilateur, avec l'option [-Xlint:all], demande qu'elles déclarent un champ [serialVersionUID], comme nous le verrons dans l'application exemple.
5.7.3. La sérialisation JSON avec Jackson
Écrire et relire un fichier binaire donnée par donnée est fastidieux, et le moindre décalage entre l'écriture et la lecture produit des résultats faux. On préfère généralement sérialiser des objets : les transformer automatiquement en une suite de caractères, puis les désérialiser pour les reconstruire.
JSON (JavaScript Object Notation) est un format texte, lisible, et universel : c'est le format d'échange des services web (chapitre 13) et de nombreux fichiers de configuration. Un objet y est écrit entre accolades sous la forme de couples "propriété": valeur, un tableau entre crochets.
La bibliothèque standard de Java ne sait pas lire ou écrire du JSON. On utilise une bibliothèque externe, la plus répandue étant Jackson, que Spring Boot utilise par défaut. Sa version 3 (2025) a changé de paquetages : [tools.jackson.*] au lieu de [com.fasterxml.jackson.*] pour la version 2, que l'on rencontre encore souvent ; ses annotations sont restées dans le paquetage [com.fasterxml.jackson.annotation]. Une bibliothèque externe s'obtient avec Maven (chapitre 2) : le programme est donc un projet Maven, [Fichiers06], dont l'arborescence est la suivante :
Le fichier [pom.xml] déclare la dépendance à Jackson :
Commentaires :
- lignes 7-9 : les coordonnées du projet ;
- lignes 11-14 : la version de Java et l'encodage des fichiers source ;
- lignes 16-23 : la dépendance à la bibliothèque [jackson-databind]. Maven la télécharge depuis le dépôt central (Maven Central), avec les bibliothèques dont elle dépend elle-même ([jackson-core] et [jackson-annotations]) ;
- lignes 27-37 : le compilateur, configuré comme dans tous les projets du cours : avertissements activés ([-Xlint:all]) et noms des paramètres conservés dans le code compilé ;
- lignes 39-46 : le greffon [exec-maven-plugin], qui exécute la classe principale avec la commande [mvn exec:java].
L'objet central de Jackson est le [JsonMapper], qui a deux méthodes principales :
méthode | rôle |
[String writeValueAsString(Object value)] | rend le texte JSON représentant l'objet [value] |
[<T> T readValue(String content, Class<T> valueType)] | reconstruit un objet de type [T] à partir du texte JSON [content] ; lance une [JacksonException] si le texte est incorrect. Une surcharge reçoit un [TypeReference] au lieu d'un [Class] |
Le programme [Fichiers06.java] les utilise :
On l'exécute dans le dossier du projet, avec la commande [mvn compile exec:java], ou depuis l'IDE. Nous ne reproduisons pas les messages de Maven, mais seulement l'affichage du programme :
Commentaires :
- ligne 1 : dans un projet Maven, les classes sont rangées dans des paquetages : la classe [Fichiers06] du paquetage [json] se trouve dans le fichier [src/main/java/json/Fichiers06.java] ;
- lignes 3-14 : une classe ordinaire, contrairement à un fichier source compact, doit importer les classes qu'elle utilise, y compris celles de la bibliothèque standard ;
- lignes 20-21 : le record [Personne] à sérialiser, déclaré dans la classe [Fichiers06]. Jackson 3 sait sérialiser un record : chaque composant devient une propriété JSON de même nom ;
- lignes 24-25 : l'annotation [@JsonProperty] fixe le nom de la propriété JSON d'un composant : [reference] au lieu de [code] ;
- ligne 27 : la méthode [main] est ici une méthode statique d'une classe. Elle déclare l'exception [IOException] que peuvent lancer les méthodes de [Files] ;
- lignes 34-36 : un [JsonMapper] est créé par un constructeur d'objet (« builder ») : [JsonMapper.builder()] rend un objet auquel on indique les options voulues, puis [build] crée le [JsonMapper]. Avec les options par défaut, le texte est écrit sur une seule ligne, et les caractères accentués sont écrits tels quels (ligne 2 des résultats) ;
- lignes 38-39 : l'option [ESCAPE_NON_ASCII] demande d'écrire les caractères non ASCII sous la forme de séquences d'échappement : [é] devient [\u00E9]. Le texte reste correct, mais il est peu lisible ; c'est utile lorsque le texte JSON doit traverser un système qui ne connaît que l'ASCII ;
- lignes 42-43 : l'option [INDENT_OUTPUT] produit un texte indenté, sur plusieurs lignes. Une liste est sérialisée en un tableau JSON ;
- lignes 45-48 : le texte JSON est enregistré dans un fichier texte, puis relu pour être affiché : c'est ainsi qu'on remplace aujourd'hui la sérialisation binaire ;
- lignes 53-54 : la désérialisation reconstruit la liste. Jackson crée les objets [Personne] en appelant le constructeur du record, dont les paramètres sont associés aux propriétés JSON de même nom. Pour une liste, il faut indiquer à Jackson le type de ses éléments. Or l'expression [List<Personne>.class] n'existe pas : les génériques de Java sont effacés à la compilation (« type erasure »), et à l'exécution il n'existe qu'une classe [List], sans paramètre de type. L'astuce de [TypeReference] consiste à créer une classe anonyme ([new TypeReference<List<Personne>>() { }]) dérivée de [TypeReference<List<Personne>>] : le type parent d'une classe, lui, est conservé dans le code compilé, et Jackson sait le lire. Pour un tableau, plus simple, on écrit [Personne[].class] ;
- lignes 61-62 : sérialisation d'un [Article] : la propriété [reference] porte le nom fixé par l'annotation. Un [BigDecimal] est écrit comme un nombre JSON ;
- lignes 64-69 : avec l'option [ACCEPT_CASE_INSENSITIVE_PROPERTIES], Jackson ne tient pas compte de la casse des noms de propriétés à la lecture : [REFERENCE] et [Prix] sont acceptés ;
- lignes 72-79 : un document JSON incorrect, ici une chaîne là où un nombre est attendu, provoque une exception dérivée de [JacksonException], ici [InvalidFormatException]. Dans Jackson 3, ces exceptions sont non contrôlées (elles dérivent de [RuntimeException]), alors qu'elles dérivaient de [IOException] dans Jackson 2. [getOriginalMessage] rend le message sans les informations de localisation, que [getLocation] et [getPathReference] donnent séparément : ligne 1, colonne 22, propriété [age] du record [Personne].
Un [JsonMapper] est coûteux à créer mais, une fois créé, il est réutilisable et peut être partagé entre plusieurs threads : un programme en crée généralement un seul, rangé dans une constante. Nous utiliserons JSON dans l'application exemple qui suit, puis tout au long du cours.
5.8. Les expressions régulières
Le paquetage [java.util.regex] permet l'utilisation d'expressions régulières. Celles-ci permettent de tester le format d'une chaîne de caractères. Ainsi on peut vérifier qu'une chaîne représentant une date est bien au format jj/mm/aa. On utilise pour cela un modèle et on compare la chaîne à ce modèle. Dans cet exemple, j, m et a doivent être des chiffres. Le modèle d'un format de date valide est alors [\d\d/\d\d/\d\d] où le symbole [\d] désigne un chiffre. Les symboles utilisables dans un modèle sont les suivants :
caractère | description |
[\] | marque le caractère suivant comme caractère spécial ou littéral. Par exemple, [n] correspond au caractère n, [\n] correspond à un caractère de nouvelle ligne, [\\] correspond à \, [\(] correspond à ( |
[^] | correspond au début de la chaîne |
[$] | correspond à la fin de la chaîne |
[*] | correspond à l'élément précédent zéro fois ou plusieurs fois. Ainsi [zo*] correspond à z ou à zoo |
[+] | correspond à l'élément précédent une ou plusieurs fois. Ainsi [zo+] correspond à zoo, mais pas à z |
[?] | correspond à l'élément précédent zéro ou une fois. Par exemple, [a?ve?] correspond à ve dans lever |
[.] | correspond à tout caractère unique, sauf le caractère de nouvelle ligne |
[(modèle)] | recherche le modèle et mémorise la correspondance (un groupe). La sous-chaîne correspondante peut être extraite du résultat par la méthode [group]. Pour trouver des correspondances avec des caractères entre parenthèses, utilisez [\(] ou [\)] |
[x|y] | correspond soit à x, soit à y. Par exemple, [z|foot] correspond à z ou à foot ; [(z|f)oo] correspond à zoo ou à foo |
[{n}] | n est un entier non négatif. Correspond exactement n fois à l'élément précédent. Par exemple, [o{2}] ne correspond pas à o dans Bob, mais aux deux premiers o de fooooot |
[{n,}] | correspond au moins n fois à l'élément précédent. Par exemple, [o{2,}] ne correspond pas à o dans Bob, mais à tous les o de fooooot. [o{1,}] équivaut à [o+] et [o{0,}] équivaut à [o*] |
[{n,m}] | correspond au moins n fois et au plus m fois à l'élément précédent. Par exemple, [o{1,3}] correspond aux trois premiers o de foooooot ; [o{0,1}] équivaut à [o?] |
[[xyz]] | jeu de caractères : correspond à l'un des caractères indiqués. Par exemple, [[abc]] correspond à a dans plat |
[[^xyz]] | jeu de caractères négatif : correspond à tout caractère non indiqué. Par exemple, [[^abc]] correspond à p dans plat |
[[a-z]] | plage de caractères : correspond à tout caractère de la série spécifiée, ici une lettre minuscule non accentuée |
[[^m-z]] | plage de caractères négative : correspond à tout caractère ne se trouvant pas entre m et z |
[\b] | correspond à une limite de mot, c'est-à-dire à la position entre un caractère de mot et un caractère qui n'en est pas un. Par exemple, [er\b] correspond à er dans lever, mais pas à er dans verbe |
[\B] | correspond à une position qui n'est pas une limite de mot. [en*t\B] correspond à ent dans « bien entendu » |
[\d] | correspond à un chiffre ; équivaut à [[0-9]] |
[\D] | correspond à un caractère qui n'est pas un chiffre |
[\f], [\n], [\r], [\t] | correspondent respectivement aux caractères saut de page, nouvelle ligne, retour chariot, tabulation |
[\s] | correspond à tout espace blanc : espace, tabulation, saut de page, nouvelle ligne… Équivaut à [[ \t\n\x0B\f\r]] |
[\S] | correspond à tout caractère qui n'est pas un espace blanc |
[\w] | correspond à tout caractère de mot : par défaut en Java, lettre non accentuée, chiffre ou trait de soulignement ; équivaut à [[a-zA-Z_0-9]]. Avec l'option [UNICODE_CHARACTER_CLASS] (voir plus loin), il reconnaît aussi les lettres accentuées |
[\W] | correspond à tout caractère qui n'est pas un caractère de mot |
[\num] | num est un entier positif : fait référence au groupe n° num. Par exemple, [(.)\1] correspond à deux caractères identiques consécutifs |
[\xnn], [\unnnn] | correspond au caractère de code hexadécimal nn (2 chiffres) ou nnnn (4 chiffres). Par exemple, [\x41] correspond à A |
Un élément dans un modèle peut être présent en 1 ou plusieurs exemplaires. Considérons quelques exemples autour du symbole [\d] qui représente 1 chiffre :
modèle | signification |
[\d] | un chiffre |
[\d?] | 0 ou 1 chiffre |
[\d*] | 0 ou davantage de chiffres |
[\d+] | 1 ou davantage de chiffres |
[\d{2}] | 2 chiffres |
[\d{3,}] | au moins 3 chiffres |
[\d{5,7}] | entre 5 et 7 chiffres |
Imaginons maintenant le modèle capable de décrire le format attendu pour une chaîne de caractères :
chaîne recherchée | modèle |
une date au format jj/mm/aa | [\d{2}/\d{2}/\d{2}] |
une heure au format hh:mm:ss | [\d{2}:\d{2}:\d{2}] |
un nombre entier non signé | [\d+] |
une suite d'espaces éventuellement vide | [\s*] |
un nombre entier non signé qui peut être précédé ou suivi d'espaces | [\s*\d+\s*] |
un nombre entier qui peut être signé et précédé ou suivi d'espaces | [\s*[+-]?\s*\d+\s*] |
un nombre réel non signé qui peut être précédé ou suivi d'espaces | [\s*\d+(\.\d*)?\s*] |
un nombre réel qui peut être signé et précédé ou suivi d'espaces | [\s*[+-]?\s*\d+(\.\d*)?\s*] |
une chaîne contenant le mot juste | [\bjuste\b] |
On notera qu'entre crochets, le caractère [|] n'a pas de signification particulière : le modèle [[+|-]], que l'on rencontre parfois pour le signe, accepte aussi le caractère | devant un nombre. De même, le point de la partie décimale doit être échappé, [\.] : un point non échappé désigne n'importe quel caractère.
On peut préciser où on recherche le modèle dans la chaîne :
modèle | signification |
[^modèle] | le modèle commence la chaîne |
[modèle$] | le modèle finit la chaîne |
[^modèle$] | le modèle commence et finit la chaîne |
[modèle] | le modèle est cherché partout dans la chaîne, en commençant par le début de celle-ci |
chaîne recherchée | modèle |
une chaîne se terminant par un point d'exclamation | [!$] |
une chaîne se terminant par un point | [\.$] |
une chaîne commençant par la séquence // | [^//] |
une chaîne ne comportant qu'un mot, éventuellement suivi ou précédé d'espaces | [^\s*\w+\s*$] |
une chaîne ne comportant que deux mots, éventuellement suivis ou précédés d'espaces | [^\s*\w+\s+\w+\s*$] |
une chaîne contenant le mot secret | [\bsecret\b] |
Les sous-ensembles d'un modèle peuvent être « récupérés ». Ainsi, non seulement on peut vérifier qu'une chaîne correspond à un modèle particulier, mais on peut récupérer dans cette chaîne les éléments correspondant aux sous-ensembles du modèle qui ont été entourés de parenthèses. Ainsi, si on analyse une chaîne contenant une date jj/mm/aa et si on veut de plus récupérer les éléments jj, mm, aa de cette date, on utilisera le modèle [(\d\d)/(\d\d)/(\d\d)].
Dans le code Java, un modèle est écrit dans une chaîne de caractères ordinaire : chaque caractère [\] du modèle doit y être doublé. Le modèle [\d+] s'écrit ["\\d+"], et le modèle [\\], qui désigne le caractère \, s'écrit ["\\\\"]. Les blocs de texte n'y changent rien : [\] y est aussi un caractère d'échappement. L'éditeur aide à la lecture : IntelliJ IDEA reconnaît les chaînes passées à [Pattern.compile], les colore comme des expressions régulières et permet de les tester.
5.8.1. Vérifier qu'une chaîne correspond à un modèle donné
Un modèle est d'abord compilé en un objet [java.util.regex.Pattern], qui fournit ensuite des objets [Matcher] chargés de la recherche dans une chaîne donnée :
méthode | rôle |
[static Pattern compile(String regex)] | compile le modèle [regex] ; lance une exception [PatternSyntaxException] si le modèle est incorrect |
[static Pattern compile(String regex, int flags)] | idem, avec des options (voir plus loin) |
[Matcher matcher(CharSequence input)] | rend un objet [Matcher] qui recherche le modèle dans [input] |
[boolean find()] (classe [Matcher]) | cherche l'occurrence suivante du modèle dans la chaîne ; rend [true] s'il en a trouvé une |
[boolean matches()] (classe [Matcher]) | rend [true] si la chaîne entière correspond au modèle |
[static boolean matches(String regex, CharSequence input)] | compile le modèle et teste si la chaîne entière y correspond |
Le programme [Regex01.java] est le suivant :
Résultats :
Commentaires :
- ligne 3 : le modèle d'un nombre entier non signé, éventuellement entouré d'espaces, qui occupe toute la chaîne (ancres [^] et [$]). Les [\] sont doublés ;
- ligne 4 : la compilation du modèle ;
- ligne 7 : [find] cherche le modèle dans la chaîne. La chaîne « 123a », entourée d'espaces, ne correspond pas au modèle : le caractère a ne peut être ni un chiffre, ni un espace. Sans les ancres [^] et [$], la chaîne correspondrait, car elle contient un nombre ;
- lignes 14-16 : la différence entre [find], qui cherche le modèle dans la chaîne, et [matches], qui exige que la chaîne entière corresponde au modèle, comme s'il était encadré par [^] et [$] ;
- lignes 18-20 : la méthode statique [Pattern.matches] et la méthode [matches] de la classe [String] testent elles aussi la chaîne entière. Elles compilent le modèle à chaque appel : pratiques pour un test isolé, elles sont à éviter dans une boucle. La méthode [split] de [String], déjà rencontrée, compile de même son modèle à chaque appel, sauf pour les séparateurs d'un seul caractère ordinaire, qu'elle traite sans expression régulière.
5.8.2. Trouver toutes les occurrences d'un modèle dans une chaîne
La méthode [find] d'un objet [Matcher] peut être appelée plusieurs fois : chaque appel cherche l'occurrence suivante du modèle. Après un appel réussi, l'objet [Matcher] donne des informations sur l'occurrence trouvée :
- [group()] : le texte de l'occurrence, donc un élément correspondant au modèle ;
- [start()] : la position où l'élément a été trouvé dans la chaîne explorée ; [end()] : la position qui suit l'élément.
Le programme [Regex02.java] est le suivant :
Résultats :
Commentaires :
- ligne 3 : le modèle recherché est une suite de chiffres ;
- ligne 5 : la chaîne dans laquelle on recherche ce modèle ;
- lignes 8-13 : chaque appel de [find] trouve l'occurrence suivante ; la boucle s'arrête quand il n'y en a plus. On affiche chaque occurrence et sa position ;
- ligne 16 : la méthode [results] (Java 9) rend le flux des occurrences, sous la forme d'objets [MatchResult] (qui ont les méthodes [group], [start], [end]) : l'API Stream s'y applique. On fait ici la somme des nombres trouvés ;
- ligne 18 : le nombre d'occurrences du modèle [[aeiouy]], qui désigne une voyelle, est le nombre d'éléments du flux.
5.8.3. Récupérer des parties d'un modèle
Comme nous l'avons dit, les parties d'un modèle entourées de parenthèses sont mémorisées : ce sont les groupes. Le programme [Regex03.java] les utilise :
Résultats :
Commentaires :
- lignes 7-8 : la chaîne [exemplaire4] est comparée au modèle [regex3] ; [find] cherche la première occurrence du modèle. Après un appel réussi, nous utilisons deux nouvelles méthodes de [Matcher] :
- [groupCount] (ligne 11) : le nombre de groupes de parenthèses du modèle ;
- [group(i)] (ligne 12) : le texte capturé par le groupe n° i ; [group(0)], ou [group()], est l'occurrence entière du modèle. [start(i)] est la position de ce texte dans la chaîne explorée. La boucle va donc de 0 à [groupCount] inclus ;
- ligne 20 : un groupe peut être nommé avec la syntaxe [(?<nom>...)]. Le modèle reconnaît ici une date au format j/m/aaaa, avec un jour et un mois d'un ou deux chiffres ;
- lignes 26-28 : un groupe nommé se désigne par son nom plutôt que par son numéro : le code est plus lisible et ne dépend pas de l'ordre des parenthèses ;
- lignes 29-31 : la classe [LocalDate] (Java 8) représente une date sans heure. Elle est affichée avec le modèle [EEEE d MMMM yyyy] : le jour de la semaine apparaît, en français ;
- ligne 35 : la méthode [replaceAll] remplace chaque occurrence du modèle par une chaîne dans laquelle [${nom}] (ou [$1], [$2]… pour les groupes numérotés) désigne la valeur d'un groupe. Les dates sont ainsi récrites au format année-mois-jour ;
- lignes 38-39 : une surcharge de [replaceAll] (Java 9) reçoit une fonction qui calcule le texte de remplacement à partir de chaque occurrence, de type [MatchResult]. On construit ici la date et on la remplace par son écriture normalisée ISO 8601, que rend la méthode [toString] de [LocalDate] : les jours et les mois ont toujours deux chiffres.
5.8.4. Un programme d'apprentissage
Trouver l'expression régulière qui permet de vérifier qu'une chaîne correspond bien à un certain modèle est parfois un véritable défi. Le programme [Regex04.java] permet de s'entraîner. Il demande un modèle et une chaîne et indique si la chaîne correspond ou non au modèle.
Commentaires :
- ligne 13 : la compilation du modèle tapé au clavier. Un modèle incorrect provoque une exception [PatternSyntaxException], dont [getDescription] rend la description de l'erreur et [getIndex] sa position dans le modèle (ligne 15). Son message complet, rendu par [getMessage], tient sur plusieurs lignes et montre le modèle avec un repère sous l'erreur ;
- ligne 28 : la recherche ne se fait pas directement dans la chaîne tapée, mais dans un objet [ChaineLimitee] (lignes 58-87), qui limite la durée de la recherche à une seconde : si la recherche dure plus longtemps, une exception [DelaiDepasseException] est lancée, puis interceptée ligne 44. Nous verrons plus loin pourquoi c'est utile et comment cela fonctionne ;
- lignes 36-39 : pour chaque correspondance, on affiche les groupes n° 1 et suivants, s'il y en a ;
- lignes 52-55 : contrairement aux programmes précédents, la ligne tapée n'est pas débarrassée de ses espaces : ils peuvent faire partie du modèle ou de la chaîne à tester.
Voici un exemple d'exécution :
Les modèles [^\s*\d+\s*$] et [^\s*(\d+)\s*$] montrent l'intérêt des groupes : le premier reconnaît la chaîne « 1456 » précédée de trois espaces, espaces compris ; le second permet en plus d'en extraire le nombre, sans les espaces. Les modèles [^(a+)+$] et [^(a+)+\1$] sont examinés ci-dessous.
5.8.5. La méthode split
Nous avons déjà rencontré la méthode [split] de la classe [String], dont le séparateur est une expression régulière. La classe [Pattern] a la même méthode, qui utilise le modèle déjà compilé :
méthode | rôle |
[String[] split(CharSequence input)] | la chaîne [input] est décomposée en champs, ceux-ci étant séparés par un séparateur correspondant au modèle |
[Stream<String> splitAsStream(CharSequence input)] | idem, mais les champs sont rendus sous la forme d'un flux |
Supposons par exemple qu'on ait dans un fichier texte des lignes de la forme champ1, champ2, ..., champn. Les champs sont séparés par une virgule, mais celle-ci peut être précédée ou suivie d'espaces. Le modèle [\s*,\s*] décrit exactement ce séparateur, comme le montre le programme [Regex05.java] :
Résultats :
Commentaires :
- ligne 5 : le séparateur : une virgule précédée et suivie d'un nombre quelconque d'espaces. Les espaces disparaissent avec le séparateur. Compilé une fois, le modèle peut découper autant de lignes qu'on veut sans être recompilé : c'est la façon de faire pour découper toutes les lignes d'un fichier ;
- ligne 11 : un séparateur qui peut prendre plusieurs formes : un point-virgule ou une barre verticale ([[;|]]), ou bien ([|]) une suite d'espaces ([\s+]) ;
- ligne 14 : [splitAsStream] rend les champs sous la forme d'un flux, auquel on applique ici l'opération [map].
5.8.6. Modèles précompilés, options, recherches trop longues
Contrairement à d'autres plates-formes, Java n'a pas de mécanisme qui génère le code de recherche d'une expression régulière au moment de la compilation du programme : un modèle est toujours analysé à l'exécution, par [Pattern.compile]. Cette compilation a un coût ; la bonne pratique est donc de compiler chaque modèle connu à l'avance une fois pour toutes et de ranger l'objet [Pattern] dans une constante, comme le fait le programme [Regex06.java]. Un objet [Pattern] est non modifiable et peut être utilisé simultanément par plusieurs threads ; un objet [Matcher], qui mémorise l'état d'une recherche, ne le peut pas.
Résultats :
Commentaires :
- lignes 4-8 : les modèles du programme sont compilés une seule fois et rangés dans des constantes [static final] (chapitre 4), écrites en majuscules selon l'usage. Dans un fichier source compact, ces constantes sont des champs de la classe implicite qui contient les méthodes du fichier ;
- lignes 12-16 : les constantes s'utilisent comme les objets [Pattern] des programmes précédents ;
- ligne 6 : le second paramètre de [compile] est une combinaison d'options. [CASE_INSENSITIVE] ignore la casse : « OUI » et « Oui » correspondent au modèle (ligne 4 des résultats) ;
- lignes 23-27 : les options se combinent avec l'opérateur [|]. [MULTILINE] fait correspondre [^] et [$] au début et à la fin de chaque ligne de la chaîne, et non plus seulement au début et à la fin de la chaîne. Mais avec les options par défaut, [\w] ne reconnaît que les lettres non accentuées : la ligne « quantité: 3 » n'est pas reconnue (ligne 5 des résultats). L'option [UNICODE_CHARACTER_CLASS] (Java 7) donne à [\w], [\d], [\s], [\b] leur sens Unicode : les lettres accentuées sont des caractères de mot (ligne 6 des résultats). On l'utilisera pour tout texte en français. D'autres options sont utiles : [DOTALL] ([.] correspond aussi au saut de ligne), [COMMENTS] (les espaces du modèle sont ignorés, ce qui permet de l'écrire sur plusieurs lignes, commentées), [UNICODE_CASE] (avec [CASE_INSENSITIVE], ignore aussi la casse des lettres accentuées : É et é) ;
- ligne 29 : les options peuvent aussi être écrites au début du modèle : [(?i)] pour [CASE_INSENSITIVE], [(?m)] pour [MULTILINE], [(?U)] pour [UNICODE_CHARACTER_CLASS]…
La suite du programme traite d'un problème de sécurité. Certains modèles sont pathologiques : appliqués à certaines chaînes, ils obligent le moteur d'expressions régulières à essayer un nombre énorme de façons de faire correspondre la chaîne au modèle avant de conclure. Le moteur de Java procède en effet par retours arrière (« backtracking ») : quand une tentative échoue, il revient au dernier choix fait et en essaie un autre. Le modèle [^(a+)+\1$] de la ligne 8 en est un exemple : appliqué à une suite de a terminée par un autre caractère, il essaie toutes les façons de répartir les a entre les répétitions du groupe et sa référence arrière [\1] avant de conclure à l'échec, et leur nombre est multiplié à chaque a supplémentaire. On parle de retour arrière catastrophique. Un tel modèle, appliqué à des chaînes saisies par des utilisateurs, est une faille de sécurité (on parle de ReDoS, « regular expression denial of service ») : quelques requêtes suffisent à bloquer un serveur.
- lignes 32-37 : la durée de la recherche est mesurée pour 10, 15 et 20 a. Elle grandit très vite (lignes 8-10 des résultats ; les durées varient d'une exécution et d'une machine à l'autre). La durée doublant à peu près à chaque a supplémentaire, la recherche durerait plusieurs minutes avec 30 a ;
- lignes 38-43 : la classe [Pattern] n'a pas d'option de délai d'expiration. On utilise une technique classique : la recherche se fait dans un objet [ChaineLimitee] (lignes 61-89), qui implémente l'interface [CharSequence] acceptée par [matcher]. Le moteur d'expressions régulières lit la chaîne caractère par caractère avec la méthode [charAt] : c'est elle qui vérifie l'heure et lance une exception [DelaiDepasseException] (lignes 92-95) si la date limite, mémorisée à la construction de l'objet, est dépassée. La recherche est abandonnée au bout de 100 ms ;
- lignes 61-65 : le record [ChaineLimitee] a deux composants, le texte et la date limite, exprimée par la valeur de l'horloge [System.nanoTime] à ne pas dépasser. Le constructeur supplémentaire calcule cette date limite à partir d'un délai, un objet [java.time.Duration] ;
- ligne 92 : l'exception [DelaiDepasseException] dérive de [RuntimeException] : c'est une exception non contrôlée, que [charAt] peut lancer sans la déclarer. Elle est déclarée [static] pour pouvoir être créée dans le record, qui est lui-même implicitement statique. Le champ [serialVersionUID] évite un avertissement du compilateur, une exception étant sérialisable ;
- lignes 45-48 : le moteur de Java sait reconnaître et traiter efficacement les cas simples de répétitions imbriquées : le modèle [^(a+)+$], catastrophique avec d'autres moteurs, répond ici immédiatement. C'est la référence arrière [\1] qui empêche cette optimisation dans le modèle de la ligne 8.
On retiendra qu'il faut se méfier des modèles comportant des répétitions imbriquées ([(a+)+], [(\w+\s?)*]…) et limiter la durée des recherches dès que le modèle ou la chaîne explorée proviennent de l'extérieur du programme. Les quantificateurs possessifs de Java ([a++], [a*+]), qui interdisent les retours arrière dans l'élément qu'ils répètent, permettent aussi d'écrire des modèles sans risque, à condition de bien en comprendre le sens.
5.9. Application exemple - V3
Nous reprenons l'application étudiée au chapitre 3 (version 1) et au chapitre 4 (version 2). Rappelons qu'il s'agit d'un exercice : le barème utilisé est celui de 2004 (revenus 2003), conservé tel quel depuis le cours d'origine.
5.9.1. Les données dans un fichier
Dans la version 2, le calcul de l'impôt se faisait dans la classe abstraite [AbstractImpot], que nous reprenons ici, dans le paquetage [impots] du projet, avec deux petites différences : les tranches sont maintenant fournies sous la forme d'un tableau [TrancheImpot[]] et non plus d'une liste, et la division est faite avec 16 chiffres significatifs au lieu de 34, ce qui est largement suffisant :
La méthode [calculer] utilise les tranches d'impôt rendues par la méthode abstraite [getTranchesImpot] (ligne 10). La classe [AbstractImpot] ne sait pas d'où viennent ces tranches : c'est aux classes dérivées de les fournir. Dans la version 2, la classe dérivée [HardwiredImpot] les plaçait « en dur » dans son code. Les calculs sont faits avec des nombres décimaux exacts [java.math.BigDecimal] (chapitre 3) : [BigDecimal.TWO] (Java 19) est la constante 2, la division du revenu par le nombre de parts est faite avec 16 chiffres significatifs ([MathContext.DECIMAL64]), et [intValue] rend la partie entière du résultat. Une tranche est représentée, comme dans la version 2, par le record [TrancheImpot] :
et le calcul est décrit par l'interface [IImpot], documentée ici par un commentaire Javadoc :
La nouvelle version place les données dans un fichier, ce qui permet de changer de barème sans recompiler le programme. Nous écrivons deux nouvelles classes dérivées de [AbstractImpot] :
- [FileImpot] lit les tranches dans un fichier texte [DataImpot.txt] ;
- [JsonImpot] les lit dans un fichier JSON [DataImpot.json].
Le fichier texte [DataImpot.txt] contient une tranche par ligne, sous la forme limite:coeffR:coeffN :
Les nombres y sont écrits avec un point décimal. Un fichier de données ne doit pas dépendre de la langue de la machine qui le lit : un fichier écrit avec des virgules décimales et lu avec les règles du français ([NumberFormat.getInstance(Locale.FRANCE)]) serait refusé ou, pire, mal interprété sur une machine anglaise. Le constructeur [new BigDecimal(String)], comme [Double.parseDouble], attend toujours un point décimal, quelle que soit la langue de la machine. C'est aussi la convention de JSON et de la plupart des formats de données.
Le fichier [DataImpot.json] contient les mêmes données, sous la forme d'un tableau JSON d'objets :
5.9.2. Les erreurs
L'exploitation de ces fichiers peut échouer : fichier absent, ligne mal formée, nombre incorrect... Nous créons une classe d'exception spéciale pour signaler ces erreurs, [ImpotException]. Elle servira aussi pour les autres sources de données (fichier JSON ici, base de données plus tard). Elle porte un code d'erreur :
Commentaires :
- ligne 7 : la classe [ImpotException] dérive de la classe [RuntimeException] : c'est une exception non contrôlée (chapitre 4). Le compilateur n'obligera pas le code qui crée un objet [FileImpot] ou [JsonImpot] à l'intercepter ; ce code l'interceptera là où il saura la traiter ;
- lignes 10-11 : toute exception est sérialisable, par héritage de la classe [Throwable]. Une classe sérialisable devrait déclarer un numéro de version [serialVersionUID], utilisé pour vérifier qu'un objet sérialisé est relu par la même version de la classe ; à défaut, le compilateur, avec l'option [-Xlint:all], émet un avertissement. L'annotation [@Serial] (Java 14) signale que le champ appartient au mécanisme de sérialisation ;
- ligne 14 : le code de l'erreur, fixé à la construction de l'exception ;
- lignes 16-24 : deux constructeurs. Le premier reçoit un message d'erreur et un code, le second reçoit en plus l'exception à l'origine de l'erreur (la cause), qui sera disponible par la méthode [getCause]. C'est cette forme d'exception, avec un code entier, que reprendront les versions suivantes de l'application.
Les codes d'erreur possibles sont décrits par une énumération :
Commentaires :
- lignes 7-12 : les codes d'erreur possibles :
- [ACCES] : erreur d'accès au fichier des données ;
- [LIGNE] : ligne n'ayant pas les trois champs attendus, ou fichier sans aucune tranche ;
- [CHAMP1], [CHAMP2], [CHAMP3] : le champ n° 1, 2 ou 3 d'une ligne est incorrect ;
- [JSON] : le document JSON est incorrect ;
- lignes 15-23 : une énumération Java est une classe : elle peut avoir des champs, un constructeur (privé) et des méthodes. Chaque constante est construite avec sa valeur entière, passée entre parenthèses ;
- lignes 26-34 : certaines erreurs peuvent se combiner : les trois champs d'une ligne peuvent être incorrects. Aussi les valeurs des codes sont-elles des puissances de 2 : chacune correspond à un bit de l'entier. Une erreur sur les champs 2 et 3 se traduira par le code 8 | 16 = 24. La méthode statique [decoder] fait l'opération inverse : elle rend l'ensemble des codes présents dans un entier, en testant chaque bit avec l'opérateur [&]. Le résultat est un [EnumSet], ensemble spécialisé pour les constantes d'une énumération, qui les mémorise lui-même sous la forme des bits d'un entier : c'est la façon Java de manipuler des combinaisons d'options. Sa méthode [toString] affiche une combinaison sous la forme « [CHAMP2, CHAMP3] ».
5.9.3. La classe FileImpot
La classe [FileImpot] fournit les tranches lues dans un fichier texte :
Commentaires :
- ligne 13 : la classe [FileImpot] dérive de la classe [AbstractImpot], comme le faisait dans la version 2 la classe [HardwiredImpot] ;
- lignes 16-18 : deux expressions régulières, compilées une fois pour toutes et rangées dans des constantes : le séparateur des champs d'une ligne limite:coeffR:coeffN (le caractère [:], précédé et suivi d'un nombre quelconque d'espaces), et un nombre positif ou nul, formé de chiffres éventuellement suivis d'un point et d'autres chiffres ;
- lignes 21-27 : les tranches lues, rendues par la méthode [getTranchesImpot] qui implémente la méthode abstraite de [AbstractImpot] ;
- ligne 29 : le constructeur reçoit le nom du fichier texte contenant les données. Son rôle est de remplir le tableau [tranchesImpot] ;
- ligne 32 : le nom du fichier sans son chemin, utilisé dans les messages d'erreur. Le constructeur peut en effet recevoir un chemin complet, qu'il n'est pas utile d'afficher à l'utilisateur ;
- ligne 34 : la lecture d'un fichier texte est séquentielle : on ne connaît le nombre de lignes qu'après avoir lu la totalité du fichier. On ne peut donc pas dimensionner le tableau à l'avance : on mémorise provisoirement les tranches dans une liste ;
- lignes 35-38 : le fichier est lu ligne par ligne avec un [BufferedReader], ouvert par une instruction [try] avec ressources ;
- lignes 46-50 : la ligne est découpée en champs grâce à l'expression régulière ; s'il n'y a pas trois champs, une exception [ImpotException] de code [LIGNE] est lancée immédiatement. Le fichier est malgré tout fermé : l'instruction [try] avec ressources ferme le [BufferedReader] quelle que soit la façon dont on sort du bloc, normalement ou par une exception ;
- lignes 52-58 : vérification des trois champs. Les erreurs sont cumulées dans la variable [code] avec l'opérateur [|=], puis signalées ensemble. La méthode [matches] exige que le champ entier corresponde au modèle [NOMBRE] : ni signe, ni espaces, ni virgule. Les nombres négatifs sont ainsi refusés, ce qui valide les données en même temps qu'on les vérifie ;
- lignes 60-61 : les champs, maintenant sûrs, sont convertis en [BigDecimal], avec un point décimal quelle que soit la langue de la machine ;
- lignes 63-67 : les erreurs d'accès au fichier ([IOException] et ses dérivées, comme [NoSuchFileException] pour un fichier absent, [AccessDeniedException] pour des droits insuffisants, ou [MalformedInputException] pour un fichier qui n'est pas en UTF-8) sont encapsulées dans une [ImpotException] de code [ACCES], l'exception d'origine devenant sa cause. Les [ImpotException] lancées dans la boucle ne sont pas des [IOException] : elles ne sont pas interceptées par cette clause [catch] et sortent du constructeur telles quelles ;
- lignes 69-74 : un fichier vide est refusé ; sinon la liste est transférée dans le tableau des tranches, ce qui était le but du constructeur.
Encapsuler les exceptions de bas niveau ([IOException]) dans une exception propre à l'application ([ImpotException]) permet au code appelant de ne traiter qu'un seul type d'exception, quelle que soit la source des données : un fichier texte, un fichier JSON et, plus tard dans le cours, une base de données. C'est aussi pourquoi [ImpotException] est non contrôlée : une exception contrôlée devrait être déclarée par chaque méthode qu'elle traverse, ce qui lierait le code appelant aux détails de la source de données.
5.9.4. La classe JsonImpot
La classe [JsonImpot] fournit les tranches lues dans un fichier JSON :
Commentaires :
- ligne 15 : un seul [JsonMapper], rangé dans une constante, sert à tous les objets [JsonImpot]. Les propriétés JSON [limite], [coeffR], [coeffN] portent les noms des composants du record [TrancheImpot] : aucune annotation n'est nécessaire ;
- lignes 31-37 : la lecture du fichier ; une erreur d'accès est encapsulée comme dans [FileImpot] ;
- ligne 41 : une seule instruction transforme le texte JSON en tableau de tranches. Jackson construit chaque record en appelant son constructeur, et convertit les nombres JSON en [BigDecimal] sans perte de précision ;
- lignes 42-46 : un document incorrect provoque une [JacksonException], encapsulée dans une [ImpotException] de code [JSON]. Le message indique l'emplacement de l'erreur, donné par [getLocation] ;
- lignes 48-56 : la désérialisation vérifie le format du document, pas la validité des données : il reste à vérifier qu'il y a au moins une tranche (le document [null] ou [[]] est un JSON valide) et qu'aucune valeur n'est négative ou absente. Une propriété absente d'un objet JSON donne en effet la valeur [null] au composant correspondant. L'opération [anyMatch] de l'API Stream fait ce dernier test en une instruction, [Arrays.stream(tranches)] rendant le flux des éléments du tableau ;
- lignes 61-63 : la méthode qui teste une valeur.
La comparaison des deux classes est instructive : avec un format de données structuré et une bibliothèque qui le connaît, l'analyse du fichier ne demande presque plus de code.
5.9.5. Le programme principal
Le projet Maven [ImpotsV3] a l'arborescence suivante :
Les fichiers de données sont placés à la racine du projet, à côté du fichier [pom.xml] : c'est le dossier courant du programme lorsqu'on l'exécute avec la commande [mvn exec:java] depuis le dossier du projet. Le fichier [pom.xml] est celui du projet [Fichiers06], aux coordonnées et à la classe principale près :
Le [groupId] [exemples.impots] sera celui de toutes les versions de l'application exemple. La classe principale, déclarée dans le greffon [exec-maven-plugin], est [impots.Main] :
Commentaires :
- lignes 7-11 : le texte d'aide, dans un bloc de texte rangé dans une constante ;
- ligne 15 : le nom du fichier de données est passé en argument ; par défaut, c'est [DataImpot.txt]. Avec Maven, les arguments du programme se passent par la propriété [exec.args] : [mvn exec:java -Dexec.args="DataImpot.json"] ;
- lignes 21-25 : une expression [switch] (chapitre 3) choisit la classe à instancier selon l'extension du fichier, extraite du nom avec [lastIndexOf] et [substring]. Les deux branches rendent des objets de types différents, [JsonImpot] et [FileImpot], tous deux de type [IImpot]. Le reste du programme ne connaît que l'interface [IImpot] et ignore d'où viennent les données ;
- lignes 26-35 : la création de l'objet peut lancer une [ImpotException], qui est affichée avant l'arrêt du programme. Le code est décodé en un ensemble de [CodeErreur] pour être affiché en clair ; l'exception d'origine, s'il y en a une, est indiquée par son type. L'instruction [return] qui suit [System.exit] n'est jamais exécutée, mais le compilateur, qui ne sait pas que [System.exit] ne rend pas la main, l'exige : sans elle, la variable [impot] ne serait pas considérée comme initialisée à la ligne 73 ;
- lignes 38-74 : la boucle de dialogue est celle de la version 2.
5.9.6. Exécutions
Avec le fichier [DataImpot.txt], par défaut :
On retrouve les valeurs des versions précédentes : 4282 euros pour un couple marié avec 2 enfants, 2959 euros avec 3 enfants, 11519 euros pour une personne seule sans enfant, avec un salaire annuel de 60000 euros. Les saisies incorrectes sont signalées comme dans la version 2.
Avec le fichier [DataImpot.json] :
Avec un fichier [xx.txt] inexistant :
Avec le fichier [DataImpotInvalide.txt] suivant, dont la troisième ligne utilise la virgule décimale :
Les champs 2 et 3 de la ligne 3 sont refusés : le code d'erreur est la combinaison [[CHAMP2, CHAMP3]]. Le champ 1 (14753), sans partie décimale, est correct.
Avec le fichier [DataImpotInvalide.json] suivant, où un coefficient est écrit sous la forme d'une chaîne :
Le message de Jackson indique précisément l'erreur : la chaîne « 0,068 » ne peut pas être convertie en [BigDecimal]. Jackson accepte une chaîne là où un nombre est attendu si elle contient un nombre valide (« 0.068 ») ; c'est la virgule qui est refusée. L'erreur est située à la ligne 3 du document, Jackson numérotant les lignes à partir de 1, et à la colonne 31, juste après la valeur incorrecte.
5.10. Conclusion
Ce chapitre a présenté les classes de Java dont on se sert dans presque tous les programmes :
- les chaînes [String], immuables, que l'on compare avec [equals] et non avec [==], et [StringBuilder] pour les construire par morceaux ; les formats de [String.format] et [formatted], les blocs de texte ;
- les tableaux et la classe [Arrays] ;
- les collections [List], [Map], [Set], [Deque] et leurs implémentations [ArrayList], [HashMap], [LinkedHashMap], [TreeMap], [HashSet], [ArrayDeque], [PriorityQueue] ; les collections non modifiables ([List.of], [List.copyOf], [Collections.unmodifiableList]), les collections séquencées de Java 21 et les comparateurs [Comparator] ; les collections de Java 1.0 ([Vector], [Hashtable], [Stack]) sont obsolètes ;
- l'API Stream, ses opérations intermédiaires et terminales, ses collecteurs et son évaluation paresseuse ;
- les fichiers texte ([Files], [Path], [BufferedReader], [BufferedWriter]), toujours en UTF-8, les fichiers binaires ([DataOutputStream], [DataInputStream], [RandomAccessFile]) et la sérialisation JSON avec Jackson, qui remplace la sérialisation binaire de Java, déconseillée ;
- les expressions régulières, compilées une fois pour toutes et protégées contre les recherches trop longues lorsqu'elles traitent des données extérieures.
L'application exemple lit maintenant ses données dans un fichier texte ou JSON. Le chapitre suivant la réorganise en couches : la lecture des données, le calcul de l'impôt et le dialogue avec l'utilisateur y deviendront des composants indépendants.