Skip to content

5. Classes Kotlin d'usage courant

Nous présentons ici quelques classes et fonctions d'usage courant : chaînes de caractères, tableaux, collections, fichiers, expressions régulières. Kotlin dispose pour cela de deux bibliothèques : sa propre bibliothèque standard (paquetages [kotlin.text], [kotlin.collections], [kotlin.io]…), qui ajoute aux types de base des centaines de fonctions, la plupart sous la forme de fonctions d'extension (chapitre 4), et la bibliothèque du JDK (l'API Java SE), que Kotlin utilise directement, sans aucune adaptation. Nous y ajoutons les opérations fonctionnelles sur les collections ([filter], [map], [groupBy]…) et les séquences, qui sont la façon habituelle, en Kotlin, de rechercher, trier, regrouper ou transformer les éléments d'une collection : elles jouent le rôle de l'API Stream de Java. Auparavant, nous montrons comment obtenir des renseignements sur les milliers de classes et de fonctions disponibles. Cette aide est indispensable au développeur Kotlin, même confirmé.

Les exemples de ce chapitre, sauf l'application exemple de la fin, sont des programmes d'un seul fichier, rangés comme au chapitre 3 dans le projet Gradle [05-classes-kotlin] du code du cours : le programme [Chaines01.kt] est dans le paquetage [chaines01] (dossier [src/main/kotlin/chaines01]) et s'exécute par la commande [gradlew -q run -Pmain=chaines01.Chaines01Kt] ou par le triangle vert d'IntelliJ IDEA. Ils s'exécutent, comme tout le cours, en langue française (locale fr-FR). Rappelons que tout fichier Kotlin importe automatiquement les paquetages [kotlin.*], [kotlin.text.*], [kotlin.collections.*], [kotlin.ranges.*], [kotlin.sequences.*], [kotlin.io.*]… ainsi que [java.lang.*] : les fonctions de la bibliothèque standard s'utilisent donc sans instruction [import]. En revanche, les classes des autres paquetages du JDK ([java.util], [java.text], [java.time], [java.nio.file]…) doivent être importées, alors que le cours Java en était dispensé par les fichiers source compacts.

5.1. Chercher de l'aide sur les classes Kotlin

La bibliothèque standard de Kotlin compte plusieurs milliers de fonctions, et un programme Kotlin pour la JVM a en outre accès aux plus de 4 000 classes publiques du JDK. Personne ne les connaît toutes : le développeur Kotlin consulte sans cesse leur documentation. Celle-ci est en ligne, gratuite, et tenue à jour pour chaque version.

5.1.1. La documentation en ligne

Le point d'entrée est le site kotlinlang.org de JetBrains. On y trouve :

  • la référence de la bibliothèque standard : https://kotlinlang.org/api/core/kotlin-stdlib/. Elle décrit, paquetage par paquetage ([kotlin.text], [kotlin.collections], [kotlin.io]…), toutes les classes, interfaces et fonctions de la bibliothèque, y compris les fonctions d'extension, classées sous le type qu'elles étendent : la page de [String] présente ainsi aussi bien ses membres ([length], [get]…) que les dizaines de fonctions d'extension qui s'y appliquent ([substringBefore], [padStart], [lines]…) ;
  • une zone de recherche, en haut de chaque page, dans laquelle on tape un nom de classe ou de fonction ([buildString], [joinToString]…) ;
  • la documentation du langage (Language guide), avec ses pages sur les chaînes, les collections, les séquences…, et une page « What's new » qui décrit les nouveautés de chaque version. Les évolutions du langage sont discutées publiquement sous la forme de propositions, les KEEP (Kotlin Evolution and Enhancement Process), consultables sur github.com/Kotlin/KEEP ;
  • play.kotlinlang.org, le « bac à sable » de Kotlin : on y tape et exécute un programme dans le navigateur, sans rien installer.

Pour les classes du JDK utilisées par un programme Kotlin ([java.time.LocalDate], [java.text.Collator], [java.nio.file.Files]…), la référence reste celle de l'API Java SE 25 : https://docs.oracle.com/en/java/javase/25/docs/api/. Elle décrit les classes avec la syntaxe de Java, qu'il faut transposer : un [int] est un [Int], un [String[]] un [Array<String>], une méthode [getNom()] s'utilise comme une propriété [nom]…

La page d'une classe ou d'une fonction, par exemple kotlin.String, donne :

  • le paquetage de la classe et les plates-formes où elle existe (Kotlin s'exécute aussi en JavaScript et en code natif : certaines fonctions sont propres à la JVM) ;
  • sa déclaration, ses types parents et les interfaces qu'elle implémente ;
  • une description générale, souvent accompagnée d'exemples exécutables ;
  • la liste de ses constructeurs, propriétés et fonctions, membres et extensions, puis le détail de chacun : paramètres, valeur rendue, exceptions possibles, et la version de Kotlin qui l'a introduit (rubrique « Since Kotlin »).

La documentation est en anglais. Une recherche avec le nom de la fonction précédé de « kotlin » (« kotlin joinToString ») mène en général directement à la bonne page.

5.1.2. KDoc et l'aide de l'éditeur

Comme la Javadoc pour Java, cette documentation est produite à partir des commentaires de documentation écrits dans le code source, appelés en Kotlin KDoc. Un tel commentaire commence par [/**] et se place juste avant une classe, une propriété ou une fonction. La fonction de calcul de l'impôt de l'application exemple de ce chapitre pourrait être documentée ainsi :

1
2
3
4
5
6
7
8
9
    /**
     * Calcule l'impôt d'un contribuable.
     *
     * @param marie vrai si le contribuable est marié
     * @param nbEnfants nombre d'enfants
     * @param salaire salaire annuel en euros
     * @return le montant de l'impôt, en euros
     */
    fun calculer(marie: Boolean, nbEnfants: Int, salaire: Int): Int

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, [@property] une propriété d'une classe). Contrairement à la Javadoc, qui utilise des balises HTML, le texte d'un commentaire KDoc s'écrit en Markdown : [*italique*], [**gras**], listes, et des liens vers d'autres éléments du code écrits entre crochets, comme [[salaire]] ou [[TrancheImpot]]. L'outil Dokka de JetBrains, ajouté au projet comme greffon Gradle ([id("org.jetbrains.dokka")]), en produit des pages HTML semblables à celles de la référence de Kotlin.

L'éditeur de code (IntelliJ IDEA, Visual Studio Code avec l'extension Kotlin) 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 propriétés et des fonctions applicables à l'objet, membres et fonctions d'extension mêlés (celles-ci sont signalées par le type qu'elles étendent, par exemple [for String in kotlin.text]), avec leurs paramètres et leur type de résultat ;
  • la documentation rapide : placer la souris sur un nom de classe ou de fonction (ou taper Ctrl+Q dans IntelliJ IDEA) affiche son KDoc ou, pour une classe du JDK, 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 fonction, le nom et le rôle de chaque paramètre, et ses éventuelles valeurs par défaut ;
  • atteindre la définition (Ctrl+B dans IntelliJ IDEA, F12 dans VS Code) : affiche le code source de la classe ou de la fonction. Gradle télécharge le code source de la bibliothèque standard de Kotlin, et le JDK est livré avec celui de sa propre bibliothèque (fichier [lib/src.zip]) : on peut ainsi lire le code de [joinToString] ou de [java.util.ArrayList], ce qui est très formateur. Le code de la bibliothèque standard de Kotlin est souvent très court, car il s'appuie sur les classes du JDK ;
  • montrer le bytecode Kotlin (menu Tools > Kotlin > Show Kotlin Bytecode, puis bouton Decompile) : affiche, sous la forme d'un code Java équivalent, ce que le compilateur a produit à partir du code Kotlin. C'est le moyen de comprendre ce que deviennent une fonction d'extension, une propriété ou un [when].

Enfin, pour essayer une fonction sans écrire de programme, IntelliJ IDEA propose les fichiers brouillons (File > New > Scratch File > Kotlin) : on y tape des instructions, et leurs résultats s'affichent en regard de chaque ligne. En ligne de commande, le lanceur [kotlinr] du compilateur (chapitre 2) évalue une expression : [kotlinr -e 'println("abc".repeat(3))'] affiche [abcabcabc].

5.2. Les chaînes de caractères

5.2.1. Le type String

Le type [String] (paquetage [kotlin]) représente les chaînes de caractères. Sur la JVM, une chaîne Kotlin est un objet [java.lang.String] : le compilateur fait correspondre les deux types, et une chaîne passe de Kotlin à Java (et inversement) sans aucune conversion. Mais le type [String] vu par Kotlin ne présente que quatre membres ([length], [get], [subSequence], [compareTo]) et l'opérateur [plus] : toutes les autres opérations sont des fonctions d'extension de la bibliothèque standard (paquetage [kotlin.text]), qui s'appellent comme des méthodes. Les méthodes Java de [java.lang.String] qui ont un équivalent Kotlin sont masquées : ["abc".strip()] est refusé par le compilateur ([unresolved reference 'strip' on receiver of type 'String']) ; on écrit ["abc".trim()]. En voici quelques-unes :

propriété ou fonction
rôle
[length: Int]
propriété : nombre de caractères de la chaîne
[operator fun get(index: Int): Char]
le caractère n° [index] ; le premier a le n° 0. S'écrit [s[index]]
[startsWith(prefix: String, ignoreCase = false)]
rend [true] si la chaîne commence par [prefix]
[endsWith(suffix: String, ignoreCase = false)]
rend [true] si la chaîne se termine par [suffix]
[contains(other: CharSequence, ignoreCase = false)]
rend [true] si la chaîne contient [other] ; s'écrit aussi [other in s]
[equals(other: String?, ignoreCase = false)]
rend [true] si la chaîne a le même contenu que [other] ; l'opérateur [==] appelle [equals]
[indexOf(string: String, startIndex = 0, ignoreCase = false)]
première position de [string] dans la chaîne, à partir de la position [startIndex], ou -1. Une autre surcharge cherche un caractère
[lastIndexOf(char: Char, ...)]
idem [indexOf] mais rend la dernière position au lieu de la première
[joinToString(separator = ", ", prefix = "", postfix = "")]
fonction d'extension des collections et des tableaux : concatène les éléments en les séparant par [separator]
[replace(oldChar: Char, newChar: Char, ignoreCase = false)]
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
[split(vararg delimiters: String, ...): List<String>]
la chaîne est vue comme une suite de champs séparés par l'un des séparateurs [delimiters], pris littéralement ; le résultat est la liste de ces champs. Une surcharge reçoit une expression régulière [Regex]
[substring(startIndex: Int, endIndex: Int)]
sous-chaîne formée des caractères de la position [startIndex] (incluse) à la position [endIndex] (exclue) ; [substring(range: IntRange)] reçoit un intervalle
[substringBefore(delimiter)], [substringAfter(delimiter)]
partie de la chaîne qui précède, qui suit la première occurrence de [delimiter] ([substringBeforeLast], [substringAfterLast] : la dernière)
[lowercase()], [uppercase()]
rend la chaîne en minuscules, en majuscules
[trim()]
rend la chaîne débarrassée de ses espaces de début et de fin ([trimStart], [trimEnd] : d'un seul côté)
[repeat(n: Int)]
rend la chaîne répétée [n] fois
[reversed()]
rend la chaîne inversée
[isEmpty()], [isBlank()]
rend [true] si la chaîne est vide, si elle est vide ou ne contient que des espaces
[lines(): List<String>]
rend la liste des lignes de la chaîne
[format(vararg args: Any?)]
rend la chaîne, utilisée comme format, dans laquelle les emplacements [%s], [%d]… ont été remplacés par les arguments

On remarquera les nombreux paramètres [ignoreCase], qui ont la valeur par défaut [false] : Kotlin n'a pas besoin, comme Java, de méthodes distinctes [equals] et [equalsIgnoreCase] ; on écrit [s.equals(autre, ignoreCase = true)] grâce aux arguments nommés (chapitre 3).

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 fonction 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 fonction. Ainsi [s1.trim()] rend une chaîne [s2], et [s1] et [s2] sont deux chaînes différentes : [s1] n'a pas changé.

Une chaîne Kotlin n'est pas un tableau, mais grâce à l'opérateur [get] (chapitre 4), on accède à son caractère n° [i] par la même notation [c[i]] qu'à l'élément d'un tableau, là où Java impose [c.charAt(i)]. De même, le nombre de caractères est la propriété [c.length], et la taille d'un tableau la propriété [tableau.size] : Kotlin n'a pas l'incohérence de Java entre la méthode [length()] des chaînes et le champ [length] des tableaux.

Le programme [Chaines01.kt] est le suivant :

package chaines01

// quelques propriétés et fonctions du type String
fun main() {
    val uneChaine = "l'oiseau vole au-dessus des nuages"
    affiche("uneChaine=$uneChaine")
    affiche("uneChaine.length=${uneChaine.length}")
    affiche("uneChaine[10]=${uneChaine[10]}")
    affiche("uneChaine.indexOf(\"vole\")=${uneChaine.indexOf("vole")}")
    affiche("uneChaine.indexOf(\"x\")=${uneChaine.indexOf("x")}")
    affiche("uneChaine.lastIndexOf('a')=${uneChaine.lastIndexOf('a')}")
    affiche("uneChaine.lastIndexOf('x')=${uneChaine.lastIndexOf('x')}")
    affiche("uneChaine.substring(4, 11)=${uneChaine.substring(4, 11)}")
    affiche("uneChaine.substring(4..10)=${uneChaine.substring(4..10)}")
    affiche("uneChaine.uppercase()=${uneChaine.uppercase()}")
    affiche("uneChaine.lowercase()=${uneChaine.lowercase()}")
    affiche("uneChaine.replace('a', 'A')=${uneChaine.replace('a', 'A')}")
    // pas de fonction insert : on recolle deux sous-chaînes...
    affiche("insertion de \"ne \" en position 9=" + uneChaine.substring(0, 9) + "ne "
            + uneChaine.substring(9))
    // ... ou on remplace la plage vide 9 until 9 par le texte à insérer
    affiche("uneChaine.replaceRange(9, 9, \"ne \")=${uneChaine.replaceRange(9, 9, "ne ")}")
    // découpage de la chaîne en mots (séparateur : une suite d'espaces)
    val champs = uneChaine.split(Regex("""\s+"""))
    for (i in champs.indices) {
        affiche("champs[$i]=[${champs[i]}]")
    }
    affiche("champs.joinToString(\":\")=${champs.joinToString(":")}")
    affiche("\"  abc  \".trim()=[${"  abc  ".trim()}]")
    // quelques fonctions sans équivalent direct en Java
    affiche("uneChaine.substringBefore(' ')=${uneChaine.substringBefore(' ')}")
    affiche("uneChaine.substringAfterLast(' ')=${uneChaine.substringAfterLast(' ')}")
    affiche("uneChaine.reversed()=${uneChaine.reversed()}")
    affiche("uneChaine.count { it == 'e' }=${uneChaine.count { it == 'e' }}")
    affiche("\"un\\ndeux\\ntrois\".lines()=${"un\ndeux\ntrois".lines()}")
    // la chaîne d'origine n'a pas été modifiée
    affiche("uneChaine=$uneChaine")
}

// une fonction d'affichage
fun affiche(msg: String) {
    println(msg)
}

Résultats :

> gradlew -q run -Pmain=chaines01.Chaines01Kt
uneChaine=l'oiseau vole au-dessus des nuages
uneChaine.length=34
uneChaine[10]=o
uneChaine.indexOf("vole")=9
uneChaine.indexOf("x")=-1
uneChaine.lastIndexOf('a')=30
uneChaine.lastIndexOf('x')=-1
uneChaine.substring(4, 11)=seau vo
uneChaine.substring(4..10)=seau vo
uneChaine.uppercase()=L'OISEAU VOLE AU-DESSUS DES NUAGES
uneChaine.lowercase()=l'oiseau vole au-dessus des nuages
uneChaine.replace('a', 'A')=l'oiseAu vole Au-dessus des nuAges
insertion de "ne " en position 9=l'oiseau ne vole au-dessus des nuages
uneChaine.replaceRange(9, 9, "ne ")=l'oiseau ne vole au-dessus des nuages
champs[0]=[l'oiseau]
champs[1]=[vole]
champs[2]=[au-dessus]
champs[3]=[des]
champs[4]=[nuages]
champs.joinToString(":")=l'oiseau:vole:au-dessus:des:nuages
"  abc  ".trim()=[abc]
uneChaine.substringBefore(' ')=l'oiseau
uneChaine.substringAfterLast(' ')=nuages
uneChaine.reversed()=segaun sed sussed-ua elov uaesio'l
uneChaine.count { it == 'e' }=5
"un\ndeux\ntrois".lines()=[un, deux, trois]
uneChaine=l'oiseau vole au-dessus des nuages

Commentaires :

  • lignes 7-8 : la longueur est une propriété, et l'opérateur [[10]] donne le caractère n° 10, donc le 11e caractère de la chaîne ;
  • lignes 10, 12 : une recherche infructueuse rend -1 ;
  • ligne 13 : 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 Kotlin comme de Java pour désigner une plage d'indices (début inclus, fin exclue) : on la retrouvera dans de nombreuses fonctions ;
  • ligne 14 : une surcharge de [substring] reçoit un intervalle [IntRange] (chapitre 3), dont les deux bornes sont incluses : [4..10] désigne les mêmes caractères que les paramètres [(4, 11)]. On aurait pu écrire aussi [4 until 11] ou [4..<11] ;
  • ligne 15 : [uppercase] et [lowercase] (Kotlin 1.5) ne dépendent pas de la langue de la machine (elles utilisent les règles d'Unicode, indépendantes de la langue) ; les anciennes [toUpperCase] et [toLowerCase] de Java, qui en dépendaient (en turc, le i minuscule devient İ), sont dépréciées en Kotlin ;
  • lignes 18-22 : le type [String] n'a pas de fonction d'insertion : on recolle le début de la chaîne, le texte inséré et la fin de la chaîne, ou bien on utilise [replaceRange], qui remplace les caractères d'une plage par un texte : remplacer la plage vide qui commence en position 9 revient à insérer. Nous verrons que la classe [StringBuilder] a une fonction [insert] ;
  • ligne 24 : [split] découpe la chaîne en champs et rend une liste ([List<String>], présentée plus loin), et non un tableau comme en Java. Attention, c'est une différence importante avec Java : avec un paramètre [String], le séparateur de [split] est pris littéralement ; pour qu'il soit une expression régulière, que nous étudierons à la fin du chapitre, il faut passer un objet [Regex]. [\s+] désigne une suite d'un ou plusieurs espaces (espace, tabulation, saut de ligne…). Elle est écrite dans une chaîne brute délimitée par trois guillemets, où le caractère [\] est un caractère ordinaire et n'a pas à être doublé (nous y revenons plus loin) ;
  • ligne 25 : la propriété [indices] d'une liste (ou d'un tableau) est l'intervalle de ses indices, ici [0..4] ;
  • ligne 28 : [joinToString] fait l'inverse de [split]. C'est une fonction d'extension des listes et des tableaux, là où Java propose la méthode statique [String.join] ;
  • ligne 29 : [trim] supprime les espaces de début et de fin. Le [trim] de Kotlin reconnaît tous les espaces d'Unicode (il utilise la fonction [Char.isWhitespace]) : il correspond donc à la méthode [strip] de Java, et non à la méthode [trim] de Java, qui ne reconnaît que les espaces du code ASCII ;
  • lignes 31-35 : quelques fonctions de la bibliothèque Kotlin sans équivalent direct dans la classe [String] de Java : [substringBefore] et [substringAfterLast] rendent ce qui précède le premier espace et ce qui suit le dernier, sans calcul de positions ; [reversed] inverse la chaîne ; [count] compte les caractères qui vérifient une condition, exprimée par une lambda : [{ it == 'e' }] est une fonction anonyme dont le paramètre implicite [it] est le caractère examiné (les lambdas sont étudiées au chapitre 7). Comme sa lambda est le dernier argument de [count], elle est écrite après le nom de la fonction, sans parenthèses ; enfin [lines] découpe un texte en lignes, quelle que soit la fin de ligne utilisée ([\n], [\r\n] ou [\r]), et rend la liste des lignes ;
  • ligne 37 : toutes les fonctions précédentes ont rendu de nouvelles chaînes : [uneChaine] n'a pas été modifiée (dernière ligne des résultats) ;
  • lignes 41-42 : une fonction de haut niveau, appelée par la fonction [main] comme au chapitre 3.

5.2.2. Les fonctions split et joinToString

Le programme [Chaines02.kt] approfondit le découpage d'une chaîne en champs. Le lecteur qui vient de Java y trouvera deux différences importantes avec la méthode [split] de Java :

package chaines02

// split et joinToString
fun main() {
    val ligne = "un:deux::trois:"
    // découpage avec le séparateur ":"
    var champs = ligne.split(":")
    for ((i, champ) in champs.withIndex()) {
        println("champs[$i]=[$champ]")
    }
    // l'opération inverse redonne la chaîne de départ : le champ vide final a été gardé
    println("joinToString=[${champs.joinToString(":")}]")

    // une limite : au plus 2 champs, le dernier contient la fin de la chaîne
    champs = ligne.split(":", limit = 2)
    println("split(\":\", limit = 2) : ${champs.size} champs $champs")

    // le séparateur est une expression régulière : ":+" désigne une suite de ':'
    champs = ligne.split(Regex(":+"))
    println("split(Regex(\":+\")) : ${champs.size} champs [${champs.joinToString("|")}]")

    // plusieurs séparateurs, puis suppression des espaces autour des champs
    val personne = " dupont , jean ;  45 "
    champs = personne.split(',', ';').map { it.trim() }
    println("split(',', ';') + trim : ${champs.size} champs [${champs.joinToString("|")}]")
    // la même chose avec une expression régulière
    champs = personne.trim().split(Regex("""\s*[,;]\s*"""))
    println("""split(Regex("\s*[,;]\s*")) : ${champs.size} champs [${champs.joinToString("|")}]""")

    // suppression des espaces et des champs vides, champ par champ
    val liste = "  rouge ,, vert ,  , bleu "
    val couleurs = mutableListOf<String>()
    for (champ in liste.split(",")) {
        val couleur = champ.trim()
        if (couleur.isNotEmpty()) {
            couleurs.add(couleur)
        }
    }
    println("trim + isNotEmpty : ${couleurs.size} champs [${couleurs.joinToString("|")}]")
    // la même chose en une instruction
    val couleurs2 = liste.split(",").map { it.trim() }.filter { it.isNotEmpty() }
    println("map + filter : ${couleurs2.size} champs [${couleurs2.joinToString("|")}]")

    // un séparateur qui contient des caractères spéciaux des expressions régulières : aucun problème
    val texte = "pomme | poire | abricot"
    println("split(\" | \") : [${texte.split(" | ").joinToString(", ")}]")

    // joinToString accepte des éléments de tout type, avec un préfixe, un suffixe et une transformation
    val nombres = intArrayOf(1, 2, 3, 4)
    println("joinToString sur des entiers : ${nombres.joinToString(" + ")}")
    println(nombres.joinToString(", ", prefix = "[", postfix = "]") { "n°$it" })
}

Résultats :

> gradlew -q run -Pmain=chaines02.Chaines02Kt
champs[0]=[un]
champs[1]=[deux]
champs[2]=[]
champs[3]=[trois]
champs[4]=[]
joinToString=[un:deux::trois:]
split(":", limit = 2) : 2 champs [un, deux::trois:]
split(Regex(":+")) : 4 champs [un|deux|trois|]
split(',', ';') + trim : 3 champs [dupont|jean|45]
split(Regex("\s*[,;]\s*")) : 3 champs [dupont|jean|45]
trim + isNotEmpty : 3 champs [rouge|vert|bleu]
map + filter : 3 champs [rouge|vert|bleu]
split(" | ") : [pomme, poire, abricot]
joinToString sur des entiers : 1 + 2 + 3 + 4
[n°1, n°2, n°3, n°4]

Commentaires :

  • ligne 7 : les champs sont séparés par le caractère [:]. Deux [:] consécutifs délimitent un champ vide (ligne 3 des résultats), et le [:] final de la chaîne délimite un cinquième champ, vide lui aussi, qui est gardé (ligne 5 des résultats). C'est la première différence avec Java, dont la méthode [split] supprime les champs vides de la fin : en Kotlin, [joinToString] reconstitue toujours la chaîne de départ (ligne 6 des résultats). Le comportement de Kotlin est plus régulier : une chaîne qui contient n séparateurs donne toujours n + 1 champs. Pour retrouver le comportement de Java, on écrirait [ligne.split(":").dropLastWhile { it.isEmpty() }] ;
  • ligne 8 : la fonction [withIndex] permet de parcourir une liste en disposant à la fois de l'indice et de l'élément, que l'on récupère par une déstructuration (chapitre 3) ;
  • ligne 15 : le paramètre facultatif [limit] de [split] fixe le nombre maximal de champs : le découpage s'arrête au premier séparateur et le dernier champ contient toute la fin de la chaîne. On utilise une limite, par exemple, pour séparer le nom d'un paramètre de sa valeur dans une ligne [nom=valeur] lorsque la valeur peut elle-même contenir le séparateur. On notera que l'affichage d'une liste par un gabarit ([$champs]) donne ses éléments entre crochets, séparés par des virgules : c'est le [toString] des listes ;
  • ligne 19 : la seconde différence avec Java : un séparateur de type [String] est pris littéralement ; pour décrire un séparateur par une expression régulière, il faut passer un objet [Regex]. [":+"] désigne un ou plusieurs caractères [:] consécutifs. Les deux [:] consécutifs forment un seul séparateur et le champ vide intermédiaire disparaît ; le dernier champ vide, lui, reste ;
  • ligne 24 : [split] accepte plusieurs séparateurs ([vararg], chapitre 3), caractères ou chaînes : ici la virgule et le point-virgule. La fonction [map] applique ensuite à chaque champ la lambda [{ it.trim() }] et rend la liste des résultats : on obtient en une instruction les trois champs « nettoyés ». Les fonctions [map], [filter] et leurs nombreuses sœurs sont présentées plus loin dans ce chapitre ;
  • ligne 27 : la même chose avec une expression régulière : 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 [trim]. Le modèle est écrit dans une chaîne brute, sans doubler les [\] ; la ligne suivante utilise aussi une chaîne brute, pour afficher le modèle tel quel ;
  • lignes 32-36 : pour supprimer à la fois les espaces autour des champs et les champs vides, on peut traiter les champs un par un : le champ formé de deux espaces entre deux virgules devient vide après [trim] et n'est pas gardé. La liste modifiable créée par [mutableListOf] pour mémoriser les champs gardés sera présentée un peu plus loin ;
  • ligne 41 : la même chose en une instruction : [map] enlève les espaces de chaque champ, puis [filter] ne garde que les champs non vides. C'est l'écriture idiomatique en Kotlin ;
  • ligne 46 : le séparateur [" | "] contient le caractère [|], qui a une signification particulière dans une expression régulière (« ou »). Comme le séparateur est pris littéralement, il n'y a aucune précaution à prendre, alors qu'en Java il faut écrire [split(Pattern.quote(" | "))]. Si l'on doit construire une expression régulière à partir d'un texte quelconque, la fonction [Regex.escape] joue le rôle de [Pattern.quote] ;
  • lignes 49-51 : contrairement à [String.join] de Java, qui n'accepte que des chaînes, [joinToString] accepte des éléments de tout type, ici un tableau d'entiers [IntArray] : chaque élément est converti par sa fonction [toString]. Ses paramètres facultatifs [prefix] et [postfix] ajoutent un texte au début et à la fin, et son dernier paramètre, [transform], est une lambda qui convertit chaque élément en texte ; écrite après les parenthèses, elle produit ici les textes « n°1 », « n°2 »… Ce que Java fait avec un [StringJoiner] ou avec l'API Stream tient en un appel.

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 que compare l'opérateur [==] ? Sur ce dernier point, Kotlin a corrigé un piège célèbre de Java. Le programme [Chaines03.kt] présente les fonctions de recherche et de comparaison :

package chaines03

import java.text.Collator
import java.util.Locale

// rechercher et comparer des chaînes
fun main() {
    val titre = "Apprentissage du langage Kotlin 2.4"

    // contains, startsWith, endsWith : comparaison exacte par défaut...
    println("contains(\"langage\") : ${titre.contains("langage")}")
    println("contains(\"LANGAGE\") : ${titre.contains("LANGAGE")}")
    println("startsWith(\"Apprentissage\") : ${titre.startsWith("Apprentissage")}")
    println("endsWith(\"2.4\") : ${titre.endsWith("2.4")}")
    // ... ou sans tenir compte de la casse
    println("contains(\"LANGAGE\", ignoreCase = true) : ${titre.contains("LANGAGE", ignoreCase = true)}")
    // l'opérateur in appelle contains
    println("\"Kotlin\" in titre : ${"Kotlin" in titre}")
    // regionMatches(début, autre, début dans autre, longueur, ignoreCase)
    println("regionMatches(0, \"apprentissage\", 0, 13, true) : "
            + titre.regionMatches(0, "apprentissage", 0, 13, ignoreCase = true))

    // égalité : == compare les contenus, === compare les références
    val reponse = "OUI"
    val oui = StringBuilder("ou").append('i').toString()
    println("oui == \"oui\" : ${oui == "oui"}")
    println("oui === \"oui\" : ${oui === "oui"}")
    println("reponse == \"oui\" : ${reponse == "oui"}")
    println("reponse.equals(\"oui\", ignoreCase = true) : ${reponse.equals("oui", ignoreCase = true)}")

    // ordre alphabétique : selon les codes des caractères ou selon la langue
    val a = "élève"
    val b = "essai"
    println("\"$a\".compareTo(\"$b\") : ${a.compareTo(b)}")
    println("\"$a\" < \"$b\" : ${a < b}")
    val collator = Collator.getInstance(Locale.FRANCE)
    println("collator.compare(\"$a\", \"$b\") : ${collator.compare(a, b)}")

    // chaînes vides ou blanches
    val vide = ""
    val blanche = "   "
    val absente: String? = System.getenv("VARIABLE_ABSENTE")
    println("\"\".isEmpty() : ${vide.isEmpty()}")
    println("\"   \".isEmpty() : ${blanche.isEmpty()}")
    println("\"   \".isBlank() : ${blanche.isBlank()}")
    println("absente.isNullOrBlank() : ${absente.isNullOrBlank()}")
    println("absente?.length : ${absente?.length}")
    println("absente.orEmpty().length : ${absente.orEmpty().length}")

    // cadrage et répétition
    println("[${"abc".padStart(8)}] [${"abc".padEnd(8)}] [${"-".repeat(8)}] [${"42".padStart(5, '0')}]")
}

Résultats :

> gradlew -q run -Pmain=chaines03.Chaines03Kt
contains("langage") : true
contains("LANGAGE") : false
startsWith("Apprentissage") : true
endsWith("2.4") : true
contains("LANGAGE", ignoreCase = true) : true
"Kotlin" in titre : true
regionMatches(0, "apprentissage", 0, 13, true) : true
oui == "oui" : true
oui === "oui" : false
reponse == "oui" : false
reponse.equals("oui", ignoreCase = true) : true
"élève".compareTo("essai") : 132
"élève" < "essai" : false
collator.compare("élève", "essai") : -1
"".isEmpty() : true
"   ".isEmpty() : false
"   ".isBlank() : true
absente.isNullOrBlank() : true
absente?.length : null
absente.orEmpty().length : 0
[     abc] [abc     ] [--------] [00042]

Commentaires :

  • lignes 3-4 : les classes du JDK utilisées par le programme doivent être importées ;
  • lignes 11-14 : par défaut, [contains], [startsWith] et [endsWith] font une comparaison exacte, caractère par caractère : « LANGAGE » n'est pas trouvé ;
  • ligne 16 : ces fonctions ont un paramètre facultatif [ignoreCase], qui demande d'ignorer la casse. Java n'en a pas l'équivalent : il faut y mettre les deux chaînes en minuscules ;
  • ligne 18 : l'opérateur [in] appelle la fonction [contains] (chapitre 4) : ["Kotlin" in titre] se lit naturellement « Kotlin est dans le titre » ;
  • lignes 19-21 : la fonction [regionMatches] compare une partie de la chaîne à une partie d'une autre chaîne. Ses paramètres 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 ; le dernier, facultatif, indique s'il faut ignorer la casse (en Java, ce paramètre vient en premier dans l'une des surcharges) ;
  • lignes 23-29 : 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). La variable [oui] 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. En Kotlin, l'opérateur [] compare les contenus : il appelle la fonction [equals], et [oui == "oui"] vaut [true] (ligne 8 des résultats). Pour comparer les références, c'est-à-dire savoir si deux variables désignent le même objet, Kotlin a un autre opérateur, [=], et [oui === "oui"] vaut [false] (ligne 9 des résultats). En Java, c'est l'inverse : [] y compare les références, et l'erreur classique qui consiste à écrire [reponse == "oui"] au lieu de [reponse.equals("oui")] n'existe pas en Kotlin. La comparaison sans tenir compte de la casse s'écrit [equals(..., ignoreCase = true)], ce qui est la bonne façon de tester une réponse « oui » saisie au clavier en majuscules ou en minuscules. On n'a pratiquement jamais besoin de [=] avec des chaînes ;
  • lignes 32-37 : la fonction [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. Comme [String] implémente [Comparable<String>], les opérateurs [<], [>], [<=], [>=] s'appliquent aux chaînes et appellent [compareTo] : [a < b] vaut [false]. Pour classer des mots selon les règles d'une langue, on utilise un objet [java.text.Collator] du JDK : pour le français, le é est classé avec le e, et « élève » précède « essai » (ligne 14 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…). La fonction [compareTo] a elle aussi un paramètre [ignoreCase] ;
  • lignes 40-48 : [isEmpty] teste une chaîne vide, [isBlank] une chaîne vide ou ne contenant que des espaces. Une chaîne peut aussi être absente : sa variable est alors de type [String?] et vaut [null] (chapitre 3). Ici, [System.getenv], méthode Java qui rend la valeur d'une variable d'environnement, rend [null] car la variable demandée n'existe pas. Le compilateur interdit d'appeler [absente.isBlank()], qui pourrait lancer une exception [NullPointerException]. La bibliothèque propose des fonctions d'extension sur le type [String?], qui acceptent [null] : [isNullOrBlank] (et [isNullOrEmpty]) font le test que Java écrit [absente == null || absente.isBlank()], et [orEmpty] rend la chaîne, ou la chaîne vide si elle est [null]. L'appel sûr [absente?.length] vaut [null] ;
  • ligne 51 : [padStart] complète une chaîne à gauche jusqu'à une longueur de 8 caractères, par des espaces ou par le caractère indiqué ([padStart(5, '0')]), et [padEnd] la complète à droite ; [repeat] répète une chaîne. On pourrait aussi utiliser les formats ["%8s"] et ["%-8s"] présentés ci-dessous.

5.2.4. Gabarits, formats et chaînes brutes

Depuis le chapitre 3, nous construisons les chaînes à afficher avec des gabarits de chaîne (string templates) : dans une chaîne, [$nom] est remplacé par la valeur de la variable [nom], et [${expression}] par la valeur d'une expression quelconque. C'est l'interpolation de chaînes que Java n'a pas (une proposition, les « String Templates », y a été essayée en aperçu dans Java 21 et 22, puis retirée) et que le C# écrit [$"{nom}"]. Le compilateur transforme un gabarit en concaténations : il n'y a aucun coût supplémentaire à l'exécution.

Pour aligner des valeurs en colonnes, fixer le nombre de décimales d'un nombre ou afficher une date, on utilise en plus des formats, avec la fonction [String.format] ou la fonction d'extension [format] appliquée au format lui-même, qui font la même chose (et appellent la méthode [String.format] de Java). Le format est un texte dans lequel des spécificateurs [%[-][largeur][.précision]conversion] indiquent où et comment insérer les paramètres suivants. Le programme [Chaines04.kt] en donne des exemples, ainsi que les chaînes brutes de Kotlin :

package chaines04

import java.text.NumberFormat
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale

// gabarits, formats, chaînes brutes
fun main() {
    val nom = "dupont"
    val age = 45
    val salaire = 2345.678
    val taux = 0.0525
    val date = LocalDateTime.of(2026, 10, 3, 14, 5, 9)

    // gabarits de chaîne : $variable ou ${expression}
    println("$nom a $age ans, et ${2026 - age} est son année de naissance")
    // %[-]largeur : largeur > 0 cadre à droite, le signe - cadre à gauche
    println(String.format("[%10s] [%-10s] [%5d]", nom, nom, age))
    println("[%12.2f] [%-12.1f] [%,.2f]".format(salaire, salaire, salaire))
    println("[%05d] [%X] [%,d] [%e]".format(age, age, 1234567, salaire))
    // if est une expression : il peut fournir un paramètre
    println("%s est %s".format(nom, if (age >= 18) "majeur" else "mineur"))
    // un gabarit affiche un Double avec sa fonction toString : point décimal, toutes les décimales
    println("salaire=$salaire, arrondi=${"%.2f".format(salaire)}, "
            + "en anglais=${"%.2f".format(Locale.US, salaire)}")

    // monnaie et pourcentage : la classe NumberFormat du JDK
    val monnaie = NumberFormat.getCurrencyInstance(Locale.FRANCE)
    val pourcentage = NumberFormat.getPercentInstance(Locale.FRANCE)
    pourcentage.minimumFractionDigits = 2
    println("[${monnaie.format(salaire)}] [${pourcentage.format(taux)}]")

    // dates : la classe DateTimeFormatter du JDK
    println("[${date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))}] "
            + "[${date.format(DateTimeFormatter.ofPattern("HH:mm:ss"))}] "
            + "[${date.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy"))}]")

    // un tableau aligné
    val produits = arrayOf("pomme", "poire", "abricot")
    val prix = doubleArrayOf(2.5, 3.15, 12.0)
    for (i in produits.indices) {
        println("%-10s|%8.2f €".format(produits[i], prix[i]))
    }

    // le caractère \ doit être doublé dans une chaîne ordinaire...
    val chemin = "C:\\data\\cours\\infos.txt"
    println(chemin)
    // ... mais pas dans une chaîne brute
    val chemin2 = """C:\data\cours\infos.txt"""
    println("chemin == chemin2 : ${chemin == chemin2}")

    // chaîne brute sur plusieurs lignes : trimIndent retire l'indentation commune
    val json = """
        {
          "nom": "$nom",
          "chemin": "C:\data",
          "prix": ${prix[0]},
          "gabarit": "${'$'}nom"
        }
        """.trimIndent()
    println(json)
    // trimMargin : chaque ligne commence, après les espaces, par le caractère |
    val texte = """
        |Nom : $nom
        |Âge : $age ans
        |Salaire : ${"%.2f €".format(salaire)}
        """.trimMargin()
    println(texte)
}

Résultats :

> gradlew -q run -Pmain=chaines04.Chaines04Kt
dupont a 45 ans, et 1981 est son année de naissance
[    dupont] [dupont    ] [   45]
[     2345,68] [2345,7      ] [2 345,68]
[00045] [2D] [1 234 567] [2,345678e+03]
dupont est majeur
salaire=2345.678, arrondi=2345,68, en anglais=2345.68
[2 345,68 €] [5,25 %]
[03/10/2026] [14:05:09] [samedi 3 octobre 2026]
pomme     |    2,50 €
poire     |    3,15 €
abricot   |   12,00 €
C:\data\cours\infos.txt
chemin == chemin2 : true
{
  "nom": "dupont",
  "chemin": "C:\data",
  "prix": 2.5,
  "gabarit": "$nom"
}
Nom : dupont
Âge : 45 ans
Salaire : 2345,68 €

Commentaires :

  • ligne 17 : un gabarit : [$nom] et [$age] sont remplacés par les valeurs des variables, et [${2026 - age}] par la valeur de l'expression. Les accolades sont nécessaires dès que ce qui suit le [$] n'est pas un simple nom de variable : [$personne.nom] insérerait la valeur de [personne] suivie du texte « .nom », alors que [${personne.nom}] insère la propriété [nom] ;
  • ligne 19 : une largeur cadre la valeur à droite dans un champ de 10 caractères ; précédée du signe [-], elle la cadre à gauche ;
  • lignes 19-21 : les conversions les plus courantes :
spécificateur
signification
exemple (fr-FR)
[%s]
chaîne ; tout objet est converti par sa fonction [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. Les types des paramètres sont vérifiés à l'exécution seulement : passer un [Int] à la conversion [%f] lance une exception [IllegalFormatConversionException] ;
  • ligne 23 : ["...".format(...)] est l'équivalent de [String.format("...", ...)] (et de la méthode [formatted] de Java). Comme [if] est une expression en Kotlin (chapitre 3), il peut fournir un paramètre, là où Java utilise l'opérateur conditionnel [? :] ;
  • ligne 25 : un gabarit ne met pas en forme les nombres : il appelle la fonction [toString] de la valeur, qui affiche un [Double] avec un point décimal et toutes ses décimales, quelle que soit la langue de la machine. Pour un affichage destiné à un utilisateur, on combine gabarit et format : [${"%.2f".format(salaire)}]. Une surcharge de [format] reçoit en premier paramètre la langue à utiliser : [Locale.US] donne un point décimal ;
  • lignes 29-32 : les montants monétaires et les pourcentages sont mis en forme par des objets [java.text.NumberFormat] du JDK, obtenus pour une langue et un pays donnés ([Locale.FRANCE]). Le format pourcentage multiplie la valeur par 100. On remarquera la ligne 31 : la méthode Java [setMinimumFractionDigits(2)] s'utilise en Kotlin comme l'affectation d'une propriété. Kotlin voit en effet les couples de méthodes Java [getXxx()]/[setXxx(...)] comme des propriétés [xxx] ;
  • lignes 34-37 : les dates de l'API [java.time] du JDK 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. Les gabarits peuvent contenir des expressions qui contiennent elles-mêmes des chaînes : ["[${date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))}] "] ;
  • lignes 40-43 : les largeurs permettent d'afficher des données en colonnes ;
  • ligne 47 : dans une chaîne ordinaire, le caractère [\] introduit une séquence d'échappement ([\n], [\t], [\"], [\$]…) ; pour obtenir le caractère [\] lui-même, il faut le doubler ;
  • ligne 50 : une chaîne brute (raw string) est délimitée par trois guillemets. Les séquences d'échappement n'y existent pas : le caractère [\] y est un caractère ordinaire. C'est la façon commode d'écrire les chemins Windows et les expressions régulières, que Java ne connaît pas (ses blocs de texte gardent les séquences d'échappement). On ne peut pas, en revanche, y écrire de saut de ligne [\n] : il faut un vrai passage à la ligne ;
  • lignes 54-61 : une chaîne brute peut s'étendre sur plusieurs lignes et contenir des guillemets sans échappement. Elle garde tout ce qui est écrit entre les guillemets, y compris la marge du code et les lignes vides du début et de la fin. La fonction [trimIndent] supprime la première et la dernière ligne si elles sont blanches, puis l'indentation commune à toutes les lignes (celle de la ligne la moins indentée) : le texte JSON s'affiche sans la marge du code et sans fin de ligne finale, donc avec [println]. C'est l'équivalent des blocs de texte de Java 15, mais réalisé par une fonction de la bibliothèque. Contrairement aux blocs de texte de Java, les gabarits fonctionnent dans les chaînes brutes : ["$nom"] et [${prix[0]]} y sont remplacés par leurs valeurs, ce qui évite le recours à [format] ; le [\] de ["C:\data"] n'a pas à être doublé ;
  • ligne 59 : comme les séquences d'échappement n'existent pas dans une chaîne brute, on ne peut pas y écrire [\$] pour obtenir un [$] suivi d'un nom sans qu'il soit pris pour un gabarit : on écrit [${'$'}], gabarit dont l'expression est le caractère [$]. Depuis Kotlin 2.2, on peut aussi préfixer la chaîne de plusieurs [$] ([$$"""..."""]) : seules les suites de deux [$] introduisent alors un gabarit, ce qui est commode pour écrire des textes qui contiennent beaucoup de [$] ;
  • lignes 64-68 : la fonction [trimMargin] offre une autre façon d'indiquer la marge : chaque ligne commence, après d'éventuels espaces, par un caractère de marge, [|] par défaut, et tout ce qui précède ce caractère, caractère compris, est supprimé. On choisit la fonction la plus lisible : [trimIndent] pour un texte copié tel quel, [trimMargin] lorsque la première colonne du texte doit être bien visible.

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 ; elle s'utilise en Kotlin exactement comme en Java.

5.2.5. La classe StringBuilder

Nous avons dit que les fonctions du type [String] qui s'appliquent à une chaîne [s1] rendent une autre chaîne [s2]. La classe [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. En Kotlin pour la JVM, [kotlin.text.StringBuilder] est un alias de type ([typealias]) de la classe [java.lang.StringBuilder] du JDK : c'est la même classe, que la bibliothèque standard enrichit de quelques fonctions d'extension ([appendLine], l'opérateur [[]]…). Elle s'utilise sans [import].

La classe admet divers constructeurs :

constructeur
rôle
[StringBuilder()]
construction d'un objet vide, de capacité initiale 16 caractères
[StringBuilder(str: String)]
construction et initialisation avec [str] ; la capacité initiale est la longueur de [str] plus 16
[StringBuilder(capacity: Int)]
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.kt] illustre cette notion de capacité :

package chaines05

// taille et capacité d'un StringBuilder
fun main() {
    val str = StringBuilder("test")
    println("taille=${str.length}, capacité=${str.capacity()}")
    repeat(10) {
        str.append("test")
        println("taille=${str.length}, capacité=${str.capacity()}")
    }
    // un StringBuilder de capacité initiale 10
    val str2 = StringBuilder(10).append("test")
    println("taille=${str2.length}, capacité=${str2.capacity()}")
    repeat(10) {
        str2.append("test")
        println("taille=${str2.length}, capacité=${str2.capacity()}")
    }
}

Résultats :

> gradlew -q run -Pmain=chaines05.Chaines05Kt
taille=4, capacité=20
taille=8, capacité=20
taille=12, capacité=20
taille=16, capacité=20
taille=20, capacité=20
taille=24, capacité=42
taille=28, capacité=42
taille=32, capacité=42
taille=36, capacité=42
taille=40, capacité=42
taille=44, capacité=86
taille=4, capacité=10
taille=8, capacité=10
taille=12, capacité=22
taille=16, capacité=22
taille=20, capacité=22
taille=24, capacité=46
taille=28, capacité=46
taille=32, capacité=46
taille=36, capacité=46
taille=40, capacité=46
taille=44, capacité=46

Commentaires :

  • ligne 5 : création d'un objet [StringBuilder] contenant « test » : sa capacité initiale est de 4 + 16 = 20 caractères. Comme pour toute classe en Kotlin, il n'y a pas d'opérateur [new] ;
  • ligne 6 : [length] est le nombre actuel de caractères. Kotlin le présente comme une propriété (sans parenthèses), comme pour [String] : la méthode Java [length()] de l'interface [CharSequence] est vue en Kotlin comme la propriété [length]. [capacity()], qui ne fait pas partie de cette interface et ne suit pas la convention [getXxx()], reste une méthode : c'est le nombre de caractères que l'objet peut contenir avant de devoir agrandir son tableau ;
  • ligne 7 : la fonction [repeat] de la bibliothèque standard exécute 10 fois la lambda qui la suit (chapitre 3) ;
  • ligne 8 : [append] ajoute une chaîne à la fin du texte ;
  • ligne 12 : 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, identiques à ceux du programme Java puisque c'est la même classe, 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 fonctions de la classe :

fonction
rôle
[append(str: String?): StringBuilder]
ajoute [str] à la fin du texte. Surchargée pour accepter les types [Int], [Double], [Char], [Boolean], [Any?]…
[appendLine(value)]
extension Kotlin : ajoute [value] puis une fin de ligne [\n] ; sans paramètre, ajoute seulement la fin de ligne
[insert(offset: Int, str: String?)]
insère [str] à la position [offset] ; surchargée comme [append]
[delete(start: Int, end: Int)]
supprime les caractères des positions [start] (incluse) à [end] (exclue) ; [deleteCharAt] supprime un caractère
[replace(start: Int, end: Int, str: String)]
remplace les caractères des positions [start] (incluse) à [end] (exclue) par [str]
[reverse()]
inverse l'ordre des caractères
[sb[index]], [sb[index] = ch]
lit, remplace le caractère n° [index] (opérateurs [get] et [set] ; [set] est une extension Kotlin de la méthode Java [setCharAt])
[indexOf(str: String)]
position de [str] dans le texte, ou -1
[toString()]
rend le texte sous la forme d'une chaîne [String]

Le programme [Chaines06.kt] les utilise, ainsi que la fonction [buildString], qui est la façon idiomatique de construire une chaîne en Kotlin :

package chaines06

// les fonctions de StringBuilder rendent l'objet StringBuilder lui-même : on peut les chaîner
fun main() {
    val str3 = StringBuilder("test")
    println(str3.append("abCD").insert(2, "xyZT").delete(0, 2).replace(0, 2, "XY"))
    // l'opérateur [] lit et modifie un caractère
    str3[0] = 'x'
    println("str3[0]=${str3[0]}, str3=$str3")
    // reverse inverse l'ordre des caractères (String a aussi la fonction reversed)
    println(StringBuilder("élève").reverse())

    // construire une chaîne par morceaux
    val lignes = StringBuilder()
    for (i in 1..3) {
        lignes.append("ligne ").append(i).append('\n')
    }
    // joinTo ajoute des éléments séparés par une virgule
    listOf("a", "b", "c").joinTo(lignes, ", ")
    lignes.appendLine()
    // une chaîne formatée
    val total = 42
    lignes.append("total=%6.1f".format(total.toDouble()))
    // la chaîne finale
    val resultat = lignes.toString()
    println(resultat)
    println("${resultat.length} caractères")

    // buildString : le StringBuilder est créé, rempli par la lambda, puis converti en String
    val tableau = buildString {
        appendLine("+-------+-------+")
        for (i in 1..3) {
            appendLine("| ${i.toString().padStart(5)} | ${(i * i).toString().padStart(5)} |")
        }
        append("+-------+-------+")
    }
    println(tableau)

    // séparateur, préfixe et suffixe : joinToString (ou StringJoiner de Java)
    println(listOf("rouge", "vert", "bleu").joinToString(", ", "[", "]"))
}

Résultats :

> gradlew -q run -Pmain=chaines06.Chaines06Kt
XYZTstabCD
str3[0]=x, str3=xYZTstabCD
evèlé
ligne 1
ligne 2
ligne 3
a, b, c
total=  42,0
44 caractères
+-------+-------+
|     1 |     1 |
|     2 |     4 |
|     3 |     9 |
+-------+-------+
[rouge, vert, bleu]

Commentaires :

  • ligne 6 : 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 fonction [replace] de [String], celle de [StringBuilder] ne recherche pas un texte : elle remplace une plage de positions. [println] affiche l'objet en appelant sa fonction [toString] ;
  • lignes 8-9 : l'opérateur [[]] lit et modifie un caractère, comme dans un tableau ; [str3[0] = 'x'] appelle [str3.setCharAt(0, 'x')]. Dans le gabarit, [$str3] appelle [toString] ;
  • ligne 11 : [reverse] inverse le texte. Pour une chaîne [String], la fonction [reversed], vue dans [Chaines01.kt], est plus simple ;
  • lignes 14-16 : 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 ;
  • lignes 19-20 : la fonction [joinTo] est la sœur de [joinToString] : au lieu de créer une nouvelle chaîne, elle ajoute les éléments séparés par le séparateur à un [StringBuilder] existant (plus exactement à tout objet [Appendable]) ; [appendLine] ajoute ensuite une fin de ligne ;
  • ligne 23 : 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 [total.toDouble()] ;
  • ligne 25 : le texte final est obtenu sous la forme d'une chaîne [String] ;
  • lignes 30-35 : la fonction [buildString] crée un [StringBuilder], exécute la lambda qui la suit, puis rend le texte construit sous la forme d'une [String]. Dans la lambda, le [StringBuilder] est le receveur implicite ([this]) : on écrit [appendLine(...)] et non [sb.appendLine(...)]. Ce type de lambda, appelé lambda avec receveur, est étudié au chapitre 7 ; il permet d'écrire des « blocs de construction » très lisibles. La variable [tableau] est un [String] immuable, déclaré par [val], et le [StringBuilder] intermédiaire n'est même pas visible. On remarquera aussi comment [padStart] aligne des nombres à droite dans les colonnes ;
  • ligne 40 : pour une suite d'éléments séparés par un séparateur, avec un préfixe et un suffixe, [joinToString] rend le service de la classe [java.util.StringJoiner] de Java, qui reste utilisable mais n'a guère d'intérêt en Kotlin.

Pour quelques concaténations, l'opérateur [+] et les gabarits suffisent : le compilateur les optimise. [StringBuilder] (ou [buildString]) s'impose lorsque le nombre d'ajouts est grand ou inconnu, typiquement dans une boucle : la concaténation [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 Java 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] ou de [split] crée de nouvelles chaînes 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. Ni Kotlin ni Java n'ont de type « vue sur une partie de chaîne » intégré au langage, mais plusieurs fonctions travaillent sur une partie d'une chaîne sans la copier. Le programme [Chaines07.kt] compare plusieurs façons d'analyser une ligne « nom:prénom:âge » :

package chaines07

import java.nio.CharBuffer

// analyser une chaîne sans créer de sous-chaînes
fun main() {
    val ligne = "dupont:jean:45"

    // la façon idiomatique : split et déstructuration ; chaque champ est une nouvelle chaîne
    val (nom1, prenom1, age1) = ligne.split(':')
    println("split : nom=$nom1, prénom=$prenom1, âge=${age1.toInt()}")

    // avec substring : chaque champ est aussi une nouvelle chaîne allouée en mémoire
    val p1 = ligne.indexOf(':')
    val p2 = ligne.lastIndexOf(':')
    val nom = ligne.substring(0, p1)
    var age = ligne.substring(p2 + 1).toInt()
    println("substring : nom=$nom, âge=$age")

    // Integer.parseInt du JDK sur une partie de la chaîne : pas de chaîne intermédiaire
    age = Integer.parseInt(ligne, p2 + 1, ligne.length, 10)
    // regionMatches compare une partie de la chaîne à une autre chaîne, sans copie
    val du = ligne.regionMatches(0, "du", 0, 2)
    println("parseInt(ligne, début, fin, base) : âge=$age, le nom commence par \"du\" : $du")

    // une vue sur une partie de la chaîne : CharBuffer.wrap ne copie pas les caractères
    val vueNom: CharSequence = CharBuffer.wrap(ligne, 0, p1)
    println("vue : nom=$vueNom, longueur de la vue=${vueNom.length}, vueNom[0]=${vueNom[0]}")
    // les fonctions de Kotlin acceptent un CharSequence
    val nbP = vueNom.count { it == 'p' }
    println("vueNom.startsWith(\"dup\")=${vueNom.startsWith("dup")}, nombre de 'p' dans la vue=$nbP")

    // indexOf à partir d'une position : le ':' cherché après p1
    println("indexOf(':', p1 + 1)=${ligne.indexOf(':', p1 + 1)}")

    // on ne crée une chaîne que si on en a vraiment besoin
    val prenom = ligne.substring(p1 + 1, p2)
    println("prénom=$prenom")
}

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=chaines07.Chaines07Kt
split : nom=dupont, prénom=jean, âge=45
substring : nom=dupont, âge=45
parseInt(ligne, début, fin, base) : âge=45, le nom commence par "du" : true
vue : nom=dupont, longueur de la vue=6, vueNom[0]=d
vueNom.startsWith("dup")=true, nombre de 'p' dans la vue=1
indexOf(':', p1 + 1)=11
prénom=jean

Commentaires :

  • ligne 10 : la façon la plus lisible, et la plus courante en Kotlin : [split] rend une liste de trois chaînes, que la déstructuration (chapitre 3) range dans trois variables ; [toInt] convertit ensuite l'âge en entier. Mais elle crée une liste et trois chaînes ;
  • lignes 16-17 : avec [substring], chaque appel crée une nouvelle chaîne, même si, comme pour l'âge, elle n'est utilisée qu'un instant ;
  • ligne 21 : la fonction [toInt] de Kotlin ne s'applique qu'à une chaîne entière ; mais la méthode statique [Integer.parseInt] du JDK a une surcharge (Java 9) qui 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. Un programme Kotlin peut l'appeler directement : c'est tout l'intérêt de l'interopérabilité avec Java ;
  • ligne 23 : [regionMatches], déjà rencontrée, compare une partie de la chaîne à une autre chaîne sans copie ;
  • ligne 27 : [CharBuffer.wrap] (paquetage [java.nio] du JDK) 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]. On notera que la fonction [subSequence] de [String], membre de cette interface, n'est pas une vue : pour une chaîne, elle fait comme [substring] une copie des caractères (elle rend une vue pour un [CharBuffer]). Kotlin voit la méthode Java [charAt] de cette interface comme l'opérateur [[]], et sa méthode [length()] comme la propriété [length] ;
  • lignes 30-31 : une grande partie des fonctions d'extension de [kotlin.text] sont définies non pas sur [String], mais sur [CharSequence] : [startsWith], [count], [contains], [indexOf], [split], [lines]… s'appliquent donc aussi à une vue, ou à un [StringBuilder], sans conversion préalable en [String]. Les expressions régulières, que nous verrons à la fin du chapitre, acceptent elles aussi un [CharSequence] ;
  • ligne 34 : le second paramètre de [indexOf] est la position à partir de laquelle on cherche ;
  • ligne 37 : 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, [split] et [substring] conviennent parfaitement. 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 », Java 9), ce qui divise par deux la mémoire qu'elles occupent ; les chaînes Kotlin, qui sont des chaînes Java, en profitent. 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. Les fonctions d'extension des tableaux

Nous avons vu au chapitre 3 que Kotlin a deux sortes de tableaux : les tableaux d'objets [Array<T>] (créés par [arrayOf]), et les tableaux de types de base [IntArray], [DoubleArray], [CharArray]… (créés par [intArrayOf], [doubleArrayOf]…), compilés en tableaux de types primitifs de Java ([int[]], [double[]]…). En Java, les tableaux n'ont pas de méthodes utiles : en dehors du champ [length], toutes les opérations sont des méthodes statiques de la classe utilitaire [java.util.Arrays]. En Kotlin, les tableaux ont une propriété [size], les opérateurs [[]], et surtout des dizaines de fonctions d'extension de la bibliothèque standard (paquetage [kotlin.collections]), qui s'appellent comme des méthodes : [tableau.sort()] au lieu de [Arrays.sort(tableau)]. Elles permettent de trier un tableau, d'y rechercher un élément, de le copier, de le remplir… Elles existent pour chaque sorte de tableau ([IntArray], [DoubleArray]…, [Array<T>]) et appellent le plus souvent, sur la JVM, les méthodes de la classe [Arrays].

fonction
rôle
[binarySearch(element, fromIndex = 0, toIndex = size)]
rend la position de [element] dans le tableau, qui doit être trié ; un nombre négatif si [element] est absent
[copyOf(newSize: Int)]
rend un nouveau tableau de [newSize] éléments contenant les premiers éléments du tableau, complété si besoin par des 0 ([false] pour les booléens) ; pour un [Array<T>], le résultat est un [Array<T?>] complété par des [null]. Sans paramètre, rend une copie de même taille
[copyOfRange(fromIndex: Int, toIndex: Int)]
rend un nouveau tableau formé des éléments de [fromIndex] (inclus) à [toIndex] (exclu) ; [sliceArray(range)] fait de même avec un intervalle
[copyInto(destination, destinationOffset = 0, startIndex = 0, endIndex = size)]
copie des éléments du tableau dans un autre tableau existant (équivalent de [System.arraycopy])
[contentEquals(other)]
rend [true] si les deux tableaux ont les mêmes éléments, dans le même ordre
[fill(element, fromIndex = 0, toIndex = size)]
affecte [element] à tous les éléments du tableau, ou à une plage d'indices
[sort()], [sort(fromIndex, toIndex)]
trie le tableau, ou une plage d'indices, selon l'ordre naturel de ses éléments ; [sortWith(comparator)] trie un tableau d'objets selon un [Comparator] (chapitre 4) ; [sortDescending()] trie en ordre décroissant
[sorted()], [sortedWith(comparator)]
rend une liste triée, sans modifier le tableau
[reverse()], [reversed()]
inverse le tableau ; rend une liste inversée
[contentToString()]
rend les éléments du tableau sous la forme d'une chaîne [[e1, e2, ...]] ; [contentDeepToString] fait de même pour un tableau de tableaux
[indexOf(element)], [lastIndexOf(element)], [contains(element)]
recherche linéaire : première ou dernière position de [element], ou -1 ; présence de [element] ([element in tableau])
[asList()]
rend une liste qui est une vue sur le tableau
[toList()], [toMutableList()]
rend une nouvelle liste, en lecture seule ou modifiable, contenant les éléments du tableau
[plus(element)]
rend un nouveau tableau, plus grand d'une case, formé des éléments du tableau suivis de [element] ; s'écrit [tableau + element]

S'y ajoutent les fonctions communes aux tableaux et aux collections ([sum], [max], [count], [filter], [map], [joinToString]…) que nous verrons plus loin.

Le programme [Tableaux01.kt] illustre l'utilisation de certaines de ces fonctions :

package tableaux01

// type de recherche
enum class TypeRecherche { LINEAIRE, DICHOTOMIQUE }

// les fonctions d'extension des tableaux : agrandir, trier, rechercher
fun main() {
    // lecture des éléments d'un tableau tapés au clavier
    val elements = saisie()
    // affichage du tableau non trié
    affiche("Tableau non trié", elements)
    // recherche linéaire dans le tableau non trié
    recherche(elements, TypeRecherche.LINEAIRE)
    // tri du tableau
    elements.sort()
    // affichage du tableau trié
    affiche("Tableau trié", elements)
    // recherche dichotomique dans le tableau trié
    recherche(elements, TypeRecherche.DICHOTOMIQUE)
}

// saisie des valeurs du tableau : rend le tableau créé
fun saisie(): DoubleArray {
    // au départ, le tableau est vide
    var elements = doubleArrayOf()
    // boucle de saisie des éléments du tableau
    while (true) {
        val reponse = lire("Elément (réel) ${elements.size} du tableau (rien pour terminer) : ")
        // fin de saisie si chaîne vide
        if (reponse.isEmpty()) break
        // vérification de la saisie : null si la chaîne n'est pas un nombre
        val element = reponse.toDoubleOrNull()
        if (element == null) {
            println("Saisie incorrecte, recommencez")
            continue
        }
        // un élément de plus : on crée un nouveau tableau, plus grand d'une case, qui contient l'élément
        elements += element
    }
    return elements
}

// affichage des éléments d'un tableau
fun affiche(texte: String, elements: DoubleArray) {
    println(texte + "-".repeat(50 - texte.length))
    for (element in elements) {
        println(element)
    }
}

// recherche d'éléments tapés au clavier dans le tableau
fun recherche(elements: DoubleArray, type: TypeRecherche) {
    while (true) {
        val reponse = lire("Elément cherché (rien pour arrêter) : ")
        // fini ?
        if (reponse.isEmpty()) break
        // vérification
        val element = reponse.toDoubleOrNull()
        if (element == null) {
            println("Erreur, recommencez...")
            continue
        }
        // on cherche l'élément dans le tableau
        val i = when (type) {
            TypeRecherche.DICHOTOMIQUE -> elements.binarySearch(element)  // tableau trié
            TypeRecherche.LINEAIRE -> elements.indexOfFirst { it == element }  // tableau quelconque
        }
        // affichage de la réponse
        println(if (i >= 0) "Trouvé en position $i" else "Pas dans le tableau ($i)")
    }
}

// lecture d'une ligne au clavier, sans les espaces qui l'entourent ; "" en fin de flux
fun lire(invite: String): String {
    print(invite)
    return readlnOrNull()?.trim() ?: ""
}

Commentaires :

  • ligne 4 : une énumération (chapitre 3), déclarée au niveau du fichier ;
  • lignes 23-40 : la fonction [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 25) et on l'agrandit d'une case à chaque nouvel élément (ligne 38). La taille d'un tableau est fixée à sa création : l'opérateur [+] ([plus]) crée en fait un nouveau tableau, plus grand d'une case, y recopie les éléments de l'ancien et y range le nouvel élément ; [elements += element] signifie ici [elements = elements + element], d'où la déclaration de [elements] par [var]. C'est l'équivalent, en une instruction, des deux instructions Java [elements = Arrays.copyOf(elements, elements.length + 1)] et [elements[elements.length - 1] = element]. 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 modifiable, comme nous le verrons bientôt ;
  • lignes 32-35 : la conversion de la chaîne saisie en nombre réel par [toDoubleOrNull], qui rend [null] si la chaîne n'est pas un nombre (chapitre 3). Après le test [element == null] suivi de [continue], le compilateur sait que [element] n'est pas [null] : son type devient [Double] (transtypage intelligent), et on peut l'ajouter au tableau. [toDoubleOrNull] ne dépend pas de la langue de la machine : elle attend toujours un point décimal, et refuse la saisie « 3,6 » (ligne 4 des résultats) ;
  • lignes 52-69 : la fonction [recherche] cherche dans le tableau des éléments tapés au clavier. Le [when] (ligne 64) choisit la recherche selon le type demandé ; comme il porte sur une énumération dont il traite toutes les valeurs, il n'a pas besoin de branche [else]. Pour un tableau non trié, on fait une recherche linéaire, qui examine les éléments un par un. La bibliothèque standard n'a pas de fonction [indexOf] pour les tableaux de réels [DoubleArray] : elle a été retirée, car la comparaison de réels par égalité pose problème pour les valeurs particulières [NaN] et [-0.0] ; [elements.indexOf(element)] est refusé par le compilateur ([unresolved reference 'indexOf']). On utilise donc [indexOfFirst], qui rend la position du premier élément vérifiant la condition exprimée par une lambda, ou -1 ; il n'y a pas, comme en Java, à écrire la fonction de recherche. Pour un tableau trié, on fait une recherche dichotomique avec [binarySearch], qui divise à chaque étape par deux la zone de recherche : elle trouve un élément parmi un million en une vingtaine de comparaisons ;
  • ligne 15 : on trie le tableau, ici selon l'ordre naturel des nombres. [elements] est déclaré par [val] dans [main] : la variable désigne toujours le même tableau, mais le contenu de celui-ci peut être modifié. [val] rend la référence constante, pas l'objet ;
  • lignes 44-47 : l'affichage d'un tableau. [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 74-76 : [print] affiche l'invite sans passer à la ligne, puis [readlnOrNull] 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. L'appel sûr [?.trim()] et l'opérateur Elvis [?: ""] rendent alors la chaîne vide.

Résultats :

> gradlew -q run -Pmain=tableaux01.Tableaux01Kt
Elément (réel) 0 du tableau (rien pour terminer) : 3.6
Elément (réel) 1 du tableau (rien pour terminer) : 7.4
Elément (réel) 2 du tableau (rien pour terminer) : -1.5
Elément (réel) 3 du tableau (rien pour terminer) : 3,6
Saisie incorrecte, recommencez
Elément (réel) 3 du tableau (rien pour terminer) : -7
Elément (réel) 4 du tableau (rien pour terminer) :
Tableau non trié----------------------------------
3.6
7.4
-1.5
-7.0
Elément cherché (rien pour arrêter) : 7.4
Trouvé en position 1
Elément cherché (rien pour arrêter) : 0
Pas dans le tableau (-1)
Elément cherché (rien pour arrêter) :
Tableau trié--------------------------------------
-7.0
-1.5
3.6
7.4
Elément cherché (rien pour arrêter) : 7.4
Trouvé en position 3
Elément cherché (rien pour arrêter) : 0
Pas dans le tableau (-3)
Elément cherché (rien pour arrêter) :

Lorsque l'élément cherché est absent, [indexOfFirst] rend -1 et [binarySearch] un nombre négatif (dernière recherche) : 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 fonctions des tableaux

Le programme [Tableaux02.kt] présente d'autres fonctions d'extension des tableaux :

package tableaux02

import java.text.Collator
import java.util.Locale

// d'autres fonctions d'extension des tableaux
fun main() {
    val nombres = intArrayOf(40, 10, 30, 20, 50)
    affiche("nombres", nombres.contentToString())

    // sort sur une partie du tableau : les éléments d'indices 1 à 3 (4 exclu)
    nombres.sort(1, 4)
    affiche("après sort(1, 4)", nombres.contentToString())

    // copie de 3 éléments dans un autre tableau
    val copie = IntArray(5)
    nombres.copyInto(copie, destinationOffset = 0, startIndex = 0, endIndex = 3)
    affiche("copyInto", copie.contentToString())
    // copyOf, copyOfRange : la copie est un nouveau tableau, raccourci ou complété par des 0
    affiche("copyOf(7)", nombres.copyOf(7).contentToString())
    affiche("copyOfRange(1, 3)", nombres.copyOfRange(1, 3).contentToString())

    // fill : remplit le tableau, ou une partie du tableau, avec une valeur
    copie.fill(7)
    affiche("après fill(7)", copie.contentToString())
    copie.fill(0, 1, 3)
    affiche("après fill(0, 1, 3)", copie.contentToString())

    // égalité de deux tableaux : === compare les références, contentEquals les contenus
    val autre = intArrayOf(7, 0, 0, 7, 7)
    println("copie === autre : ${copie === autre}, copie contentEquals autre : ${copie contentEquals autre}")

    // les tableaux ont indexOf, lastIndexOf, reverse...
    val couleurs = arrayOf("rouge", "vert", "bleu", "vert")
    println("indexOf(vert)=${couleurs.indexOf("vert")}, lastIndexOf(vert)=${couleurs.lastIndexOf("vert")}"
            + ", indexOf(noir)=${couleurs.indexOf("noir")}, \"bleu\" in couleurs=${"bleu" in couleurs}")
    // ... et asList : une liste qui est une vue sur le tableau
    val vue: List<String> = couleurs.asList()
    couleurs.reverse()
    affiche("après reverse, vue", vue.toString())
    // toList : une liste qui est une copie du tableau
    val copieListe = couleurs.toList()
    couleurs[0] = "noir"
    affiche("après couleurs[0] = noir", "vue=$vue, copieListe=$copieListe")

    // tri d'un tableau de chaînes : ordre des codes des caractères
    val mots = arrayOf("essai", "Zoé", "élève", "abricot")
    // sorted rend une liste triée, sans modifier le tableau
    affiche("mots.sorted()", mots.sorted().toString())
    // tri selon les règles de la langue française
    mots.sortWith(Collator.getInstance(Locale.FRANCE))
    affiche("mots triés (Collator)", mots.contentToString())

    // quelques calculs sur le tableau
    println("sum=${nombres.sum()}, average=${nombres.average()}, max=${nombres.max()}, "
            + "count { it > 25 }=${nombres.count { it > 25 }}")

    // un tableau à deux dimensions est un tableau de tableaux
    val matrice = Array(2) { IntArray(3) }
    matrice[1][2] = 5
    println("matrice.size=${matrice.size}, matrice[0].size=${matrice[0].size}, "
            + "contentDeepToString=${matrice.contentDeepToString()}")
    // un tableau initialisé par une lambda qui reçoit l'indice
    val carres = IntArray(5) { i -> i * i }
    affiche("IntArray(5) { i -> i * i }", carres.contentToString())
}

// affichage d'un texte et d'un tableau sur une ligne
fun affiche(texte: String, tableau: String) {
    println("%-28s: %s".format(texte, tableau))
}

Résultats :

> gradlew -q run -Pmain=tableaux02.Tableaux02Kt
nombres                     : [40, 10, 30, 20, 50]
après sort(1, 4)            : [40, 10, 20, 30, 50]
copyInto                    : [40, 10, 20, 0, 0]
copyOf(7)                   : [40, 10, 20, 30, 50, 0, 0]
copyOfRange(1, 3)           : [10, 20]
après fill(7)               : [7, 7, 7, 7, 7]
après fill(0, 1, 3)         : [7, 0, 0, 7, 7]
copie === autre : false, copie contentEquals autre : true
indexOf(vert)=1, lastIndexOf(vert)=3, indexOf(noir)=-1, "bleu" in couleurs=true
après reverse, vue          : [vert, bleu, vert, rouge]
après couleurs[0] = noir    : vue=[noir, bleu, vert, rouge], copieListe=[vert, bleu, vert, rouge]
mots.sorted()               : [Zoé, abricot, essai, élève]
mots triés (Collator)       : [abricot, élève, essai, Zoé]
sum=150, average=30.0, max=50, count { it > 25 }=3
matrice.size=2, matrice[0].size=3, contentDeepToString=[[0, 0, 0], [0, 0, 5]]
IntArray(5) { i -> i * i }  : [0, 1, 4, 9, 16]

Commentaires :

  • ligne 9 : [contentToString] permet d'afficher un tableau sur une ligne. Afficher directement un tableau avec [println(nombres)] ou ["$nombres"] donnerait un texte comme [[I@1b6d3586]] : le type ([[I]], tableau d'entiers de la JVM) et un identifiant de l'objet, sans ses éléments. Les tableaux Kotlin sont des tableaux Java, et en ont le [toString] ;
  • ligne 12 : on ne trie que les éléments d'indices 1 à 3, le second indice étant exclu : 10, 30, 20 deviennent 10, 20, 30 ;
  • lignes 16-17 : [IntArray(5)] crée un tableau de 5 entiers nuls. [copyInto] copie les 3 premiers éléments de [nombres] au début de [copie], dont les autres éléments gardent leur valeur initiale 0. Ses paramètres ont des valeurs par défaut et sont ici nommés, ce qui rend l'appel bien plus lisible que celui de [System.arraycopy(nombres, 0, copie, 0, 3)] en Java ;
  • lignes 20-21 : [copyOf] et [copyOfRange] créent un nouveau tableau. Un tableau plus long que l'original est complété par des 0 ;
  • lignes 24-26 : [fill] remplit le tableau, puis remet à 0 les éléments d'indices 1 et 2 (3 exclu). Attention à l'ordre des paramètres, différent de celui de Java : la valeur vient en premier, puis la plage d'indices ;
  • lignes 30-31 : pour les tableaux, et contrairement aux chaînes et aux collections, l'opérateur [] compare les références : les tableaux n'ont pas de fonction [equals] qui compare les contenus. Le compilateur le sait et signale l'erreur probable par un avertissement si l'on écrit [copie == autre] : **[warning: '' on arrays compares only references. Replace '' with 'contentEquals' to compare the arrays' contents or use '=' to remove the warning.] (nos projets traitant les avertissements comme des erreurs, la compilation échoue). On écrit donc [===] pour comparer les références, et [contentEquals] pour comparer les contenus. [contentEquals] est ici appelée en notation infixe* ([copie contentEquals autre]*), sans point ni parenthèses, ce que permet le modificateur [infix] de sa déclaration (chapitre 4) ;
  • lignes 34-36 : les tableaux d'objets ont directement les fonctions de recherche [indexOf] et [lastIndexOf], et l'opérateur [in]. En Java, il faut passer par [Arrays.asList] pour en disposer ;
  • lignes 38-40 : [asList] rend une [List] (présentée dans la section suivante) qui est une vue sur le tableau : elle ne copie pas le tableau mais y donne accès, avec les fonctions des listes. La fonction [reverse] inverse le tableau lui-même ; la liste étant une vue sur le tableau, elle voit ses éléments inversés (ligne 10 des résultats). Contrairement à Java, [reverse] existe pour toutes les sortes de tableaux, y compris [IntArray] ;
  • lignes 42-44 : [toList] rend au contraire une copie des éléments du tableau sous la forme d'une liste. Après la modification de l'élément n° 0 du tableau, la vue voit le changement, la copie non (ligne 11 des résultats). Ce point est important : une vue est économique mais reflète toutes les modifications de l'objet d'origine ; une copie lui est indépendante ;
  • ligne 49 : un tableau de chaînes est trié selon l'ordre de la fonction [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. [sorted] rend une liste triée et laisse le tableau intact ;
  • ligne 51 : pour trier selon les règles du français, on passe à [sortWith] un objet [Collator], qui est un [Comparator] de chaînes ; le tableau lui-même est trié ;
  • lignes 55-56 : les tableaux ont aussi les fonctions de calcul communes avec les collections : somme, moyenne, maximum, nombre d'éléments vérifiant une condition… Nous les retrouverons avec les collections ;
  • lignes 59-62 : Kotlin, comme Java, n'a pas de tableaux rectangulaires à deux dimensions : un tableau de tableaux se crée par [Array(2) { IntArray(3) }], tableau de 2 éléments dont chacun est créé par la lambda : un tableau de 3 entiers. [matrice.size] est le nombre de lignes, [matrice[0].size] le nombre d'éléments de la ligne 0. [contentDeepToString] affiche un tableau de tableaux ;
  • ligne 64 : plus généralement, les constructeurs [Array], [IntArray]… reçoivent une taille et une lambda qui calcule chaque élément à partir de son indice : ici, les carrés des entiers de 0 à 4.

Il faut retenir qu'en Kotlin les tableaux sont surtout utilisés pour leurs performances (tableaux de types primitifs), pour les arguments [vararg] et pour dialoguer avec les bibliothèques Java. Pour tout le reste, on leur préfère les collections, et d'abord les listes, qui font l'objet de la section suivante.

5.4. Les collections

Outre le tableau, Kotlin propose diverses collections pour stocker des ensembles d'éléments. Sur la JVM, Kotlin n'a pas réécrit de classes de collections : il utilise celles du framework des collections de Java (paquetage [java.util]), [ArrayList], [HashMap], [LinkedHashMap], [HashSet]…, rendues disponibles sans [import] sous des alias de type du paquetage [kotlin.collections]. En revanche, il les présente à travers ses propres interfaces, organisées autour d'une distinction que Java ne fait pas dans ses types : les collections en lecture seule et les collections modifiables.

interface
rôle
implémentations et fonctions de création
[List<E>]
[MutableList<E>]
une suite ordonnée d'éléments, accessibles par leur position
[ArrayList] ; [listOf], [mutableListOf], [arrayListOf], [buildList], [emptyList]
[Set<E>]
[MutableSet<E>]
un ensemble : pas de doublons
[LinkedHashSet], [HashSet], [java.util.TreeSet] ; [setOf], [mutableSetOf], [hashSetOf], [sortedSetOf]
[Map<K, V>]
[MutableMap<K, V>]
un dictionnaire : des couples (clé, valeur), les clés étant uniques
[LinkedHashMap], [HashMap], [java.util.TreeMap] ; [mapOf], [mutableMapOf], [hashMapOf], [sortedMapOf]
[ArrayDeque<E>]
une file à double entrée, utilisable comme file ou comme pile (classe propre à Kotlin)
[ArrayDeque]

Les interfaces [List] et [Set] dérivent de l'interface [Collection<E>] ([size], [isEmpty], [contains]…), qui dérive elle-même de [Iterable<E>], l'interface des objets que l'on peut parcourir avec une boucle [for]. Ces interfaces n'ont que des fonctions de lecture : une [List<String>] n'a pas de fonction [add] ni [remove]. Les fonctions de modification ([add], [remove], [clear], [[i] = valeur]…) sont déclarées par les interfaces dérivées [MutableIterable], [MutableCollection], [MutableList], [MutableSet] et [MutableMap]. Une [ArrayList] implémente [MutableList], donc aussi [List]. La règle des [val] et des [var] s'applique ainsi aux collections : on choisit le type en lecture seule chaque fois qu'on n'a pas besoin de modifier la collection, par exemple pour les paramètres des fonctions, et le compilateur refuse toute modification au travers de ce type.

Sur la JVM, ces interfaces n'existent qu'à la compilation : [List] et [MutableList] sont toutes deux compilées en l'interface [java.util.List]. C'est ce qui permet de passer une liste Kotlin à une méthode Java, et inversement, sans conversion. Une collection reçue d'une méthode Java a pour Kotlin un type de plate-forme, noté [(Mutable)List<String!>] dans les messages du compilateur : Kotlin ne sait pas si elle est modifiable, ni si ses éléments peuvent être [null], et laisse le programmeur choisir.

Comme en Java, la bonne pratique est de déclarer les variables et les paramètres avec le type d'une interface et de choisir l'implémentation à la création, le plus souvent par une fonction de création : [val villes: MutableList<String> = mutableListOf()]. On notera que les fonctions de création de Kotlin choisissent, pour les ensembles et les dictionnaires, des implémentations qui conservent l'ordre d'ajout des éléments ([LinkedHashSet], [LinkedHashMap]) : un point qui distingue souvent un programme Kotlin de son équivalent Java, qui utilise plutôt [HashSet] et [HashMap].

Une collection ne contient que des objets. Kotlin n'a pas de types primitifs visibles dans le langage, et l'on écrit [List<Int>] ; mais sur la JVM, les éléments d'une telle liste sont des objets [java.lang.Integer], et le compilateur convertit automatiquement un [Int] en [Integer] lors d'un ajout et inversement lors d'une lecture (« boxing » et « unboxing »). Cette conversion, invisible en Kotlin, a le même coût qu'en Java : 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 [IntArray].

Le paquetage [java.util] contient aussi les collections de Java 1.0 : [Vector] (une liste), [Hashtable] (un dictionnaire), [Stack] (une pile). Elles sont obsolètes : synchronisées sur chaque opération, elles sont plus lentes que leurs remplaçantes, et l'on ne doit plus les utiliser. Quant aux types bruts de Java (une collection générique sans paramètre de type, [List] au lieu de [List<String>]), ils n'existent pas en Kotlin : le paramètre de type est obligatoire, ou déduit par le compilateur.

5.4.1. Les listes et la classe ArrayList

La classe [ArrayList<E>] implémente une liste modifiable 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]. C'est l'implémentation créée par [mutableListOf] et [arrayListOf]. Les listes en lecture seule créées par [listOf] sont, sur la JVM, des listes de taille fixe de la bibliothèque Java ([Arrays.asList] sur le tableau des arguments, ou une liste spécialisée pour un seul élément ou une liste vide). L'élément i d'une liste [l] est obtenu par [l[i]] et modifié (si la liste est modifiable) par [l[i] = valeur] : les opérateurs [get] et [set] (chapitre 4) remplacent les méthodes [get(i)] et [set(i, valeur)] de Java.

Voici quelques-unes des fonctions des interfaces [List<E>] et [MutableList<E>] :

fonction
rôle
[size: Int]
propriété : nombre d'éléments de la liste ; [indices] est l'intervalle des indices, [lastIndex] le dernier indice
[l[index]], [getOrNull(index)], [getOrElse(index) { ... }]
lit l'élément n° [index] ; une exception [IndexOutOfBoundsException] est lancée si l'indice est invalide, sauf avec [getOrNull] (qui rend [null]) et [getOrElse] (qui rend la valeur calculée par la lambda)
[first()], [last()], [firstOrNull()], [lastOrNull()]
le premier, le dernier élément ; une exception si la liste est vide, sauf avec les variantes [OrNull]
[contains(element)]
rend [true] si [element] est dans la liste ; s'écrit [element in liste]
[indexOf(element)], [lastIndexOf(element)]
rend la première, la dernière position de [element] dans la liste, ou -1
[subList(fromIndex, toIndex)]
rend une vue sur les éléments de [fromIndex] (inclus) à [toIndex] (exclu) ; [slice(range)] rend une copie
[sorted()], [sortedDescending()], [sortedWith(comparator)], [reversed()]
rend une nouvelle liste triée, triée en ordre décroissant, triée selon un [Comparator], inversée
[binarySearch(element)]
recherche dichotomique dans une liste triée
[toTypedArray()], [toMutableList()]
rend les éléments de la liste sous la forme d'un tableau, d'une nouvelle liste modifiable
[add(element)], [add(index, element)]
([MutableList]) ajoute [element] à la fin de la liste, à la position [index] ; l'opérateur [+=] ajoute un élément ou une collection
[addAll(elements)]
([MutableList]) ajoute les éléments d'une collection à la fin de la liste
[l[index] = element]
([MutableList]) remplace l'élément n° [index]
[remove(element)], [removeAt(index)]
([MutableList]) supprime la première occurrence de [element] (rend [true] si l'opération réussit), l'élément n° [index] (rend l'élément supprimé)
[removeFirst()], [removeLast()], [removeIf { ... }]
([MutableList]) supprime le premier, le dernier élément, les éléments qui vérifient une condition
[clear()]
([MutableList]) supprime tous les éléments de la liste
[sort()], [sortDescending()], [sortWith(comparator)], [reverse()], [shuffle()]
([MutableList]) trie, inverse, mélange la liste elle-même

On remarquera la convention de nommage de la bibliothèque, déjà rencontrée avec les tableaux : un verbe ([sort], [reverse], [shuffle]) modifie la collection elle-même et n'existe que pour les collections modifiables ; un participe passé ([sorted], [reversed], [shuffled]) rend une nouvelle collection et laisse l'originale intacte. Les fonctions de la classe utilitaire [java.util.Collections] de Java ([Collections.sort(liste)]…) restent utilisables, mais ces fonctions d'extension les rendent inutiles.

Reprenons l'exemple traité précédemment avec un tableau et traitons-le maintenant avec une liste modifiable de réels, [MutableList<Double>]. Parce que la liste est un objet proche du tableau, le code change peu. C'est le programme [Collections01.kt] :

package collections01

// type de recherche
enum class TypeRecherche { LINEAIRE, DICHOTOMIQUE }

// les listes : le programme Tableaux01.kt réécrit avec une liste
fun main() {
    // lecture des éléments d'une liste tapés au clavier
    val elements = saisie()
    // nombre d'éléments
    println("La liste a ${elements.size} éléments")
    // affichage de la liste non triée
    affiche("Liste non triée", elements)
    // recherche linéaire dans la liste non triée
    recherche(elements, TypeRecherche.LINEAIRE)
    // tri de la liste selon l'ordre naturel de ses éléments
    elements.sort()
    // affichage de la liste triée
    affiche("Liste triée", elements)
    // recherche dichotomique dans la liste triée
    recherche(elements, TypeRecherche.DICHOTOMIQUE)
}

// saisie des valeurs de la liste : rend la liste créée
fun saisie(): MutableList<Double> {
    // au départ, la liste est vide
    val elements = mutableListOf<Double>()
    // boucle de saisie des éléments de la liste
    while (true) {
        val reponse = lire("Elément (réel) ${elements.size} de la liste (rien pour terminer) : ")
        // fin de saisie si chaîne vide
        if (reponse.isEmpty()) break
        // vérification de la saisie
        val element = reponse.toDoubleOrNull()
        if (element == null) {
            println("Saisie incorrecte, recommencez")
            continue
        }
        // un élément de plus dans la liste : elle s'agrandit toute seule
        elements.add(element)
    }
    return elements
}

// fonction générique pour afficher les éléments d'une collection quelconque
fun <T> affiche(texte: String, elements: Iterable<T>) {
    println(texte + "-".repeat(50 - texte.length))
    for (element in elements) {
        println(element)
    }
}

// recherche d'éléments tapés au clavier dans la liste (en lecture seule)
fun recherche(elements: List<Double>, type: TypeRecherche) {
    while (true) {
        val reponse = lire("Elément cherché (rien pour arrêter) : ")
        // fini ?
        if (reponse.isEmpty()) break
        // vérification
        val element = reponse.toDoubleOrNull()
        if (element == null) {
            println("Erreur, recommencez...")
            continue
        }
        // on cherche l'élément dans la liste
        val i = when (type) {
            TypeRecherche.DICHOTOMIQUE -> elements.binarySearch(element)  // liste triée
            TypeRecherche.LINEAIRE -> elements.indexOf(element)  // liste quelconque
        }
        // affichage de la réponse
        println(if (i >= 0) "Trouvé en position $i" else "Pas dans la liste ($i)")
    }
}

// lecture d'une ligne au clavier, sans les espaces qui l'entourent ; "" en fin de flux
fun lire(invite: String): String {
    print(invite)
    return readlnOrNull()?.trim() ?: ""
}

Commentaires :

  • ligne 27 : la liste est créée vide par la fonction [mutableListOf], qui crée une [ArrayList]. Le type des éléments, [Double], est donné en paramètre de type de la fonction, car il ne peut pas être déduit d'une liste vide ; on aurait pu écrire aussi [val elements: MutableList<Double> = mutableListOf()] ou [val elements = ArrayList<Double>()] ;
  • ligne 40 : il n'y a plus d'agrandissement à gérer : la liste s'agrandit d'elle-même. On aurait pu écrire [elements += element] : pour une liste modifiable déclarée par [val], l'opérateur [+=] ajoute l'élément à la liste ([plusAssign]), alors que pour le tableau déclaré par [var] de [Tableaux01.kt], il créait un nouveau tableau. La variable [elements] est ici un [val] : elle désigne toujours la même liste, dont le contenu change ;
  • ligne 25 : la fonction [saisie] rend une liste modifiable, car le programme principal doit pouvoir la trier ;
  • ligne 11 : le nombre d'éléments de la liste est la propriété [size], comme pour les tableaux. Contrairement à [StringBuilder], [ArrayList] ne permet pas de connaître sa capacité ; la méthode [ensureCapacity] permet seulement de la fixer à l'avance ;
  • lignes 17, 67, 68 : le tri et la recherche dichotomique sont faits par les fonctions d'extension [sort] et [binarySearch] de la liste, la recherche linéaire par sa fonction [indexOf]. Pour une liste de [Double], [indexOf] existe, contrairement au tableau [DoubleArray] : elle compare les éléments avec leur fonction [equals], comme en Java ;
  • ligne 54 : la fonction [recherche] ne modifie pas la liste : son paramètre est déclaré de type [List<Double>], en lecture seule. On peut lui passer une [MutableList<Double>], qui est une [List<Double>]. Si elle essayait de modifier la liste, par exemple par [elements.add(element)], le compilateur refuserait : [unresolved reference 'add' on receiver of type 'List<Double>']. Le type du paramètre documente et garantit ainsi que la fonction ne touche pas à la liste ;
  • lignes 46-49 : la fonction 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 [Iterable<T>], dont la seule fonction est [operator fun iterator(): Iterator<T>].

La boucle [for (element in elements)] est valide pour tout objet qui a une fonction [operator fun iterator()], membre ou extension : c'est le cas de toutes les collections, qui implémentent [Iterable], des tableaux, des chaînes, des intervalles… Les tableaux n'implémentent pas [Iterable] : la fonction [affiche] ne convient pas pour un tableau, à moins de lui passer [tableau.asList()]. Nous reviendrons sur cette interface avec les séquences.

Résultats (en ligne de commande, les saisies au clavier apparaissent après les invites) :

> gradlew -q run -Pmain=collections01.Collections01Kt
Elément (réel) 0 de la liste (rien pour terminer) : 3.6
Elément (réel) 1 de la liste (rien pour terminer) : 7.4
Elément (réel) 2 de la liste (rien pour terminer) : -1.5
Elément (réel) 3 de la liste (rien pour terminer) : -7
Elément (réel) 4 de la liste (rien pour terminer) :
La liste a 4 éléments
Liste non triée-----------------------------------
3.6
7.4
-1.5
-7.0
Elément cherché (rien pour arrêter) : 7.4
Trouvé en position 1
Elément cherché (rien pour arrêter) : 0
Pas dans la liste (-1)
Elément cherché (rien pour arrêter) :
Liste triée---------------------------------------
-7.0
-1.5
3.6
7.4
Elément cherché (rien pour arrêter) : 7.4
Trouvé en position 3
Elément cherché (rien pour arrêter) : 0
Pas dans la liste (-3)
Elément cherché (rien pour arrêter) :

Les résultats sont les mêmes qu'avec le tableau.

5.4.2. Les fonctions des listes

Le programme [Collections02.kt] utilise les autres fonctions des listes :

package collections02

// les principales fonctions des listes
fun main() {
    // une liste modifiable, initialisée avec des valeurs
    val villes = mutableListOf("Paris", "Lyon", "Angers")
    affiche("départ", villes)

    // ajouts
    villes.add("Nantes")                        // à la fin
    villes.add(1, "Brest")                      // en position 1
    villes.addAll(listOf("Lille", "Tours"))     // plusieurs éléments à la fin
    affiche("après add, add(1, ...), addAll", villes)
    villes += "Nice"                            // l'opérateur += ajoute un élément...
    villes -= "Nice"                            // ... et -= le supprime
    affiche("après += \"Nice\", -= \"Nice\"", villes)

    // accès par indice, en écriture et en lecture
    villes[0] = "Marseille"
    println("villes[0]=${villes[0]}, first()=${villes.first()}, last()=${villes.last()}, "
            + "size=${villes.size}, lastIndex=${villes.lastIndex}")
    // un indice invalide : getOrNull rend null au lieu de lancer une exception
    println("getOrNull(10)=${villes.getOrNull(10)}, getOrElse(10) { \"?\" }=${villes.getOrElse(10) { "?" }}")

    // recherche
    println("\"Lyon\" in villes=${"Lyon" in villes}, indexOf(\"Angers\")=${villes.indexOf("Angers")}, "
            + "indexOf(\"Nice\")=${villes.indexOf("Nice")}")

    // suppressions
    val supprime = villes.remove("Lyon")        // par valeur
    villes.removeAt(0)                          // par position
    affiche("après remove(\"Lyon\") ($supprime), removeAt(0)", villes)

    // tri, inversion, sous-liste : la liste elle-même est modifiée
    villes.sort()
    affiche("après sort()", villes)
    villes.reverse()
    affiche("après reverse()", villes)
    val extrait = villes.subList(1, 3)
    affiche("subList(1, 3)", extrait)
    // sorted, reversed : une nouvelle liste, l'originale est inchangée
    affiche("sorted()", villes.sorted())
    affiche("sortedBy { it.length }", villes.sortedBy { it.length })
    affiche("villes (inchangée)", villes)

    // conversion en tableau et retour
    val tableau: Array<String> = villes.toTypedArray()
    val autre: MutableList<String> = tableau.toMutableList()
    println("tableau de ${tableau.size} éléments, nouvelle liste de ${autre.size} éléments")

    // une liste d'entiers : remove supprime par valeur, removeAt par position
    val nombres = mutableListOf(10, 20, 30, 1)
    nombres.removeAt(1)                         // supprime l'élément n° 1 (20)
    nombres.remove(1)                           // supprime la valeur 1
    affiche("nombres", nombres)

    // une liste en lecture seule : listOf
    val jours: List<String> = listOf("lundi", "mardi", "mercredi")
    // + crée une nouvelle liste : jours n'est pas modifiée
    val jours2 = jours + "jeudi"
    affiche("jours, jours + \"jeudi\"", "$jours, $jours2")

    // clear vide la liste
    villes.clear()
    println("après clear : size=${villes.size}, isEmpty()=${villes.isEmpty()}")
}

fun affiche(texte: String, liste: Any) {
    println("%-38s: %s".format(texte, liste))
}

Résultats :

> gradlew -q run -Pmain=collections02.Collections02Kt
départ                                : [Paris, Lyon, Angers]
après add, add(1, ...), addAll        : [Paris, Brest, Lyon, Angers, Nantes, Lille, Tours]
après += "Nice", -= "Nice"            : [Paris, Brest, Lyon, Angers, Nantes, Lille, Tours]
villes[0]=Marseille, first()=Marseille, last()=Tours, size=7, lastIndex=6
getOrNull(10)=null, getOrElse(10) { "?" }=?
"Lyon" in villes=true, indexOf("Angers")=3, indexOf("Nice")=-1
après remove("Lyon") (true), removeAt(0): [Brest, Angers, Nantes, Lille, Tours]
après sort()                          : [Angers, Brest, Lille, Nantes, Tours]
après reverse()                       : [Tours, Nantes, Lille, Brest, Angers]
subList(1, 3)                         : [Nantes, Lille]
sorted()                              : [Angers, Brest, Lille, Nantes, Tours]
sortedBy { it.length }                : [Tours, Lille, Brest, Nantes, Angers]
villes (inchangée)                    : [Tours, Nantes, Lille, Brest, Angers]
tableau de 5 éléments, nouvelle liste de 5 éléments
nombres                               : [10, 30]
jours, jours + "jeudi"                : [lundi, mardi, mercredi], [lundi, mardi, mercredi, jeudi]
après clear : size=0, isEmpty()=true

Commentaires :

  • ligne 6 : [mutableListOf] crée une liste modifiable contenant les éléments donnés ; le type des éléments, [String], est déduit des arguments. C'est l'équivalent de l'écriture Java [new ArrayList<>(List.of("Paris", "Lyon", "Angers"))] ;
  • lignes 10-12 : trois façons d'ajouter des éléments ;
  • lignes 14-15 : pour une liste modifiable, les opérateurs [+=] et [-=] ajoutent et suppriment un élément (ou tous les éléments d'une collection) ;
  • ligne 19 : l'élément n° 0 est remplacé par l'opérateur [[]], qui appelle la fonction [set] ;
  • lignes 20-21 : l'opérateur [[]] lit un élément ; [first] et [last] rendent le premier et le dernier élément, et la propriété [lastIndex] vaut [size - 1] ;
  • ligne 23 : un indice invalide lance, comme en Java, une exception [IndexOutOfBoundsException]. Les fonctions [getOrNull] et [getOrElse] permettent de l'éviter : la première rend [null], la seconde la valeur calculée par une lambda qui reçoit l'indice. Dans le gabarit, le texte [{ \"?\" }] est un simple texte affiché, tandis que l'expression [villes.getOrElse(10) { "?" }] appelle la fonction ;
  • lignes 26-27 : la recherche d'un élément, par l'opérateur [in] ([contains]) ou par [indexOf] ;
  • ligne 30 : [remove] rend [true] si l'élément a été trouvé et supprimé ;
  • ligne 31 : [removeAt] supprime l'élément d'une position donnée et le rend ;
  • lignes 35-38 : [sort] trie la liste elle-même selon l'ordre naturel de ses éléments, celui de [compareTo], et [reverse] l'inverse ;
  • ligne 39 : [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 42-44 : [sorted] rend une nouvelle liste triée, et [sortedBy] une nouvelle liste triée selon une clé calculée par une lambda, ici la longueur des noms. Le tri est stable : les villes de même longueur (Tours, Lille, Brest) restent dans leur ordre d'origine. La liste [villes] n'a pas changé. [sortedBy] évite d'écrire un [Comparator] ; on pourrait aussi écrire [villes.sortedWith(compareBy({ it.length }, { it }))] pour trier par longueur puis par ordre alphabétique (chapitre 4) ;
  • lignes 47-48 : passage d'une liste à un tableau, et d'un tableau à une liste. Grâce aux génériques [reified] (chapitre 4), [toTypedArray] sait créer un tableau de chaînes de la bonne taille, sans l'aide que demande la méthode [toArray(String[]::new)] de Java ;
  • lignes 52-54 : un piège de Java évité. En Java, l'interface [List] a deux méthodes [remove], l'une par position, l'autre par valeur, et [nombres.remove(1)] sur une [List<Integer>] supprime l'élément n° 1. Kotlin donne deux noms différents aux deux opérations : [removeAt] supprime par position, [remove] par valeur. [nombres.remove(1)] supprime donc bien la valeur 1 ;
  • lignes 58-60 : [listOf] crée une liste en lecture seule : [jours.add("jeudi")] serait refusé à la compilation. L'opérateur [+] est en revanche disponible : il crée une nouvelle liste, formée des éléments de [jours] suivis de « jeudi », et [jours] n'est pas modifiée ;
  • lignes 64-65 : [clear] vide la liste, [isEmpty] teste si elle est vide ;
  • ligne 68 : la fonction [affiche] accepte n'importe quel objet ([Any], chapitre 4) : une liste de chaînes, une liste d'entiers ou un texte. Une collection s'affiche directement : sa fonction [toString] rend ses éléments entre crochets.

5.4.3. Les dictionnaires : Map et MutableMap

Un dictionnaire est un ensemble de couples (clé, valeur). On peut le voir 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 fonction [equals]). Un tel dictionnaire pourrait ressembler à ceci si les types [K] et [V] désignaient des classes :

Image

Comme pour les listes, Kotlin distingue l'interface en lecture seule [Map<K, V>] et l'interface modifiable [MutableMap<K, V>]. Les implémentations sont celles de Java :

  • 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é (fonction [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 ;
  • une [LinkedHashMap] est une [HashMap] qui, en plus, chaîne ses couples dans l'ordre de leur ajout : elle est aussi rapide, consomme un peu plus de mémoire, et énumère ses couples dans l'ordre où ils ont été ajoutés. C'est l'implémentation choisie par les fonctions de création [mutableMapOf] et [mapOf] : un dictionnaire Kotlin conserve donc, par défaut, l'ordre d'ajout de ses clés. Pour obtenir une [HashMap], on utilise [hashMapOf] ou son constructeur.

Les couples d'un dictionnaire créé par [mapOf] s'écrivent [cle to valeur] : [to] est une fonction infixe de la bibliothèque standard qui crée une paire [Pair(cle, valeur)] (chapitre 3). Les principales fonctions des dictionnaires sont les suivantes :

fonction
rôle
[size]
propriété : nombre de couples (clé, valeur) du dictionnaire
[dico[key]]
rend la valeur associée à [key], ou [null] si la clé est absente (fonction [get]) ; le type du résultat est donc [V?]
[getValue(key)]
rend la valeur associée à [key], de type [V] ; lance une exception [NoSuchElementException] si la clé est absente
[getOrDefault(key, defaultValue)], [getOrElse(key) { ... }]
rend la valeur associée à [key], ou [defaultValue], ou la valeur calculée par la lambda si la clé est absente
[containsKey(key)], [containsValue(value)]
rend [true] si [key] est une clé du dictionnaire ([key in dico]), si [value] est une de ses valeurs
[keys], [values], [entries]
propriétés : l'ensemble des clés, la collection des valeurs, l'ensemble des couples du dictionnaire ; un couple [Map.Entry] a les propriétés [key] et [value] et peut être déstructuré : [for ((cle, valeur) in dico)]
[dico[key] = value]
([MutableMap]) associe [value] à [key] : ajoute le couple si la clé est absente, remplace la valeur sinon (fonction [set]) ; [dico += cle to valeur] fait de même
[put(key, value)]
([MutableMap]) idem, mais rend l'ancienne valeur, ou [null]
[putIfAbsent(key, value)]
([MutableMap]) n'ajoute le couple que si la clé est absente ; rend la valeur existante, ou [null]
[getOrPut(key) { ... }]
([MutableMap]) rend la valeur associée à [key] ; si la clé est absente, calcule la valeur par la lambda, ajoute le couple et rend la valeur
[remove(key)]
([MutableMap]) supprime le couple de clé [key] et rend sa valeur, ou [null] ; [dico -= key] fait de même
[clear()]
([MutableMap]) supprime tous les couples du dictionnaire

Considérons le programme exemple [Collections03.kt] :

package collections03

// les dictionnaires : Map, MutableMap, LinkedHashMap, HashMap
fun main() {
    // création d'un dictionnaire <String, Int> à partir d'un tableau de chaînes "nom:âge"
    val liste = arrayOf("jean:20", "paul:18", "mélanie:10", "violette:15")
    val dico = mutableMapOf<String, Int>()
    // le même dictionnaire, implémenté par une HashMap
    val dicoHash = HashMap<String, Int>()
    for (element in liste) {
        val (nom, age) = element.split(":")
        dico[nom] = age.toInt()
        dicoHash[nom] = age.toInt()
    }
    // l'ordre des clés : celui de l'ajout pour mutableMapOf, celui de la table de hachage pour HashMap
    println("mutableMapOf : $dico, HashMap : $dicoHash")
    // nombre d'éléments du dictionnaire
    println("Le dictionnaire a ${dico.size} éléments")
    // liste des clés
    affiche("[Liste des clés]", dico.keys)
    // liste des valeurs
    affiche("[Liste des valeurs]", dico.values)
    // liste des clés et valeurs
    println("[Liste des clés & valeurs]")
    for (cle in dico.keys) {
        println("clé=$cle valeur=${dico[cle]}")
    }
    // on supprime la clé "paul"
    println("[Suppression d'une clé]")
    dico.remove("paul")
    // parcours des couples (clé, valeur), déstructurés
    println("[Liste des clés & valeurs]")
    for ((cle, valeur) in dico) {
        println("clé=$cle valeur=$valeur")
    }
    // put remplace la valeur d'une clé existante et rend l'ancienne valeur
    println("put(\"jean\", 21) rend ${dico.put("jean", 21)}, put(\"zoé\", 12) rend ${dico.put("zoé", 12)}")
    // putIfAbsent n'ajoute que si la clé est absente
    println("putIfAbsent(\"jean\", 22) rend ${dico.putIfAbsent("jean", 22)}, dico[\"jean\"]=${dico["jean"]}")
    // [] rend null si la clé est absente ; getOrDefault, getOrElse rendent une valeur par défaut
    println("dico[\"x\"]=${dico["x"]}, getOrDefault(\"x\", -1)=${dico.getOrDefault("x", -1)}, "
            + "getOrElse(\"x\") { 0 }=${dico.getOrElse("x") { 0 }}, \"x\" in dico=${"x" in dico}")
    // compter : ajouter 1 à la valeur d'une clé, ou créer la clé avec la valeur 1
    for (nom in listOf("zoé", "léa")) {
        dico[nom] = (dico[nom] ?: 0) + 1
    }
    println("après comptage : $dico")
    // getOrPut : rend la valeur de la clé ; si la clé est absente, l'ajoute avec la valeur calculée
    println("getOrPut(\"max\") { 30 }=${dico.getOrPut("max") { 30 }}, getOrPut(\"jean\") { 30 }="
            + dico.getOrPut("jean") { 30 })
    // un dictionnaire en lecture seule, créé par mapOf et des couples clé to valeur
    val capitales: Map<String, String> = mapOf("France" to "Paris", "Italie" to "Rome")
    println("capitales : $capitales, capitales[\"Italie\"]=${capitales["Italie"]}")

    // recherche dans le dictionnaire
    while (true) {
        print("Nom recherché (rien pour arrêter) : ")
        val nomCherche = readlnOrNull()?.trim() ?: ""
        if (nomCherche.isEmpty()) break
        // [] rend la valeur associée à la clé, ou null si la clé n'existe pas
        val age = dico[nomCherche]
        if (age != null) {
            println("$nomCherche,$age")
        } else {
            println("Nom $nomCherche inconnu")
        }
    }
}

// fonction générique pour afficher les éléments d'une collection
fun <T> affiche(texte: String, elements: Iterable<T>) {
    println(texte + "-".repeat(50 - texte.length))
    for (element in elements) {
        println(element)
    }
}

Commentaires :

  • ligne 6 : un tableau de chaînes qui va servir à initialiser le dictionnaire [<String, Int>] ;
  • ligne 7 : le dictionnaire, vide, créé par [mutableMapOf] : c'est une [LinkedHashMap] ;
  • ligne 9 : le même dictionnaire, implémenté cette fois par une [HashMap], créée par son constructeur ;
  • lignes 10-13 : l'initialisation des deux dictionnaires à partir du tableau de chaînes. Chaque élément est découpé par [split], et la déstructuration de la liste obtenue range ses deux champs dans les variables [nom] et [age]. L'ajout d'un couple s'écrit [dico[nom] = valeur], comme l'affectation d'un élément de tableau ;
  • ligne 16 : un dictionnaire s'affiche directement, ses couples entre accolades. Les couples de [dico] apparaissent dans leur ordre d'ajout ; ceux de [dicoHash] dans l'ordre de la table de hachage, qui dépend des codes de hachage des clés (ligne 1 des résultats) : une [HashMap] ne garantit aucun ordre. Le programme Java de ce chapitre, qui utilise une [HashMap], affichait ses clés dans ce second ordre ;
  • ligne 18 : le nombre de couples du dictionnaire ;
  • lignes 20-22 : les clés et les valeurs du dictionnaire. Les propriétés [dico.keys] et [dico.values] sont des collections, donc des [Iterable] : la fonction [affiche] déjà rencontrée convient ;
  • lignes 25-26 : le parcours des clés, et la recherche de la valeur de chaque clé par l'opérateur [[]] ;
  • ligne 30 : suppression d'un couple du dictionnaire ;
  • lignes 33-34 : un dictionnaire n'est pas lui-même une collection, mais la boucle [for] sait le parcourir : la bibliothèque lui fournit une fonction d'extension [iterator] qui énumère ses couples ([entries]). Chaque couple, de type [Map.Entry<String, Int>], est déstructuré en [(cle, valeur)], grâce aux fonctions d'extension [component1] et [component2] de [Map.Entry]. Ce parcours est plus efficace que le précédent, qui recherche la valeur de chaque clé, et plus lisible que son équivalent Java ([for (Map.Entry<String, Integer> entree : dico.entrySet())], puis [entree.getKey()] et [entree.getValue()]) ;
  • ligne 37 : [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é. Quand cette valeur n'est pas utile, on écrit [dico[cle] = valeur] ;
  • ligne 39 : [putIfAbsent] ne remplace pas la valeur d'une clé existante ;
  • lignes 41-42 : la lecture d'une clé absente ne provoque pas d'erreur : l'opérateur [[]] rend [null]. Son résultat est de type [Int?], et le compilateur oblige à traiter le cas [null] avant d'utiliser la valeur. [getOrDefault] et [getOrElse] permettent de choisir la valeur rendue dans ce cas, et l'opérateur [in] ([containsKey]) de tester l'existence d'une clé ;
  • lignes 44-47 : la façon habituelle de compter des occurrences : on ajoute 1 à la valeur de la clé, ou 0 + 1 si elle est absente, grâce à l'opérateur Elvis [?:]. La clé « zoé » passe de 12 à 13, la clé « léa » est créée avec la valeur 1. La méthode [merge] de Java est aussi utilisable : [dico.merge(nom, 1, Int::plus)] ;
  • lignes 49-50 : [getOrPut] rend la valeur associée à une clé ; si la clé est absente, il calcule une valeur par la lambda, ajoute le couple au dictionnaire et rend la valeur. La clé « max » est créée avec la valeur 30 ; la clé « jean » existe déjà, et sa valeur 21 est rendue sans que la lambda soit exécutée. C'est la fonction idéale pour un cache, ou pour regrouper des éléments dans des listes : [groupes.getOrPut(cle) { mutableListOf() }.add(element)] ;
  • lignes 52-53 : un dictionnaire en lecture seule, créé par [mapOf] à partir de couples [cle to valeur]. [capitales["Espagne"] = "Madrid"] serait refusé à la compilation ;
  • lignes 55-65 : recherche d'une clé dans le dictionnaire. [dico[nomCherche]] rend un [Int?] : on teste le cas [null] avant d'utiliser la valeur ; dans la branche [age != null], le compilateur sait que [age] est un [Int] (transtypage intelligent). En Java, [int age = dico.get(nomCherche);] compile mais lance une [NullPointerException] pour une clé absente ; en Kotlin, [val age: Int = dico[nomCherche]] est refusé à la compilation.

Résultats :

> gradlew -q run -Pmain=collections03.Collections03Kt
mutableMapOf : {jean=20, paul=18, mélanie=10, violette=15}, HashMap : {violette=15, jean=20, mélanie=10, paul=18}
Le dictionnaire a 4 éléments
[Liste des clés]----------------------------------
jean
paul
mélanie
violette
[Liste des valeurs]-------------------------------
20
18
10
15
[Liste des clés & valeurs]
clé=jean valeur=20
clé=paul valeur=18
clé=mélanie valeur=10
clé=violette valeur=15
[Suppression d'une clé]
[Liste des clés & valeurs]
clé=jean valeur=20
clé=mélanie valeur=10
clé=violette valeur=15
put("jean", 21) rend 20, put("zoé", 12) rend null
putIfAbsent("jean", 22) rend 21, dico["jean"]=21
dico["x"]=null, getOrDefault("x", -1)=-1, getOrElse("x") { 0 }=0, "x" in dico=false
après comptage : {jean=21, mélanie=10, violette=15, zoé=13, léa=1}
getOrPut("max") { 30 }=30, getOrPut("jean") { 30 }=21
capitales : {France=Paris, Italie=Rome}, capitales["Italie"]=Rome
Nom recherché (rien pour arrêter) : violette
violette,15
Nom recherché (rien pour arrêter) : x
Nom x inconnu
Nom recherché (rien pour arrêter) : jean
jean,21
Nom recherché (rien pour arrêter) :

Contrairement au programme Java, dont la [HashMap] énumérait les clés dans un ordre imprévisible, les clés du dictionnaire sont énumérées dans leur ordre d'ajout : « zoé » et « léa » apparaissent à la fin. Si l'on a besoin de clés maintenues triées, on utilisera une [TreeMap], présentée ci-dessous.

5.4.4. D'autres collections

La bibliothèque standard de Kotlin et le paquetage [java.util] contiennent d'autres collections, chacune adaptée à un usage particulier :

classe (fonction de création)
rôle
[LinkedHashSet<E>] ([setOf], [mutableSetOf])
un ensemble : pas de doublons, test d'appartenance très rapide ; garde l'ordre d'ajout de ses éléments. C'est l'ensemble par défaut de Kotlin
[HashSet<E>] ([hashSetOf])
un ensemble sans ordre, un peu plus économe en mémoire
[java.util.TreeSet<E>] ([sortedSetOf], [toSortedSet])
un ensemble dont les éléments sont maintenus triés
[ArrayDeque<E>]
classe propre à Kotlin ([kotlin.collections.ArrayDeque], à ne pas confondre avec [java.util.ArrayDeque]) : une file à double entrée, qui implémente aussi [MutableList]. 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>] ([mapOf], [mutableMapOf])
un dictionnaire qui garde l'ordre d'ajout de ses clés. C'est le dictionnaire par défaut de Kotlin
[java.util.TreeMap<K, V>] ([sortedMapOf], [toSortedMap])
un dictionnaire dont les clés sont maintenues triées
[java.util.PriorityQueue<E>]
une file de priorité : l'élément qui sort est le plus petit selon l'ordre de la file
[java.util.LinkedList<E>]
une liste doublement chaînée : insertions et suppressions rapides aux extrémités et au cours d'un parcours

Pour les ensembles, la distinction lecture seule / modifiable est la même que pour les listes : [Set<E>] n'a que des fonctions de lecture, [MutableSet<E>] y ajoute [add], [remove]…

Le programme [Collections04.kt] fait un rapide survol de ces collections :

package collections04

import java.util.PriorityQueue
import java.util.TreeMap

// un patient et la priorité de sa prise en charge
data class Urgence(val patient: String, val priorite: Int)

// un survol d'autres collections
fun main() {
    // mutableSetOf : un ensemble sans doublons, qui garde l'ordre d'ajout (LinkedHashSet)
    val langages = mutableSetOf("C#", "Java", "PHP")
    val ajoute1 = langages.add("Python")
    val ajoute2 = langages.add("Java")              // déjà présent : refusé
    println("Set : add(Python)=$ajoute1, add(Java)=$ajoute2, size=${langages.size}, "
            + "\"PHP\" in langages=${"PHP" in langages}")
    // opérations ensemblistes : intersect, union, subtract rendent un nouvel ensemble
    val web = setOf("PHP", "JavaScript", "Python")
    println("Set : langages intersect web = ${langages intersect web}")
    println("Set : langages union web = ${langages union web}")
    println("Set : langages subtract web = ${langages subtract web}")
    // hashSetOf : une HashSet, sans ordre ; sortedSetOf : un TreeSet, éléments triés
    println("HashSet : ${hashSetOf("Kotlin", "Java", "Go", "C#")}, "
            + "TreeSet : ${sortedSetOf("Kotlin", "Java", "Go", "C#")}")
    // toSet élimine les doublons d'une liste
    println("toSet : ${listOf(3, 1, 3, 2, 1).toSet()}")

    // ArrayDeque utilisé comme file : premier entré, premier sorti
    val file = ArrayDeque<String>()
    file.addLast("client1")
    file.addLast("client2")
    file.addLast("client3")
    println("file : first()=${file.first()}, removeFirst()=${file.removeFirst()}, "
            + "removeFirst()=${file.removeFirst()}, reste ${file.size}")

    // ArrayDeque utilisé comme pile : dernier entré, premier sorti
    val pile = ArrayDeque<Int>()
    pile.addLast(1)
    pile.addLast(2)
    pile.addLast(3)
    println("pile : last()=${pile.last()}, removeLast()=${pile.removeLast()}, "
            + "removeLast()=${pile.removeLast()}, reste ${pile.size}")
    // removeLast sur une pile vide lance une exception, removeLastOrNull rend null
    pile.removeLast()
    println("pile : removeLastOrNull() sur une pile vide = ${pile.removeLastOrNull()}")
    try {
        pile.removeLast()
    } catch (e: NoSuchElementException) {
        println("pile : removeLast() sur une pile vide : ${e::class.simpleName} (${e.message})")
    }

    // TreeMap : un dictionnaire dont les clés sont maintenues triées
    val ages = TreeMap<String, Int>()
    ages["violette"] = 15
    ages["jean"] = 20
    ages["mélanie"] = 10
    ages["paul"] = 18
    println("TreeMap : $ages, firstKey()=${ages.firstKey()}, headMap(\"n\")=${ages.headMap("n")}")
    // sortedMapOf crée aussi une TreeMap ; mutableMapOf garde l'ordre d'ajout
    println("sortedMapOf : ${sortedMapOf("violette" to 15, "jean" to 20, "mélanie" to 10)}")
    println("mutableMapOf : ${mutableMapOf("violette" to 15, "jean" to 20, "mélanie" to 10)}")

    // PriorityQueue (JDK) : on sort toujours l'élément de plus petite priorité
    val urgences = PriorityQueue(compareBy<Urgence> { it.priorite })
    urgences.add(Urgence("entorse", 3))
    urgences.add(Urgence("arrêt cardiaque", 1))
    urgences.add(Urgence("rhume", 5))
    urgences.add(Urgence("fracture", 2))
    print("PriorityQueue :")
    while (true) {
        // poll rend null quand la file est vide
        val urgence = urgences.poll() ?: break
        print(" ${urgence.patient} (${urgence.priorite})")
    }
    println()
}

Résultats :

> gradlew -q run -Pmain=collections04.Collections04Kt
Set : add(Python)=true, add(Java)=false, size=4, "PHP" in langages=true
Set : langages intersect web = [PHP, Python]
Set : langages union web = [C#, Java, PHP, Python, JavaScript]
Set : langages subtract web = [C#, Java]
HashSet : [Go, C#, Java, Kotlin], TreeSet : [C#, Go, Java, Kotlin]
toSet : [3, 1, 2]
file : first()=client1, removeFirst()=client1, removeFirst()=client2, reste 1
pile : last()=3, removeLast()=3, removeLast()=2, reste 1
pile : removeLastOrNull() sur une pile vide = null
pile : removeLast() sur une pile vide : NoSuchElementException (ArrayDeque is empty.)
TreeMap : {jean=20, mélanie=10, paul=18, violette=15}, firstKey()=jean, headMap("n")={jean=20, mélanie=10}
sortedMapOf : {jean=20, mélanie=10, violette=15}
mutableMapOf : {violette=15, jean=20, mélanie=10}
PriorityQueue : arrêt cardiaque (1) fracture (2) entorse (3) rhume (5)

Commentaires :

  • lignes 3-4 : les classes [PriorityQueue] et [TreeMap] du JDK n'ont pas d'alias dans la bibliothèque Kotlin et doivent être importées. [ArrayDeque] ne l'est pas : c'est la classe de Kotlin qui est utilisée ;
  • ligne 7 : un patient est représenté par une classe de données (chapitre 4), l'équivalent du [record] de Java ;
  • ligne 12 : un ensemble modifiable, initialisé avec trois éléments ;
  • ligne 14 : [add] rend [false] si l'élément est déjà dans l'ensemble, qui reste inchangé ;
  • lignes 18-21 : [setOf] crée un ensemble en lecture seule. Les fonctions infixes [intersect] (intersection), [union] (union) et [subtract] (différence) rendent un nouvel ensemble et ne modifient pas [langages] : il n'est pas nécessaire, comme en Java avec [retainAll], [addAll] et [removeAll], de travailler sur une copie. L'ensemble créé garde l'ordre des éléments : ceux de [langages], puis ceux de [web] qui n'y étaient pas ;
  • lignes 23-24 : un [HashSet] présente ses éléments dans un ordre sans signification, celui de sa table de hachage ; un [TreeSet] les présente triés ;
  • ligne 26 : [toSet] rend un ensemble formé des éléments d'une liste : c'est la façon la plus simple d'en éliminer les doublons, en gardant l'ordre de leur première apparition ([distinct] rendrait une liste) ;
  • lignes 29-34 : [ArrayDeque] utilisée comme une file : [addLast] fait entrer un élément à la fin de la file, [removeFirst] fait sortir le plus ancien, [first] le consulte sans le faire sortir. Les expressions d'un gabarit sont évaluées de gauche à droite : [first], puis les deux [removeFirst], puis [size] ;
  • lignes 37-42 : [ArrayDeque] utilisée comme une pile : [addLast] empile un élément, [removeLast] dépile le plus récent, [last] consulte le sommet de la pile. Les classes [Stack] de Java 1.0, et même [java.util.ArrayDeque] et ses méthodes [push]/[pop]/[offer]/[poll], n'ont pas d'intérêt en Kotlin : les noms [addFirst], [addLast], [removeFirst], [removeLast], [first], [last] sont les mêmes pour [ArrayDeque] et pour toutes les listes modifiables ;
  • lignes 44-49 : sur une pile vide, [removeLast] lance une exception [NoSuchElementException], alors que [removeLastOrNull] rend [null]. On retrouve la convention de nommage de la bibliothèque : une fonction [xxx] lance une exception lorsqu'elle ne peut pas rendre de résultat, sa variante [xxxOrNull] rend [null] ([first]/[firstOrNull], [removeFirst]/[removeFirstOrNull], [toInt]/[toIntOrNull]…). [e::class.simpleName] est le nom simple de la classe de l'exception (chapitre 4) ;
  • lignes 53-58 : les couples d'une [TreeMap] sont énumérés dans l'ordre des clés. On l'alimente avec l'opérateur [[]] comme tout dictionnaire modifiable. Le tri des clés permet des opérations qu'une table de hachage ne peut pas offrir : [firstKey] rend la plus petite clé, [headMap] la partie du dictionnaire dont les clés précèdent une valeur donnée ;
  • lignes 60-61 : [sortedMapOf] crée une [TreeMap] à partir de couples, triés par clé ; [mutableMapOf] garde l'ordre d'ajout ;
  • lignes 64-75 : une [PriorityQueue] a besoin de savoir comparer ses éléments. On lui passe ici un [Comparator] qui compare les priorités : [compareBy<Urgence> { it.priorite }] (chapitre 4) se lit « comparer les urgences selon leur propriété [priorite] ». Les patients sortent par ordre de priorité croissante, quel que soit leur ordre d'arrivée. La méthode [poll] rend l'élément de plus petite priorité, ou [null] si la file est vide : l'opérateur Elvis [?: break] sort alors de la boucle. C'est une écriture idiomatique, puisque [break] est une expression en Kotlin. En Java, on écrirait [while ((urgence = urgences.poll()) != null)] ; Kotlin refuse les affectations dans une expression.

5.4.5. Lecture seule, vues non modifiables et copies

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] : il utilise des collections non modifiables, qui implémentent l'interface [List] complète mais dont les méthodes de modification lancent une exception [UnsupportedOperationException] à l'exécution. Kotlin répond d'abord à ce besoin par ses types : il suffit de rendre la collection sous le type [List], et c'est le compilateur qui interdit les modifications. Il faut cependant bien comprendre ce que garantit ce type, et ce qu'il ne garantit pas. Le programme [Collections05.kt] présente les trois façons de montrer une collection — un type en lecture seule, une vue non modifiable, une copie :

package collections05

import java.util.Collections

// une classe qui garde le contrôle de sa liste
class Classe {
    // la liste modifiable est privée : seule la classe peut la modifier
    private val listeEleves = mutableListOf<String>()

    // la liste, vue par le code extérieur sous un type en lecture seule
    val eleves: List<String>
        get() = listeEleves

    // une vue non modifiable sur la liste (classe du JDK)
    val elevesProteges: List<String>
        get() = Collections.unmodifiableList(listeEleves)

    // une copie de la liste
    val copieEleves: List<String>
        get() = listeEleves.toList()

    fun inscrire(nom: String) {
        listeEleves += nom
    }
}

// les collections en lecture seule, les vues non modifiables et les copies
fun main() {
    val classe = Classe()
    classe.inscrire("dupont")
    classe.inscrire("martin")
    // le code extérieur peut lire la liste des élèves...
    val eleves = classe.eleves
    println("${eleves.size} élèves : $eleves, eleves[0]=${eleves[0]}")
    // ... mais pas la modifier : eleves.add("intrus") ne compile pas
    println("classe de eleves : ${eleves.javaClass.name}")
    // la liste en lecture seule reflète les modifications faites par la classe propriétaire
    classe.inscrire("durand")
    println("${eleves.size} élèves : $eleves")

    // lecture seule n'est pas immuable : un transtypage permet de modifier la liste
    (eleves as MutableList<String>).add("intrus")
    println("après le transtypage : ${classe.eleves}")
    // la vue non modifiable du JDK, elle, refuse toute modification à l'exécution
    try {
        (classe.elevesProteges as MutableList<String>).add("intrus2")
    } catch (e: UnsupportedOperationException) {
        println("add sur la vue non modifiable : ${e::class.simpleName}")
    }

    // toList : une copie, qui ne voit plus les modifications de l'original
    val copie = classe.copieEleves
    classe.inscrire("petit")
    println("copie : $copie, vue : $eleves")

    // listOf, setOf, mapOf : des collections en lecture seule créées en une instruction
    val notes: Map<String, Int> = mapOf("dupont" to 12, "martin" to 15)
    println("notes[\"martin\"]=${notes["martin"]}, \"durand\" in notes=${"durand" in notes}")
    // notes["durand"] = 9 ne compile pas
    // les collections de Kotlin acceptent null... si le type des éléments l'accepte
    val liste: List<String?> = listOf("a", null)
    println("listOf(\"a\", null) : $liste, filterNotNull() : ${liste.filterNotNull()}")
    // une copie défensive : le groupe n'est pas affecté par les modifications de la liste reçue
    val membres = mutableListOf("dupont", "martin")
    val groupe = Groupe("TP1", membres)
    membres += "intrus"
    println("membres : $membres, groupe : $groupe")
}

// une classe qui garde une copie de la liste reçue
class Groupe(val nom: String, eleves: List<String>) {
    val eleves: List<String> = eleves.toList()

    override fun toString() = "Groupe(nom=$nom, eleves=$eleves)"
}

Résultats :

> gradlew -q run -Pmain=collections05.Collections05Kt
2 élèves : [dupont, martin], eleves[0]=dupont
classe de eleves : java.util.ArrayList
3 élèves : [dupont, martin, durand]
après le transtypage : [dupont, martin, durand, intrus]
add sur la vue non modifiable : UnsupportedOperationException
copie : [dupont, martin, durand, intrus], vue : [dupont, martin, durand, intrus, petit]
notes["martin"]=15, "durand" in notes=false
listOf("a", null) : [a, null], filterNotNull() : [a]
membres : [dupont, martin, intrus], groupe : Groupe(nom=TP1, eleves=[dupont, martin])

Commentaires :

  • lignes 6-23 : la classe [Classe] gère une liste d'élèves. La liste modifiable elle-même est privée ; seule la fonction [inscrire] permet de la modifier ;
  • lignes 11-12 : la propriété [eleves] rend la liste privée, mais sous le type [List<String>], en lecture seule. C'est l'écriture idiomatique en Kotlin : une liste modifiable privée et une propriété publique en lecture seule. Le code extérieur peut lire la liste, mais [eleves.add("intrus")] est refusé à la compilation ([unresolved reference 'add' on receiver of type 'List<String>']), là où Java ne le refuse qu'à l'exécution ;
  • ligne 36 : la propriété [eleves] ne crée aucun objet : elle rend la liste privée elle-même, une [java.util.ArrayList]. Elle voit donc les modifications faites par la classe propriétaire (ligne 3 des résultats) ;
  • lignes 41-43 : lecture seule ne veut pas dire immuable. Le type [List] interdit la modification au travers de cette référence, mais l'objet est une [ArrayList] parfaitement modifiable : un transtypage en [MutableList] (ou le passage de la liste à une méthode Java, pour qui c'est une [java.util.List] ordinaire) permet de la modifier, et l'élève « intrus » est ajouté à la liste de la classe (ligne 4 des résultats). Le type en lecture seule protège contre les erreurs, pas contre les intentions malveillantes ;
  • lignes 15-16 et 44-48 : pour une protection complète, on rend une vue non modifiable créée par la méthode [Collections.unmodifiableList] du JDK : même après un transtypage, toute tentative de modification lance une exception [UnsupportedOperationException]. Comme la précédente, cette vue ne copie pas la liste et voit les modifications de la classe propriétaire ;
  • lignes 19-20 et 52-54 : [toList] rend au contraire une copie de la liste : la liste [copie] ne voit pas l'inscription de « petit ». Une copie est indépendante de l'original, mais elle a un coût : le getter crée une nouvelle liste à chaque lecture de la propriété ;
  • lignes 57-58 : [mapOf], comme [listOf] et [setOf], crée en une instruction une collection initialisée, sous un type en lecture seule : [notes["durand"] = 9] ne compile pas ([no 'set' operator method providing array access]). Ces collections sont l'équivalent des [List.of], [Set.of] et [Map.of] de Java, avec la même réserve que ci-dessus : elles sont en lecture seule par leur type, mais [mapOf] crée une [LinkedHashMap] ordinaire ;
  • lignes 61-62 : les collections créées par [List.of] en Java refusent la valeur [null] à l'exécution. En Kotlin, c'est le type qui décide : une [List<String>] ne peut pas contenir [null] (le compilateur le refuse), une [List<String?>] le peut. [filterNotNull] rend la liste des éléments non [null], de type [List<String>] ;
  • lignes 64-67 et 71-74 : une copie défensive. La classe [Groupe] reçoit une liste dans son constructeur ; si elle la gardait telle quelle, celui qui la lui a passée pourrait la modifier ensuite, ici en ajoutant « intrus » à la liste [membres]. Le paramètre [eleves] du constructeur n'est donc pas une propriété (il n'est pas précédé de [val]) : il sert à initialiser la propriété [eleves] avec une copie. Le groupe n'est pas affecté par l'ajout (dernière ligne des résultats). On ne peut pas procéder ainsi avec les propriétés du constructeur primaire d'une classe de données ; si une classe de données reçoit une collection, l'appelant lui passera une collection qu'il ne modifie plus, par exemple créée par [listOf] ou [toList].

Le type [List<String>] dit donc ce que le code qui le reçoit peut faire, pas ce que la liste est. Pour des collections réellement immuables, il existe la bibliothèque [kotlinx.collections.immutable] de JetBrains ([persistentListOf], [persistentMapOf]…), et l'on peut aussi utiliser les collections non modifiables de Java ([java.util.List.copyOf(liste)]). Une bonne pratique répandue, en Kotlin comme en Java, est de rendre des collections en lecture seule et de créer une nouvelle collection chaque fois qu'on veut en modifier une ([liste + element], [liste - element], [liste.filter { ... }]…) : les opérateurs et les fonctions de la bibliothèque standard rendent cette pratique naturelle.

5.4.6. Trier, premier et dernier éléments, parcourir

Le programme [Collections06.kt] présente trois points utiles de l'utilisation des collections : le tri selon des critères, l'accès aux extrémités d'une collection (ce que Java 21 a appelé les collections séquencées), et la suppression d'éléments pendant un parcours :

package collections06

import java.text.Collator
import java.util.Locale
import java.util.TreeMap

// un élève et sa note
data class Eleve(val nom: String, val note: Int)

// les noms des élèves d'une liste
fun noms(eleves: List<Eleve>): List<String> = eleves.map { it.nom }

// trier avec un Comparator, premier et dernier éléments, parcours et suppression
fun main() {
    val eleves = mutableListOf(
        Eleve("martin", 15), Eleve("dupont", 12), Eleve("Durand", 15),
        Eleve("élise", 9), Eleve("petit", 12)
    )

    // 1 - trier selon une clé, un Comparator, plusieurs critères
    eleves.sortBy { it.nom }
    println("par nom                  : ${noms(eleves)}")
    eleves.sortWith(compareBy(Collator.getInstance(Locale.FRANCE)) { it.nom })
    println("par nom (Collator)       : ${noms(eleves)}")
    eleves.sortWith(compareByDescending<Eleve> { it.note }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.nom })
    println("note décroissante, nom   : ${noms(eleves)}")

    // 2 - premier, dernier, ordre inverse
    println("first()=${eleves.first().nom}, last()=${eleves.last().nom}, "
            + "asReversed()=${noms(eleves.asReversed())}")
    println("first { it.note < 15 }=${eleves.first { it.note < 15 }}, "
            + "lastOrNull { it.note > 18 }=${eleves.lastOrNull { it.note > 18 }}")
    val villes = linkedSetOf("Angers", "Paris", "Nantes")
    villes.addFirst("Lyon")
    println("LinkedHashSet : $villes, first()=${villes.first()}, last()=${villes.last()}, "
            + "reversed()=${villes.reversed()}")
    val notes = TreeMap(mapOf("martin" to 15, "dupont" to 12, "petit" to 12))
    println("TreeMap : firstEntry()=${notes.firstEntry()}, lastEntry()=${notes.lastEntry()}, "
            + "descendingMap()=${notes.descendingMap()}")

    // 3 - supprimer des éléments pendant un parcours for : exception (essai sur une copie)
    val copie = eleves.toMutableList()
    try {
        for (eleve in copie) {
            if (eleve.note == 12) {
                copie.remove(eleve)
            }
        }
    } catch (e: ConcurrentModificationException) {
        println("remove dans un for : ${e::class.simpleName}")
    }
    // la bonne façon : l'itérateur explicite et sa fonction remove...
    val iterateur = eleves.iterator()
    while (iterateur.hasNext()) {
        val eleve = iterateur.next()
        if (eleve.note < 10) {
            iterateur.remove()
        }
    }
    println("après iterateur.remove() : ${noms(eleves)}")
    // ... ou, plus simplement, removeAll avec une condition
    eleves.removeAll { it.note < 15 }
    println("après removeAll { ... }  : ${noms(eleves)}")
    // forEach : un parcours exprimé par une lambda
    eleves.forEach { println("  $it") }
}

Résultats :

> gradlew -q run -Pmain=collections06.Collections06Kt
par nom                  : [Durand, dupont, martin, petit, élise]
par nom (Collator)       : [dupont, Durand, élise, martin, petit]
note décroissante, nom   : [Durand, martin, dupont, petit, élise]
first()=Durand, last()=élise, asReversed()=[élise, petit, dupont, martin, Durand]
first { it.note < 15 }=Eleve(nom=dupont, note=12), lastOrNull { it.note > 18 }=null
LinkedHashSet : [Lyon, Angers, Paris, Nantes], first()=Lyon, last()=Nantes, reversed()=[Nantes, Paris, Angers, Lyon]
TreeMap : firstEntry()=dupont=12, lastEntry()=petit=12, descendingMap()={petit=12, martin=15, dupont=12}
remove dans un for : ConcurrentModificationException
après iterateur.remove() : [Durand, martin, dupont, petit]
après removeAll { ... }  : [Durand, martin]
  Eleve(nom=Durand, note=15)
  Eleve(nom=martin, note=15)

Commentaires :

  • ligne 8 : un élève est représenté par une classe de données ;
  • ligne 11 : la fonction [noms] rend la liste des noms des élèves. La fonction [map], que nous avons déjà utilisée sans la présenter, applique une lambda à chaque élément d'une collection et rend la liste des résultats : en Java, il faut une boucle ou l'API Stream. La fonction est écrite sous la forme d'une fonction à corps d'expression ([= expression], chapitre 3) ;
  • ligne 21 : [sortBy] trie la liste elle-même selon une clé extraite de chaque élément par une lambda, ici le nom. 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 23 : [sortWith] trie selon un [Comparator]. La fonction [compareBy] (chapitre 4) construit un comparateur à partir de la fonction qui extrait la clé ; une surcharge reçoit en premier paramètre le comparateur à utiliser pour comparer les clés, ici un [Collator] : l'ordre est celui d'un dictionnaire français. C'est l'équivalent de [Comparator.comparing(Eleve::nom, collator)] en Java ;
  • ligne 25 : les comparateurs se composent : [compareByDescending] compare les notes en ordre décroissant et [thenBy] 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. Le paramètre de type [<Eleve>] de [compareByDescending] est nécessaire ici, car le compilateur ne peut pas déduire le type des éléments d'une expression qui se poursuit par [thenBy] ;
  • lignes 29-32 : les fonctions [first] et [last] rendent le premier et le dernier élément, et [asReversed] une vue de la liste dans l'ordre inverse, sans la copier ([reversed] rendrait une copie inversée). [first] et [lastOrNull] acceptent aussi une condition : elles rendent le premier (le dernier) élément qui la vérifie ; [first] lance une exception [NoSuchElementException] s'il n'y en a pas, [lastOrNull] rend [null]. Kotlin offre ces fonctions sur toutes les collections depuis sa première version : les méthodes [getFirst], [getLast] et [reversed] que Java 21 a ajoutées à ses collections séquencées ([SequencedCollection], [SequencedSet], [SequencedMap]) n'y apportent pas grand-chose ;
  • lignes 33-36 : [linkedSetOf] crée un [LinkedHashSet], déclaré ici avec le type de sa classe. On peut alors utiliser les méthodes que Java 21 a ajoutées à cette classe : [addFirst] ajoute « Lyon » en tête de l'ensemble. Attention à une subtilité : lorsqu'une classe Java a une méthode de même nom qu'une fonction d'extension, la méthode membre l'emporte toujours (chapitre 4). Ici, [villes.reversed()] appelle la méthode [reversed] de Java 21, qui rend une vue inversée de l'ensemble, et non la fonction d'extension [reversed] de Kotlin, qui rendrait une liste. En revanche [villes.last()] appelle l'extension de Kotlin, qui parcourt l'ensemble jusqu'à son dernier élément, car la méthode Java s'appelle [getLast] ; Kotlin la présente comme une propriété, [villes.last], qui rend le dernier élément sans parcours. Les résultats sont les mêmes, seul le coût diffère ;
  • lignes 37-39 : une [TreeMap] initialisée avec les couples d'un dictionnaire, et ses méthodes [firstEntry], [lastEntry] et [descendingMap] (vue des couples dans l'ordre décroissant des clés) ;
  • lignes 42-50 : 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. Le comportement est celui de Java, puisque la liste est une [ArrayList] ;
  • lignes 53-60 : une façon correcte est d'écrire explicitement la boucle avec un itérateur et de supprimer l'élément courant par la fonction [remove] de l'itérateur. Pour une liste modifiable, [iterator] rend un [MutableIterator], qui a cette fonction ; un [Iterator] ordinaire (celui d'une [List]) ne l'a pas. Kotlin n'ayant pas de boucle [for] à trois clauses, la boucle s'écrit avec [while] ;
  • ligne 62 : plus simplement, la fonction [removeAll] supprime les éléments qui vérifient une condition, exprimée par une lambda [{ it.note < 15 }], qui se lit « la fonction qui, à un élève [it], associe le booléen [it.note < 15] ». La méthode [removeIf] de Java est aussi utilisable, et [retainAll] garde au contraire les éléments qui vérifient la condition. 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 65 : la fonction [forEach] exécute une lambda pour chaque élément ; une boucle [for] ferait aussi bien l'affaire. L'affichage d'une classe de données par sa fonction [toString] donne le nom de la classe et les valeurs de ses propriétés : [Eleve(nom=Durand, note=15)].

5.5. Les opérations sur les collections et les séquences

Le cours Java présentait ici l'API Stream (paquetage [java.util.stream]), qui permet de traiter les éléments d'une collection comme on interroge une base de données : filtrer, trier, regrouper, calculer des totaux… Kotlin n'en a pas besoin : ces opérations sont, dans sa bibliothèque standard, des fonctions d'extension des collections elles-mêmes ([Iterable<T>], [List<T>], [Map<K, V>]…). On écrit [personnes.filter { it.age >= 18 }] là où Java écrit [personnes.stream().filter(p -> p.age() >= 18).toList()] : pas de passage par un flux, pas de collecteur final, le résultat est directement une liste.

Ces opérations sur les collections sont immédiates : chacune parcourt toute la collection et construit une nouvelle collection. Pour retrouver l'évaluation paresseuse des flux Java (les éléments sont calculés un par un, à la demande, sans collections intermédiaires), Kotlin propose les séquences ([Sequence<T>]), qui ont les mêmes opérations. Enfin, les flux Java restent utilisables depuis Kotlin, comme toute classe du JDK : nous en dirons un mot à la fin de la section.

Les programmes de cette section gardent le nom de leurs équivalents Java : [Streams01.kt] à [Streams05.kt].

5.5.1. Les interfaces Iterable et Iterator

Revenons d'abord sur l'interface [Iterable<T>], celle des objets que l'on parcourt avec une boucle [for]. Comme en Java, 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.kt] en donne un exemple, présente les générateurs de Kotlin, que Java n'a pas, et une séquence infinie :

package streams01

// les interfaces Iterable et Iterator ; les générateurs sequence { yield } ; une séquence infinie
fun main() {
    // 1 - for sur un objet Iterable qui produit ses éléments à la demande
    println("[for sur Pairs(6)]")
    for (n in Pairs(6)) {
        println("  reçu $n")
    }

    // 2 - ce que fait for : il demande un itérateur à l'objet
    println("[itération à la main]")
    val iterateur: Iterator<Int> = Pairs(4).iterator()
    while (iterateur.hasNext()) {
        println("  next()=${iterateur.next()}")
    }

    // 3 - un générateur : la fonction sequence { } produit ses éléments avec yield
    println("[générateur sequence { yield }]")
    for (n in pairs(4)) {
        println("  reçu $n")
    }

    // 4 - une séquence infinie : ses éléments ne sont calculés qu'au fur et à mesure des besoins
    println("[séquence infinie]")
    val fibonacci = generateSequence(0L to 1L) { (a, b) -> b to a + b }.map { it.first }
    println(fibonacci.takeWhile { it <= 100 }.joinToString(" "))

    // 5 - for accepte tout objet qui a une méthode operator fun iterator()
    println("[autres objets parcourables]")
    for (c in "abc") print("$c-")
    for (i in Compte(3)) print(" $i")
    println()
}

// les nombres pairs de 0 à max, produits un par un par un itérateur
class Pairs(private val max: Int) : Iterable<Int> {

    override fun iterator(): Iterator<Int> {
        println("  (début de l'itération)")
        // un objet anonyme qui implémente l'interface Iterator<Int>
        return object : Iterator<Int> {
            // le prochain nombre à produire
            private var suivant = 0

            override fun hasNext(): Boolean {
                val encore = suivant <= max
                if (!encore) {
                    println("  (fin de l'itération)")
                }
                return encore
            }

            override fun next(): Int {
                if (suivant > max) {
                    throw NoSuchElementException()
                }
                println("  (production de $suivant)")
                val n = suivant
                suivant += 2
                return n
            }
        }
    }
}

// les mêmes nombres pairs, produits par un générateur
fun pairs(max: Int): Sequence<Int> = sequence {
    println("  (début du générateur)")
    var n = 0
    while (n <= max) {
        println("  (production de $n)")
        // yield rend n au consommateur et suspend le générateur jusqu'à la demande suivante
        yield(n)
        n += 2
    }
    println("  (fin du générateur)")
}

// une classe qui n'implémente pas Iterable, mais qui a un opérateur iterator()
class Compte(private val n: Int) {
    operator fun iterator(): Iterator<Int> = (1..n).iterator()
}

Résultats :

> gradlew -q run -Pmain=streams01.Streams01Kt
[for sur Pairs(6)]
  (début de l'itération)
  (production de 0)
  reçu 0
  (production de 2)
  reçu 2
  (production de 4)
  reçu 4
  (production de 6)
  reçu 6
  (fin de l'itération)
[itération à la main]
  (début de l'itération)
  (production de 0)
  next()=0
  (production de 2)
  next()=2
  (production de 4)
  next()=4
  (fin de l'itération)
[générateur sequence { yield }]
  (début du générateur)
  (production de 0)
  reçu 0
  (production de 2)
  reçu 2
  (production de 4)
  reçu 4
  (fin du générateur)
[séquence infinie]
0 1 1 2 3 5 8 13 21 34 55 89
[autres objets parcourables]
a-b-c- 1 2 3

Commentaires :

  • à partir de la ligne 37 : la classe [Pairs] implémente l'interface [Iterable<Int>] : sa méthode [iterator], redéfinie ([override]), rend un itérateur qui produit les nombres pairs de 0 à [max]. Elle ne construit aucune liste : l'itérateur mémorise seulement le prochain nombre à produire (ligne 44) ;
  • ligne 42 : l'itérateur est un objet anonyme : une expression objet [object : Iterator<Int> { ... }], qui déclare et instancie en une seule expression une classe sans nom implémentant l'interface. C'est l'équivalent de la classe anonyme Java [new Iterator<>() { ... }]. Elle a accès à la propriété [max] de l'objet [Pairs] ;
  • lignes 46-51 : [hasNext] indique s'il reste un élément à produire ;
  • lignes 54-61 : [next] produit l'élément suivant. Appelée alors qu'il n'y a plus d'élément, elle doit lancer une [NoSuchElementException] (en Kotlin, [NoSuchElementException] est un alias de la classe Java [java.util.NoSuchElementException], disponible sans [import]) ;
  • lignes 7-8 : les résultats (lignes 2-11) montrent que la production d'un élément par l'itérateur et sa consommation par la boucle [for] alternent ;
  • lignes 13-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 68-77 : les mêmes nombres pairs, produits par un générateur. La fonction [sequence] de la bibliothèque standard reçoit un bloc de code (une lambda) et rend une séquence [Sequence<Int>]. Chaque appel de [yield(n)] (ligne 74) transmet un élément au consommateur et suspend le bloc, qui reprendra juste après [yield] quand l'élément suivant sera demandé. On écrit ainsi un itérateur comme une simple boucle, sans gérer soi-même un état entre deux appels de [next]. C'est l'équivalent du [yield return] de C#, que Java n'a pas : le cours Java devait écrire la classe de l'itérateur ou utiliser [Stream.iterate]. Ce mécanisme de suspension est celui des coroutines, que nous étudierons au chapitre 9 ;
  • les résultats (lignes 22-29) montrent que le générateur et la boucle [for] alternent exactement comme l'itérateur écrit à la main : rien n'est produit d'avance ;
  • lignes 26-27 : une séquence infinie, la suite de Fibonacci. [generateSequence(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 une paire ([Pair<Long, Long>], créée par [to]) de deux nombres consécutifs de la suite ; la lambda déstructure la paire reçue [(a, b)] et rend la paire suivante. [map] n'en garde que le premier nombre, [takeWhile] arrête la séquence au premier nombre supérieur à 100, et [joinToString] concatène les nombres restants avec un séparateur. La séquence n'est jamais calculée en entier : seuls les éléments demandés le sont ;
  • ligne 31 : contrairement à Java, une chaîne se parcourt directement avec [for], caractère par caractère : la bibliothèque standard de Kotlin définit une fonction [operator fun CharSequence.iterator()] ;
  • lignes 81-82 : c'est la règle générale : la boucle [for] de Kotlin accepte tout objet qui a une fonction [operator fun iterator()] (membre ou extension) rendant un objet qui a des fonctions [operator fun hasNext()] et [operator fun next()]. La classe [Compte] n'implémente pas [Iterable] ; sa fonction [iterator], marquée [operator], suffit ([(1..n).iterator()] est l'itérateur de l'intervalle [1..n]). C'est une convention, comme celles de la surcharge des opérateurs vue au chapitre 4.

Pour résumer : un [Iterable] se parcourt avec [for], et l'on obtient très simplement un objet qui produit ses éléments à la demande avec [sequence { ... yield(x) ... }] ou [generateSequence]. C'est ce mécanisme de production à la demande qu'utilisent toutes les séquences.

5.5.2. Filtrer, projeter, trier

Les exemples qui suivent travaillent sur une petite liste de personnes. Une personne est représentée par une classe de données ([data class]), présentée au chapitre 4, équivalent Kotlin du [record] Java : en une ligne, on obtient une classe avec un constructeur, des propriétés en lecture seule ([p.nom], [p.age]…), une méthode [toString] qui affiche les propriétés, une égalité qui compare leurs valeurs, et une fonction [copy].

Le programme [Streams02.kt] présente les opérations de base sur les collections :

package streams02

// les opérations sur les collections : filtrer (filter), projeter (map), trier (sorted…)
fun main() {
    // les données : une liste de personnes
    val personnes = listOf(
        Personne("dupont", "jean", 45, "Angers"),
        Personne("martin", "marie", 17, "Paris"),
        Personne("durand", "paul", 32, "Angers"),
        Personne("leroy", "sophie", 28, "Nantes"),
        Personne("moreau", "luc", 15, "Paris"),
        Personne("simon", "julie", 52, "Angers"),
        Personne("laurent", "marc", 32, "Lyon"),
    )

    // filter : les personnes majeures ; { p -> p.age >= 18 } est une fonction anonyme (lambda)
    val majeurs: List<Personne> = personnes.filter { p -> p.age >= 18 }
    affiche("[filter : les majeurs]", majeurs)

    // map : une projection, ici chaque personne devient une chaîne ; it = le paramètre unique
    val noms: List<String> = personnes.map { "${it.prenom} ${it.nom.uppercase()}" }
    affiche("[map : prénom NOM]", noms)

    // les opérations s'enchaînent : les noms des Angevins, par ordre alphabétique
    val angevins = personnes
        .filter { it.ville == "Angers" }
        .map(Personne::nom)
        .sorted()
    affiche("[filter + map + sorted : les Angevins]", angevins)

    // sortedBy, sortedByDescending : tri selon une clé
    affiche("[sortedBy(nom), 3 premiers]", personnes.sortedBy { it.nom }.take(3).map { it.nom })

    // tri sur plusieurs critères : âge décroissant, puis nom croissant
    val tries = personnes.sortedWith(compareByDescending<Personne> { it.age }.thenBy { it.nom })
    affiche("[sortedWith avec un Comparator]", tries)

    // la position de l'élément : mapIndexed, withIndex
    affiche("[mapIndexed : numérotation]", personnes.take(3).mapIndexed { i, p -> "${i + 1}. ${p.nom}" })
    for ((i, p) in personnes.withIndex().filter { it.value.age < 18 }) {
        println("[withIndex] mineur à l'indice $i : ${p.nom}")
    }

    // une liste se parcourt autant de fois qu'on le veut…
    val mineurs = personnes.filter { it.age < 18 }
    println("[une liste] ${mineurs.count()} mineurs, puis encore ${mineurs.count()}")
    // …mais pas un flux Java (java.util.stream.Stream), utilisable depuis Kotlin
    val flux = personnes.stream().filter { it.age < 18 }
    println("[un flux Java déjà parcouru] ${flux.count()} mineurs")
    try {
        flux.count()
    } catch (e: IllegalStateException) {
        println("  ${e.javaClass.simpleName} : ${e.message}")
    }
}

// affichage des éléments d'une collection
fun <T> affiche(titre: String, elements: Iterable<T>) {
    println(titre)
    elements.forEach { println("  $it") }
}

// une personne
data class Personne(val nom: String, val prenom: String, val age: Int, val ville: String)

Résultats :

> gradlew -q run -Pmain=streams02.Streams02Kt
[filter : les majeurs]
  Personne(nom=dupont, prenom=jean, age=45, ville=Angers)
  Personne(nom=durand, prenom=paul, age=32, ville=Angers)
  Personne(nom=leroy, prenom=sophie, age=28, ville=Nantes)
  Personne(nom=simon, prenom=julie, age=52, ville=Angers)
  Personne(nom=laurent, prenom=marc, age=32, ville=Lyon)
[map : prénom NOM]
  jean DUPONT
  marie MARTIN
  paul DURAND
  sophie LEROY
  luc MOREAU
  julie SIMON
  marc LAURENT
[filter + map + sorted : les Angevins]
  dupont
  durand
  simon
[sortedBy(nom), 3 premiers]
  dupont
  durand
  laurent
[sortedWith avec un Comparator]
  Personne(nom=simon, prenom=julie, age=52, ville=Angers)
  Personne(nom=dupont, prenom=jean, age=45, ville=Angers)
  Personne(nom=durand, prenom=paul, age=32, ville=Angers)
  Personne(nom=laurent, prenom=marc, age=32, ville=Lyon)
  Personne(nom=leroy, prenom=sophie, age=28, ville=Nantes)
  Personne(nom=martin, prenom=marie, age=17, ville=Paris)
  Personne(nom=moreau, prenom=luc, age=15, ville=Paris)
[mapIndexed : numérotation]
  1. dupont
  2. martin
  3. durand
[withIndex] mineur à l'indice 1 : martin
[withIndex] mineur à l'indice 4 : moreau
[une liste] 2 mineurs, puis encore 2
[un flux Java déjà parcouru] 2 mineurs
  IllegalStateException : stream has already been operated upon or closed

Commentaires :

  • lignes 6-13 : la liste des personnes, en lecture seule, créée par [listOf]. La virgule après le dernier élément (« virgule finale ») est permise en Kotlin : elle facilite l'ajout d'un élément ;
  • ligne 64 : la classe de données [Personne], déclarée au niveau du fichier, à côté des fonctions ;
  • ligne 17 : la fonction [filter] filtre la liste : 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. Quand le dernier paramètre d'une fonction est une fonction, la lambda s'écrit après les parenthèses, qui disparaissent si elles sont vides : [personnes.filter { ... }] et non [personnes.filter({ ... })] ;
  • le résultat de [filter] est une nouvelle liste [List<Personne>] : la liste [personnes] n'est pas modifiée. C'est vrai de toutes les opérations présentées dans cette section, qui s'appliquent aussi bien aux listes en lecture seule qu'aux listes modifiables ;
  • lignes 58-60 : la fonction générique [affiche] parcourt une collection avec [forEach] et affiche chaque élément ; une personne s'affiche avec la méthode [toString] de la classe de données : [Personne(nom=dupont, ...)] (des parenthèses là où Java mettait des crochets) ;
  • ligne 21 : la fonction [map] projette chaque élément sur une nouvelle valeur, ici une chaîne. Le résultat est une liste [List<String>]. Une lambda à un seul paramètre peut ne pas le nommer : il s'appelle alors [it] ;
  • lignes 25-28 : les opérations rendent des collections, auxquelles 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 propriété, écriture abrégée de la lambda [{ it.nom }] ;
  • ligne 28 : [sorted] trie selon l'ordre naturel des éléments, ici des chaînes ([sortedDescending] trie en ordre inverse) ;
  • ligne 32 : [sortedBy] trie selon une clé calculée par une lambda ([sortedByDescending] en ordre décroissant) ; [take(3)] garde les trois premiers éléments ;
  • ligne 35 : pour un tri sur plusieurs critères, [sortedWith] accepte un [Comparator], construit avec les fonctions de Kotlin vues au chapitre 4 : [compareByDescending<Personne> { it.age }] (âge décroissant), puis [thenBy { it.nom }] pour départager les personnes de même âge. durand et laurent, qui ont tous deux 32 ans, sont classés par nom. Le type [Personne] doit être précisé à [compareByDescending], car le compilateur ne peut pas le déduire de la lambda seule ;
  • ligne 39 : pour numéroter, [mapIndexed] passe à la lambda l'indice de l'élément en plus de l'élément ; il existe de même [filterIndexed], [forEachIndexed]… Java devait parcourir les indices avec un [IntStream.range] ;
  • lignes 40-41 : [withIndex] transforme chaque élément en un objet [IndexedValue] (propriétés [index] et [value]), que l'on peut filtrer, puis déstructurer en [(i, p)] dans la boucle ;
  • lignes 45-46 : une liste, comme toute collection, se parcourt autant de fois qu'on le veut : le résultat de [filter] est une vraie liste, pas un calcul en attente ;
  • lignes 48-53 : un flux Java ([java.util.stream.Stream]), obtenu par la méthode [stream] de la liste (une liste Kotlin est, à l'exécution, une [java.util.List]), est utilisable depuis Kotlin ; les lambdas Kotlin sont converties automatiquement en interfaces fonctionnelles Java. Mais il reste à usage unique : une fois parcouru par une opération terminale (ici [count]), il lance une [IllegalStateException]. [e.javaClass.simpleName] est l'écriture Kotlin de [e.getClass().getSimpleName()].

Les opérations sur les collections de Kotlin ne distinguent donc pas opérations intermédiaires et terminales : chacune produit un résultat complet. Cette simplicité a un coût : une chaîne de n opérations crée n collections intermédiaires. Ce n'est pas un problème pour les petites collections, de loin les plus courantes ; pour les grandes, ou pour les traitements qui peuvent s'arrêter tôt, on utilise les séquences (programme [Streams05.kt]). Comme Java, et contrairement à C# (LINQ), Kotlin n'a pas de syntaxe de requête proche de SQL : les traitements s'écrivent sous forme de chaînes d'appels de fonctions.

5.5.3. Quantificateurs, agrégats, choix d'éléments

Le programme [Streams03.kt] présente des opérations qui rendent une seule valeur, et des opérations qui sélectionnent une partie d'une collection :

package streams03

// quantificateurs, agrégats, choix d'éléments, découpages
fun main() {
    val personnes = listOf(
        Personne("dupont", "jean", 45, "Angers"),
        Personne("martin", "marie", 17, "Paris"),
        Personne("durand", "paul", 32, "Angers"),
        Personne("leroy", "sophie", 28, "Nantes"),
        Personne("moreau", "luc", 15, "Paris"),
        Personne("simon", "julie", 52, "Angers"),
        Personne("laurent", "marc", 32, "Lyon"),
    )

    // quantificateurs : any (au moins un), all (tous), none (aucun)
    println("any(mineur)=${personnes.any { it.age < 18 }}, all(mineur)=${personnes.all { it.age < 18 }}, " +
            "none(Brest)=${personnes.none { it.ville == "Brest" }}")

    // agrégats : count, sumOf ; sum, average, min, max sur une liste de nombres
    println("count()=${personnes.count()}, count(Angers)=${personnes.count { it.ville == "Angers" }}")
    val ages: List<Int> = personnes.map { it.age }
    println("sumOf(âge)=${personnes.sumOf { it.age }}, sum()=${ages.sum()}, " +
            "average()=${"%.2f".format(ages.average())}")
    println("min()=${ages.min()}, max()=${ages.max()}, minOf(âge)=${personnes.minOf { it.age }}")
    // minBy, maxBy rendent l'élément lui-même
    println("minBy(âge)=${personnes.minBy { it.age }.nom}, maxBy(âge)=${personnes.maxBy { it.age }.nom}")
    // fold, reduce : un agrégat calculé par une fonction
    val totalAges = personnes.fold(0) { total, p -> total + p.age }
    val plusLong = personnes.map { it.nom }.reduce { a, b -> if (b.length > a.length) b else a }
    println("fold(somme des âges)=$totalAges, reduce(nom le plus long)=$plusLong")

    // choix d'un élément : first, last, first { condition }
    val parisien = personnes.first { it.ville == "Paris" }
    println("first()=${personnes.first().nom}, premier de Paris=${parisien.nom}, " +
            "last()=${personnes.last().nom}")
    // firstOrNull rend null s'il n'y a pas d'élément
    val brestois: Personne? = personnes.firstOrNull { it.ville == "Brest" }
    println("Brest : firstOrNull=$brestois, ?.nom ?: : ${brestois?.nom ?: "personne"}")
    try {
        // first exige qu'il y ait un élément
        personnes.first { it.ville == "Brest" }
    } catch (e: NoSuchElementException) {
        println("Brest : first { } : ${e.javaClass.simpleName} : ${e.message}")
    }
    // single exige qu'il y ait exactement un élément
    println("single(Nantes)=${personnes.single { it.ville == "Nantes" }.nom}, " +
            "singleOrNull(Paris)=${personnes.singleOrNull { it.ville == "Paris" }}")

    // distinct : élimine les doublons ; distinctBy : un élément par valeur de la clé
    println("distinct(villes)=${personnes.map { it.ville }.distinct()}")
    println("une personne par ville=${personnes.distinctBy { it.ville }.map { it.nom }}")

    // découpages : take, drop, takeWhile, dropWhile, takeLast, dropLast
    val noms = personnes.map { it.nom }
    println("take(2)=${noms.take(2)}, drop(5)=${noms.drop(5)}, takeLast(1)=${noms.takeLast(1)}")
    println("drop(2).take(2)=${noms.drop(2).take(2)}, slice(2..3)=${noms.slice(2..3)}")
    println("takeWhile(longueur 6)=${noms.takeWhile { it.length == 6 }}, " +
            "dropWhile(longueur 6)=${noms.dropWhile { it.length == 6 }}")

    // chunked : découpage en paquets de taille fixe (pagination) ; windowed : fenêtres glissantes
    noms.chunked(3).forEachIndexed { i, page -> println("page ${i + 1} : ${page.joinToString(", ")}") }
    println("windowed(3)=${ages.windowed(3)}")
    println("windowed(3) { moyenne }=${ages.windowed(3) { it.average() }.map { "%.1f".format(it) }}")
}

data class Personne(val nom: String, val prenom: String, val age: Int, val ville: String)

Résultats :

> gradlew -q run -Pmain=streams03.Streams03Kt
any(mineur)=true, all(mineur)=false, none(Brest)=true
count()=7, count(Angers)=3
sumOf(âge)=221, sum()=221, average()=31,57
min()=15, max()=52, minOf(âge)=15
minBy(âge)=moreau, maxBy(âge)=simon
fold(somme des âges)=221, reduce(nom le plus long)=laurent
first()=dupont, premier de Paris=martin, last()=laurent
Brest : firstOrNull=null, ?.nom ?: : personne
Brest : first { } : NoSuchElementException : Collection contains no element matching the predicate.
single(Nantes)=leroy, singleOrNull(Paris)=null
distinct(villes)=[Angers, Paris, Nantes, Lyon]
une personne par ville=[dupont, martin, leroy, laurent]
take(2)=[dupont, martin], drop(5)=[simon, laurent], takeLast(1)=[laurent]
drop(2).take(2)=[durand, leroy], slice(2..3)=[durand, leroy]
takeWhile(longueur 6)=[dupont, martin, durand], dropWhile(longueur 6)=[leroy, moreau, simon, laurent]
page 1 : dupont, martin, durand
page 2 : leroy, moreau, simon
page 3 : laurent
windowed(3)=[[45, 17, 32], [17, 32, 28], [32, 28, 15], [28, 15, 52], [15, 52, 32]]
windowed(3) { moyenne }=[31,3, 25,7, 25,0, 31,7, 33,0]

Commentaires :

  • lignes 15-17 : [any] rend [true] si au moins un élément vérifie la condition, [all] si tous la vérifient, [none] si aucun ne la vérifie (les [anyMatch], [allMatch], [noneMatch] des flux Java). Sans paramètre, [any()] indique si la collection a au moins un élément. Dans un gabarit de chaîne, [${ ... }] peut contenir n'importe quelle expression, y compris un appel de fonction avec une lambda ;
  • ligne 20 : [count] compte les éléments ; avec une condition, il compte ceux qui la vérifient, sans qu'il faille filtrer d'abord. Pour une collection, la propriété [size] est évidemment préférable à [count()] ;
  • lignes 21-23 : [sumOf { it.age }] additionne une valeur calculée pour chaque élément. Sur une collection de nombres ([List<Int>]), les fonctions [sum] et [average] s'appliquent directement : pas besoin, comme en Java, de passer par un flux spécialisé ([mapToInt(...)] puis [IntStream]). [average] rend un [Double], qui vaut [NaN] (« pas un nombre ») pour une collection vide. ["%.2f".format(x)] formate le nombre avec la langue du système : d'où la virgule décimale ;
  • ligne 24 : [min] et [max] rendent le plus petit et le plus grand élément d'une collection d'éléments comparables ; [minOf { it.age }] rend la plus petite des valeurs calculées. Ces fonctions lancent une [NoSuchElementException] si la collection est vide ; leurs variantes [minOrNull], [maxOrNull], [minOfOrNull]… rendent alors [null] ;
  • ligne 26 : [minBy] et [maxBy] rendent l'élément (ici la personne) dont la clé est minimale ou maximale, et non la valeur de la clé. Là où Java rend un [Optional<Personne>] qu'il faut ouvrir avec [get], Kotlin rend directement la personne (et lance une exception si la collection est vide ; [minByOrNull] rend alors [null]) ;
  • lignes 28-30 : [fold] calcule un agrégat quelconque : elle part d'une valeur initiale (ici 0), puis appelle la lambda pour chaque élément avec le résultat précédent ([total]) et l'élément ([p]), et rend le dernier résultat. [reduce] fait de même en prenant le premier élément comme valeur initiale : ici, à chaque étape, on garde le plus long des deux noms. [sum], [count], [max]… ne sont que des cas particuliers de ces deux fonctions ;
  • lignes 32-35 : [first] rend le premier élément ; avec une condition, le premier élément qui la vérifie. [last] rend le dernier ;
  • lignes 37-38 : il n'y a personne à Brest. [firstOrNull] rend alors [null] : son type de retour est [Personne?]. Là où Java utilise un [Optional], Kotlin utilise un type nullable, et ses opérateurs : [brestois?.nom ?: "personne"] donne le nom s'il y a une personne, et « personne » sinon. C'est l'équivalent exact du [map(Personne::nom).orElse("personne")] de Java ;
  • lignes 39-43 : [first] exige qu'il y ait un élément : sinon, elle lance une [NoSuchElementException]. On l'utilise quand l'absence de valeur est une erreur ; quand elle est normale, on utilise [firstOrNull]. La même règle de nommage vaut pour toute la bibliothèque : [last]/[lastOrNull], [single]/[singleOrNull], [elementAt]/[elementAtOrNull], [getOrNull] (pour un indice)… ;
  • lignes 45-47 : [single] exige qu'il y ait exactement un élément (qui vérifie la condition) : sinon, elle lance une exception. [singleOrNull] rend [null] s'il n'y en a aucun, ou plusieurs, comme ici à Paris. Les flux Java n'ont pas d'équivalent ;
  • ligne 50 : [distinct] élimine les doublons (au sens de la méthode [equals]) ;
  • ligne 51 : [distinctBy] élimine les éléments dont la clé a déjà été vue : on garde ainsi la première personne de chaque ville. Le cours Java devait écrire cette opération lui-même, avec un ensemble des villes déjà vues ;
  • lignes 54-58 : [take(n)] garde les n premiers éléments, [drop(n)] les saute ([limit] et [skip] en Java) ; [takeLast] et [dropLast] font de même à partir de la fin. Combinées, [drop] et [take] extraient une « page » de résultats ; pour une liste, [slice(2..3)] extrait directement les éléments d'un intervalle d'indices. [takeWhile] 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 : [chunked(3)] découpe la collection en listes de 3 éléments, la dernière pouvant être plus courte : c'est la pagination. Java 24 a dû introduire pour cela les « gatherers » ([gather(Gatherers.windowFixed(3))]) ; Kotlin a [chunked] depuis sa version 1.2 ;
  • lignes 62-63 : [windowed(3)] produit les fenêtres glissantes de 3 éléments consécutifs : éléments 0-2, puis 1-3, puis 2-4… (le [windowSliding(3)] de Java 24). Comme [chunked], [windowed] accepte une lambda qui transforme chaque fenêtre : ici, on calcule la moyenne mobile des âges sur trois personnes.

5.5.4. Classes de données locales, regroupements, jointures

Le programme [Streams04.kt] présente des opérations plus élaborées. Là où Java les réalise pour la plupart avec l'opération terminale [collect] et les collecteurs de la classe [Collectors], Kotlin a une fonction pour chacune :

package streams04

// classes de données locales, regroupements, dictionnaires, jointures
fun main() {
    val personnes = listOf(
        Personne("dupont", "jean", 45, "Angers"),
        Personne("martin", "marie", 17, "Paris"),
        Personne("durand", "paul", 32, "Angers"),
        Personne("leroy", "sophie", 28, "Nantes"),
        Personne("moreau", "luc", 15, "Paris"),
        Personne("simon", "julie", 52, "Angers"),
        Personne("laurent", "marc", 32, "Lyon"),
    )
    val villes = listOf(
        Ville("Angers", "Maine-et-Loire"),
        Ville("Nantes", "Loire-Atlantique"),
        Ville("Paris", "Paris"),
        Ville("Brest", "Finistère"),
    )

    // 1 - une classe de données locale : un type déclaré dans la fonction, pour un résultat intermédiaire
    data class Resume(val nom: String, val decennie: Int)
    println("[classe de données locale]")
    personnes
        .filter { it.age > 40 }
        .map { Resume(it.nom, it.age / 10 * 10) }
        .forEach { (nom, decennie) -> println("  $nom a plus de $decennie ans") }

    // 2 - groupBy : regroupement des personnes par ville, dans l'ordre de première apparition
    println("[groupBy]")
    val parVille: Map<String, List<Personne>> = personnes.groupBy { it.ville }
    for ((ville, groupe) in parVille) {
        println("  %s : %d personne(s), âge moyen %.1f (%s)".format(
            ville, groupe.size, groupe.map { it.age }.average(), groupe.joinToString(", ") { it.nom }))
    }
    // groupBy avec une projection des valeurs
    println("[groupBy(ville, nom)] ${personnes.groupBy({ it.ville }, { it.nom })}")

    // 3 - une jointure : chaque personne est associée à la ville de même nom
    println("[jointure avec flatMap]")
    personnes
        .flatMap { p ->
            villes.filter { it.nom == p.ville }.map { v -> "${p.nom} habite le département ${v.departement}" }
        }
        .forEach { println("  $it") }
    // la même jointure avec un dictionnaire des villes, plus efficace : une recherche par personne
    val villesParNom: Map<String, Ville> = villes.associateBy { it.nom }
    val jointure = personnes
        .filter { it.age >= 18 }
        .mapNotNull { p -> villesParNom[p.ville]?.let { v -> "${p.nom}/${v.departement}" } }
        .joinToString(", ")
    println("[jointure avec un dictionnaire] $jointure")

    // 4 - dictionnaires et partitions : associate, associateWith, partition, zip
    val agesParNom: Map<String, Int> = personnes.associate { it.nom to it.age }
    val longueurs = listOf("un", "deux", "trois").associateWith { it.length }
    println("[associate] agesParNom[\"leroy\"]=${agesParNom["leroy"]} ; [associateWith] $longueurs")
    val (majeurs, mineurs) = personnes.partition { it.age >= 18 }
    println("[partition] majeurs=${majeurs.map { it.nom }}, mineurs=${mineurs.map { it.nom }}")
    val couples = personnes.map { it.prenom }.zip(personnes.map { it.nom }) { prenom, nom -> "$prenom $nom" }
    println("[zip] ${couples.take(3)} ; ${listOf(1, 2, 3).zip(listOf("a", "b"))}")

    // 5 - compter, additionner par groupe
    println("[groupingBy + eachCount] ${personnes.groupingBy { it.ville }.eachCount().toSortedMap()}")
    val sommeParVille = personnes.groupBy { it.ville }.mapValues { (_, groupe) -> groupe.sumOf { it.age } }
    println("[groupBy + mapValues + sumOf] ${sommeParVille.toSortedMap()}")
    val ageMaxParVille = personnes.groupingBy { it.ville }.fold(0) { max, p -> maxOf(max, p.age) }
    println("[groupingBy + fold] $ageMaxParVille")

    // 6 - flatMap, flatten : mettre bout à bout des listes
    println("[flatMap] ${villes.take(2).flatMap { listOf(it.nom, it.departement) }}")
    println("[flatten] ${listOf(listOf(1, 2), listOf(3), emptyList()).flatten()}")

    // 7 - la position de chaque élément d'une liste filtrée
    println("[position]")
    personnes.filter { it.age < 30 }.forEachIndexed { i, p -> println("  $i : ${p.nom}") }
}

data class Personne(val nom: String, val prenom: String, val age: Int, val ville: String)

data class Ville(val nom: String, val departement: String)

Résultats :

> gradlew -q run -Pmain=streams04.Streams04Kt
[classe de données locale]
  dupont a plus de 40 ans
  simon a plus de 50 ans
[groupBy]
  Angers : 3 personne(s), âge moyen 43,0 (dupont, durand, simon)
  Paris : 2 personne(s), âge moyen 16,0 (martin, moreau)
  Nantes : 1 personne(s), âge moyen 28,0 (leroy)
  Lyon : 1 personne(s), âge moyen 32,0 (laurent)
[groupBy(ville, nom)] {Angers=[dupont, durand, simon], Paris=[martin, moreau], Nantes=[leroy], Lyon=[laurent]}
[jointure avec flatMap]
  dupont habite le département Maine-et-Loire
  martin habite le département Paris
  durand habite le département Maine-et-Loire
  leroy habite le département Loire-Atlantique
  moreau habite le département Paris
  simon habite le département Maine-et-Loire
[jointure avec un dictionnaire] dupont/Maine-et-Loire, durand/Maine-et-Loire, leroy/Loire-Atlantique, simon/Maine-et-Loire
[associate] agesParNom["leroy"]=28 ; [associateWith] {un=2, deux=4, trois=5}
[partition] majeurs=[dupont, durand, leroy, simon, laurent], mineurs=[martin, moreau]
[zip] [jean dupont, marie martin, paul durand] ; [(1, a), (2, b)]
[groupingBy + eachCount] {Angers=3, Lyon=1, Nantes=1, Paris=2}
[groupBy + mapValues + sumOf] {Angers=129, Lyon=32, Nantes=28, Paris=32}
[groupingBy + fold] {Angers=52, Paris=17, Nantes=28, Lyon=32}
[flatMap] [Angers, Maine-et-Loire, Nantes, Loire-Atlantique]
[flatten] [1, 2, 3]
[position]
  0 : martin
  1 : leroy
  2 : moreau

Commentaires :

  • ligne 22 : une classe de données locale, déclarée dans la fonction, là où elle sert (l'équivalent du record local de Java 16). Elle permet de donner un nom et un type aux résultats intermédiaires d'un traitement, sans avoir à déclarer une classe au niveau du fichier : ligne 26, chaque personne de plus de 40 ans est transformée en un [Resume] qui ne garde que les données utiles ;
  • ligne 27 : la lambda déstructure le [Resume] reçu : [(nom, decennie)] entre parenthèses déclare deux paramètres qui reçoivent les deux propriétés de l'objet, dans l'ordre du constructeur. C'est possible pour toute classe de données (chapitre 3), et pour les paires et les entrées des dictionnaires ;
  • ligne 31 : [groupBy] 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. Les dictionnaires créés par la bibliothèque standard de Kotlin ([groupBy], [associate], [mapOf]…) sont des [LinkedHashMap] : ils gardent les clés dans l'ordre de leur première apparition. Java, qui crée par défaut une [HashMap] à l'ordre imprévisible, devait le demander explicitement ([LinkedHashMap::new]) ;
  • lignes 32-34 : une boucle [for] parcourt les entrées du dictionnaire, déstructurées en [(ville, groupe)]. Pour chaque groupe, on affiche sa taille, l'âge moyen et les noms ; [joinToString(", ") { it.nom }] concatène les noms avec un séparateur : sa lambda transforme chaque élément en chaîne, ce qui évite un [map] préalable ;
  • ligne 37 : une deuxième lambda, facultative, transforme les éléments rangés dans chaque groupe : ici, on ne garde que les noms (c'est le collecteur [mapping] de Java) ;
  • lignes 42-45 : une jointure. Comme les flux Java, les collections de Kotlin n'ont pas d'opération de jointure ; on l'obtient avec [flatMap], dont la lambda rend une liste pour chaque élément : [flatMap] met bout à bout toutes ces listes. Chaque personne est ici remplacée par la liste 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 47-51 : la même jointure, plus efficace. [associateBy { it.nom }] construit un dictionnaire des villes, dont la clé est le nom de la ville et la valeur la ville elle-même (le collecteur [toMap(Ville::nom, v -> v)] de Java) ; on y cherche ensuite la ville de chaque personne majeure. [villesParNom[p.ville]] rend une [Ville?], [null] si la ville est absente ; [?.let { ... }] ne construit la chaîne que si la ville a été trouvée, et [mapNotNull] est un [map] qui élimine les résultats [null] : filtrage et projection se font en une seule opération. Contrairement à [toMap] en Java, [associateBy] ne signale pas les clés en double : la dernière valeur l'emporte ;
  • lignes 55-57 : [associate] construit un dictionnaire à partir d'une lambda qui rend, pour chaque élément, une paire clé-valeur, écrite [it.nom to it.age]. [associateWith] prend les éléments comme clés et calcule les valeurs : ici, la longueur de chaque mot ;
  • lignes 58-59 : [partition] partage les éléments en deux listes selon une condition : ceux qui la vérifient, et les autres. Le résultat est une paire de listes, que l'on déstructure aussitôt en [(majeurs, mineurs)] ; Java rendait un dictionnaire de clés [true] et [false] ([partitioningBy]) ;
  • lignes 60-61 : [zip] associe les éléments de même rang de deux collections, et s'arrête à la fin de la plus courte. Sans lambda, il rend une liste de paires ([(1, a)]…) ; avec une lambda, il combine chaque couple d'éléments, ici en un prénom suivi d'un nom. Les flux Java n'ont pas d'équivalent ;
  • ligne 64 : compter les éléments de chaque groupe. [groupingBy { it.ville }] ne construit pas les groupes : il rend un objet [Grouping] qui sait calculer la clé de chaque élément ; [eachCount] parcourt alors la collection une seule fois en comptant les éléments de chaque clé (le collecteur [counting] de Java). [toSortedMap] copie le dictionnaire dans un dictionnaire trié par clés (une [TreeMap]), pour l'affichage ;
  • lignes 65-66 : additionner une valeur par groupe : on construit les groupes avec [groupBy], puis [mapValues] transforme chaque valeur du dictionnaire (ici une liste de personnes) en une autre valeur (ici la somme des âges), en gardant les clés. La lambda reçoit une entrée du dictionnaire, déstructurée en [(_, groupe)] : le tiret bas [_] indique que la clé ne nous intéresse pas ;
  • lignes 67-68 : un [Grouping] a aussi [fold], [reduce] et [aggregate], qui calculent un agrégat par groupe en un seul parcours, sans construire les listes des groupes : ici, l'âge maximal de chaque ville ;
  • lignes 71-72 : [flatMap] encore, qui transforme chaque ville en une liste de deux chaînes mises bout à bout ; [flatten] « aplatit » une liste de listes ;
  • ligne 76 : la position de chaque élément d'une liste filtrée : [forEachIndexed] passe l'indice à la lambda. Java devait ranger le flux dans une liste, puis en parcourir les indices.

Le tableau suivant résume les correspondances entre les collecteurs Java et les fonctions de Kotlin rencontrés dans ce programme :

Java (flux et collecteurs)
Kotlin (collections)
[collect(toList())], [toList()]
inutile : le résultat est déjà une liste ; [toList()], [toMutableList()] pour copier
[groupingBy(f)]
[groupBy(f)] ; [groupBy(f, g)] pour [groupingBy(f, mapping(g, toList()))]
[groupingBy(f, counting())]
[groupingBy(f).eachCount()]
[groupingBy(f, summingInt(g))]
[groupBy(f).mapValues { (_, l) -> l.sumOf(g) }]
[toMap(f, v -> v)]
[associateBy(f)]
[toMap(f, g)]
[associate { f(it) to g(it) }], [associateBy(f, g)]
[partitioningBy(p)]
[partition(p)] (une paire de listes)
[joining(", ")]
[joinToString(", ")]
[flatMap(e -> flux)]
[flatMap { liste }], [flatten()]

5.5.5. L'évaluation paresseuse : les séquences

Les opérations sur les collections sont exécutées immédiatement, chacune en entier. Les opérations sur les séquences ([Sequence<T>]) ne sont pas exécutées au moment où on les écrit, mais au moment où une opération terminale parcourt la séquence : c'est l'évaluation paresseuse (« lazy evaluation »), celle des flux Java. Le programme [Streams05.kt] compare les deux, avec des lambdas qui affichent ce qu'elles font :

package streams05

import java.util.stream.IntStream
import kotlin.streams.asSequence
import kotlin.streams.asStream

// l'évaluation paresseuse : les séquences (Sequence) comparées aux collections ; les flux Java
fun main() {
    val nombres = mutableListOf(1, 2, 3, 4, 5, 6)

    // 1 - sur une liste, chaque opération est exécutée en entier et crée une nouvelle liste
    println("[liste : évaluation immédiate]")
    val carres = nombres
        .filter { println("  filter examine $it"); it % 2 == 0 }
        .map { println("  map calcule $it*$it"); it * it }
    println("  résultat : $carres")

    // 2 - la définition d'une séquence ne l'exécute pas
    println("[définition de la séquence]")
    val carresPairs: Sequence<Int> = nombres.asSequence()
        .filter {
            println("  filter examine $it")
            it % 2 == 0
        }
        .map {
            println("  map calcule $it*$it")
            it * it
        }
    println("[la séquence est définie, rien n'a encore été exécuté]")

    // 3 - elle s'exécute lors de l'opération terminale, élément par élément
    println("[forEach]")
    carresPairs.forEach { println("  forEach reçoit $it") }

    // 4 - une séquence se parcourt de nouveau : le calcul est refait sur l'état actuel de la source
    val grands = nombres.asSequence().filter { it > 4 }
    println("[avant ajout] grands=${grands.toList()}")
    nombres.add(10)
    println("[après ajout de 10] grands=${grands.toList()}")

    // 5 - toList mémorise le résultat à un instant donné
    val instantane = grands.toList()
    nombres.add(20)
    println("[après ajout de 20] grands=${grands.toList()}, instantane=$instantane")

    // 6 - first arrête le parcours dès qu'il a trouvé un élément
    println("[first sur une séquence]")
    val premier = nombres.asSequence()
        .filter { println("  filter examine $it"); it > 2 }
        .first()
    println("  premier=$premier")

    // 7 - sorted doit connaître tous les éléments avant d'en transmettre un seul
    println("[sorted]")
    sequenceOf(3, 1, 2)
        .onEach { println("  avant sorted : $it") }
        .sorted()
        .forEach { println("  après sorted : $it") }

    // 8 - des séquences infinies : generateSequence, et une séquence de tous les entiers impairs
    val puissances = generateSequence(1) { it * 2 }
    println("[generateSequence] ${puissances.take(10).toList()}")
    val impairs = generateSequence(1) { it + 2 }
    println("[premier impair dont le carré dépasse 200] ${impairs.first { it * it > 200 }}")

    // 9 - les flux Java restent utilisables ; conversions asSequence() / asStream()
    val sommeCarres = IntStream.rangeClosed(1, 10).filter { it % 2 == 0 }.map { it * it }.sum()
    val sommeKotlin = (1..10).filter { it % 2 == 0 }.sumOf { it * it }
    println("[IntStream] somme des carrés des nombres pairs de 1 à 10 = $sommeCarres ; " +
            "en Kotlin : $sommeKotlin")
    println("[conversions] ${IntStream.of(5, 3, 8).boxed().asSequence().sorted().toList()}, " +
            "${sequenceOf("a", "b").asStream().map { it.uppercase() }.toList()}")
}

Résultats :

> gradlew -q run -Pmain=streams05.Streams05Kt
[liste : évaluation immédiate]
  filter examine 1
  filter examine 2
  filter examine 3
  filter examine 4
  filter examine 5
  filter examine 6
  map calcule 2*2
  map calcule 4*4
  map calcule 6*6
  résultat : [4, 16, 36]
[définition de la séquence]
[la séquence est définie, rien n'a encore été exécuté]
[forEach]
  filter examine 1
  filter examine 2
  map calcule 2*2
  forEach reçoit 4
  filter examine 3
  filter examine 4
  map calcule 4*4
  forEach reçoit 16
  filter examine 5
  filter examine 6
  map calcule 6*6
  forEach reçoit 36
[avant ajout] grands=[5, 6]
[après ajout de 10] grands=[5, 6, 10]
[après ajout de 20] grands=[5, 6, 10, 20], instantane=[5, 6, 10]
[first sur une séquence]
  filter examine 1
  filter examine 2
  filter examine 3
  premier=3
[sorted]
  avant sorted : 3
  avant sorted : 1
  avant sorted : 2
  après sorted : 1
  après sorted : 2
  après sorted : 3
[generateSequence] [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
[premier impair dont le carré dépasse 200] 15
[IntStream] somme des carrés des nombres pairs de 1 à 10 = 220 ; en Kotlin : 220
[conversions] [3, 5, 8], [A, B]

Commentaires :

  • lignes 13-16 : la chaîne [filter] + [map] appliquée à une liste. Les lambdas affichent un message avant de rendre leur valeur : le corps d'une lambda peut contenir plusieurs instructions (séparées ici par un point-virgule, puisqu'elles sont sur la même ligne), et c'est la valeur de la dernière expression qui est rendue : il n'y a pas de [return] ;
  • lignes 2-10 des résultats : [filter] examine les six nombres et construit une liste intermédiaire [[2, 4, 6]] ; ensuite seulement, [map] calcule les trois carrés et construit la liste résultat. Chaque étape est exécutée en entier avant la suivante ;
  • lignes 20-29 : le même traitement sur une séquence, obtenue par [nombres.asSequence()]. Les lambdas sont ici écrites sur plusieurs lignes ;
  • ligne 13 des résultats : après la définition de la séquence, 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 15-26 des résultats : le calcul s'exécute pendant l'opération terminale [forEach] (ligne 33), é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 36-39 : contrairement à un flux Java, une séquence obtenue à partir d'une collection peut être parcourue plusieurs fois : chaque opération terminale ([toList] ici) relance le calcul depuis le début, sur l'état actuel de la collection. La séquence créée avant l'ajout de 10 en tient donc compte. Java devait mémoriser une lambda qui recrée le flux ([Supplier<Stream<Integer>>]) ; une séquence est déjà cette recette réutilisable. Certaines séquences, en revanche, ne se parcourent qu'une fois : celles construites sur un itérateur ou un flux d'entrée, par exemple ; [constrainOnce()] rend une séquence qui lance une [IllegalStateException] au deuxième parcours ;
  • lignes 42-44 : [toList] exécute le traitement et mémorise ses résultats : [instantane] est une liste, qui ne voit pas l'ajout de 20 ;
  • lignes 48-50 : [first] arrête le parcours dès qu'il a obtenu un élément : les nombres qui suivent 3 ne sont jamais examinés. [any], [take], [takeWhile], [firstOrNull]… arrêtent de même le parcours au plus tôt ; c'est ce qui permet de traiter des séquences infinies. Sur une liste, [filter { ... }.first()] aurait examiné les huit nombres avant de rendre le premier ; c'est pourquoi on écrit plutôt [first { ... }], qui s'arrête au premier élément trouvé ;
  • lignes 55-58 : 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. [sequenceOf(3, 1, 2)] crée une séquence à partir de valeurs ; l'opération [onEach], qui exécute une lambda sur chaque élément au passage sans le modifier (le [peek] de Java), montre que les trois éléments sont reçus par [sorted] avant qu'il n'en transmette un seul ;
  • lignes 61-64 : des séquences infinies créées par [generateSequence], déjà rencontrée dans [Streams01.kt] : les puissances de 2, dont on garde les dix premières avec [take(10)], et les nombres impairs, dont on cherche le premier dont le carré dépasse 200. Une opération qui doit parcourir toute la séquence ([toList], [count], [sorted]…) ne se terminerait jamais sur une séquence infinie : il faut la limiter d'abord ;
  • ligne 67 : les flux Java restent utilisables depuis Kotlin, y compris les flux de primitifs ([IntStream], [LongStream], [DoubleStream]) ; [IntStream.rangeClosed(1, 10)] produit les entiers de 1 à 10. Ligne 68, le même calcul en Kotlin, sur l'intervalle [1..10]. Il n'y a pas, en Kotlin, de collections ou de séquences spécialisées pour les types primitifs : une [List<Int>] contient des objets [Integer]. Pour les très gros volumes de nombres, on utilise un tableau [IntArray] (qui a lui aussi [filter], [map], [sum]…) ;
  • lignes 71-72 : les fonctions d'extension [asSequence] et [asStream] du paquetage [kotlin.streams] (importées ligne 4) convertissent un flux Java en séquence, et inversement. On les utilise à la frontière avec une bibliothèque Java qui produit ou attend des flux. [boxed] transforme un [IntStream] en [Stream<Integer>].

Quand utiliser une séquence plutôt qu'une collection ?

  • les opérations sur les collections sont le choix par défaut : elles sont simples, et rapides sur les petites et moyennes collections, où le coût des collections intermédiaires est négligeable ;
  • on passe aux séquences ([asSequence()]) quand la chaîne d'opérations est longue et la collection volumineuse (pas de collections intermédiaires), quand le traitement peut s'arrêter tôt ([first], [take], [any]…), quand les éléments sont produits à la demande (séquences infinies, générateurs, lignes d'un gros fichier : nous le verrons avec [useLines]) ;
  • les flux Java ([stream()]) n'ont guère d'intérêt dans un programme Kotlin, sauf pour dialoguer avec une bibliothèque Java, et pour leur version parallèle : la méthode [parallelStream] d'une collection rend un flux dont les éléments sont traités simultanément par plusieurs threads (chapitre 8), ce que les séquences ne savent pas faire. 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.

Le tableau suivant compare les trois outils :

 
collections Kotlin
séquences Kotlin
flux Java
évaluation
immédiate, opération par opération
paresseuse, élément par élément
paresseuse, élément par élément
collections intermédiaires
une par opération
aucune
aucune
parcours multiples
oui
oui (sauf séquences à usage unique)
non : un flux par parcours
éléments infinis
non
oui ([generateSequence], [sequence])
oui ([Stream.iterate], [Stream.generate])
traitement parallèle
non
non
oui ([parallelStream])
types primitifs
non (sauf tableaux [IntArray]…)
non
oui ([IntStream]…)

Nous retrouverons ces opérations tout au long du cours, et nous verrons au chapitre 7 comment sont construits les types fonctionnels et les fonctions d'ordre supérieur qu'elles utilisent.

5.6. Les fichiers texte

Kotlin n'a pas d'API de fichiers qui lui soit propre : il utilise celles du JDK, et les complète par des fonctions d'extension qui en simplifient l'usage. Rappelons que 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]…), plus complet et qui signale mieux les erreurs. La bibliothèque standard de Kotlin offre des extensions pour les deux :

  • le paquetage [kotlin.io] (importé automatiquement) étend la classe [java.io.File] : [File("infos.txt").readText()], [readLines()], [forEachLine { }], [useLines { }], [writeText(texte)], [appendText(texte)], [bufferedReader()], [printWriter()]… ;
  • le paquetage [kotlin.io.path] (à importer) étend l'interface [java.nio.file.Path] avec des fonctions de même nom, et ajoute une fonction de création [Path("infos.txt")], l'opérateur [/] pour construire les chemins, des propriétés ([name], [extension]…) et des fonctions qui reprennent celles de la classe [Files] ([exists], [createDirectories], [listDirectoryEntries], [walk]…).

Comme en Java, c'est [Path] qu'il faut privilégier aujourd'hui : nous utiliserons [kotlin.io.path]. Un chemin relatif, comme [Path("infos.txt")], est complété par le dossier courant du programme, celui d'où il a été lancé.

Toutes ces fonctions utilisent l'encodage UTF-8 par défaut, quel que soit le système d'exploitation, et ce depuis la première version de Kotlin ; un paramètre facultatif permet d'en préciser un autre. Côté Java, l'encodage par défaut des classes de [java.io] ([FileReader], [PrintWriter]…) est UTF-8 depuis Java 18 (JEP 400) ; auparavant, il dépendait du système (souvent Windows-1252 sous Windows en français). Avec Kotlin et le JDK 25, la question ne se pose plus.

Enfin, rappelons que Kotlin n'a pas d'exceptions contrôlées (chapitre 3) : les erreurs d'entrée-sortie sont signalées par des exceptions dérivées de [java.io.IOException], que le compilateur n'oblige ni à intercepter ni à déclarer. C'est au programmeur de décider où il les traite.

5.6.1. Lire un fichier texte

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. Kotlin ajoute à [Path] (et à [File]) des fonctions qui créent ce lecteur ou qui l'utilisent pour nous :

fonction
rôle
[path.bufferedReader(charset = Charsets.UTF_8)]
ouvre le fichier [path] en lecture et rend un [BufferedReader] ; le paramètre facultatif précise l'encodage : [path.bufferedReader(Charsets.ISO_8859_1)]
[lecteur.readLine(): String?]
rend la ligne suivante, sans la marque de fin de ligne, ou [null] si on était à la fin du flux (méthode Java)
[lecteur.lineSequence()], [lecteur.readLines()], [lecteur.readText()]
les lignes restantes sous la forme d'une séquence (lue à la demande, parcourable une seule fois), d'une liste ; tout le texte restant (extensions Kotlin)
[lecteur.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 la fonction [use]
[path.readText()], [path.readLines()]
ouvre le fichier, rend tout son contenu (une chaîne, une liste de lignes), le ferme
[path.forEachLine { ligne -> ... }]
ouvre le fichier, appelle la lambda pour chaque ligne, le ferme
[path.useLines { lignes -> ... }]
ouvre le fichier, passe à la lambda la séquence [Sequence<String>] de ses lignes, lues à la demande, le ferme et rend le résultat de la lambda

Le programme [Fichiers01.kt] est le suivant :

package fichiers01

import java.io.IOException
import java.nio.file.NoSuchFileException
import kotlin.io.path.Path
import kotlin.io.path.absolute
import kotlin.io.path.bufferedReader
import kotlin.io.path.forEachLine
import kotlin.io.path.name
import kotlin.io.path.readText
import kotlin.io.path.useLines

// lecture d'un fichier texte : bufferedReader, readText, forEachLine, useLines
fun main(args: Array<String>) {
    // le dossier courant : c'est là que sont cherchés les fichiers désignés par un nom relatif
    println("Dossier courant : ${Path("").absolute().name}")
    // le nom du fichier peut être passé en argument ; sinon infos.txt
    val fichier = Path(args.firstOrNull() ?: "infos.txt")
    try {
        // lecture 1 : ligne par ligne, avec un BufferedReader
        println("Lecture 1----------------")
        fichier.bufferedReader().use { lecteur ->
            while (true) {
                // readLine rend null à la fin du fichier
                val ligne = lecteur.readLine() ?: break
                println(ligne)
            }
        } // ici le flux est fermé

        // lecture 2 : tout le fichier d'un coup
        println("Lecture 2----------------")
        print(fichier.readText())

        // lecture 3 : une fonction appelée pour chaque ligne
        println("Lecture 3----------------")
        fichier.forEachLine { println(it) }

        // lecture 4 : une séquence de lignes lues à la demande, fermée à la fin de useLines
        println("Lecture 4----------------")
        val nbLignes = fichier.useLines { lignes -> lignes.count { it.isNotBlank() } }
        println("$nbLignes lignes non vides")
    } catch (e: NoSuchFileException) {
        // e.file est le chemin du fichier introuvable
        println("${e.javaClass.simpleName} : le fichier [${e.file}] n'existe pas")
    } catch (e: IOException) {
        // les autres erreurs d'entrée-sortie
        println("${e.javaClass.simpleName} : ${e.message}")
    }
}

Commentaires :

  • lignes 5-11 : les fonctions d'extension de [kotlin.io.path] doivent être importées une par une (ou toutes ensemble par [import kotlin.io.path.*], comme nous le ferons plus loin) ; IntelliJ IDEA ajoute ces lignes automatiquement ;
  • ligne 14 : la fonction [main] reçoit ici les arguments de la ligne de commande, dans le tableau [args] ;
  • ligne 16 : affiche le nom du dossier courant, celui à partir duquel on a lancé le programme. [Path("")] désigne le dossier courant ; [absolute()] le transforme en chemin complet, dont la propriété [name] ne garde que le dernier élément, pour ne pas encombrer les résultats d'un chemin complet ;
  • ligne 18 : le fichier lu est celui passé en argument, ou [infos.txt]. [args.firstOrNull()] rend le premier argument, ou [null] s'il n'y en a pas ; l'opérateur Elvis [?:] fournit alors la valeur par défaut. Comme c'est un nom relatif, le fichier est cherché dans le dossier courant ;
  • ligne 22 : [bufferedReader()] ouvre le fichier et rend un [BufferedReader]. La fonction d'extension [use] (chapitre 3) exécute la lambda qu'on lui passe, en lui donnant l'objet ([lecteur]), puis ferme cet objet à la sortie de la lambda, même si une exception s'y est produite. C'est l'équivalent Kotlin de l'instruction [try] avec ressources de Java ; elle s'applique à tout objet implémentant l'interface [AutoCloseable] (ou [Closeable]), c'est-à-dire possédant une méthode [close] qui libère des ressources (fichier, connexion réseau, connexion à une base de données…) ;
  • lignes 23-26 : le fichier est lu ligne par ligne. En Java, on écrit [while ((ligne = lecteur.readLine()) != null)], mais en Kotlin, une affectation n'est pas une expression : on ne peut pas la tester. On écrit donc une boucle infinie, et [val ligne = lecteur.readLine() ?: break] : [readLine] rend une [String?] ; si elle rend [null] (fin du fichier), l'opérateur Elvis exécute [break], qui sort de la boucle ; sinon, [ligne] est une [String] non nullable. Une autre écriture, plus fonctionnelle, est [lecteur.lineSequence().forEach { println(it) }] ;
  • ligne 32 : [readText] lit tout le fichier d'un coup et le ferme. Ligne 36, [forEachLine] ouvre le fichier, appelle la lambda pour chaque ligne et le ferme : c'est l'équivalent, en une ligne, de la lecture 1 ;
  • ligne 40 : [useLines] ouvre le fichier et passe à la lambda la séquence de ses lignes, qui sont lues à la demande, au fur et à mesure du parcours ; le fichier est fermé à la fin de la lambda, dont [useLines] rend le résultat (ici le nombre de lignes non vides). On peut ainsi traiter un fichier de plusieurs gigaoctets, avec toutes les opérations des séquences, sans le charger en mémoire. La séquence ne doit pas sortir de la lambda : après la fermeture du fichier, elle ne peut plus être parcourue ;
  • lignes 42-47 : les erreurs d'entrée-sortie sont signalées par des exceptions dérivées de [java.io.IOException]. Un fichier absent provoque une [NoSuchFileException] (de [java.nio.file]), qui en dérive : elle doit donc être interceptée avant. Sa propriété [file] (la méthode Java [getFile]) rend le chemin du fichier cherché. Les fonctions qui étendent [java.io.File] signalent la même erreur par une [FileNotFoundException]. Le compilateur n'exige pas ces clauses [catch] : sans elles, une erreur arrêterait le programme avec l'affichage de l'exception.

Le fichier [infos.txt] est le suivant :

1
2
3
12620:0:0
13190:0,05:631
15640:0,1:1290,5

Il est placé à la racine du projet Gradle [05-classes-kotlin], qui est le dossier courant de la commande [gradlew run] :

> gradlew -q run -Pmain=fichiers01.Fichiers01Kt
Dossier courant : 05-classes-kotlin
Lecture 1----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5
Lecture 2----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5
Lecture 3----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5
Lecture 4----------------
3 lignes non vides

Avec un fichier inexistant (les arguments du programme sont passés à [gradlew run] par l'option [--args]) :

1
2
3
4
> gradlew -q run -Pmain=fichiers01.Fichiers01Kt --args="xx.txt"
Dossier courant : 05-classes-kotlin
Lecture 1----------------
NoSuchFileException : le fichier [xx.txt] n'existe pas

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 :

1
2
3
4
> gradlew -q run -Pmain=fichiers01.Fichiers01Kt --args="."
Dossier courant : 05-classes-kotlin
Lecture 1----------------
IOException : Is a directory

Ces exécutions ont été faites sous Linux ; sous Windows, le message de cette dernière erreur serait différent. C'est donc à la racine du projet que nous placerons les fichiers de données de nos programmes, et de l'application exemple. Lancés depuis IntelliJ IDEA, les programmes ont aussi pour dossier courant celui du projet.

5.6.2. Écrire un fichier texte

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.

fonction
rôle
[path.bufferedWriter(charset = Charsets.UTF_8, vararg options)]
ouvre le fichier [path] en écriture et rend un [BufferedWriter]. Le fichier est créé, ou vidé s'il existe. Les paramètres facultatifs précisent l'encodage et le mode d'ouverture : [path.bufferedWriter(options = arrayOf(StandardOpenOption.APPEND))] ajoute les écritures à la fin du fichier existant
[ecrivain.write(str)]
écrit [str] dans le fichier
[ecrivain.newLine()]
écrit une marque de fin de ligne : celle du système, "\r\n" sous Windows, "\n" sous Linux et macOS
[ecrivain.flush()]
écrit dans le fichier le contenu du tampon, sans attendre qu'il soit plein
[ecrivain.close()]
ferme le flux, après avoir écrit dans le fichier le contenu du tampon. À faire obligatoirement après exploitation du flux : [use] s'en charge
[path.writeText(texte)], [path.appendText(texte)]
crée (ou écrase) le fichier et y écrit [texte] ; ajoute [texte] à la fin du fichier
[file.printWriter()]
(pour [java.io.File]) ouvre le fichier et rend un [PrintWriter], qui a les méthodes [print], [println] et [printf]

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 [PrintWriter(writer, true)]).

Le programme [Fichiers02.kt] est le suivant :

package fichiers02

import java.io.IOException
import java.nio.charset.Charset
import kotlin.io.path.Path
import kotlin.io.path.bufferedWriter
import kotlin.io.path.fileSize
import kotlin.io.path.readText

// écriture d'un fichier texte avec un BufferedWriter
fun main() {
    val fichier = Path("infos2.txt")
    try {
        // création du fichier texte (s'il existe déjà, il est écrasé)
        fichier.bufferedWriter().use { ecrivain ->
            println("encodage par défaut : ${Charset.defaultCharset()}")
            // boucle tant que la ligne saisie est non vide
            while (true) {
                print("ligne (rien pour arrêter) : ")
                val ligne = readlnOrNull()?.trim() ?: ""
                if (ligne.isEmpty()) {
                    break
                }
                // écriture de la ligne dans le fichier texte, suivie d'une fin de ligne
                ecrivain.write(ligne)
                ecrivain.newLine()
            }
        } // fermeture du flux : le tampon est écrit dans le fichier

        // vérification : on relit le fichier créé
        println("Contenu de $fichier (${fichier.fileSize()} octets) :")
        print(fichier.readText())
    } catch (e: IOException) {
        println("L'erreur suivante s'est produite : $e")
    }
}

Résultats (les lignes tapées au clavier ont été insérées à la suite des invites) :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=fichiers02.Fichiers02Kt
encodage par défaut : UTF-8
ligne (rien pour arrêter) : 1ère ligne
ligne (rien pour arrêter) : 2ième ligne
ligne (rien pour arrêter) :
Contenu de infos2.txt (25 octets) :
1ère ligne
2ième ligne

Commentaires :

  • ligne 15 : [bufferedWriter()] crée le fichier et rend un [BufferedWriter] ; de nouveau, la fonction [use] ferme automatiquement le flux à la sortie de la lambda (accolade fermante de la ligne 28). C'est cette fermeture qui écrit dans le fichier le texte resté dans le tampon : sans elle, le fichier pourrait rester incomplet ;
  • ligne 16 : l'encodage par défaut du JDK, UTF-8. Les fonctions de Kotlin, elles, utilisent UTF-8 par défaut quel que soit l'encodage du JDK ;
  • lignes 19-20 : l'invite est affichée par [print], qui ne passe pas à la ligne, puis [readlnOrNull] lit une ligne au clavier ; elle rend [null] si l'entrée standard est fermée (fin de fichier), d'où [?.trim() ?: ""] ;
  • lignes 25-26 : écriture d'une ligne dans le fichier texte, puis d'une fin de ligne ;
  • ligne 31 : la fonction [fileSize] (la méthode Java [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). Un objet [Path] inséré dans un gabarit de chaîne s'affiche par sa méthode [toString] : le chemin ;
  • ligne 33 : pourquoi un [try / catch], que le compilateur n'exige pas ? 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…). Un programme qui écrit un fichier doit s'attendre à ces erreurs : le [try / catch] les transforme ici en un message clair, au lieu d'un arrêt du programme.

5.6.3. Les chemins et les fonctions de kotlin.io.path

Pour les opérations courantes, les fonctions de [kotlin.io.path] évitent de manipuler des flux : une seule fonction ouvre le fichier, le lit ou l'écrit entièrement, puis le ferme. Elles permettent aussi de créer, copier, déplacer, supprimer des fichiers et des dossiers. La plupart appellent une méthode de la classe Java [Files], qu'elles rendent plus agréable à utiliser : [chemin.exists()] plutôt que [Files.exists(chemin)].

fonction
rôle
[Path("donnees", "villes.txt")], [dossier / "villes.txt"]
construit un chemin, avec le séparateur du système (l'opérateur [/] appelle la méthode Java [dossier.resolve("villes.txt")])
[path.name], [path.nameWithoutExtension], [path.extension], [path.parent], [path.absolute()]
le nom du fichier, sans son extension, son extension, son dossier, son chemin complet
[path.readText()], [path.readLines()], [path.useLines { }]
rend le contenu du fichier sous la forme d'une chaîne, d'une liste de lignes, d'une séquence de lignes lues à la demande
[path.writeText(texte)], [path.appendText(texte)]
crée (ou écrase) le fichier et y écrit [texte] ; ajoute [texte] à la fin du fichier
[path.writeLines(lignes)], [path.appendLines(lignes)]
écrit, ajoute une collection (ou une séquence) de lignes, chacune suivie d'une fin de ligne
[path.readBytes()], [path.writeBytes(octets)]
lit, écrit le fichier sous la forme d'un tableau d'octets
[path.exists()], [path.isDirectory()], [path.fileSize()], [path.deleteExisting()], [path.deleteIfExists()], [path.copyTo(cible)], [path.moveTo(cible)]
teste l'existence d'un fichier, si c'est un dossier, donne sa taille, le supprime, le copie, le déplace
[path.createDirectories()]
crée le dossier et ses dossiers parents s'ils n'existent pas
[dossier.listDirectoryEntries(glob)], [dossier.walk()]
les éléments du dossier (une liste, éventuellement filtrée par un motif comme ["*.txt"]), de toute son arborescence (une séquence)

Le programme [Fichiers03.kt] les utilise :

package fichiers03

import java.io.File
import java.nio.charset.MalformedInputException
import kotlin.io.path.*

// les chemins (Path) et les fonctions d'extension de kotlin.io.path
fun main() {
    // l'opérateur / construit un chemin avec le séparateur du système (\ sous Windows, / sous Linux)
    val dossier = Path("donnees")
    val chemin = dossier / "villes.txt"
    println("chemin=$chemin, name=${chemin.name}, parent=${chemin.parent}, " +
            "nameWithoutExtension=${chemin.nameWithoutExtension}, extension=${chemin.extension}")

    // createDirectories crée le dossier (et ses parents) s'il n'existe pas
    dossier.createDirectories()

    // writeText : crée (ou écrase) un fichier avec le texte donné, en UTF-8
    chemin.writeText("Angers\nNantes\n")
    // appendLines, appendText : ajoutent à la fin du fichier
    chemin.appendLines(listOf("Paris", "Lyon"))
    // readLines : toutes les lignes dans une liste
    val lignes: List<String> = chemin.readLines()
    println("readLines : ${lignes.size} lignes $lignes")
    // readText : tout le fichier dans une chaîne
    println("readText : ${chemin.readText().length} caractères")
    // useLines : une séquence de lignes lues à la demande, fermée à la fin du bloc
    chemin.useLines { villes ->
        villes.filter { it.startsWith("N") || it.startsWith("L") }
            .forEach { println("useLines + filter : $it") }
    }
    // la classe java.io.File a les mêmes fonctions d'extension, et quelques autres (printWriter…)
    val carres = File("donnees", "carres.txt")
    carres.printWriter().use { pw ->
        for (i in 1..3) pw.println("$i;${i * i}")
    }
    println("File.readLines : ${carres.readLines()}, toPath=${carres.toPath()}")

    // l'encodage : UTF-8 par défaut ; on peut en imposer un autre
    val texte = "élève"
    (dossier / "utf8.txt").writeText(texte)
    (dossier / "utf8bom.txt").writeText("\uFEFF" + texte)
    (dossier / "latin1.txt").writeText(texte, Charsets.ISO_8859_1)
    val hex = HexFormat { upperCase = true }
    for (fichier in listOf("utf8.txt", "utf8bom.txt", "latin1.txt")) {
        val octets: ByteArray = (dossier / fichier).readBytes()
        println("%-12s: %2d octets : %s".format(fichier, octets.size, octets.toHexString(hex)))
    }
    // un fichier Latin-1 relu en UTF-8 : readLines refuse les octets invalides…
    val latin1 = dossier / "latin1.txt"
    try {
        println(latin1.readLines())
    } catch (e: MalformedInputException) {
        println("latin1.txt lu en UTF-8 par readLines : ${e.javaClass.simpleName} : ${e.message}")
    }
    // …readText remplace chaque octet invalide par le caractère �
    println("latin1.txt lu en UTF-8 par readText : ${latin1.readText()}")
    println("latin1.txt lu en Latin-1 : ${latin1.readText(Charsets.ISO_8859_1)}")
    // le BOM n'est pas retiré à la lecture : c'est un caractère de plus
    println("utf8bom.txt : ${(dossier / "utf8bom.txt").readText().length} caractères")

    // exists, listDirectoryEntries
    println("exists(villes.txt)=${chemin.exists()}, exists(xx.txt)=${(dossier / "xx.txt").exists()}")
    for (fichier in dossier.listDirectoryEntries("*.txt").sorted()) {
        println("  %-12s %3d octets".format(fichier.name, fichier.fileSize()))
    }

    // ménage : un dossier doit être vide pour être supprimé ; on supprime d'abord son contenu
    dossier.walk(PathWalkOption.INCLUDE_DIRECTORIES)
        .sortedDescending()
        .forEach { it.deleteExisting() }
    println("après suppression : exists(donnees)=${dossier.exists()}")
}

Résultats :

> gradlew -q run -Pmain=fichiers03.Fichiers03Kt
chemin=donnees/villes.txt, name=villes.txt, parent=donnees, nameWithoutExtension=villes, extension=txt
readLines : 4 lignes [Angers, Nantes, Paris, Lyon]
readText : 25 caractères
useLines + filter : Nantes
useLines + filter : Lyon
File.readLines : [1;1, 2;4, 3;9], toPath=donnees/carres.txt
utf8.txt    :  7 octets : C3A96CC3A87665
utf8bom.txt : 10 octets : EFBBBFC3A96CC3A87665
latin1.txt  :  5 octets : E96CE87665
latin1.txt lu en UTF-8 par readLines : MalformedInputException : Input length = 1
latin1.txt lu en UTF-8 par readText : �l�ve
latin1.txt lu en Latin-1 : élève
utf8bom.txt : 6 caractères
exists(villes.txt)=true, exists(xx.txt)=false
  carres.txt    12 octets
  latin1.txt     5 octets
  utf8.txt       7 octets
  utf8bom.txt   10 octets
  villes.txt    25 octets
après suppression : exists(donnees)=false

Commentaires :

  • ligne 5 : toutes les fonctions de [kotlin.io.path] sont importées ;
  • lignes 10-13 : l'opérateur [/] (fonction [operator fun Path.div(other: String)], voir la surcharge des opérateurs au chapitre 4) construit le chemin [donnees/villes.txt], avec 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 ainsi, plutôt qu'en concaténant des chaînes, rend le code portable. Les propriétés [name], [parent], [nameWithoutExtension] et [extension] décomposent le chemin ; les deux dernières n'existent pas en Java, où il faut chercher le point avec [lastIndexOf] ;
  • ligne 16 : la création du dossier [donnees] ne provoque pas d'erreur s'il existe déjà ;
  • lignes 19-26 : écriture, ajout et lecture en une instruction chacune. [appendLines] écrit chaque élément de la liste suivi d'une fin de ligne ;
  • lignes 28-30 : [useLines] rend les lignes sous la forme d'une séquence : elles sont lues une à une, au fur et à mesure du parcours, et l'on peut y appliquer toutes les opérations des séquences. On traite ainsi un fichier de plusieurs gigaoctets sans le charger en mémoire, ce que [readLines] ferait. Le fichier reste ouvert pendant le parcours, et [useLines] le ferme à la fin de la lambda : c'est l'équivalent de [Files.lines(chemin)] dans un [try] avec ressources ;
  • lignes 33-37 : la classe [java.io.File] a les mêmes fonctions d'extension ([readText], [readLines], [writeText]…), et quelques autres, comme [printWriter], qui rend un [PrintWriter] (méthodes [print] et [println]). On passe d'un [File] à un [Path] par [toPath()], et inversement par [toFile()] ;
  • lignes 40-47 : le même texte « élève » est écrit avec trois encodages, puis les octets des fichiers sont affichés en hexadécimal avec la fonction [toHexString] de Kotlin, configurée par un objet [HexFormat] (en majuscules). Notons que la classe [HexFormat] utilisée ici est celle de Kotlin ([kotlin.text.HexFormat], importée automatiquement), et non celle de Java ([java.util.HexFormat]) :
    • en UTF-8, l'encodage par défaut, 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. Ni Java ni Kotlin n'écrivent de BOM d'eux-mêmes : 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). L'objet [Charsets] de Kotlin donne accès aux encodages courants ([UTF_8], [ISO_8859_1], [UTF_16]…), comme la classe Java [StandardCharsets] ;
  • lignes 50-57 : 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. Les fonctions de [kotlin.io.path] ne réagissent pas toutes de la même façon, et l'exécution le montre : [readLines] (qui appelle [Files.readAllLines]) refuse un tel fichier en lançant une exception [MalformedInputException] (dérivée de [IOException]), alors que [readText] remplace chaque octet invalide par le caractère �, sans signaler d'erreur. Nous avons vérifié que [useLines] et [forEachLine] sont stricts comme [readLines], et que [bufferedReader] et les fonctions de [java.io.File] sont tolérantes comme [readText] ;
  • ligne 58 : relu avec le bon encodage, le fichier est correct ;
  • ligne 60 : à la lecture, ni Java ni Kotlin ne reconnaissent 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 ([texte.removePrefix("\uFEFF")]). Aujourd'hui, la règle est simple : on écrit et on lit les fichiers texte en UTF-8, sans BOM ;
  • lignes 63-65 : [exists] teste l'existence d'un fichier ; [listDirectoryEntries("*.txt")] rend la liste des éléments du dossier dont le nom correspond au motif (un « glob » : [*] remplace n'importe quelle suite de caractères). Cette fonction ne garantit pas l'ordre des noms : [sorted] les trie. Contrairement à [Files.list], elle rend une liste et non un flux : il n'y a rien à fermer ;
  • lignes 69-72 : suppression du dossier. [deleteExisting] (la méthode Java [Files.delete]) refuse de supprimer un dossier non vide : on parcourt toute l'arborescence avec [walk], qui rend une séquence. Par défaut, [walk] ne rend que les fichiers ; l'option [PathWalkOption.INCLUDE_DIRECTORIES] lui fait rendre aussi les dossiers. On trie les chemins dans l'ordre inverse ([sortedDescending]) pour supprimer le contenu d'un dossier avant le dossier lui-même. Kotlin a aussi une fonction [deleteRecursively()] qui fait tout cela, mais elle est encore expérimentale : le compilateur exige qu'on l'accepte explicitement par l'annotation [@OptIn(ExperimentalPathApi::class)].

Les fonctions de fichiers bloquent le thread qui les appelle jusqu'à la fin de l'opération. Nous verrons au chapitre 9, consacré aux coroutines, 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]. Kotlin n'a pas d'équivalent propre : il utilise ces classes du JDK, avec quelques fonctions d'extension pour les créer ([path.outputStream()], [path.inputStream()], [flux.buffered()]…) et la fonction [use] pour les fermer.

Considérons l'application suivante :

1
2
3
4
// syntaxe : gradlew -q run -Pmain=fichiers04.Fichiers04Kt --args="texte binaire logs"
// on lit un fichier texte (texte) et on range son contenu dans un fichier binaire (binaire)
// le fichier texte a des lignes de la forme nom : âge qu'on écrit sous la forme (String, Int)
// (logs) est un fichier texte de logs

Le fichier texte [personnes1.txt], placé à la racine du projet, a le contenu suivant :

1
2
3
4
5
6
7
8
9
paul : 10
helene : 15

jacques : 11
sylvain : 12
xx : -1

xx: yy : zz
xx : yy

Le programme [Fichiers04.kt] est le suivant :

package fichiers04

import java.io.DataOutputStream
import java.io.IOException
import java.nio.file.NoSuchFileException
import kotlin.io.path.Path
import kotlin.io.path.bufferedReader
import kotlin.io.path.outputStream
import kotlin.io.path.writer
import kotlin.system.exitProcess

// syntaxe : gradlew -q run -Pmain=fichiers04.Fichiers04Kt --args="texte binaire logs"
// on lit un fichier texte (texte) et on range son contenu dans un fichier binaire (binaire)
// le fichier texte a des lignes de la forme nom : âge qu'on écrit sous la forme (String, Int)
// (logs) est un fichier texte de logs
fun main(args: Array<String>) {
    // il faut 3 arguments
    if (args.size != 3) {
        println("syntaxe : Fichiers04 texte binaire logs")
        exitProcess(1)
    }
    val (texte, binaire, journal) = args
    try {
        // ouverture des trois fichiers ; chaque use fermera son fichier, dans l'ordre inverse de l'ouverture
        Path(texte).bufferedReader().use { input ->
            DataOutputStream(Path(binaire).outputStream().buffered()).use { output ->
                Path(journal).writer().buffered().use { logs ->
                    // exploitation du fichier texte
                    var numLigne = 0
                    var nbPersonnes = 0
                    // une fonction locale pour écrire une erreur dans le fichier des logs
                    fun log(message: String) {
                        logs.appendLine("La ligne n° [$numLigne] du fichier [$texte] $message")
                    }
                    input.forEachLine { ligne ->
                        // une ligne de plus
                        numLigne++
                        // ligne vide ? on l'ignore
                        if (ligne.isBlank()) return@forEachLine
                        // une ligne nom : âge
                        val champs = ligne.trim().split(Regex("""\s*:\s*"""))
                        // il nous faut 2 champs
                        if (champs.size != 2) {
                            log("a un nombre de champs incorrect")
                            return@forEachLine
                        }
                        // le 1er champ doit être non vide
                        var erreur = false
                        val (nom, champAge) = champs
                        if (nom.isEmpty()) {
                            log("a un nom vide")
                            erreur = true
                        }
                        // le second champ doit être un entier >= 0 ; toIntOrNull rend null sinon
                        val age = champAge.toIntOrNull() ?: -1
                        if (age < 0) {
                            log("a un âge [$champAge] incorrect")
                            erreur = true
                        }
                        // si pas d'erreur, on écrit les données dans le fichier binaire
                        if (!erreur) {
                            output.writeUTF(nom)
                            output.writeInt(age)
                            nbPersonnes++
                        }
                    }
                    println("$numLigne lignes lues, $nbPersonnes personnes écrites dans [$binaire], " +
                            "erreurs dans [$journal]")
                }
            }
        }
    } catch (e: NoSuchFileException) {
        println("Le fichier [${e.file}] n'existe pas")
        exitProcess(2)
    } catch (e: IOException) {
        println("L'erreur suivante s'est produite : $e")
        exitProcess(3)
    }
}

Attardons-nous sur les opérations concernant la classe [DataOutputStream] :

  • ligne 26 : 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 ») :
    • [Path(binaire).outputStream()] (la méthode Java [Files.newOutputStream]) rend un flux d'octets vers le fichier, créé ou vidé s'il existe ;
    • [buffered()] lui ajoute une mémoire tampon, pour ne pas accéder au disque à chaque écriture : c'est une fonction d'extension de Kotlin qui rend un [BufferedOutputStream(this)] ;
    • [DataOutputStream] lui ajoute des méthodes d'écriture des types de base ;
  • lignes 62-63 : 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 25-27 : les trois fichiers sont ouverts par trois appels de [use] emboîtés : Kotlin n'a pas d'équivalent de l'instruction [try] Java qui déclare plusieurs ressources séparées par des points-virgules. Chaque [use] ferme son flux à la sortie de sa lambda : les trois fichiers sont donc fermés dans l'ordre inverse de leur ouverture, même en cas d'exception. Fermer le flux extérieur ([DataOutputStream]) ferme les flux qu'il contient. Le fichier des logs est écrit au travers d'un [Writer] ([writer()]) muni d'un tampon ([buffered()], qui rend ici un [BufferedWriter]) ;
  • ligne 22 : la déstructuration s'applique aussi aux tableaux : les trois premiers éléments de [args] sont rangés dans trois variables aux noms explicites ;
  • lignes 32-33 : une fonction locale (chapitre 3), déclarée dans la lambda, qui écrit un message d'erreur dans le fichier des logs. Elle a accès aux variables qui l'entourent ([logs], [numLigne], [texte]), ce qui évite de répéter le début du message. [appendLine] est une fonction d'extension de Kotlin qui écrit une chaîne suivie d'une fin de ligne (["\n"], quel que soit le système) dans tout objet [Appendable], donc dans tout [Writer] ;
  • ligne 35 : le fichier texte est lu ligne par ligne par [forEachLine], ici une extension du [BufferedReader] ([Reader]). Le corps de la boucle est une lambda : pour passer à la ligne suivante, on n'écrit pas [continue] mais [return@forEachLine] (lignes 39 et 45), un retour étiqueté qui termine l'exécution de la lambda pour la ligne courante. Un simple [return] terminerait la fonction [main] ;
  • ligne 41 : le séparateur est le caractère [:], entouré d'espaces éventuels, qui disparaissent avec lui. La fonction [split] de Kotlin reçoit ici une expression régulière ([Regex], étudiée plus loin dans ce chapitre), écrite dans une chaîne brute ["""..."""] où la barre oblique inversée n'a pas besoin d'être doublée. Attention : [split(":")] avec une chaîne découperait selon cette chaîne prise littéralement, et non selon une expression régulière, contrairement à la méthode [split] de Java ;
  • ligne 49 : déstructuration d'une liste : ses deux éléments sont rangés dans deux variables ;
  • ligne 55 : l'âge doit être un entier positif ou nul. Là où le programme Java devait écrire une méthode qui intercepte l'exception [NumberFormatException], Kotlin a [toIntOrNull], qui rend [null] si la chaîne n'est pas un entier ; l'opérateur Elvis la remplace alors par -1, valeur impossible pour un âge ;
  • lignes 20, 74, 77 : [exitProcess(n)] (paquetage [kotlin.system]) 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 fonction [main] se termine normalement). Elle appelle [System.exit(n)] et a le type de retour [Nothing] : le compilateur sait que les instructions qui la suivent ne seront jamais exécutées.

Les trois arguments du programme sont passés par l'option [--args] de [gradlew run]. Les résultats sont les suivants :

> gradlew -q run -Pmain=fichiers04.Fichiers04Kt --args="personnes1.txt personnes1.bin logs.txt"
9 lignes lues, 4 personnes écrites dans [personnes1.bin], erreurs dans [logs.txt]

Le fichier de logs [logs.txt] a le contenu suivant :

1
2
3
4
> cat logs.txt
La ligne n° [6] du fichier [personnes1.txt] a un âge [-1] incorrect
La ligne n° [8] du fichier [personnes1.txt] a un nombre de champs incorrect
La ligne n° [9] du fichier [personnes1.txt] a un âge [yy] incorrect

Sans arguments, ou avec un fichier texte inexistant :

1
2
3
4
> gradlew -q run -Pmain=fichiers04.Fichiers04Kt
syntaxe : Fichiers04 texte binaire logs
> gradlew -q run -Pmain=fichiers04.Fichiers04Kt --args="xx.txt xx.bin xx.log"
Le fichier [xx.txt] n'existe pas

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) :

1
2
3
4
5
> od -A d -t x1z personnes1.bin
0000000 00 04 70 61 75 6c 00 00 00 0a 00 06 68 65 6c 65  >..paul......hele<
0000016 6e 65 00 00 00 0f 00 07 6a 61 63 71 75 65 73 00  >ne......jacques.<
0000032 00 00 0b 00 07 73 79 6c 76 61 69 6e 00 00 00 0c  >.....sylvain....<
0000048

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 (dernière ligne : 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(name: String, mode: String)]
ouvre le fichier en lecture ([mode] "r") ou en lecture et écriture ("rw")
[length(): Long]
la taille du fichier, en octets
[filePointer: Long]
la position courante dans le fichier (la méthode Java [getFilePointer], vue de Kotlin comme une propriété)
[seek(pos: Long)]
se place à la position [pos] du fichier

Le programme [Fichiers05.kt] 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.

package fichiers05

import java.io.EOFException
import java.io.FileNotFoundException
import java.io.IOException
import java.io.RandomAccessFile
import kotlin.io.path.Path
import kotlin.io.path.bufferedWriter
import kotlin.system.exitProcess

// syntaxe : gradlew -q run -Pmain=fichiers05.Fichiers05Kt --args="binaire texte"
// on lit un fichier binaire (binaire) et on range son contenu dans un fichier texte (texte)
// le fichier binaire contient des couples (String, Int)
// le fichier texte aura des lignes de la forme nom:âge
fun main(args: Array<String>) {
    // il faut 2 arguments
    if (args.size != 2) {
        println("syntaxe : Fichiers05 binaire texte")
        exitProcess(1)
    }
    val (binaire, texte) = args
    try {
        // ouverture du fichier binaire en lecture ("r") et du fichier texte en écriture
        RandomAccessFile(binaire, "r").use { input ->
            Path(texte).bufferedWriter().use { output ->
                // exploitation du fichier binaire, tant qu'on n'est pas à la fin du fichier
                var nbPersonnes = 0
                while (input.filePointer < input.length()) {
                    // lecture nom, puis âge : dans l'ordre où ils ont été écrits
                    val nom = input.readUTF()
                    val age = input.readInt()
                    // écriture dans le fichier texte
                    output.appendLine("$nom:$age")
                    nbPersonnes++
                }
                println("$nbPersonnes personnes lues dans [$binaire] et écrites dans [$texte]")
            }
            // accès direct : retour au début du fichier et relecture de la première personne
            input.seek(0)
            println("première personne : ${input.readUTF()}, ${input.readInt()} ans")
        }
    } catch (_: EOFException) {
        // le fichier se termine au milieu d'un enregistrement
        println("Le fichier [$binaire] est tronqué")
        exitProcess(2)
    } catch (e: FileNotFoundException) {
        // RandomAccessFile signale un fichier absent par une FileNotFoundException
        println(e.message)
        exitProcess(3)
    } catch (e: IOException) {
        println("L'erreur suivante s'est produite : $e")
        exitProcess(4)
    }
}

Attardons-nous sur les opérations concernant la classe [RandomAccessFile] :

  • ligne 24 : l'objet [RandomAccessFile] est ouvert en lecture seule (mode "r"). Il implémente [Closeable] : [use] le fermera. Le fichier texte est ouvert par un second [use], emboîté dans le premier, et sera fermé avant lui ;
  • ligne 28 : on lit tant que la position courante dans le fichier n'a pas atteint sa taille. Kotlin présente les méthodes Java de la forme [getXxx()] comme des propriétés [xxx] : on écrit [input.filePointer] et non [input.getFilePointer()]. [length] n'ayant pas la forme [getXxx], reste une méthode ;
  • lignes 30-31 : les opérations de lecture, [readUTF] et [readInt]. Elles doivent être appelées dans l'ordre des écritures ;
  • ligne 33 : écriture d'une ligne dans le fichier texte ;
  • lignes 39-40 : 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 42 : l'exception [EOFException] signale maintenant une vraie anomalie : un fichier qui s'arrête au milieu d'un enregistrement. L'exception elle-même n'étant pas utilisée, on la nomme [_] ;
  • ligne 46 : [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 :

1
2
3
> gradlew -q run -Pmain=fichiers05.Fichiers05Kt --args="personnes1.bin personnes2.txt"
4 personnes lues dans [personnes1.bin] et écrites dans [personnes2.txt]
première personne : paul, 10 ans

Le contenu de [personnes2.txt] est le suivant :

1
2
3
4
5
> cat personnes2.txt
paul:10
helene:15
jacques:11
sylvain:12

On retrouve les 4 personnes valides du fichier [personnes1.txt]. Avec un fichier [tronque.bin] formé des 33 premiers octets de [personnes1.bin] (commande Linux [head -c 33 personnes1.bin > tronque.bin]), la lecture de l'âge de jacques échoue :

> gradlew -q run -Pmain=fichiers05.Fichiers05Kt --args="tronque.bin tronque.txt"
Le fichier [tronque.bin] est tronqué

Et avec un fichier inexistant :

> gradlew -q run -Pmain=fichiers05.Fichiers05Kt --args="xx.bin xx.txt"
xx.bin (No such file or directory)

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]. Il est utilisable depuis Kotlin, mais 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.

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.

Ni la bibliothèque standard de Java ni celle de Kotlin ne savent lire ou écrire du JSON. On utilise une bibliothèque externe. Trois choix s'offrent à un programme Kotlin :

  • Jackson, la bibliothèque Java la plus répandue, 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]. Comme toute bibliothèque Java, elle s'utilise directement depuis Kotlin : c'est elle que nous utilisons ici, comme le cours Java ;
  • Jackson complété par le module [jackson-module-kotlin] ([tools.jackson.module:jackson-module-kotlin] pour Jackson 3), qui lui apprend les particularités de Kotlin : valeurs par défaut des paramètres, types nullables, classes [object]… Nous verrons à la fin de cette section ce qui manque à Jackson sans ce module ;
  • kotlinx.serialization, la bibliothèque de sérialisation de JetBrains, écrite pour Kotlin : un greffon du compilateur ([kotlin("plugin.serialization")]) génère, pour chaque classe annotée [@Serializable], le code qui la sérialise, sans réflexion à l'exécution. Elle connaît naturellement les valeurs par défaut et les types nullables, et fonctionne aussi hors de la JVM (Kotlin Multiplatform). Elle est le choix naturel d'un projet purement Kotlin ; Jackson reste celui des projets Spring et des projets qui mêlent Java et Kotlin.

Une bibliothèque externe est déclarée dans le fichier de construction du projet (chapitre 2). Le projet Gradle [05-classes-kotlin] déclare la dépendance à Jackson dans son fichier [build.gradle.kts] :

// chapitre 5 : les classes Kotlin d'usage courant
plugins {
    kotlin("jvm") version "2.4.20"
    application
}

group = "exemples"
version = "1.0"

repositories {
    mavenCentral()
}

dependencies {
    // les bibliothèques utilisées par les programmes du projet
    // JSON : Jackson 3
    implementation("tools.jackson.core:jackson-databind:3.1.5")
}

kotlin {
    // le code est compilé et exécuté avec le JDK 25
    jvmToolchain(25)
    compilerOptions {
        // un avertissement du compilateur est traité comme une erreur
        allWarningsAsErrors = true
        // les noms des paramètres des fonctions sont conservés dans le bytecode
        javaParameters = true
    }
}

application {
    // le programme exécuté par [gradlew run] est désigné par la propriété [main] :
    // gradlew -q run -Pmain=chaines01.Chaines01Kt
    mainClass = providers.gradleProperty("main").orElse("chaines01.Chaines01Kt")
}

tasks.named<JavaExec>("run") {
    // le programme exécuté peut lire le clavier
    standardInput = System.`in`
}

// sous Windows, les sorties écran du programme lancé par Gradle (run, bootRun...) sont encodées en UTF-8,
// sinon les caractères accentués sont abîmés dans la console d'IntelliJ IDEA ou le terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}

Commentaires :

  • lignes 14-17 : la dépendance à la bibliothèque [jackson-databind]. Gradle la télécharge depuis le dépôt central (Maven Central, ligne 11), avec les bibliothèques dont elle dépend elle-même ([jackson-core] et [jackson-annotations]) ;
  • ligne 27 : le compilateur conserve dans le bytecode les noms des paramètres des fonctions et des constructeurs. C'est indispensable ici : sans le module Kotlin, Jackson crée une classe de données en appelant son constructeur primaire, et c'est grâce aux noms de ses paramètres qu'il associe chacun d'eux à la propriété JSON de même nom. C'est l'équivalent de l'option [-parameters] du compilateur Java ;
  • lignes 31-34 : le programme exécuté par [gradlew run] est désigné par la propriété [main] de la ligne de commande ([-Pmain=...]) ;
  • lignes 42-45 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).

L'objet central de Jackson est le [JsonMapper], qui a deux méthodes principales :

méthode
rôle
[writeValueAsString(value: Any?): String]
rend le texte JSON représentant l'objet [value]
[readValue(content: String, valueType: Class<T>): T]
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.kt] les utilise :

package fichiers06

import com.fasterxml.jackson.annotation.JsonProperty
import java.math.BigDecimal
import kotlin.io.path.Path
import kotlin.io.path.readText
import kotlin.io.path.writeText
import tools.jackson.core.JacksonException
import tools.jackson.core.json.JsonWriteFeature
import tools.jackson.core.type.TypeReference
import tools.jackson.databind.MapperFeature
import tools.jackson.databind.SerializationFeature
import tools.jackson.databind.json.JsonMapper

// sérialisation JSON avec Jackson 3

// une personne : une classe de données, dont les propriétés deviennent les propriétés JSON
data class Personne(val nom: String, val age: Int, val loisirs: List<String>)

// un article : l'annotation @JsonProperty fixe le nom de la propriété JSON
data class Article(@JsonProperty("reference") val code: String, val prix: BigDecimal)

// des valeurs par défaut… que Jackson ignore sans le module Kotlin
data class Abonne(val nom: String, val age: Int = 18, val loisirs: List<String> = listOf("lecture"))

// une propriété booléenne dont le nom commence par is
data class Compte(val numero: String, val isActif: Boolean)

// désérialisation d'un type générique : le type T est connu à l'exécution grâce à reified
inline fun <reified T> JsonMapper.lire(json: String): T = readValue(json, object : TypeReference<T>() {})

fun main() {
    val personnes = listOf(
        Personne("paul", 10, listOf("football")),
        Personne("hélène", 15, listOf("piano", "tennis")),
        Personne("jacques", 11, listOf()),
    )

    // 1 - un JsonMapper avec la configuration par défaut : une seule ligne
    val mapper = JsonMapper.builder().build()
    println("[par défaut] ${mapper.writeValueAsString(personnes[1])}")
    // les caractères non ASCII peuvent être échappés
    val ascii = JsonMapper.builder().enable(JsonWriteFeature.ESCAPE_NON_ASCII).build()
    println("[ESCAPE_NON_ASCII] ${ascii.writeValueAsString(personnes[1])}")

    // 2 - une option : l'indentation
    val indente = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build()
    // écriture dans un fichier texte
    val fichier = Path("personnes.json")
    fichier.writeText(indente.writeValueAsString(personnes))
    println("[personnes.json]")
    println(fichier.readText())

    // 3 - désérialisation : du texte JSON vers des objets
    val texte = fichier.readText()
    // TypeReference transmet le type complet List<Personne> à Jackson
    val relues: List<Personne> = mapper.readValue(texte, object : TypeReference<List<Personne>>() {})
    println("[relues] ${relues.size} personnes")
    for ((nom, age, loisirs) in relues) {
        println("  $nom, $age ans, loisirs : $loisirs")
    }
    // la même chose avec la fonction lire, et avec un tableau
    val relues2 = mapper.lire<List<Personne>>(texte)
    val tableau = mapper.readValue(texte, Array<Personne>::class.java)
    println("[lire] égales=${relues2 == relues}, [Array] ${tableau.size} personnes")

    // 4 - un nom de propriété fixé par une annotation
    println("[@JsonProperty] ${mapper.writeValueAsString(Article("A12", BigDecimal("12.5")))}")
    // la lecture peut ignorer la casse des noms de propriétés
    val casse = JsonMapper.builder()
        .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
        .build()
    val article = casse.readValue("""{ "REFERENCE": "B7", "Prix": 3 }""", Article::class.java)
    println("[ACCEPT_CASE_INSENSITIVE_PROPERTIES] $article")

    // 5 - un document JSON incorrect provoque une JacksonException
    try {
        mapper.readValue("""{ "nom": "x", "age": "douze" }""", Personne::class.java)
    } catch (e: JacksonException) {
        println("[erreur] ${e.javaClass.simpleName} : ${e.originalMessage}")
        println("[erreur] ligne ${e.location.lineNr}, colonne ${e.location.columnNr}, " +
                "chemin : ${e.pathReference}")
    }

    // 6 - ce que Jackson ne sait pas des classes Kotlin
    // une propriété JSON inconnue est ignorée
    val brestois = mapper.lire<Personne>("""{"nom":"x","age":1,"loisirs":[],"ville":"Brest"}""")
    println("[propriété inconnue] $brestois")
    // les valeurs par défaut des paramètres ne sont pas utilisées
    for (json in listOf("""{"nom":"x","age":20,"loisirs":["vélo"]}""", """{"nom":"x","age":20}""",
            """{"nom":"x"}""", """{"nom":null,"age":20,"loisirs":[]}""")) {
        val resultat = try {
            mapper.lire<Abonne>(json).toString()
        } catch (e: JacksonException) {
            "${e.javaClass.simpleName} : ${e.originalMessage}"
        }
        println("[Abonne] $json -> $resultat")
    }
    // une propriété isActif est écrite sous le nom actif… et ne se relit pas
    val jsonCompte = mapper.writeValueAsString(Compte("C1", true))
    println("[isActif] $jsonCompte")
    try {
        mapper.lire<Compte>(jsonCompte)
    } catch (e: JacksonException) {
        println("[isActif] ${e.javaClass.simpleName} : ${e.originalMessage}")
    }
}

On l'exécute dans le dossier du projet, ou depuis l'IDE :

> gradlew -q run -Pmain=fichiers06.Fichiers06Kt
[par défaut] {"nom":"hélène","age":15,"loisirs":["piano","tennis"]}
[ESCAPE_NON_ASCII] {"nom":"h\u00E9l\u00E8ne","age":15,"loisirs":["piano","tennis"]}
[personnes.json]
[ {
  "nom" : "paul",
  "age" : 10,
  "loisirs" : [ "football" ]
}, {
  "nom" : "hélène",
  "age" : 15,
  "loisirs" : [ "piano", "tennis" ]
}, {
  "nom" : "jacques",
  "age" : 11,
  "loisirs" : [ ]
} ]
[relues] 3 personnes
  paul, 10 ans, loisirs : [football]
  hélène, 15 ans, loisirs : [piano, tennis]
  jacques, 11 ans, loisirs : []
[lire] égales=true, [Array] 3 personnes
[@JsonProperty] {"reference":"A12","prix":12.5}
[ACCEPT_CASE_INSENSITIVE_PROPERTIES] Article(code=B7, prix=3)
[erreur] InvalidFormatException : Cannot deserialize value of type `int` from String "douze": not a valid `int` value
[erreur] ligne 1, colonne 22, chemin : fichiers06.Personne["age"]
[propriété inconnue] Personne(nom=x, age=1, loisirs=[])
[Abonne] {"nom":"x","age":20,"loisirs":["vélo"]} -> Abonne(nom=x, age=20, loisirs=[vélo])
[Abonne] {"nom":"x","age":20} -> ValueInstantiationException : Cannot construct instance of `fichiers06.Abonne`, problem: Parameter specified as non-null is null: method fichiers06.Abonne.<init>, parameter loisirs
[Abonne] {"nom":"x"} -> MismatchedInputException : Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)
[Abonne] {"nom":null,"age":20,"loisirs":[]} -> ValueInstantiationException : Cannot construct instance of `fichiers06.Abonne`, problem: Parameter specified as non-null is null: method fichiers06.Abonne.<init>, parameter nom
[isActif] {"numero":"C1","actif":true}
[isActif] MismatchedInputException : Cannot map `null` into type `boolean` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)

Commentaires :

  • lignes 3-13 : les classes de Jackson utilisées. Contrairement au programme Java, qui devait être un projet Maven séparé, [Fichiers06.kt] est un programme du projet Gradle du chapitre, comme les autres : la dépendance à Jackson est déclarée pour tout le projet ;
  • ligne 18 : la classe de données [Personne] à sérialiser. Jackson ne connaît pas Kotlin, mais il voit ce que le compilateur a produit : une classe Java avec des méthodes d'accès [getNom], [getAge], [getLoisirs], et un constructeur à trois paramètres. Chaque méthode d'accès donne une propriété JSON ([getNom] → [nom]) ;
  • ligne 21 : l'annotation [@JsonProperty] fixe le nom de la propriété JSON : [reference] au lieu de [code]. Placée sur un paramètre du constructeur primaire qui déclare une propriété, l'annotation pourrait s'appliquer au paramètre, à la propriété, au champ ou à la méthode d'accès : Kotlin 2.4 l'applique ici au paramètre du constructeur et au champ privé qui stocke la propriété (nous l'avons vérifié dans le code compilé avec la commande [javap -v] ; les versions plus anciennes de Kotlin ne l'appliquaient qu'au paramètre). Jackson, qui relie le paramètre, le champ et la méthode d'accès [getCode] à une même propriété, utilise le nom [reference] aussi bien pour l'écriture que pour la lecture. On peut préciser la cible : [@param:JsonProperty], [@get:JsonProperty], [@field:JsonProperty] ;
  • lignes 40-41 : 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 1 des résultats). [personnes[1]] est l'écriture Kotlin de [personnes.get(1)] ;
  • lignes 43-44 : 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 ;
  • ligne 47 : l'option [INDENT_OUTPUT] produit un texte indenté, sur plusieurs lignes. Une liste est sérialisée en un tableau JSON ;
  • lignes 49-52 : le texte JSON est enregistré dans un fichier texte par [writeText], puis relu pour être affiché : c'est ainsi qu'on remplace aujourd'hui la sérialisation binaire ;
  • ligne 57 : la désérialisation reconstruit la liste. Jackson crée les objets [Personne] en appelant le constructeur de la classe de données, 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.java] n'existe pas : les génériques sont effacés à la compilation (« type erasure »), en Kotlin comme en Java, et à l'exécution il n'existe qu'une classe [List], sans paramètre de type. L'astuce de [TypeReference] consiste à créer un objet anonyme ([object : TypeReference<List<Personne>>() {}]) d'une classe 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 ;
  • lignes 59-60 : la boucle déstructure chaque personne relue ;
  • ligne 30 : Kotlin permet d'écrire cette astuce une fois pour toutes. [lire] est une fonction d'extension de [JsonMapper], [inline] et à paramètre de type [reified] (chapitre 4) : son code est recopié à chaque appel, avec le type réel à la place de [T]. L'appel [mapper.lire<List<Personne>>(texte)] (ligne 63) crée donc un objet [TypeReference<List<Personne>>], et non [TypeReference<T>]. Le module [jackson-module-kotlin] fournit une fonction semblable, [readValue<T>()], et une fonction [jacksonTypeRef<T>()] ;
  • ligne 64 : pour un tableau, plus simple, on passe directement la classe [Array<Personne>::class.java] (le [Personne[].class] de Java). La ligne suivante vérifie que les deux listes relues sont égales, au sens de l'égalité des classes de données ;
  • ligne 68 : 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 70-74 : 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. Le texte JSON est écrit dans une chaîne brute ["""..."""], où les guillemets n'ont pas à être précédés d'une barre oblique inversée ;
  • lignes 76-82 : 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 dérivent de [RuntimeException], alors qu'elles dérivaient de [IOException] dans Jackson 2 ; pour Kotlin, qui n'a pas d'exceptions contrôlées, la différence ne se voit pas. [originalMessage] rend le message sans les informations de localisation, que [location] et [pathReference] donnent séparément : ligne 1, colonne 22, propriété [age] de la classe [Personne]. Kotlin présente les méthodes Java [getOriginalMessage()], [getLocation()], [getLineNr()]… comme des propriétés.

La dernière partie du programme montre ce que Jackson, sans le module Kotlin, ne sait pas des classes Kotlin. Nous l'avons vérifié avec Jackson 3.1.5 :

  • ligne 87 : une propriété JSON inconnue de la classe ([ville]) est ignorée. C'est un changement de Jackson 3 : l'option [DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES], active par défaut dans Jackson 2, est désactivée par défaut dans Jackson 3 ; on la réactive par [JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)] si l'on veut que le document soit refusé ;
  • ligne 24 : la classe [Abonne] a des valeurs par défaut pour [age] (18) et [loisirs] (une liste contenant « lecture »). Ces valeurs ne sont pas calculées par le constructeur lui-même, mais par un second constructeur, synthétique, que le compilateur ajoute à la classe et qu'utilisent les appels écrits en Kotlin : Jackson, qui appelle directement le constructeur principal par réflexion, les ignore. Pour une propriété absente du document JSON, il passe au constructeur la valeur « vide » du type : [null] pour un objet, et pour un type primitif comme [Int]… une erreur, car Jackson 3 active par défaut l'option [FAIL_ON_NULL_FOR_PRIMITIVES] (elle était désactivée dans Jackson 2, qui passait 0) ;
  • lignes 90-97 : les quatre documents de l'essai donnent (lignes 27-30 des résultats) :
    • un document complet : l'objet est créé normalement ;
    • sans [loisirs] : Jackson passe [null] au paramètre [loisirs], de type non nullable [List<String>]. Le constructeur Kotlin vérifie ses paramètres non nullables, et lance une [NullPointerException] (« Parameter specified as non-null is null »), que Jackson transmet dans une [ValueInstantiationException]. La valeur par défaut [listOf("lecture")] n'a pas été utilisée ;
    • sans [age] : [MismatchedInputException], il n'y a pas de valeur pour le type primitif [int]. La valeur par défaut 18 n'a pas été utilisée ;
    • avec ["nom": null] : la même vérification du constructeur refuse [null] pour [nom]. Ce comportement est plutôt une bonne nouvelle : la sûreté vis-à-vis de [null] de Kotlin n'est pas contournée par Jackson ;
  • lignes 27, 100-105 : une propriété booléenne dont le nom commence par [is], comme [isActif], a pour méthode d'accès [isActif()] (Kotlin ne la nomme pas [getIsActif]) ; Jackson, selon les conventions JavaBeans, en déduit une propriété JSON [actif]. Mais le paramètre du constructeur s'appelle [isActif] : Jackson ne trouve pas cette propriété dans le document qu'il a lui-même écrit, et la relecture échoue.

Le module [jackson-module-kotlin] corrige ces trois points (valeurs par défaut utilisées, propriétés [isXxx] gardant leur nom, messages d'erreur clairs pour [null]) ; kotlinx.serialization les traite aussi correctement. Sans eux, on retiendra la règle suivante : avec Jackson seul, une classe de données destinée à JSON ne doit compter ni sur ses valeurs par défaut ni sur des noms de propriétés commençant par [is]. C'est ce que nous ferons dans la suite du cours, où nos classes JSON resteront simples.

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 (une propriété de haut niveau, ou d'un [object]). Nous utiliserons JSON dans l'application exemple de la fin du chapitre, puis tout au long du cours.

5.8. Les expressions régulières

La bibliothèque standard de Kotlin fournit la classe [kotlin.text.Regex], qui 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. Sur la JVM, la classe [Regex] est une enveloppe de la classe [java.util.regex.Pattern] du JDK : la syntaxe des modèles et le moteur de recherche sont exactement ceux de Java, seule l'interface de programmation change. 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 propriété [groupValues] ou [groups]. 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, lettre non accentuée, chiffre ou trait de soulignement ; équivaut à [[a-zA-Z_0-9]]. Avec l'option [(?U)] (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)].

Reste à écrire le modèle dans le code. Dans une chaîne ordinaire, délimitée par un seul guillemet de chaque côté, le caractère [\] est, comme en Java, un caractère d'échappement : chaque [\] du modèle doit y être doublé, et le modèle [\d+] s'écrit ["\\d+"]. Kotlin offre mieux : les chaînes brutes, entre triples guillemets, déjà rencontrées, dans lesquelles [\] est un caractère ordinaire. Le modèle s'y écrit tel quel : ["""\d+"""], et le modèle [\\], qui désigne le caractère \, s'écrit ["""\\"""] au lieu de ["\\\\"]. C'est l'usage en Kotlin, que nous suivrons. Une seule précaution : dans une chaîne brute, le caractère [$] suivi d'un identificateur ou d'une accolade introduit un gabarit. Le [$] de fin de chaîne, suivi des guillemets, ne pose pas de problème ; pour les autres cas, Kotlin 2.2 a introduit les chaînes à interpolation « multi-dollar », que nous verrons dans le programme [Regex03.kt]. L'éditeur aide à la lecture : IntelliJ IDEA reconnaît les chaînes passées à [Regex] ou à [toRegex], les colore comme des expressions régulières et permet de les tester (Alt+Entrée, Check RegExp).

5.8.1. Vérifier qu'une chaîne correspond à un modèle donné

Un modèle est d'abord compilé en un objet [Regex], qui offre ensuite les fonctions de recherche. Là où Java passe par un objet intermédiaire [Matcher], qui mémorise l'état d'une recherche, Kotlin rend directement des résultats :

fonction
rôle
[Regex(pattern: String)]
compile le modèle [pattern] ; lance une exception [PatternSyntaxException] si le modèle est incorrect
[Regex(pattern, option)], [Regex(pattern, options)]
idem, avec une option [RegexOption] ou un ensemble [Set<RegexOption>] d'options (voir plus loin)
[String.toRegex()]
fonction d'extension : ["""\d+""".toRegex()] équivaut à [Regex("""\d+""")]
[containsMatchIn(input)]
rend [true] si la chaîne [input] contient une occurrence du modèle
[matches(input)]
rend [true] si la chaîne entière correspond au modèle
[CharSequence.matches(regex)]
fonction d'extension infixe de [String] : ["18/05/97" matches date]
[toPattern()]
rend l'objet [java.util.regex.Pattern] enveloppé par l'objet [Regex]

Le programme [Regex01.kt] est le suivant :

package regex01

// vérifier qu'une chaîne correspond à un modèle
fun main() {
    // une chaîne brute : les \ du modèle ne sont pas doublés
    val modele1 = """^\s*\d+\s*$"""
    val regex1 = Regex(modele1)
    // comparer des exemplaires au modèle
    for (exemplaire in listOf("  123  ", "  123a  ", "4567")) {
        if (regex1.containsMatchIn(exemplaire)) {
            println("[$exemplaire] correspond au modèle [$modele1]")
        } else {
            println("[$exemplaire] ne correspond pas au modèle [$modele1]")
        }
    }
    // containsMatchIn cherche le modèle dans la chaîne ; matches exige que toute la chaîne corresponde
    val nombre = """\d+""".toRegex()
    println("containsMatchIn(\"abc 123\") : ${nombre.containsMatchIn("abc 123")}"
            + ", matches(\"abc 123\") : ${nombre.matches("abc 123")}")
    // la fonction infixe matches de String : toute la chaîne
    val date = Regex("""\d{2}/\d{2}/\d{2}""")
    println("\"18/05/97\" matches date : ${"18/05/97" matches date}")
    println("\"5/1/01\".matches(date) : ${"5/1/01".matches(date)}")
    // l'objet Regex enveloppe un objet java.util.regex.Pattern
    val pattern: java.util.regex.Pattern = date.toPattern()
    println("date.toPattern().matcher(\"18/05/97\").matches() : ${pattern.matcher("18/05/97").matches()}")
}

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=regex01.Regex01Kt
[  123  ] correspond au modèle [^\s*\d+\s*$]
[  123a  ] ne correspond pas au modèle [^\s*\d+\s*$]
[4567] correspond au modèle [^\s*\d+\s*$]
containsMatchIn("abc 123") : true, matches("abc 123") : false
"18/05/97" matches date : true
"5/1/01".matches(date) : false
date.toPattern().matcher("18/05/97").matches() : true

Commentaires :

  • ligne 6 : le modèle d'un nombre entier non signé, éventuellement entouré d'espaces, qui occupe toute la chaîne (ancres [^] et [$]). Grâce à la chaîne brute, les [\] ne sont pas doublés : le modèle s'écrit comme il s'affiche dans les résultats ;
  • ligne 7 : la compilation du modèle, par le constructeur de la classe [Regex] ;
  • ligne 10 : [containsMatchIn] 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. C'est l'équivalent de [regex1.matcher(exemplaire).find()] en Java ;
  • ligne 17 : la fonction d'extension [toRegex] de [String] est une autre façon de compiler un modèle ;
  • lignes 18-19 : la différence entre [containsMatchIn], 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 21-23 : la fonction d'extension [matches] de [String] reçoit un objet [Regex] ; elle est déclarée [infix] et peut s'écrire sans point ni parenthèses (ligne 22). Contrairement à Java, Kotlin n'offre pas de fonction [matches(String)] qui recevrait le modèle sous forme de chaîne et le compilerait à chaque appel : l'appel ["5/1/01".matches("\\d{2}")] est refusé par le compilateur ([argument type mismatch: actual type is 'String', but 'Regex' was expected]). Le modèle doit être compilé explicitement, ce qui incite à ne le faire qu'une fois ;
  • lignes 25-26 : l'objet [Regex] enveloppe un objet [java.util.regex.Pattern], que rend la fonction [toPattern]. Toute l'API de Java reste ainsi accessible ; inversement, la fonction d'extension [toRegex] de [Pattern] transforme un [Pattern] en [Regex]. Nous nous en servirons dans le programme [Regex06.kt].

5.8.2. Trouver toutes les occurrences d'un modèle dans une chaîne

La fonction [find] rend la première occurrence du modèle dans une chaîne, ou [null] s'il n'y en a pas. La fonction [findAll] rend toutes les occurrences, sous la forme d'une séquence ([Sequence], évaluée paresseusement : chaque occurrence n'est cherchée qu'au moment où on la demande). Une occurrence est un objet [MatchResult], qui donne des informations sur l'élément trouvé :

  • [value] : le texte de l'occurrence, donc un élément correspondant au modèle ;
  • [range] : l'intervalle [IntRange] des positions de l'élément dans la chaîne explorée ; [range.first] est la position où l'élément a été trouvé, [range.last] celle de son dernier caractère ;
  • [next()] : l'occurrence suivante, ou [null] s'il n'y en a plus.

Le programme [Regex02.kt] est le suivant :

package regex02

// trouver toutes les occurrences d'un modèle dans une chaîne
fun main() {
    val modele2 = """\d+"""
    val regex2 = Regex(modele2)
    val exemplaire3 = "  123  456  789 "
    println("Modèle=[$modele2], exemplaire=[$exemplaire3]")
    // findAll rend la séquence des occurrences
    var nbOccurrences = 0
    for (occurrence in regex2.findAll(exemplaire3)) {
        nbOccurrences++
        println("[${occurrence.value}] trouvé en position ${occurrence.range.first}")
    }
    println("Il y a $nbOccurrences occurrences du modèle dans l'exemplaire")
    // find rend la première occurrence (ou null) ; next rend l'occurrence suivante (ou null)
    var resultat = regex2.find(exemplaire3)
    while (resultat != null) {
        print("${resultat.value} : ${resultat.range}  ")
        resultat = resultat.next()
    }
    println()
    // les opérations sur les séquences s'appliquent aux occurrences
    val somme = regex2.findAll(exemplaire3).sumOf { it.value.toInt() }
    println("somme des nombres trouvés : $somme")
    val voyelles = Regex("[aeiouy]").findAll("anticonstitutionnellement").count()
    println("nombre de voyelles de \"anticonstitutionnellement\" : $voyelles")
}

Résultats :

1
2
3
4
5
6
7
8
9
> gradlew -q run -Pmain=regex02.Regex02Kt
Modèle=[\d+], exemplaire=[  123  456  789 ]
[123] trouvé en position 2
[456] trouvé en position 7
[789] trouvé en position 12
Il y a 3 occurrences du modèle dans l'exemplaire
123 : 2..4  456 : 7..9  789 : 12..14
somme des nombres trouvés : 1368
nombre de voyelles de "anticonstitutionnellement" : 10

Commentaires :

  • ligne 5 : le modèle recherché est une suite de chiffres ;
  • ligne 7 : la chaîne dans laquelle on recherche ce modèle ;
  • lignes 10-15 : la boucle [for] parcourt la séquence des occurrences rendue par [findAll]. On affiche chaque occurrence et sa position. Il n'y a ni objet [Matcher], ni appels répétés de [find] comme en Java ;
  • lignes 17-20 : la même exploration, pas à pas : [find] rend la première occurrence, de type [MatchResult?], et [next] la suivante, jusqu'à obtenir [null]. Dans la boucle, le compilateur sait que [resultat] n'est pas [null] (transtypage intelligent, chapitre 4). L'affichage de [range] montre les intervalles de positions : 2..4 pour 123 ;
  • ligne 24 : la séquence des occurrences se prête à toutes les opérations des séquences ; on fait ici la somme des nombres trouvés avec [sumOf]. En Java, il faut passer par la méthode [results] de [Matcher], qui rend un flux de l'API Stream ;
  • ligne 26 : le nombre d'occurrences du modèle [[aeiouy]], qui désigne une voyelle, est le nombre d'éléments de la séquence, rendu par [count].

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. Un objet [MatchResult] les donne de plusieurs façons :

  • [groups] : la collection des groupes, de type [MatchGroupCollection]. Son élément n° 0 est l'occurrence entière, les suivants sont les groupes n° 1, 2… Chaque groupe est un objet [MatchGroup], qui a les propriétés [value] (le texte capturé) et [range] (ses positions) ; il est de type [MatchGroup?], car un groupe facultatif, comme [(\.\d+)?], peut ne rien capturer ;
  • [groupValues] : la liste des textes capturés, de type [List<String>] ; un groupe qui n'a rien capturé y est représenté par une chaîne vide ;
  • [destructured] : les groupes n° 1 et suivants, prêts à être déstructurés : [val (h, m, s) = resultat.destructured].

Le programme [Regex03.kt] les utilise :

package regex03

import java.time.LocalDate
import java.time.format.DateTimeFormatter

// récupérer des parties d'un modèle : les groupes
fun main() {
    val modele3 = """(\d\d):(\d\d):(\d\d)"""
    val regex3 = Regex(modele3)
    val exemplaire4 = "Il est 18:05:49"
    // find cherche la première occurrence du modèle ; null s'il n'y en a pas
    val resultat = regex3.find(exemplaire4)
    if (resultat != null) {
        println("L'exemplaire [$exemplaire4] correspond au modèle [$modele3]")
        // on affiche les groupes de parenthèses ; le groupe 0 est l'occurrence entière
        resultat.groups.forEachIndexed { i, groupe ->
            println("groupes[$i]=[${groupe?.value}] trouvé en position ${groupe?.range?.first}")
        }
        // les valeurs des groupes 1, 2, 3 par déstructuration
        val (heures, minutes, secondes) = resultat.destructured
        println("heures=$heures, minutes=$minutes, secondes=$secondes")
    } else {
        println("L'exemplaire [$exemplaire4] ne correspond pas au modèle [$modele3]")
    }

    // des groupes nommés : (?<nom>...)
    val regexDate = Regex("""(?<jour>\d{1,2})/(?<mois>\d{1,2})/(?<annee>\d{4})""")
    val texte = "Rendez-vous le 3/10/2026, puis le 15/11/2026."
    val format = DateTimeFormatter.ofPattern("EEEE d MMMM yyyy")
    for (m in regexDate.findAll(texte)) {
        // un groupe se désigne par son nom plutôt que par son numéro
        val jour = m.groups["jour"]!!.value.toInt()
        val mois = m.groups["mois"]!!.value.toInt()
        val annee = m.groups["annee"]!!.value.toInt()
        val date = LocalDate.of(annee, mois, jour)
        println("[${m.value}] : jour=$jour, mois=$mois, année=$annee -> ${date.format(format)}")
    }

    // replace : les groupes peuvent être réutilisés dans la chaîne de remplacement
    // $$"..." : seul $$ introduit un gabarit, $ est un caractère ordinaire
    val inverse = regexDate.replace(texte, $$"${annee}-${mois}-${jour}")
    println("replace(chaîne) : $inverse")
    // replace avec une lambda : le remplacement est calculé pour chaque occurrence
    val iso = regexDate.replace(texte) { r ->
        val (jour, mois, annee) = r.destructured
        LocalDate.of(annee.toInt(), mois.toInt(), jour.toInt()).toString()
    }
    println("replace(lambda) : $iso")
    // matchEntire : la chaîne entière doit correspondre au modèle ; groupValues : les valeurs des groupes
    println("matchEntire(\"25/12/2026\") : ${regexDate.matchEntire("25/12/2026")?.groupValues}")
    println("matchEntire(\"le 25/12/2026\") : ${regexDate.matchEntire("le 25/12/2026")?.groupValues}")
}

Résultats :

> gradlew -q run -Pmain=regex03.Regex03Kt
L'exemplaire [Il est 18:05:49] correspond au modèle [(\d\d):(\d\d):(\d\d)]
groupes[0]=[18:05:49] trouvé en position 7
groupes[1]=[18] trouvé en position 7
groupes[2]=[05] trouvé en position 10
groupes[3]=[49] trouvé en position 13
heures=18, minutes=05, secondes=49
[3/10/2026] : jour=3, mois=10, année=2026 -> samedi 3 octobre 2026
[15/11/2026] : jour=15, mois=11, année=2026 -> dimanche 15 novembre 2026
replace(chaîne) : Rendez-vous le 2026-10-3, puis le 2026-11-15.
replace(lambda) : Rendez-vous le 2026-10-03, puis le 2026-11-15.
matchEntire("25/12/2026") : [25/12/2026, 25, 12, 2026]
matchEntire("le 25/12/2026") : null

Commentaires :

  • ligne 12 : la chaîne [exemplaire4] est comparée au modèle [regex3] ; [find] cherche la première occurrence du modèle et rend [null] s'il n'y en a pas. Le test de la ligne 13 rend ensuite [resultat] utilisable sans précaution ;
  • lignes 16-17 : on parcourt la collection [groups] avec son indice : l'élément n° 0 est l'occurrence entière, les éléments n° 1 à 3 sont les groupes de parenthèses. Les éléments étant de type [MatchGroup?], on y accède avec l'opérateur [?.] ; ici, tous les groupes ont capturé un texte. [range.first] est la position de ce texte dans la chaîne explorée ;
  • ligne 20 : la propriété [destructured] permet de récupérer les groupes n° 1, 2, 3 dans trois variables en une instruction, par déstructuration (chapitre 3). C'est la façon la plus concise d'exploiter un modèle dont on connaît les groupes ;
  • ligne 27 : 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 32-34 : un groupe nommé se désigne par son nom plutôt que par son numéro, avec l'opérateur d'indexation de [groups] : le code est plus lisible et ne dépend pas de l'ordre des parenthèses. Le résultat est de type [MatchGroup?] (un nom inconnu provoque une exception [IllegalArgumentException], un groupe qui n'a rien capturé donne [null]) ; ici, les trois groupes capturent toujours un texte quand le modèle est trouvé, et l'opérateur [!!] exprime cette certitude ;
  • lignes 35-36 : la classe [java.time.LocalDate] 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. Kotlin utilise directement les classes [java.time] du JDK ;
  • ligne 41 : la fonction [replace] 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. Mais en Kotlin, [${annee}] dans une chaîne est un gabarit, qui insérerait la valeur d'une variable [annee] ! On pourrait écrire ["\${annee}-\${mois}-\${jour}"]. La chaîne est écrite ici avec l'interpolation multi-dollar de Kotlin 2.2 : dans une chaîne précédée de [$$], un gabarit s'écrit [$${...}] et le caractère [$] isolé est un caractère ordinaire. Avec [$$$], il en faudrait trois, et ainsi de suite ;
  • lignes 44-46 : une variante de [replace] reçoit une lambda (chapitre 7) qui calcule le texte de remplacement à partir de chaque occurrence, de type [MatchResult]. Écrite après les parenthèses, selon l'usage de Kotlin pour le dernier paramètre d'une fonction, la lambda récupère les groupes par déstructuration, construit la date et rend son écriture normalisée ISO 8601, que donne la fonction [toString] de [LocalDate] : les jours et les mois ont toujours deux chiffres. C'est l'équivalent de la méthode [replaceAll] de [Matcher] qui reçoit une fonction (Java 9) ;
  • lignes 50-51 : la fonction [matchEntire] exige, comme [matches], que la chaîne entière corresponde au modèle, mais rend le résultat de la comparaison ([MatchResult]), ou [null] en cas d'échec : c'est la fonction à utiliser pour vérifier une chaîne et en extraire les parties. On affiche ici la propriété [groupValues] du résultat : l'occurrence entière, puis la valeur de chaque groupe.

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.kt] 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.

package regex04

import java.util.regex.PatternSyntaxException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource

// un programme d'apprentissage des expressions régulières
fun main() {
    while (true) {
        // on demande le modèle
        val modele = lire("Tapez le modèle à tester ou rien pour arrêter : ")
        // fini ?
        if (modele.isBlank()) break
        // on compile l'expression régulière ; un modèle incorrect provoque une exception
        val regex = try {
            Regex(modele)
        } catch (e: PatternSyntaxException) {
            println("Erreur : ${e.description} (position ${e.index})")
            continue
        }
        // on demande les exemplaires à comparer au modèle
        while (true) {
            val chaine = lire("Tapez la chaîne à comparer au modèle [$modele] ou rien pour arrêter : ")
            // fini ?
            if (chaine.isBlank()) break
            try {
                // on fait la comparaison, avec une limite d'une seconde
                val limite = TimeSource.Monotonic.markNow() + 1.seconds
                var trouve = false
                for (m in regex.findAll(ChaineLimitee(chaine, limite))) {
                    trouve = true
                    // on affiche l'élément correspondant au modèle
                    println("J'ai trouvé la correspondance [${m.value}] en position [${m.range.first}]")
                    // et ses sous-éléments (groupes 1, 2, ...)
                    for (groupe in m.groups.drop(1)) {
                        println("    sous-élément [${groupe?.value}] en position [${groupe?.range?.first}]")
                    }
                }
                if (!trouve) println("Je n'ai pas trouvé de correspondances")
            } catch (_: DelaiDepasseException) {
                println("La recherche a été abandonnée : elle durait plus d'une seconde")
            }
        }
    }
}

// lecture d'une ligne au clavier ; "" en fin de flux
fun lire(invite: String): String {
    print(invite)
    return readlnOrNull() ?: ""
}

// une chaîne qui refuse d'être lue au-delà d'une date limite
// les membres de CharSequence non redéfinis (length) sont délégués à l'objet [texte]
class ChaineLimitee(
    private val texte: CharSequence,
    private val limite: TimeSource.Monotonic.ValueTimeMark
) : CharSequence by texte {

    // le moteur d'expressions régulières lit la chaîne caractère par caractère
    override fun get(index: Int): Char {
        if (limite.hasPassedNow()) throw DelaiDepasseException()
        return texte[index]
    }

    override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
        ChaineLimitee(texte.subSequence(startIndex, endIndex), limite)

    override fun toString() = texte.toString()
}

// l'exception lancée quand la date limite est dépassée
class DelaiDepasseException : RuntimeException()

Commentaires :

  • lignes 15-19 : la compilation du modèle tapé au clavier. Un modèle incorrect provoque une exception [java.util.regex.PatternSyntaxException] : la classe [Regex] laisse passer l'exception de la classe [Pattern] qu'elle enveloppe. Ses propriétés [description] et [index] (les méthodes Java [getDescription] et [getIndex], vues par Kotlin comme des propriétés) donnent la description de l'erreur et sa position dans le modèle. Son message complet, la propriété [message], tient sur plusieurs lignes et montre le modèle avec un repère sous l'erreur. On notera que [try] est une expression (chapitre 3) : sa valeur est celle du bloc [try], l'objet [Regex], ou celle du bloc [catch]. Celui-ci se termine par [continue], une expression de type [Nothing] qui ne rend jamais de valeur : le compilateur en déduit que [regex] est de type [Regex] ;
  • lignes 28-30 : la recherche ne se fait pas directement dans la chaîne tapée, mais dans un objet [ChaineLimitee] (lignes 55-69), 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 40. Nous verrons plus loin pourquoi c'est utile et comment cela fonctionne. La date limite est calculée avec les classes [kotlin.time] de la bibliothèque standard : [TimeSource.Monotonic.markNow()] est l'instant présent, mesuré par une horloge monotone (insensible aux changements de l'heure du système), et [1.seconds] une durée [Duration], écrite grâce à une propriété d'extension de [Int] ;
  • lignes 35-36 : pour chaque correspondance, on affiche les groupes n° 1 et suivants, s'il y en a : [drop(1)] écarte l'élément n° 0 de la collection [groups], l'occurrence entière ;
  • ligne 40 : le nom [_] indique que l'exception interceptée n'est pas utilisée ;
  • lignes 48-50 : 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. En fin de flux, [readlnOrNull] rend [null], remplacé par une chaîne vide, ce qui arrête le programme.

Voici un exemple d'exécution (les saisies, redirigées depuis un fichier, ont été replacées après les invites) :

> gradlew -q run -Pmain=regex04.Regex04Kt
Tapez le modèle à tester ou rien pour arrêter : \d+
Tapez la chaîne à comparer au modèle [\d+] ou rien pour arrêter : 123 456 789
J'ai trouvé la correspondance [123] en position [0]
J'ai trouvé la correspondance [456] en position [4]
J'ai trouvé la correspondance [789] en position [8]
Tapez la chaîne à comparer au modèle [\d+] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : (\d{2}):(\d\d)
Tapez la chaîne à comparer au modèle [(\d{2}):(\d\d)] ou rien pour arrêter : 14:15 abcd 17:18 xyzt
J'ai trouvé la correspondance [14:15] en position [0]
    sous-élément [14] en position [0]
    sous-élément [15] en position [3]
J'ai trouvé la correspondance [17:18] en position [11]
    sous-élément [17] en position [11]
    sous-élément [18] en position [14]
Tapez la chaîne à comparer au modèle [(\d{2}):(\d\d)] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : ^\s*\d+\s*$
Tapez la chaîne à comparer au modèle [^\s*\d+\s*$] ou rien pour arrêter :    1456
J'ai trouvé la correspondance [   1456] en position [0]
Tapez la chaîne à comparer au modèle [^\s*\d+\s*$] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : ^\s*(\d+)\s*$
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter : 1456
J'ai trouvé la correspondance [1456] en position [0]
    sous-élément [1456] en position [0]
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter : abcd 1456
Je n'ai pas trouvé de correspondances
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : ^(a+)+$
Tapez la chaîne à comparer au modèle [^(a+)+$] ou rien pour arrêter : aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
Je n'ai pas trouvé de correspondances
Tapez la chaîne à comparer au modèle [^(a+)+$] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : ^(a+)+\1$
Tapez la chaîne à comparer au modèle [^(a+)+\1$] ou rien pour arrêter : aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
La recherche a été abandonnée : elle durait plus d'une seconde
Tapez la chaîne à comparer au modèle [^(a+)+\1$] ou rien pour arrêter :
Tapez le modèle à tester ou rien pour arrêter : (\d+
Erreur : Unclosed group (position 4)
Tapez le modèle à tester ou rien pour arrêter :

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. Le dernier modèle, incorrect, est refusé : il manque une parenthèse fermante (« Unclosed group »), ce que le moteur constate à la position 4, à la fin du modèle. Le message de l'exception, produit par le JDK, est en anglais.

5.8.5. La fonction split

Nous avons déjà rencontré la fonction [split] de la classe [String]. La classe [Regex] a la même fonction, qui utilise le modèle déjà compilé :

fonction
rôle
[split(input, limit = 0)]
la chaîne [input] est décomposée en champs, ceux-ci étant séparés par un séparateur correspondant au modèle ; rend une liste [List<String>]. Si [limit] est positif, il fixe le nombre maximal de champs
[splitToSequence(input)]
idem, mais les champs sont rendus sous la forme d'une séquence, évaluée paresseusement
[CharSequence.split(regex)]
fonction d'extension de [String] : [ligne.split(separateur)] équivaut à [separateur.split(ligne)]

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.kt] :

package regex05

// split : le séparateur est défini par un modèle
fun main() {
    val ligne = "abc  , def  ,ghi ,   jkl"
    // une virgule précédée et suivie d'un nombre quelconque d'espaces
    val separateur = Regex("""\s*,\s*""")
    val champs = separateur.split(ligne)
    champs.forEachIndexed { i, champ -> println("champs[$i]=[$champ]") }
    // plusieurs séparateurs possibles : ; ou | ou une suite d'espaces
    val mots = "un;deux|trois   quatre".split(Regex("""[;|]|\s+"""))
    println("mots=$mots")
    // attention : avec un paramètre String, split découpe selon un texte, pas selon un modèle
    println("\"1.5.2\".split(\".\") : ${"1.5.2".split(".")}")
    // les champs vides de la fin sont conservés ; limit fixe le nombre maximal de champs
    println("\"a,b,,\".split(\",\") : ${"a,b,,".split(",")}")
    println("separateur.split(ligne, 2) : ${separateur.split(ligne, 2)}")
    // splitToSequence : les champs sous la forme d'une séquence
    println("splitToSequence : ${separateur.splitToSequence(ligne).map { it.uppercase() }.toList()}")
}

Résultats :

> gradlew -q run -Pmain=regex05.Regex05Kt
champs[0]=[abc]
champs[1]=[def]
champs[2]=[ghi]
champs[3]=[jkl]
mots=[un, deux, trois, quatre]
"1.5.2".split(".") : [1, 5, 2]
"a,b,,".split(",") : [a, b, , ]
separateur.split(ligne, 2) : [abc, def  ,ghi ,   jkl]
splitToSequence : [ABC, DEF, GHI, JKL]

Commentaires :

  • ligne 7 : 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 9 : le résultat de [split] est une liste, parcourue avec ses indices par [forEachIndexed] ;
  • ligne 11 : un séparateur qui peut prendre plusieurs formes : un point-virgule ou une barre verticale ([[;|]]), ou bien ([|]) une suite d'espaces ([\s+]). Le modèle est passé à la fonction [split] de [String] sous la forme d'un objet [Regex] ;
  • ligne 14 : attention, voici une différence importante avec Java. Lorsque la fonction [split] de Kotlin reçoit une chaîne de caractères ([String]), celle-ci est un séparateur littéral, et non un modèle : ["1.5.2".split(".")] découpe la chaîne selon le caractère point et rend [[1, 5, 2]]. En Java, la méthode [split] de [String] interprète toujours son paramètre comme une expression régulière, dans laquelle le point désigne n'importe quel caractère : ["1.5.2".split(".")] rend un tableau vide, piège classique. En Kotlin, on ne peut pas se tromper : un modèle se passe sous la forme d'un objet [Regex], et une chaîne est toujours prise à la lettre ;
  • ligne 16 : autre différence avec Java : les champs vides de la fin de la chaîne sont conservés. La chaîne « a,b,, » donne quatre champs, dont les deux derniers sont vides, là où la méthode [split] de Java n'en rend que deux. Le nombre de champs d'une ligne mal formée n'est ainsi jamais faussé : une ligne qui se termine par un séparateur en trop a un champ de trop ;
  • ligne 17 : le paramètre [limit] limite le nombre de champs : le dernier champ contient alors le reste de la chaîne ;
  • ligne 18 : [splitToSequence] rend les champs sous la forme d'une séquence, à laquelle on applique ici l'opération [map], avant de la transformer en liste. Les champs ne sont découpés qu'au fur et à mesure des besoins, ce qui est utile pour une très longue chaîne dont on n'exploite que les premiers champs. C'est l'équivalent de la méthode [splitAsStream] de [Pattern].

5.8.6. Modèles précompilés, options, recherches trop longues

Contrairement à d'autres plates-formes, ni Java, ni Kotlin n'ont 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 le constructeur de [Regex]. 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 [Regex] dans une propriété en lecture seule ([val]), de haut niveau ou dans un [companion object], comme le fait le programme [Regex06.kt]. On évitera en particulier d'écrire ["""\d+""".toRegex()] au cœur d'une boucle : le modèle serait recompilé à chaque tour. Un objet [Regex] est non modifiable et peut être utilisé simultanément par plusieurs threads : contrairement à l'objet [Matcher] de Java, il ne mémorise pas l'état d'une recherche, chaque recherche produisant ses propres résultats.

package regex06

import java.util.regex.Pattern
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlin.time.measureTimedValue

// des modèles compilés une fois pour toutes, les options, la protection contre les recherches trop longues

// une heure hh:mm:ss avec des groupes nommés
val HEURE = Regex("""(?<h>\d\d):(?<m>\d\d):(?<s>\d\d)""")
// oui, quelle que soit la casse
val OUI = Regex("""^\s*oui\s*$""", RegexOption.IGNORE_CASE)
// un modèle pathologique : un groupe répété suivi d'une référence arrière à ce groupe
val CATASTROPHE = Regex("""^(a+)+\1$""")

fun main() {
    // 1 - des modèles compilés une seule fois, rangés dans des propriétés de haut niveau
    println("HEURE.containsMatchIn(\"Il est 18:05:49\") : ${HEURE.containsMatchIn("Il est 18:05:49")}")
    HEURE.find("départ à 07:45:00")?.let { m ->
        val (h, mn, s) = listOf("h", "m", "s").map { nom -> m.groups[nom]?.value }
        println("heures=$h, minutes=$mn, secondes=$s")
    }

    // 2 - les options : ici IGNORE_CASE, fixée à la compilation du modèle
    for (reponse in listOf("oui", "OUI", "Oui", "non", "ouiii")) {
        print("$reponse=${OUI.matches(reponse)} ")
    }
    println()
    // IGNORE_CASE ignore aussi la casse des lettres accentuées
    println("Regex(\"élève\", IGNORE_CASE) sur \"ÉLÈVE\" : "
            + Regex("élève", RegexOption.IGNORE_CASE).matches("ÉLÈVE"))
    // MULTILINE : ^ et $ correspondent à chaque début et fin de ligne ; \w ne reconnaît que [a-zA-Z0-9_]
    val texte = "prix: 10\nquantité: 3\ntotal: 30"
    affiche(Regex("""^(\w+): (\d+)$""", RegexOption.MULTILINE), texte)
    // (?U) au début du modèle : \w reconnaît aussi les lettres accentuées
    affiche(Regex("""(?U)^(\w+): (\d+)$""", RegexOption.MULTILINE), texte)
    // une option de java.util.regex absente de RegexOption : on compile un Pattern et on le convertit
    val unicode = Pattern.compile("""^(\w+): (\d+)$""", Pattern.MULTILINE or Pattern.UNICODE_CHARACTER_CLASS)
    affiche(unicode.toRegex(), texte)
    // les options peuvent aussi s'écrire dans le modèle : (?i) pour IGNORE_CASE, (?m) pour MULTILINE
    println("(?i)oui sur \"OUI\" : ${"OUI".matches(Regex("(?i)oui"))}")

    // 3 - un modèle à retour arrière catastrophique, sans puis avec limite de temps
    for (n in listOf(10, 15, 20)) {
        val chaine = "a".repeat(n) + "!"
        val (trouve, duree) = measureTimedValue { CATASTROPHE.containsMatchIn(chaine) }
        println("$n a : $trouve en ${duree.inWholeMilliseconds} ms")
    }
    val chaine = "a".repeat(30) + "!"
    try {
        val limite = TimeSource.Monotonic.markNow() + 100.milliseconds
        println(CATASTROPHE.containsMatchIn(ChaineLimitee(chaine, limite)))
    } catch (e: DelaiDepasseException) {
        println("30 a : ${e.javaClass.simpleName} : recherche abandonnée au bout de 100 ms")
    }
    // le moteur de Java reconnaît le modèle (a+)+ sans référence arrière et répond immédiatement
    val (trouve, duree) = measureTimedValue { Regex("""^(a+)+$""").containsMatchIn(chaine) }
    println("^(a+)+$ sur 30 a : $trouve, réponse en moins d'une seconde : ${duree < 1.seconds}")
}

// affichage des couples nom=valeur trouvés dans un texte
fun affiche(modele: Regex, texte: String) {
    for ((nom, valeur) in modele.findAll(texte).map { it.destructured }) {
        print("[$nom=$valeur] ")
    }
    println()
}

// une chaîne qui refuse d'être lue au-delà d'une date limite
class ChaineLimitee(
    private val texte: CharSequence,
    private val limite: TimeSource.Monotonic.ValueTimeMark
) : CharSequence by texte {

    override fun get(index: Int): Char {
        if (limite.hasPassedNow()) throw DelaiDepasseException()
        return texte[index]
    }

    override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
        ChaineLimitee(texte.subSequence(startIndex, endIndex), limite)

    override fun toString() = texte.toString()
}

// l'exception lancée quand la date limite est dépassée
class DelaiDepasseException : RuntimeException()

Résultats :

> gradlew -q run -Pmain=regex06.Regex06Kt
HEURE.containsMatchIn("Il est 18:05:49") : true
heures=07, minutes=45, secondes=00
oui=true OUI=true Oui=true non=false ouiii=false
Regex("élève", IGNORE_CASE) sur "ÉLÈVE" : true
[prix=10] [total=30]
[prix=10] [quantité=3] [total=30]
[prix=10] [quantité=3] [total=30]
(?i)oui sur "OUI" : true
10 a : false en 1 ms
15 a : false en 6 ms
20 a : false en 238 ms
30 a : DelaiDepasseException : recherche abandonnée au bout de 100 ms
^(a+)+$ sur 30 a : false, réponse en moins d'une seconde : true

Commentaires :

  • lignes 12-16 : les modèles du programme sont compilés une seule fois et rangés dans des propriétés de haut niveau, écrites en majuscules selon l'usage pour des constantes. Elles ne peuvent pas être déclarées [const] : seules les valeurs de types de base et les chaînes, connues à la compilation, peuvent l'être ; un objet [Regex] est créé à l'exécution, à la première utilisation du fichier ;
  • lignes 20-23 : les propriétés s'utilisent comme les objets [Regex] des programmes précédents. La fonction [let], appelée par l'opérateur [?.], n'exécute son bloc que si [find] a trouvé une occurrence ; la ligne 22 récupère les trois groupes nommés en une instruction, par une projection [map] suivie d'une déstructuration ;
  • ligne 14 : le second paramètre du constructeur de [Regex] est une option, de l'énumération [RegexOption]. [IGNORE_CASE] ignore la casse : « OUI » et « Oui » correspondent au modèle (ligne 3 des résultats). Plusieurs options se passent sous la forme d'un ensemble : [Regex(modele, setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))], là où Java combine des entiers avec l'opérateur [|] ;
  • lignes 31-33 : sur la JVM, Kotlin ajoute toujours l'option [UNICODE_CASE] de Java à l'option [IGNORE_CASE] : la casse des lettres accentuées est elle aussi ignorée, « ÉLÈVE » correspond à « élève » (ligne 4 des résultats). En Java, [CASE_INSENSITIVE] seul ne le ferait pas ;
  • lignes 35-36 : [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) ;
  • ligne 38 : l'énumération [RegexOption] ne propose pas l'option [UNICODE_CHARACTER_CLASS] de Java, qui donne à [\w], [\d], [\s], [\b] leur sens Unicode. On l'obtient en écrivant [(?U)] au début du modèle : les lettres accentuées sont alors des caractères de mot (ligne 6 des résultats). On l'utilisera pour tout texte en français ;
  • lignes 40-41 : une autre solution consiste à compiler le modèle avec la classe [Pattern] de Java, dont toutes les options sont disponibles (combinées ici avec l'opérateur [or], le « ou » bit à bit de Kotlin), puis à transformer l'objet [Pattern] en [Regex] avec la fonction d'extension [toRegex] (ligne 7 des résultats). Les autres options de [RegexOption] sont [DOT_MATCHES_ALL] ([.] 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, dans une chaîne brute), [LITERAL] (le modèle est un texte ordinaire ; voir aussi [Regex.escape] et [Regex.fromLiteral]), [UNIX_LINES] et [CANON_EQ] ;
  • ligne 43 : les options peuvent aussi être écrites au début du modèle : [(?i)] pour [IGNORE_CASE], [(?m)] pour [MULTILINE], [(?s)] pour [DOT_MATCHES_ALL], [(?U)]… ;
  • lignes 64-66 : la fonction [affiche] parcourt la séquence des valeurs [destructured] des occurrences, et déstructure chacune d'elles directement dans la boucle [for] : [(nom, valeur)] sont les groupes n° 1 et 2.

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, celui qu'utilise Kotlin sur la JVM, 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 16 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 46-49 : la durée de la recherche est mesurée pour 10, 15 et 20 a. La fonction [measureTimedValue] de [kotlin.time] exécute le bloc qu'elle reçoit et rend à la fois sa valeur et sa durée, un objet [Duration], sous la forme d'un objet [TimedValue] que l'on déstructure. La durée grandit très vite (lignes 9-11 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 51-56 : la classe [Regex], pas plus que la classe [Pattern], n'a d'option de délai d'expiration. On utilise une technique classique : la recherche se fait dans un objet [ChaineLimitee] (lignes 72-85), qui implémente l'interface [CharSequence] acceptée par les fonctions de [Regex]. Le moteur d'expressions régulières lit la chaîne caractère par caractère avec la méthode [charAt] de l'interface Java [CharSequence], qui est en Kotlin l'opérateur [get] : c'est lui qui vérifie l'heure et lance une exception [DelaiDepasseException] (ligne 89) 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 72-75 : la classe [ChaineLimitee] a deux propriétés, le texte et la date limite, un instant de l'horloge monotone de [kotlin.time] ([ValueTimeMark]), dont la fonction [hasPassedNow] indique s'il est dépassé. La clause [CharSequence by texte] est une délégation : la classe implémente l'interface [CharSequence] en confiant à l'objet [texte] tous les membres qu'elle ne redéfinit pas, ici la propriété [length]. Le compilateur génère les fonctions qui transmettent les appels ; en Java, il faut les écrire à la main. Seuls sont redéfinis l'opérateur [get], qui surveille l'heure, et la fonction [subSequence], qui rend une [ChaineLimitee] soumise à la même date limite. La fonction [toString], membre de [Any] et non de l'interface, n'est pas déléguée : elle est redéfinie elle aussi ;
  • ligne 89 : l'exception [DelaiDepasseException] dérive de [RuntimeException]. Kotlin n'ayant pas d'exceptions contrôlées, toute exception pourrait être lancée par [get] ; dériver de [RuntimeException] reste l'usage, et les codes Java qui appelleraient la classe la verraient comme une exception non contrôlée. Le champ [serialVersionUID] que l'on trouve dans les exceptions Java est inutile ici : le compilateur Kotlin ne signale pas son absence ;
  • lignes 59-60 : 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 16. Les durées de type [Duration] se comparent avec l'opérateur [<].

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 du moteur 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 [impotsv3] du projet : comme en Java, la version 3 est autonome et ne dépend pas des classes du chapitre 4. Les tranches y sont fournies par une propriété abstraite, une liste en lecture seule, et la division est faite avec 16 chiffres significatifs, ce qui est largement suffisant :

package impotsv3

import java.math.BigDecimal
import java.math.MathContext

abstract class AbstractImpot : IImpot {

    // les tranches d'impôt nécessaires au calcul proviennent d'une source extérieure
    // que seules les classes dérivées connaissent
    protected abstract val tranchesImpot: List<TrancheImpot>

    // calcul de l'impôt
    override fun calculer(marie: Boolean, nbEnfants: Int, salaire: Int): Int {
        // calcul du nombre de parts
        var nbParts = nbEnfants.toBigDecimal().divide(BigDecimal.TWO)
        nbParts += if (marie) BigDecimal.TWO else BigDecimal.ONE
        if (nbEnfants >= 3) {
            nbParts += "0.5".toBigDecimal()
        }
        // calcul du revenu imposable et du quotient familial
        val revenu = "0.72".toBigDecimal() * salaire.toBigDecimal()
        val quotient = revenu.divide(nbParts, MathContext.DECIMAL64)
        // recherche de la tranche d'impôt : la dernière sert si aucune limite ne convient
        val tranche = tranchesImpot.dropLast(1).firstOrNull { quotient <= it.limite } ?: tranchesImpot.last()
        // calcul de l'impôt
        return (revenu * tranche.coeffR - nbParts * tranche.coeffN).toInt()
    }
}

La fonction [calculer] utilise les tranches d'impôt rendues par la propriété abstraite [tranchesImpot] (ligne 10). La classe [AbstractImpot] ne sait pas d'où viennent ces tranches : c'est aux classes dérivées de les fournir, en redéfinissant la propriété. 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), que la bibliothèque standard de Kotlin rend agréables à manipuler :

  • les fonctions d'extension [toBigDecimal] de [Int] et de [String] remplacent [BigDecimal.valueOf(...)] et [new BigDecimal(...)] (["0.72".toBigDecimal()], ligne 21) ;
  • les opérateurs [+], [-], [*], [+=] sont définis pour [BigDecimal] (ce sont des fonctions d'extension [plus], [minus], [times], déclarées [operator], chapitre 4) : la ligne 26 se lit comme la formule qu'elle calcule ;
  • [BigDecimal] implémente [Comparable<BigDecimal>] : les opérateurs de comparaison s'appliquent ([quotient <= it.limite], ligne 24) et appellent [compareTo] ;
  • attention cependant à l'opérateur [/] : il appelle [divide(other, RoundingMode.HALF_EVEN)], qui conserve le nombre de décimales du dividende. Pour deux entiers, le résultat est arrondi à l'entier : [3.toBigDecimal() / 2.toBigDecimal()] vaut 2, et [1.toBigDecimal() / 2.toBigDecimal()] vaut 0. Les deux divisions de la fonction sont donc écrites avec la méthode [divide] : la division exacte par 2 (ligne 15), qui lancerait une exception si le résultat n'était pas décimal fini, et la division avec 16 chiffres significatifs ([MathContext.DECIMAL64], ligne 22) ;
  • ligne 24 : la recherche de la tranche s'écrit avec les opérations des collections : [dropLast(1)] écarte la dernière tranche, [firstOrNull] rend la première dont la limite n'est pas dépassée par le quotient familial, ou [null] s'il n'y en a pas, et l'opérateur [?:] choisit alors la dernière tranche, [last()] ;
  • [toInt] rend la partie entière du résultat (méthode [intValue] de [BigDecimal]).

Une tranche est représentée par la classe de données [TrancheImpot] :

1
2
3
4
5
6
package impotsv3

import java.math.BigDecimal

// une tranche du barème de l'impôt
data class TrancheImpot(val limite: BigDecimal, val coeffR: BigDecimal, val coeffN: BigDecimal)

et le calcul est décrit par l'interface [IImpot], documentée ici par un commentaire KDoc, l'équivalent Kotlin de Javadoc (mêmes balises [@param], [@return]) :

package impotsv3

/**
 * Le calcul de l'impôt.
 */
interface IImpot {

    /**
     * Calcule l'impôt d'un contribuable.
     *
     * @param marie vrai si le contribuable est marié
     * @param nbEnfants nombre d'enfants
     * @param salaire salaire annuel en euros
     * @return le montant de l'impôt, en euros
     */
    fun calculer(marie: Boolean, nbEnfants: Int, salaire: Int): Int
}

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 :

1
2
3
4
5
6
7
4962:0:0
8382:0.068:291.09
14753:0.191:1322.92
23888:0.283:2668.39
38868:0.374:4846.98
47932:0.426:6883.66
0:0.481:9505.54

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. La fonction [toBigDecimal] de [String], comme [toDouble], 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 :

1
2
3
4
5
6
7
8
9
[
  { "limite": 4962, "coeffR": 0, "coeffN": 0 },
  { "limite": 8382, "coeffR": 0.068, "coeffN": 291.09 },
  { "limite": 14753, "coeffR": 0.191, "coeffN": 1322.92 },
  { "limite": 23888, "coeffR": 0.283, "coeffN": 2668.39 },
  { "limite": 38868, "coeffR": 0.374, "coeffN": 4846.98 },
  { "limite": 47932, "coeffR": 0.426, "coeffN": 6883.66 },
  { "limite": 0, "coeffR": 0.481, "coeffN": 9505.54 }
]

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 :

1
2
3
4
5
6
7
package impotsv3

// l'exception lancée par les classes qui lisent les tranches d'impôt
// code : le code de l'erreur (une combinaison de valeurs CodeErreur)
// cause : l'exception à l'origine de l'erreur, s'il y en a une
class ImpotException(message: String, val code: Int, cause: Throwable? = null) :
    RuntimeException(message, cause)

Commentaires :

  • ligne 6 : toute la classe tient dans son en-tête. Le constructeur primaire reçoit le message, le code et la cause de l'erreur ; le code devient une propriété en lecture seule ([val]), tandis que le message et la cause, simples paramètres, sont transmis au constructeur de la classe parent [RuntimeException], qui les rend disponibles par les propriétés [message] et [cause] ;
  • le paramètre [cause], l'exception à l'origine de l'erreur, a une valeur par défaut, [null] : un seul constructeur remplace les deux constructeurs de la version Java, [ImpotException(message, code)] et [ImpotException(message, code, e)] s'écrivant avec le même. Les versions suivantes de l'application reprendront cette exception porteuse d'un code entier, avec un constructeur un peu différent (chapitre 6) ;
  • ligne 7 : la classe dérive de [RuntimeException]. Kotlin n'ayant pas d'exceptions contrôlées (chapitre 3), le choix de la classe parent n'oblige ici personne à rien ; mais pour un code Java qui utiliserait la classe, [ImpotException] sera une exception non contrôlée, ce qui est le comportement voulu, comme nous le verrons. Le champ [serialVersionUID] de la version Java n'est pas nécessaire : le compilateur Kotlin n'émet pas d'avertissement à son sujet.

Les codes d'erreur possibles sont décrits par une énumération :

package impotsv3

import java.util.EnumSet

// les codes d'erreur : des puissances de 2, qui peuvent se combiner
enum class CodeErreur(val valeur: Int) {
    ACCES(1),       // le fichier ne peut pas être lu
    LIGNE(2),       // une ligne n'a pas trois champs
    CHAMP1(4),      // le champ 1 (limite) est incorrect
    CHAMP2(8),      // le champ 2 (coeffR) est incorrect
    CHAMP3(16),     // le champ 3 (coeffN) est incorrect
    JSON(32);       // le document JSON est incorrect

    companion object {
        // les codes présents dans une combinaison de codes
        fun decoder(code: Int): EnumSet<CodeErreur> =
            entries.filterTo(EnumSet.noneOf(CodeErreur::class.java)) { code and it.valeur != 0 }
    }
}

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 ;
  • ligne 6 : une énumération Kotlin ([enum class], chapitre 3) peut avoir des propriétés, déclarées dans son constructeur primaire, et des fonctions. Chaque constante est construite avec sa valeur entière, passée entre parenthèses ; la liste des constantes se termine par un point-virgule lorsqu'elle est suivie d'autres membres ;
  • lignes 14-17 : 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 or 16] = 24 ([or] est le « ou » bit à bit de Kotlin). La fonction [decoder], placée dans le [companion object] de l'énumération pour être appelée par [CodeErreur.decoder(code)], fait l'opération inverse : elle rend l'ensemble des codes présents dans un entier, en testant chaque bit avec l'opérateur [and]. La propriété [entries] (Kotlin 1.9) est la liste des constantes de l'énumération, et [filterTo] range celles qui satisfont la condition dans la collection qu'on lui passe, ici un [java.util.EnumSet] vide. Cet ensemble spécialisé pour les constantes d'une énumération les mémorise lui-même sous la forme des bits d'un entier ; sa méthode [toString] affiche une combinaison sous la forme « [CHAMP2, CHAMP3] ». On pourrait aussi bien rendre un [Set<CodeErreur>] de Kotlin ([entries.filter { ... }.toSet()]), dont l'affichage serait le même.

5.9.3. La classe FileImpot

La classe [FileImpot] fournit les tranches lues dans un fichier texte :

package impotsv3

import java.io.IOException
import kotlin.io.path.Path
import kotlin.io.path.useLines

// les tranches d'impôt lues dans un fichier texte de lignes limite:coeffR:coeffN
class FileImpot(nomFichier: String) : AbstractImpot() {

    companion object {
        // le séparateur des champs : le caractère : entouré d'espaces éventuels
        private val SEPARATEUR = Regex("""\s*:\s*""")
        // un nombre positif ou nul : des chiffres, éventuellement suivis d'un point et de chiffres
        private val NOMBRE = Regex("""\d+(\.\d+)?""")
        // les codes d'erreur des trois champs d'une ligne
        private val CODES_CHAMPS = listOf(CodeErreur.CHAMP1, CodeErreur.CHAMP2, CodeErreur.CHAMP3)
    }

    // les tranches d'impôt lues dans le fichier (implémentation de la propriété abstraite du parent)
    override val tranchesImpot: List<TrancheImpot>

    init {
        // le nom du fichier, sans son chemin, pour les messages d'erreur
        val chemin = Path(nomFichier)
        val nom = chemin.fileName.toString()
        val tranches = try {
            // lecture du fichier ligne par ligne ; useLines le ferme dans tous les cas
            chemin.useLines { lignes ->
                lignes.mapIndexed { index, ligne -> index + 1 to ligne.trim() }
                    // on ignore les lignes vides
                    .filter { (_, ligne) -> ligne.isNotEmpty() }
                    .map { (numLigne, ligne) -> tranche(numLigne, ligne) }
                    .toList()
            }
        } catch (e: IOException) {
            // erreur d'accès au fichier : on l'encapsule dans une ImpotException
            throw ImpotException("Erreur lors de la lecture du fichier $nom", CodeErreur.ACCES.valeur, e)
        }
        // il faut au moins une tranche
        if (tranches.isEmpty()) {
            throw ImpotException("Le fichier $nom ne contient aucune tranche", CodeErreur.LIGNE.valeur)
        }
        tranchesImpot = tranches
    }

    // la tranche d'impôt décrite par une ligne du fichier
    private fun tranche(numLigne: Int, ligne: String): TrancheImpot {
        // la ligne doit avoir trois champs
        val champs = ligne.split(SEPARATEUR)
        if (champs.size != 3) {
            throw ImpotException("Ligne n° $numLigne incorrecte ($ligne)", CodeErreur.LIGNE.valeur)
        }
        // trois nombres décimaux positifs ou nuls, écrits avec un point décimal
        val code = CODES_CHAMPS.zip(champs)
            .filter { (_, champ) -> !NOMBRE.matches(champ) }
            .fold(0) { code, (codeErreur, _) -> code or codeErreur.valeur }
        if (code != 0) {
            throw ImpotException("Ligne n° $numLigne incorrecte ($ligne)", code)
        }
        // la nouvelle tranche d'impôt
        val (limite, coeffR, coeffN) = champs.map { it.toBigDecimal() }
        return TrancheImpot(limite, coeffR, coeffN)
    }
}

Commentaires :

  • ligne 8 : la classe [FileImpot] dérive de la classe [AbstractImpot], comme le faisait dans la version 2 la classe [HardwiredImpot]. Son constructeur primaire reçoit le nom du fichier texte contenant les données. Le paramètre [nomFichier], déclaré sans [val], n'est pas une propriété : il n'est utilisé que pendant la construction de l'objet ;
  • lignes 10-16 : deux expressions régulières, compilées une fois pour toutes et rangées dans le [companion object] de la classe, l'équivalent Kotlin des constantes [private static final] de Java : 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. Les chaînes brutes évitent de doubler les [\]. La liste [CODES_CHAMPS] associe à chaque champ son code d'erreur ;
  • ligne 20 : la propriété qui redéfinit la propriété abstraite de [AbstractImpot]. Elle est déclarée sans valeur initiale : elle sera initialisée dans le bloc [init], que le compilateur oblige à lui donner une valeur, et une seule ;
  • lignes 22-43 : le bloc [init] s'exécute à la construction de l'objet, comme le corps d'un constructeur Java. Son rôle est de remplir la liste [tranchesImpot] ;
  • ligne 25 : 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. La fonction [Path] de [kotlin.io.path] crée l'objet [java.nio.file.Path] ; [fileName] est la méthode [getFileName] de Java vue comme une propriété ;
  • lignes 26-33 : la lecture du fichier. La fonction d'extension [useLines] de [Path] ouvre le fichier (en UTF-8), passe la séquence de ses lignes à la lambda, puis ferme le fichier quelle que soit la façon dont on sort de la lambda, normalement ou par une exception : c'est l'équivalent du [try] avec ressources de Java. La lecture d'un fichier texte étant séquentielle, on ne connaît le nombre de lignes qu'à la fin ; la séquence se prête à un traitement en chaîne :
    • [mapIndexed] associe à chaque ligne, débarrassée de ses espaces par [trim], son numéro (l'indice commence à 0, la numérotation des lignes à 1), sous la forme d'une paire [numéro to ligne] ;
    • [filter] écarte les lignes vides ; la paire est déstructurée dans les paramètres de la lambda, [(_, ligne)], le numéro inutile étant remplacé par [_] ;
    • [map] transforme chaque ligne en tranche, par la fonction [tranche] ;
    • [toList] parcourt la séquence et range les tranches dans une liste. Cet appel doit se faire dans la lambda : une séquence est évaluée paresseusement, et une séquence rendue telle quelle par [useLines] serait parcourue après la fermeture du fichier ;
  • le bloc [try] est une expression : sa valeur est la liste des tranches, rangée dans la variable [tranches] ;
  • lignes 35-37 : 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 par la fonction [tranche] ne sont pas des [IOException] : elles ne sont pas interceptées par cette clause [catch] et sortent du constructeur telles quelles, le fichier étant malgré tout fermé par [useLines] ;
  • lignes 40-43 : un fichier vide est refusé ; sinon la liste devient la valeur de la propriété [tranchesImpot], ce qui était le but du bloc [init] ;
  • lignes 49-51 : la fonction [tranche] analyse une ligne. Celle-ci 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. Rappelons que [split] conserve en Kotlin les champs vides de la fin : une ligne terminée par un séparateur en trop, « 4962:0:0: », a quatre champs et est refusée, alors que la méthode [split] de Java, qui supprime les champs vides de la fin, l'accepterait ;
  • lignes 54-58 : vérification des trois champs. La fonction [zip] associe chaque code d'erreur au champ correspondant, sous la forme de paires ; [filter] garde les champs qui ne correspondent pas au modèle [NOMBRE], et [fold] cumule leurs codes avec l'opérateur [or], en partant de 0. Les erreurs sont ainsi signalées ensemble. La fonction [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 61-62 : les champs, maintenant sûrs, sont convertis en [BigDecimal] par [toBigDecimal], avec un point décimal quelle que soit la langue de la machine ; la liste des trois nombres est déstructurée pour construire la tranche.

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. En Java, c'est aussi la raison pour laquelle [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. Kotlin, qui n'a pas d'exceptions contrôlées, n'impose pas ce choix, mais l'encapsulation garde tout son intérêt : le code appelant n'a pas à connaître les exceptions de l'API des fichiers.

5.9.4. La classe JsonImpot

La classe [JsonImpot] fournit les tranches lues dans un fichier JSON. Elle utilise la bibliothèque Jackson 3, déjà déclarée dans le fichier [build.gradle.kts] du projet :

package impotsv3

import java.io.IOException
import java.math.BigDecimal
import kotlin.io.path.Path
import kotlin.io.path.readText
import tools.jackson.core.JacksonException
import tools.jackson.databind.json.JsonMapper

// les tranches d'impôt lues dans un fichier JSON
class JsonImpot(nomFichier: String) : AbstractImpot() {

    companion object {
        // le JsonMapper est réutilisable et utilisable par plusieurs threads : on n'en crée qu'un
        private val MAPPER = JsonMapper.builder().build()

        // une valeur négative est incorrecte
        private fun negatif(valeur: BigDecimal) = valeur.signum() < 0
    }

    // les tranches d'impôt lues dans le fichier
    override val tranchesImpot: List<TrancheImpot>

    init {
        // le nom du fichier, sans son chemin, pour les messages d'erreur
        val chemin = Path(nomFichier)
        val nom = chemin.fileName.toString()
        // lecture du fichier
        val json = try {
            chemin.readText()
        } catch (e: IOException) {
            throw ImpotException("Erreur lors de la lecture du fichier $nom", CodeErreur.ACCES.valeur, e)
        }
        // désérialisation du tableau des tranches ; le document JSON null donne la valeur null
        val tranches: Array<TrancheImpot>? = try {
            MAPPER.readValue(json, Array<TrancheImpot>::class.java)
        } catch (e: JacksonException) {
            val position = "ligne ${e.location?.lineNr}, colonne ${e.location?.columnNr}"
            throw ImpotException("Le fichier $nom est incorrect : ${e.originalMessage} ($position)",
                CodeErreur.JSON.valeur, e)
        }
        // il faut au moins une tranche, sans valeur négative
        if (tranches.isNullOrEmpty()) {
            throw ImpotException("Le fichier $nom ne contient aucune tranche", CodeErreur.JSON.valeur)
        }
        if (tranches.any { negatif(it.limite) || negatif(it.coeffR) || negatif(it.coeffN) }) {
            throw ImpotException("Le fichier $nom contient une valeur négative", CodeErreur.JSON.valeur)
        }
        tranchesImpot = tranches.toList()
    }
}

Commentaires :

  • ligne 15 : un seul [JsonMapper], rangé dans le [companion object], sert à tous les objets [JsonImpot]. Les propriétés JSON [limite], [coeffR], [coeffN] portent les noms des paramètres du constructeur de la classe de données [TrancheImpot] : aucune annotation n'est nécessaire, car le compilateur conserve ces noms dans le code compilé (option [javaParameters = true] du fichier [build.gradle.kts]), et Jackson 3 sait les lire ;
  • lignes 29-32 : la lecture du fichier, par la fonction d'extension [readText] de [Path] ; une erreur d'accès est encapsulée comme dans [FileImpot] ;
  • ligne 36 : une seule instruction transforme le texte JSON en tableau de tranches ; [Array<TrancheImpot>::class.java] est la classe Java du tableau, l'équivalent de [TrancheImpot[].class]. Jackson construit chaque tranche en appelant le constructeur de la classe de données, et convertit les nombres JSON en [BigDecimal] sans perte de précision. La méthode Java [readValue] pouvant rendre [null] (pour le document JSON [null]), le résultat est rangé dans une variable de type [Array<TrancheImpot>?] : sans cette déclaration, Kotlin lui donnerait un type « de plate-forme », dont il ne sait pas s'il accepte [null] ;
  • lignes 37-40 : un document incorrect provoque une [JacksonException], encapsulée dans une [ImpotException] de code [JSON]. Le message indique l'emplacement de l'erreur, donné par la propriété [location] (méthode [getLocation]) ; l'opérateur [?.] protège contre une position inconnue ;
  • lignes 43-49 : 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), ce que fait [isNullOrEmpty] en un seul test, et qu'aucune valeur n'est négative, ce que fait l'opération [any], appliquée directement au tableau. Après le premier test, le compilateur sait que [tranches] n'est pas [null]. Le tableau est enfin transformé en liste par [toList] ;
  • ligne 18 : la fonction qui teste une valeur, écrite en une expression.

La version Java vérifiait aussi qu'aucune valeur n'était absente : une propriété absente d'un objet JSON donne la valeur [null] au composant correspondant d'un record. En Kotlin, ce test est inutile : les propriétés de [TrancheImpot] sont de type [BigDecimal], et non [BigDecimal?]. Le compilateur Kotlin ajoute au début du constructeur de la classe un contrôle de chaque paramètre non nullable : si Jackson lui passe [null], le constructeur lance une exception, que Jackson transmet sous la forme d'une [JacksonException]. La sûreté vis-à-vis de [null] s'étend ainsi aux objets créés par une bibliothèque Java : une tranche incomplète ne peut pas exister. Nous le vérifierons plus loin.

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. Signalons qu'il existe un module de Jackson propre à Kotlin, [jackson-module-kotlin], qui tient compte des valeurs par défaut des paramètres et des types nullables, et offre des fonctions comme [mapper.readValue<Array<TrancheImpot>>(json)], où le type est donné par un paramètre générique ([reified], chapitre 4). JetBrains propose aussi sa propre bibliothèque de sérialisation, [kotlinx.serialization], qui génère le code de sérialisation à la compilation grâce à un greffon du compilateur. Pour une classe de données aussi simple que [TrancheImpot], Jackson seul suffit.

5.9.5. Le programme principal

Contrairement à la version Java, qui faisait l'objet d'un projet Maven séparé, la version 3 est un programme de plusieurs fichiers du projet Gradle du chapitre, rangé dans son propre paquetage [impotsv3]. La partie du projet qui la concerne a l'arborescence suivante :

05-classes-kotlin/
  build.gradle.kts
  DataImpot.json
  DataImpot.txt
  DataImpotIncomplet.json
  DataImpotInvalide.json
  DataImpotInvalide.txt
  src/main/kotlin/impotsv3/
    AbstractImpot.kt
    CodeErreur.kt
    FileImpot.kt
    IImpot.kt
    ImpotException.kt
    JsonImpot.kt
    Main.kt
    TrancheImpot.kt

Les fichiers de données sont placés à la racine du projet, à côté du fichier [build.gradle.kts] : c'est le dossier courant du programme lorsqu'on l'exécute avec la commande [gradlew run], ou avec le triangle vert d'IntelliJ IDEA. La bibliothèque Jackson est déclarée dans le fichier [build.gradle.kts] du projet, qui sert à tous les programmes du chapitre :

1
2
3
4
5
dependencies {
    // les bibliothèques utilisées par les programmes du projet
    // JSON : Jackson 3
    implementation("tools.jackson.core:jackson-databind:3.1.5")
}

Le programme principal est le fichier [Main.kt] ; sa classe JVM est [impotsv3.MainKt] :

package impotsv3

import kotlin.system.exitProcess

// calcul de l'impôt, version 3 : les tranches d'impôt sont lues dans un fichier
// syntaxe : gradlew -q run -Pmain=impotsv3.MainKt [--args="fichier"]
// fichier : DataImpot.txt par défaut, ou un fichier .json

private val SYNTAXE = """
    syntaxe : Marié NbEnfants Salaire
    Marié : o pour marié, n pour non marié
    NbEnfants : nombre d'enfants
    Salaire : salaire annuel en euros""".trimIndent()

// l'invite de la boucle de saisie
private const val INVITE =
    "Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : "

// le séparateur des paramètres saisis : une suite d'espaces
private val ESPACES = Regex("""\s+""")

fun main(args: Array<String>) {
    // le fichier de données est cherché dans le dossier courant
    val nomFichier = args.firstOrNull() ?: "DataImpot.txt"
    println("Tranches d'impôt lues dans [$nomFichier]")

    // création d'un objet IImpot ; la classe dépend de l'extension du fichier
    val impot: IImpot = try {
        when (nomFichier.substringAfterLast('.').lowercase()) {
            "json" -> JsonImpot(nomFichier)
            else -> FileImpot(nomFichier)
        }
    } catch (e: ImpotException) {
        // affichage de l'erreur ; le code est affiché sous la forme d'un ensemble de CodeErreur
        val origine = e.cause?.let { ", Exception d'origine : ${it.javaClass.simpleName}" } ?: ""
        println("L'erreur suivante s'est produite : [Code=${CodeErreur.decoder(e.code)}, "
                + "Message=${e.message}$origine]")
        // arrêt du programme
        exitProcess(1)
    }

    // boucle de saisie
    while (true) {
        // on demande les paramètres du calcul de l'impôt
        print(INVITE)
        val parametres = readlnOrNull()?.trim() ?: ""
        // quelque chose à faire ?
        if (parametres.isEmpty()) break
        // vérification du nombre de champs de la ligne saisie
        val champs = parametres.split(ESPACES)
        if (champs.size != 3) {
            println(SYNTAXE)
            continue
        }
        // vérification de la validité des paramètres
        // marié
        val marie = champs[0].lowercase()
        if (marie != "o" && marie != "n") {
            println("$SYNTAXE\nArgument marié incorrect : tapez o ou n")
            continue
        }
        // nbEnfants : un entier positif ou nul
        val nbEnfants = champs[1].toIntOrNull()?.takeIf { it >= 0 }
        if (nbEnfants == null) {
            println("$SYNTAXE\nArgument NbEnfants incorrect : tapez un entier positif ou nul")
            continue
        }
        // salaire : un entier positif ou nul
        val salaire = champs[2].toIntOrNull()?.takeIf { it >= 0 }
        if (salaire == null) {
            println("$SYNTAXE\nArgument salaire incorrect : tapez un entier positif ou nul")
            continue
        }
        // les paramètres sont corrects : on calcule l'impôt
        println("Impôt=${impot.calculer(marie == "o", nbEnfants, salaire)} euros")
    }
}

Commentaires :

  • lignes 9-13 : le texte d'aide, dans une chaîne brute dont [trimIndent] retire l'indentation commune et la première ligne, vide. Le résultat d'un appel de fonction n'étant pas une constante de compilation, la propriété ne peut pas être déclarée [const] : [const val SYNTAXE = """...""".trimIndent()] est refusé par le compilateur ([const 'val' initializer must be a constant value]). L'invite de la boucle de saisie, une chaîne littérale, est en revanche une constante [const val] (ligne 16) ;
  • ligne 20 : le séparateur des paramètres saisis, compilé une fois pour toutes ;
  • ligne 24 : le nom du fichier de données est passé en argument ; par défaut, c'est [DataImpot.txt]. [firstOrNull] rend le premier argument, ou [null] s'il n'y en a pas, remplacé alors par la valeur par défaut grâce à l'opérateur [?:]. Avec Gradle, les arguments du programme se passent par l'option [--args] : [gradlew -q run -Pmain=impotsv3.MainKt --args="DataImpot.json"] ;
  • lignes 28-31 : une expression [when] (chapitre 3) choisit la classe à instancier selon l'extension du fichier, extraite du nom avec [substringAfterLast]. Les deux branches rendent des objets de types différents, [JsonImpot] et [FileImpot], tous deux de type [IImpot], le type déclaré de la variable [impot]. Le reste du programme ne connaît que l'interface [IImpot] et ignore d'où viennent les données ;
  • lignes 33-39 : 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 : [e.cause?.let { ... } ?: ""] construit le texte si la cause existe et rend une chaîne vide sinon. La fonction [exitProcess] de la bibliothèque standard arrête le programme ; elle est de type [Nothing] : le compilateur sait qu'elle ne rend jamais la main, et donc que la variable [impot] est initialisée après le bloc [try]. L'instruction [return] inutile qui suivait [System.exit] dans la version Java, exigée par le compilateur Java, disparaît ;
  • lignes 43-75 : la boucle de dialogue reprend celle de la version 2. La ligne lue par [readlnOrNull] est [null] en fin de flux, remplacée alors par une chaîne vide (ligne 46). Le nombre d'enfants et le salaire sont convertis par [toIntOrNull], qui rend [null] si la chaîne n'est pas un entier, et [takeIf { it >= 0 }] rend lui aussi [null] pour un entier négatif (lignes 63, 69) : un seul test sur [null] détecte les deux erreurs. L'expression est écrite directement, sans la fonction [entierPositif] de la version 2 (ni la fonction [entier] de la version Java, qui rendait -1 en cas d'erreur). Après ce test, le compilateur sait que [nbEnfants] et [salaire] sont de type [Int].

5.9.6. Exécutions

Avec le fichier [DataImpot.txt], par défaut (les saisies, redirigées depuis un fichier, ont été replacées après les invites) :

> gradlew -q run -Pmain=impotsv3.MainKt
Tranches d'impôt lues dans [DataImpot.txt]
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : q s d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument marié incorrect : tapez o ou n
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument salaire incorrect : tapez un entier positif ou nul
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :

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] :

1
2
3
4
5
6
7
8
9
> gradlew -q run -Pmain=impotsv3.MainKt --args="DataImpot.json"
Tranches d'impôt lues dans [DataImpot.json]
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :

Avec un fichier [xx.txt] inexistant :

1
2
3
> gradlew -q run -Pmain=impotsv3.MainKt --args="xx.txt"
Tranches d'impôt lues dans [xx.txt]
L'erreur suivante s'est produite : [Code=[ACCES], Message=Erreur lors de la lecture du fichier xx.txt, Exception d'origine : NoSuchFileException]

Avec le fichier [DataImpotInvalide.txt] suivant, dont la troisième ligne utilise la virgule décimale :

1
2
3
4
4962:0:0
8382:0.068:291.09
14753:0,191:1322,92
23888:0.283:2668.39
1
2
3
> gradlew -q run -Pmain=impotsv3.MainKt --args="DataImpotInvalide.txt"
Tranches d'impôt lues dans [DataImpotInvalide.txt]
L'erreur suivante s'est produite : [Code=[CHAMP2, CHAMP3], Message=Ligne n° 3 incorrecte (14753:0,191:1322,92)]

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 :

1
2
3
4
[
  { "limite": 4962, "coeffR": 0, "coeffN": 0 },
  { "limite": 8382, "coeffR": "0,068", "coeffN": 291.09 }
]
1
2
3
> gradlew -q run -Pmain=impotsv3.MainKt --args="DataImpotInvalide.json"
Tranches d'impôt lues dans [DataImpotInvalide.json]
L'erreur suivante s'est produite : [Code=[JSON], Message=Le fichier DataImpotInvalide.json est incorrect : Cannot deserialize value of type `java.math.BigDecimal` from String "0,068": not a valid representation (ligne 3, colonne 31), Exception d'origine : InvalidFormatException]

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, au début de la valeur incorrecte.

Enfin, avec le fichier [DataImpotIncomplet.json] suivant, où la propriété [coeffR] de la seconde tranche est absente :

1
2
3
4
[
  { "limite": 4962, "coeffR": 0, "coeffN": 0 },
  { "limite": 8382, "coeffN": 291.09 }
]
1
2
3
> gradlew -q run -Pmain=impotsv3.MainKt --args="DataImpotIncomplet.json"
Tranches d'impôt lues dans [DataImpotIncomplet.json]
L'erreur suivante s'est produite : [Code=[JSON], Message=Le fichier DataImpotIncomplet.json est incorrect : Cannot construct instance of `impotsv3.TrancheImpot`, problem: Parameter specified as non-null is null: method impotsv3.TrancheImpot.<init>, parameter coeffR (ligne 3, colonne 38), Exception d'origine : ValueInstantiationException]

C'est le contrôle ajouté par le compilateur Kotlin au constructeur de la classe de données [TrancheImpot] qui refuse la valeur [null] du paramètre [coeffR] (« Parameter specified as non-null is null »). Jackson transmet l'erreur sous la forme d'une exception [ValueInstantiationException], dérivée de [JacksonException], située à la fin de l'objet incomplet (ligne 3, colonne 38) : la tranche incomplète n'a jamais existé, sans qu'aucun test ait été écrit.

5.10. Conclusion

Ce chapitre a présenté les classes et les fonctions de Kotlin dont on se sert dans presque tous les programmes :

  • les chaînes [String], immuables, que l'on compare avec [==] (qui appelle [equals]), leurs nombreuses fonctions d'extension, [buildString] et [StringBuilder] pour les construire par morceaux, les gabarits de chaînes, la fonction [format] et les chaînes brutes ([trimIndent], [trimMargin]) ;
  • les tableaux ([Array], [IntArray]…) et leurs fonctions ;
  • les collections de Kotlin, [List], [Set], [Map], en lecture seule ou modifiables ([MutableList], [MutableSet], [MutableMap]), créées par [listOf], [mutableListOf], [mapOf]… et qui reposent sur les collections du JDK, ainsi que d'autres collections ([ArrayDeque], ensembles et dictionnaires triés…) ;
  • les opérations fonctionnelles sur les collections ([filter], [map], [sortedBy], [groupBy], [associate], [fold], [partition], [zip]…) et les séquences, leur version paresseuse, l'API Stream de Java restant utilisable ;
  • les fichiers texte ([File], [Path] et les extensions de [kotlin.io] et [kotlin.io.path] : [readLines], [useLines], [writeText]…), en UTF-8 par défaut, les fichiers binaires ([DataOutputStream], [DataInputStream], [RandomAccessFile]) et la sérialisation JSON avec Jackson et les classes de données ;
  • les expressions régulières de la classe [Regex], écrites dans des chaînes brutes, compilées une fois pour toutes et protégées contre les recherches trop longues lorsqu'elles traitent des données extérieures.

On retiendra surtout la façon dont Kotlin s'appuie sur la plate-forme Java : la bibliothèque standard de Kotlin ne remplace pas le JDK, elle l'enrichit de fonctions d'extension ([readLines], [toBigDecimal], [toRegex]…), d'opérateurs ([BigDecimal]) et d'interfaces en lecture seule, tandis que les classes Java ([Path], [BigDecimal], [LocalDate], [Pattern], Jackson…) s'utilisent directement.

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.