Skip to content

4. Classes, interfaces, classes de données

4.1. L'objet par l'exemple

4.1.1. Généralités

Nous abordons maintenant, par l'exemple, la programmation objet. Un objet est une entité qui contient des données qui définissent son état (on les appelle des champs, attributs, propriétés…) et des fonctions (on les appelle des méthodes). Un objet est créé selon un modèle qu'on appelle une classe. Une classe [C1] déclare par exemple deux propriétés [var p1: Type1] et [var p2: Type2] et deux méthodes [fun m3(...): Type3 { ... }] et [fun m4(...): Type4 { ... }], le tout entre les accolades de la déclaration [class C1 { ... }].

À partir de la classe [C1], on peut créer de nombreux objets [o1], [o2], … Tous auront les propriétés [p1], [p2], … et les méthodes [m3], [m4], … Mais ils auront des valeurs différentes pour leurs propriétés [pi], ayant ainsi chacun un état qui leur est propre. Si [o1] est un objet de type [C1], [o1.p1] désigne la propriété [p1] de [o1] et [o1.m3(...)] l'appel de la méthode [m3] de [o1].

Considérons un premier modèle d'objet : la classe [Personne].

4.1.2. Un programme formé de plusieurs fichiers

Dans les exemples du chapitre précédent, un programme tenait dans un unique fichier source. À partir de maintenant, un programme pourra être formé de plusieurs fichiers source : par exemple une classe par fichier, plus le programme principal. Contrairement à Java, Kotlin n'impose aucun lien entre le nom d'un fichier et les classes qu'il contient : un fichier peut contenir plusieurs classes et des fonctions de haut niveau. La convention, que nous suivrons, est néanmoins de placer une classe importante dans un fichier qui porte son nom : la classe [Personne] est dans le fichier [Personne.kt].

Les programmes de ce chapitre sont rassemblés dans le projet Gradle [04-classes], construit comme celui du chapitre précédent ([03-bases]) : chaque programme est un paquetage du dossier [src/main/kotlin]. Le programme de plusieurs fichiers que nous étudions est le paquetage [personnes] :

1
2
3
4
04-classes/src/main/kotlin/
  personnes/
    Personne.kt     la classe Personne
    Main.kt         le programme principal

Les deux fichiers commencent par la ligne [package personnes] : ils appartiennent au même paquetage et se voient sans importation. Le fichier [Main.kt] contient la fonction [main] de haut niveau ; le compilateur la place dans une classe JVM nommée d'après le fichier, [MainKt], dont le nom complet est [personnes.MainKt]. C'est ce nom que l'on donne à Gradle pour exécuter le programme :

> gradlew -q run -Pmain=personnes.MainKt
[Jean, Dupont, 30]

Gradle compile tous les fichiers du projet, puis exécute la fonction [main] de la classe indiquée. Dans IntelliJ IDEA, il suffit de cliquer sur le triangle vert affiché dans la marge, devant la fonction [main]. Le compilateur écrit un fichier [.class] (du code intermédiaire, le « bytecode » de la JVM) par classe : ici [personnes/Personne.class] et [personnes/MainKt.class], dans le dossier [build] du projet.

4.1.3. Définition de la classe Personne

La définition de la classe [Personne] dans le fichier source [Personne.kt] sera la suivante :

package personnes

class Personne {
    // propriétés privées
    private var prenom = ""
    private var nom = ""
    private var age = 0

    // méthode
    fun initialise(p: String, n: String, age: Int) {
        this.prenom = p
        this.nom = n
        this.age = age
    }

    // méthode
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}
  • ligne 1 : la classe appartient au paquetage [personnes]. Les paquetages sont présentés plus loin dans ce chapitre ;
  • ligne 3 : la classe n'est précédée d'aucun mot clé de visibilité : elle est publique, utilisable par toutes les autres classes. En Kotlin, [public] est la visibilité par défaut ;
  • lignes 5-7 : les trois propriétés de la classe. Le mot clé [var] indique qu'elles sont modifiables (le mot clé [val] les rendrait en lecture seule). Kotlin exige qu'une propriété reçoive une valeur initiale (ici la chaîne vide et 0) : contrairement à Java, il n'y a pas de valeur par défaut implicite ([null], 0, [false]). Le type des propriétés, [String] et [Int], est déduit de ces valeurs initiales, comme pour une variable locale ;
  • lignes 10-13 : une méthode qui fixe l'état de l'objet ;
  • lignes 17-18 : une méthode qui affiche l'état de l'objet.

Nous avons ici la définition d'une classe, donc d'un type de données. Lorsqu'on va créer des variables de ce type, on les appellera des objets ou des instances de classe. Une classe est donc un moule à partir duquel sont construits des objets. Par convention, le nom d'une classe commence par une majuscule ([Personne]), celui d'une propriété ou d'une méthode par une minuscule ([prenom], [initialise]).

Kotlin parle de propriétés plutôt que de champs : nous verrons un peu plus loin qu'une propriété est un champ accompagné de ses méthodes de lecture et d'écriture, comme les propriétés du C#. Une propriété privée, comme ici, se réduit à un simple champ. Les membres d'une classe peuvent être des données (propriétés) ou des méthodes (fonctions). Ces membres peuvent être accompagnés de l'un des mots clés suivants :

Mot clé
Accès
[private]
un membre privé n'est accessible que par les seules méthodes de la classe
[protected]
un membre protégé est accessible par les méthodes de la classe et par celles des classes dérivées (voir plus loin le concept d'héritage). Contrairement à Java, il ne l'est pas des autres classes du paquetage
[internal]
un membre interne est accessible par toute méthode du même module, c'est-à-dire de l'ensemble des fichiers compilés ensemble (un projet ou sous-projet Gradle, un module d'IntelliJ IDEA)
[public]
un membre public est accessible par toute méthode, définie ou non au sein de la classe. C'est l'accès par défaut, en l'absence de mot clé

En général, les données d'une classe sont déclarées privées alors que ses méthodes sont publiques. Cela signifie que l'utilisateur d'un objet (le programmeur) :

  • n'aura pas accès directement aux données privées de l'objet ;
  • pourra faire appel aux méthodes publiques de l'objet et notamment à celles qui donneront accès à ses données privées.

L'ordre de déclaration des membres est quelconque. On notera que l'accès par défaut est l'accès public, à l'inverse de Java, dont l'accès par défaut est l'accès paquetage : en Kotlin, il faut penser à écrire [private] pour cacher un membre. Kotlin n'a pas d'accès « paquetage » ; l'accès [internal], au niveau du module, en tient lieu.

Les mots clés [private], [internal] et [public] s'appliquent aussi aux déclarations de haut niveau (classes, fonctions, propriétés écrites hors de toute classe). Une déclaration de haut niveau [private] n'est visible que dans son fichier.

4.1.4. La méthode initialise

Quel est le rôle de la méthode [initialise] ? Parce que [nom], [prenom] et [age] sont des données privées de la classe [Personne], une instruction comme [p1.prenom = "Jean"], écrite dans le programme principal, est illégale. Le compilateur la refuse :

1
2
3
Main.kt:5:8: error: cannot access 'var prenom: String': it is private in 'personnes.Personne'.
    p1.prenom = "Jean"
       ^^^^^^

Le message indique le fichier, la ligne et la colonne de l'erreur ([5:8]), puis la souligne par des accents circonflexes. Dans IntelliJ IDEA, l'erreur est soulignée en rouge dès la saisie, et Gradle affiche le même message, précédé de [e:] et du chemin complet du fichier. Le message est en anglais : le compilateur Kotlin n'a pas de traduction française de ses messages. Il nous faut initialiser un objet de type [Personne] via une méthode publique. C'est le rôle de la méthode [initialise]. On écrira [p1.initialise("Jean", "Dupont", 30)]. Cette écriture est légale car [initialise] est d'accès public.

4.1.5. La création d'un objet : pas d'opérateur new

Il manque pourtant encore quelque chose. Il faut créer l'objet. Dans le programme [Main.kt], c'est l'expression [Personne()] qui crée un objet de type [Personne] : on « appelle » la classe comme une fonction. Kotlin n'a pas d'opérateur [new], à la différence de Java et de C#. L'objet créé est non encore initialisé : ses propriétés ont leur valeur initiale, ici la chaîne vide pour [nom] et [prenom] et 0 pour [age].

La déclaration [val p1 = Personne()] déclare [p1] comme une référence à cet objet. Si on déclare la variable sans l'initialiser, [val p1: Personne], puis qu'on écrit [p1.initialise("Jean", "Dupont", 30)], on fait appel à la méthode [initialise] d'un objet qui n'existe pas et le compilateur signale l'erreur : la variable [p1] n'a pas reçu de valeur.

1
2
3
Main.kt:5:5: error: variable 'p1' must be initialized.
    p1.initialise("Jean", "Dupont", 30)
    ^^

En Java, on peut écrire [Personne p1 = null;] pour indiquer que la variable ne référence encore aucun objet ; l'appel [p1.initialise(...)] est accepté par le compilateur et provoque à l'exécution une exception [NullPointerException]. Kotlin, nous l'avons vu au chapitre précédent, distingue les types qui admettent la valeur [null] de ceux qui ne l'admettent pas. Le type [Personne] ne l'admet pas :

1
2
3
Main.kt:4:24: error: null cannot be a value of a non-null type 'Personne'.
    val p1: Personne = null
                       ^^^^

Le type [Personne?] l'admet, mais le compilateur refuse alors l'appel direct d'une méthode :

1
2
3
Main.kt:5:7: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'Personne?'.
    p1.initialise("Jean", "Dupont", 30)
      ^

Il faut écrire [p1?.initialise(...)], qui n'appelle la méthode que si [p1] n'est pas [null], ou [p1!!.initialise(...)], qui affirme au compilateur que [p1] n'est pas [null]. Dans ce dernier cas, si l'affirmation est fausse, on retrouve à l'exécution l'exception de Java :

1
2
3
4
> gradlew -q run -Pmain=personnes.MainKt
Exception in thread "main" java.lang.NullPointerException
    at personnes.MainKt.main(Main.kt:5)
    at personnes.MainKt.main(Main.kt)

Les erreurs que Java découvre à l'exécution sont ainsi, pour la plupart, découvertes par le compilateur Kotlin. Pour que [p1] référence un objet, on écrit [val p1 = Personne()]. Maintenant que [p1] référence un objet, l'instruction d'initialisation de cet objet [p1.initialise("Jean", "Dupont", 30)] est valide.

4.1.6. Le mot clé this

Regardons le code de la méthode [initialise] :

1
2
3
4
5
    fun initialise(p: String, n: String, age: Int) {
        this.prenom = p
        this.nom = n
        this.age = age
    }

L'instruction [this.prenom = p] signifie que la propriété [prenom] de l'objet courant ([this]) reçoit la valeur [p]. Le mot clé [this] désigne l'objet courant : celui sur lequel la méthode est exécutée. Comment le connaît-on ? Regardons comment se fait l'initialisation de l'objet référencé par [p1] dans le programme appelant : [p1.initialise("Jean", "Dupont", 30)]. C'est la méthode [initialise] de l'objet [p1] qui est appelée. Lorsque, dans cette méthode, on référence l'objet [this], on référence en fait l'objet [p1].

Lorsqu'une méthode d'un objet référence une propriété [a] de cet objet, l'écriture [this.a] est implicite : la méthode [initialise] aurait pu écrire [prenom = p] et [nom = n]. On doit utiliser [this] explicitement lorsqu'il y a conflit d'identificateurs. C'est le cas de l'instruction [this.age = age] (ligne 4) où [age] désigne à la fois une propriété de l'objet courant et le paramètre [age] reçu par la méthode. Dans la méthode, le nom [age] seul désigne le paramètre ; il faut désigner la propriété par [this.age]. On remarquera au passage qu'un paramètre de fonction est en lecture seule : l'instruction [age = 0] écrite dans la méthode serait refusée par le compilateur.

4.1.7. Un programme de test

Voici un court programme de test. Celui-ci est écrit dans le fichier source [Main.kt] :

1
2
3
4
5
6
7
8
9
package personnes

// le programme principal
fun main() {
    // un objet Personne
    val p1 = Personne()
    p1.initialise("Jean", "Dupont", 30)
    p1.identifie()
}
  • ligne 4 : la fonction [main] est le point d'entrée du programme. Comme au chapitre précédent, c'est une fonction de haut niveau, écrite hors de toute classe ;
  • ligne 6 : création d'un objet [Personne] référencé par [p1]. La classe [Personne] appartenant au même paquetage, elle est connue sans importation. Le type de [p1], [Personne], est déduit par le compilateur ;
  • ligne 7 : initialisation de l'objet ;
  • ligne 8 : affichage de son état.

Résultats :

> gradlew -q run -Pmain=personnes.MainKt
[Jean, Dupont, 30]

4.1.8. Plusieurs classes dans un fichier

Pour un exemple court, un paquetage de plusieurs fichiers est inutilement lourd. Comme au chapitre précédent, nous écrirons alors un unique fichier, [Classes01.kt] par exemple, qui contient la fonction [main], éventuellement d'autres fonctions, et les classes dont le programme a besoin. Chaque fichier est placé dans un paquetage à son nom ([classes01]), ce qui permet à chacun des programmes du projet d'avoir sa propre classe [Personne] : la classe [Personne] du fichier [Classes01.kt] a pour nom complet [classes01.Personne], celle du fichier [Classes02.kt] [classes02.Personne], etc. On l'exécute avec la commande [gradlew -q run -Pmain=classes01.Classes01Kt].

Il faut savoir comment le compilateur traite un tel fichier. Les classes du fichier sont des classes ordinaires, de haut niveau, exactement comme si elles étaient écrites dans leur propre fichier ; les fonctions de haut niveau, dont [main], sont rassemblées dans une classe nommée d'après le fichier, [Classes01Kt]. La compilation du fichier le montre :

1
2
3
4
> kotlinc src/main/kotlin/classes01/Classes01.kt -d classes
> ls classes/classes01
Classes01Kt.class
Personne.class

Cela a une conséquence qui distingue Kotlin de Java. En Java, les classes d'un fichier source compact sont imbriquées dans une classe implicite et partagent leurs membres privés avec la méthode [main] du fichier. En Kotlin, il n'y a pas de classe englobante : un membre [private] d'une classe reste inaccessible à la fonction [main], même si elle est écrite dans le même fichier. Les exemples courts de ce chapitre montreront donc les mêmes erreurs de compilation que le programme [personnes].

4.1.9. Une autre méthode initialise : la surcharge

Considérons toujours la classe [Personne] et ajoutons-lui une seconde méthode [initialise]. Le programme [Classes01.kt] est le suivant :

// surcharge de la méthode initialise
package classes01

fun main() {
    val p1 = Personne()
    p1.initialise("Jean", "Dupont", 30)
    p1.identifie()
    val p2 = Personne()
    p2.initialise(p1)
    p2.identifie()
}

// la classe Personne est déclarée après la fonction main
class Personne {
    // propriétés privées
    private var prenom = ""
    private var nom = ""
    private var age = 0

    // initialisation à partir de trois valeurs
    fun initialise(p: String, n: String, age: Int) {
        prenom = p
        nom = n
        this.age = age
    }

    // initialisation à partir d'une autre personne
    fun initialise(p: Personne) {
        prenom = p.prenom
        nom = p.nom
        age = p.age
    }

    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}
  • ligne 2 : le paquetage du programme ;
  • lignes 4-10 : le programme principal ;
  • ligne 14 : la classe [Personne] est déclarée après la fonction [main]. En Kotlin, l'ordre des déclarations d'un fichier est indifférent ;
  • lignes 21-24 : la méthode [initialise] du paragraphe précédent ;
  • lignes 28-31 : une seconde méthode [initialise].

On a maintenant deux méthodes portant le nom [initialise] : c'est légal tant qu'elles admettent des paramètres différents (en nombre ou en type). On dit que la méthode [initialise] est surchargée. Le compilateur choisit la méthode à appeler d'après les paramètres effectifs : [p1.initialise("Jean", "Dupont", 30)] (ligne 6) appelle la première, [p2.initialise(p1)] (ligne 9) appelle la seconde. Le type du résultat ne suffit pas à distinguer deux méthodes : deux méthodes de même nom et de mêmes paramètres qui ne différeraient que par leur type de retour seraient refusées. Nous verrons qu'en Kotlin, les valeurs par défaut des paramètres évitent souvent d'écrire plusieurs versions d'une même méthode.

Le paramètre de la seconde méthode est une référence [p] à une personne. Les propriétés de la personne [p] sont alors affectées à l'objet courant ([this]). On remarquera que la méthode [initialise] a un accès direct aux propriétés de l'objet [p] bien que celles-ci soient privées (lignes 29-31). C'est toujours vrai : une méthode d'une classe [C] a accès aux membres privés de tous les objets de la même classe [C].

Résultats :

1
2
3
> gradlew -q run -Pmain=classes01.Classes01Kt
[Jean, Dupont, 30]
[Jean, Dupont, 30]

4.1.10. Constructeurs de la classe Personne

Un constructeur est une fonction appelée lors de la création de l'objet. On s'en sert généralement pour l'initialiser. Il peut accepter des paramètres mais ne rend aucun résultat. Si une classe [C] a un constructeur acceptant n paramètres, la création d'un objet de cette classe s'écrira [val objet = C(arg1, arg2, ... argn)].

Lorsqu'une classe [C] a un ou plusieurs constructeurs, l'un de ces constructeurs doit obligatoirement être utilisé pour créer un objet de cette classe. Si une classe [C] ne déclare aucun constructeur, elle en a un par défaut, sans paramètres, qui ne fait rien. Les propriétés de l'objet gardent alors leurs valeurs initiales. C'est ce qui s'est passé dans les programmes précédents, où on avait écrit [Personne()].

Kotlin distingue deux sortes de constructeurs :

  • le constructeur primaire (« primary constructor »), au plus un par classe, déclaré dans l'en-tête de la classe, juste après son nom : [class Personne(prenom: String, nom: String, age: Int)] ;
  • les constructeurs secondaires, déclarés dans le corps de la classe avec le mot clé [constructor]. Lorsque la classe a un constructeur primaire, chaque constructeur secondaire doit l'appeler, directement ou par l'intermédiaire d'un autre constructeur secondaire.

Le programme [Classes02.kt] donne trois constructeurs à la classe [Personne] :

// constructeurs de la classe Personne
package classes02

fun main() {
    val p1 = Personne("Jean", "Dupont", 30)
    p1.identifie()
    val p2 = Personne(p1)
    p2.identifie()
    val p3 = Personne()
    p3.identifie()
}

// le constructeur principal est déclaré dans l'en-tête de la classe :
// ses paramètres précédés de val (ou var) sont aussi des propriétés de la classe
class Personne(private val prenom: String, private val nom: String, private val age: Int) {
    // constructeur secondaire de copie : il appelle le constructeur principal
    constructor(p: Personne) : this(p.prenom, p.nom, p.age)

    // constructeur secondaire sans paramètres : il appelle lui aussi le constructeur principal
    constructor() : this("?", "?", 0)

    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}
  • ligne 15 : le constructeur primaire. Ses trois paramètres sont précédés de [private val] : c'est une écriture abrégée qui déclare à la fois un paramètre du constructeur et une propriété privée, en lecture seule, de même nom, initialisée avec la valeur du paramètre. La classe n'a pas d'autre propriété : celles-ci sont déclarées dans l'en-tête ;
  • ligne 17 : le constructeur secondaire de copie. L'écriture [: this(p.prenom, p.nom, p.age)] appelle le constructeur de la même classe qui accepte trois paramètres [String], [String], [Int], ici le constructeur primaire. On dit que les constructeurs sont chaînés. Le code d'initialisation n'est ainsi écrit qu'une fois. Ce constructeur n'a pas de corps : il n'a rien d'autre à faire ;
  • ligne 20 : le constructeur secondaire sans paramètres, chaîné lui aussi au constructeur primaire. Comme la classe a déclaré un constructeur primaire avec paramètres, elle n'a plus de constructeur par défaut : si on veut pouvoir écrire [Personne()], il faut le déclarer ;
  • lignes 4-10 : les trois constructeurs sont utilisés.

L'écriture abrégée de la ligne 15 est équivalente à l'écriture suivante, plus longue, où les paramètres du constructeur primaire, sans [val], ne sont que des paramètres, utilisés pour initialiser des propriétés déclarées dans le corps de la classe :

1
2
3
4
5
6
7
class Personne(prenom: String, nom: String, age: Int) {
    // propriétés initialisées avec les paramètres du constructeur primaire
    private val prenom: String = prenom
    private val nom: String = nom
    private val age: Int = age
    ...
}

C'est bien plus court que la version Java, où il faut déclarer les champs, puis les initialiser un par un dans le constructeur ([this.prenom = prenom;]…). Le constructeur primaire n'a pas de corps : les instructions à exécuter à la construction de l'objet sont placées dans des blocs [init], que nous verrons un peu plus loin. Nous verrons aussi que, grâce aux valeurs par défaut des paramètres, Kotlin se passe souvent des constructeurs secondaires : [class Personne(..., private val age: Int = 0)] permet d'écrire aussi bien [Personne("Jean", "Dupont", 30)] que [Personne("Jean", "Dupont")].

Résultats :

1
2
3
4
> gradlew -q run -Pmain=classes02.Classes02Kt
[Jean, Dupont, 30]
[Jean, Dupont, 30]
[?, ?, 0]

4.1.11. Les références d'objets

Nous utilisons la classe [Personne] du programme précédent, sans son constructeur sans paramètres mais avec la méthode [initialise(String, String, Int)] du début du chapitre. Ses propriétés sont donc déclarées avec [var] et non plus [val]. Le programme de test devient le suivant [Classes03.kt] :

// les références d'objets
package classes03

fun main() {
    // p1
    val p1 = Personne("Jean", "Dupont", 30)
    print("p1="); p1.identifie()
    // p2 référence le même objet que p1
    val p2 = p1
    print("p2="); p2.identifie()
    // p3 référence un objet qui est une copie de l'objet référencé par p1
    val p3 = Personne(p1)
    print("p3="); p3.identifie()
    // on change l'état de l'objet référencé par p1
    p1.initialise("Micheline", "Benoît", 67)
    print("p1="); p1.identifie()
    // comme p2 = p1, l'objet référencé par p2 a changé d'état
    print("p2="); p2.identifie()
    // p3 ne référence pas le même objet que p1 : l'objet référencé par p3 n'a pas changé
    print("p3="); p3.identifie()
    // p1 et p2 sont-elles la même référence ? et p1 et p3 ?
    println("p1 === p2 : ${p1 === p2}, p1 === p3 : ${p1 === p3}")
}

class Personne(private var prenom: String, private var nom: String, private var age: Int) {
    // constructeur secondaire de copie
    constructor(p: Personne) : this(p.prenom, p.nom, p.age)

    // initialisation
    fun initialise(p: String, n: String, age: Int) {
        prenom = p
        nom = n
        this.age = age
    }

    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}

La fonction [print] (lignes 7, 10, …) écrit son paramètre sans passer à la ligne. Les deux instructions de ces lignes sont séparées par un point-virgule, qui n'est nécessaire en Kotlin que pour écrire plusieurs instructions sur une même ligne. Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=classes03.Classes03Kt
p1=[Jean, Dupont, 30]
p2=[Jean, Dupont, 30]
p3=[Jean, Dupont, 30]
p1=[Micheline, Benoît, 67]
p2=[Micheline, Benoît, 67]
p3=[Jean, Dupont, 30]
p1 === p2 : true, p1 === p3 : false

Lorsqu'on déclare la variable [p1] par [val p1 = Personne("Jean", "Dupont", 30)] (ligne 6), [p1] référence l'objet [Personne("Jean", "Dupont", 30)] mais n'est pas l'objet lui-même. En C, on dirait que c'est un pointeur, c'est-à-dire l'adresse de l'objet créé. Si [p1] était déclarée [var p1: Personne? = ...] et qu'on écrivait ensuite [p1 = null], ce ne serait pas l'objet [Personne("Jean", "Dupont", 30)] qui serait modifié, mais la référence [p1] qui changerait de valeur. L'objet serait « perdu » s'il n'était référencé par aucune autre variable : la mémoire qu'il occupe serait récupérée plus tard par le ramasse-miettes (« garbage collector ») de la machine virtuelle Java, qui exécute les programmes Kotlin comme les programmes Java.

Lorsqu'on écrit [val p2 = p1] (ligne 9), on initialise la référence [p2] : elle « pointe » sur le même objet (elle désigne le même objet) que la référence [p1]. Ainsi si on modifie l'objet « pointé » (ou référencé) par [p1], on modifie aussi celui référencé par [p2] (lignes 4-5 des résultats). On notera que [p1] et [p2] sont déclarées avec [val] : le mot clé interdit de changer la référence contenue dans la variable, pas de modifier l'objet référencé, ce que fait l'appel [p1.initialise(...)] (ligne 15).

Lorsqu'on écrit [val p3 = Personne(p1)] (ligne 12), il y a création d'un nouvel objet [Personne]. Ce nouvel objet sera référencé par [p3]. Si on modifie l'objet référencé par [p1], on ne modifie en rien celui référencé par [p3] (ligne 6 des résultats).

Enfin, ligne 22, l'opérateur [===] appliqué à deux références d'objets indique si elles désignent le même objet : [p1] et [p2] désignent le même objet, [p1] et [p3] deux objets différents, bien que de même contenu (ligne 7 des résultats). C'est l'opérateur [] de Java. En Kotlin, l'opérateur [] compare les contenus des objets, en appelant leur méthode [equals] : nous verrons plus loin comment la définir. Pour une classe qui ne définit pas [equals], comme ici, [p1 == p3] compare encore les références et vaut [false].

4.1.12. Passage de paramètres de type référence d'objet

Dans le chapitre précédent, nous avons vu que Kotlin, comme Java, passe les paramètres d'une fonction par valeur : la fonction reçoit une copie de la valeur du paramètre effectif. Voyons ce que cela signifie lorsque le paramètre est une référence d'objet. Nous utilisons pour cela la classe [StringBuilder] (la classe du JDK, que Kotlin utilise directement). Un objet [String] ne peut pas être modifié : une opération comme [s.uppercase()] rend un nouvel objet [String]. Un objet [StringBuilder], au contraire, est une chaîne modifiable : sa méthode [append] ajoute des caractères à la chaîne sans créer de nouvel objet.

En Java, l'exemple affectait un nouvel objet au paramètre [sbf1] de la fonction appelée. C'est impossible en Kotlin : un paramètre de fonction est en lecture seule, comme une variable [val]. Le compilateur refuse l'instruction [sbf1 = StringBuilder("essai1*****")] :

1
2
3
Classes04.kt:5:5: error: 'val' cannot be reassigned.
    sbf1 = StringBuilder("essai1*****")
    ^^^^

Le programme [Classes04.kt] copie donc le paramètre [sbf1] dans une variable locale modifiable :

// passage de paramètres de type référence d'objet
package classes04

fun main() {
    val sb0 = StringBuilder("essai0")
    val sb1 = StringBuilder("essai1")
    println("Dans fonction appelante avant appel : sb0=$sb0, sb1=$sb1")
    val sb2 = changeStringBuilder(sb0, sb1)
    println("Dans fonction appelante après appel : sb0=$sb0, sb1=$sb1, sb2=$sb2")
}

// la fonction appelée
fun changeStringBuilder(sbf0: StringBuilder, sbf1: StringBuilder): StringBuilder {
    println("Début fonction appelée : sbf0=$sbf0, sbf1=$sbf1")
    // on modifie l'objet référencé par sbf0
    sbf0.append("*****")
    // un paramètre est en lecture seule : sbf1 = StringBuilder(...) serait refusé.
    // La variable locale sbl référence d'abord le même objet que sbf1, puis un nouvel objet
    var sbl = sbf1
    sbl = StringBuilder("essai1*****")
    // un nouvel objet est rendu à l'appelant
    val sbf2 = StringBuilder("essai2*****")
    println("Fin fonction appelée : sbf0=$sbf0, sbf1=$sbf1, sbl=$sbl, sbf2=$sbf2")
    return sbf2
}
  • lignes 5-6 : deux références [sb0] et [sb1] à des objets de type [StringBuilder] ;
  • ligne 8 : elles sont passées à la fonction [changeStringBuilder], dont le résultat, une troisième référence, est affecté à [sb2] ;
  • lignes 13-24 : une fonction de haut niveau, qui a les paramètres formels [sbf0] et [sbf1]. Au démarrage de la fonction, [sbf0] et [sb0] sont deux références distinctes qui pointent sur le même objet : la référence [sb0] a été copiée dans [sbf0] (passage par valeur des références). Il en est de même pour [sbf1] et [sb1] ;
  • lignes 19-20 : la variable locale [sbl] reçoit d'abord la référence [sbf1], puis la référence d'un nouvel objet. C'est ce que faisait le programme Java avec le paramètre [sbf1] lui-même.

Résultats :

1
2
3
4
5
> gradlew -q run -Pmain=classes04.Classes04Kt
Dans fonction appelante avant appel : sb0=essai0, sb1=essai1
Début fonction appelée : sbf0=essai0, sbf1=essai1
Fin fonction appelée : sbf0=essai0*****, sbf1=essai1, sbl=essai1*****, sbf2=essai2*****
Dans fonction appelante après appel : sb0=essai0*****, sb1=essai1, sb2=essai2*****

Explications :

  • [sb0] et [sbf0] sont deux références distinctes sur le même objet. Celui-ci a été modifié via [sbf0] (ligne 16). Cette modification est visible via [sb0] (ligne 4 des résultats) ;
  • [sb1], [sbf1] et, au départ, [sbl] sont trois références distinctes sur le même objet. [sbl] reçoit une nouvelle valeur et pointe désormais sur un nouvel objet (ligne 20). Cela ne change en rien la valeur de [sbf1] ni celle de [sb1], qui continuent à pointer sur l'objet d'origine, non modifié (lignes 3 et 4 des résultats) ;
  • une fonction Kotlin ne peut donc pas changer la valeur d'une variable de l'appelant. Pour transmettre un nouvel objet à l'appelant, elle le rend comme résultat (lignes 22 et 24). Lorsqu'il y a plusieurs résultats, on les rassemble dans un objet : une paire ([Pair]), vue au chapitre précédent, ou une classe de données (voir plus loin).

Retenons qu'une fonction peut modifier l'objet référencé par un paramètre, mais pas la variable de l'appelant qui contient la référence. Comme Java, et contrairement à C#, Kotlin n'a pas de passage de paramètres par référence ; il va plus loin que Java en interdisant toute affectation à un paramètre, ce qui évite de confondre le paramètre et la variable de l'appelant.

4.1.13. Les objets temporaires

Dans une expression, on peut faire appel explicitement au constructeur d'un objet : celui-ci est construit, mais nous n'avons pas de variable pour y accéder ultérieurement (pour le modifier par exemple). Cet objet temporaire est construit pour les besoins de l'évaluation de l'expression puis abandonné. L'espace mémoire qu'il occupait sera automatiquement récupéré ultérieurement par le ramasse-miettes, dont le rôle est de récupérer l'espace mémoire occupé par des objets qui ne sont plus référencés par les données du programme.

Considérons le programme [Classes05.kt] suivant, où les constructeurs de la classe [Personne] affichent un message. C'est l'occasion de présenter le bloc d'initialisation [init] :

// les objets temporaires
package classes05

fun main() {
    Personne(Personne("Jean", "Dupont", 30)).identifie()
}

class Personne(private val prenom: String, private val nom: String, private val age: Int) {
    // le bloc init est exécuté par le constructeur principal
    init {
        println("Constructeur Personne(String, String, Int)")
    }

    // le constructeur secondaire appelle d'abord le constructeur principal, puis exécute son corps
    constructor(p: Personne) : this(p.prenom, p.nom, p.age) {
        println("Constructeur Personne(Personne)")
    }

    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}
  • ligne 5 : l'objet [Personne("Jean", "Dupont", 30)] est construit puis passé au constructeur de copie qui construit un second objet, dont on appelle la méthode [identifie]. Aucun des deux objets n'est référencé par une variable ;
  • lignes 9-11 : le constructeur primaire n'ayant pas de corps, les instructions à exécuter à la construction de l'objet sont placées dans un bloc [init]. Une classe peut avoir plusieurs blocs [init] : ils sont exécutés, avec les initialisations des propriétés déclarées dans le corps de la classe, dans l'ordre où ils sont écrits. Ils font partie du constructeur primaire et ont accès à ses paramètres ;
  • lignes 15-16 : un constructeur secondaire peut avoir un corps. Il est exécuté après le constructeur appelé par [: this(...)], ici le constructeur primaire avec ses blocs [init].

Résultats :

1
2
3
4
5
> gradlew -q run -Pmain=classes05.Classes05Kt
Constructeur Personne(String, String, Int)
Constructeur Personne(String, String, Int)
Constructeur Personne(Personne)
[Jean, Dupont, 30]

Les résultats montrent la construction successive des deux objets temporaires. Le premier est construit par le constructeur primaire (ligne 1 des résultats). Le second est construit par le constructeur secondaire, qui commence par appeler le constructeur primaire, d'où l'exécution du bloc [init] (ligne 2), puis exécute son propre corps (ligne 3). En Java, le constructeur de copie de l'exemple n'était pas chaîné au constructeur primaire et n'affichait que son propre message ; en Kotlin, dès lors qu'une classe a un constructeur primaire, tout objet passe par lui : les blocs [init] sont toujours exécutés.

4.1.14. Les propriétés : accesseurs et modifieurs

En Java, l'usage est de déclarer des champs privés et de donner accès à leur valeur par des méthodes publiques : les accesseurs (« getters »), comme [getAge()], et les modifieurs (« setters »), comme [setAge(int)]. Ces méthodes s'écrivent à la main (ou sont générées par l'environnement de développement), et l'utilisateur de la classe écrit [p.setAge(p.getAge() + 1)]. Kotlin, comme C#, a des propriétés : une propriété publique est une donnée que l'utilisateur lit et écrit avec la notation pointée, comme un champ, mais dont la lecture et l'écriture passent en réalité par un accesseur et un modifieur. Le compilateur les génère. Le programme [Classes06.kt] le montre :

// des propriétés à la place des accesseurs et des modifieurs
package classes06

fun main() {
    val p = Personne("Jean", "Michelin", 34)
    // lecture des propriétés : appel implicite de leur accesseur
    println("p=(${p.prenom},${p.nom},${p.age})")
    // écriture d'une propriété : appel implicite de son modifieur
    p.age = 56
    println("p=(${p.prenom},${p.nom},${p.age})")
}

// trois propriétés publiques, en lecture et écriture
class Personne(var prenom: String, var nom: String, var age: Int)
  • ligne 14 : la classe [Personne] tient en une ligne. Les trois paramètres du constructeur primaire sont précédés de [var] : ce sont trois propriétés publiques (visibilité par défaut), en lecture et écriture. La classe n'ayant pas de corps, les accolades sont omises ;
  • ligne 7 : la lecture des propriétés [p.prenom], [p.nom], [p.age]. Chaque lecture est un appel de l'accesseur de la propriété ;
  • ligne 9 : l'écriture de la propriété [age], c'est-à-dire l'appel de son modifieur.

Résultats :

1
2
3
> gradlew -q run -Pmain=classes06.Classes06Kt
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)

L'outil [javap] du JDK, qui affiche le contenu d'un fichier [.class], montre ce que le compilateur Kotlin a produit pour la classe [Personne] (l'option [-p] affiche aussi les membres privés) :

> javap -p -cp build/classes/kotlin/main classes06.Personne
Compiled from "Classes06.kt"
public final class classes06.Personne {
  private java.lang.String prenom;
  private java.lang.String nom;
  private int age;
  public classes06.Personne(java.lang.String, java.lang.String, int);
  public final java.lang.String getPrenom();
  public final void setPrenom(java.lang.String);
  public final java.lang.String getNom();
  public final void setNom(java.lang.String);
  public final int getAge();
  public final void setAge(int);
}

Chaque propriété a donné un champ privé (lignes 3-5), un accesseur et un modifieur publics (lignes 7-12), qui suivent la convention de nommage établie dès les débuts de Java, celle des « JavaBeans » : l'accesseur de la propriété [age] s'appelle [getAge], son modifieur [setAge] ; une propriété booléenne dont le nom commence par [is], comme [isActif], a un accesseur de même nom, [isActif], et un modifieur [setActif]. C'est important : beaucoup de bibliothèques Java (Jackson pour le JSON, Hibernate pour les bases de données, que nous utiliserons plus loin) s'appuient sur cette convention pour trouver les données d'un objet ; elles trouvent celles des objets Kotlin sans difficulté. Inversement, un programme Kotlin qui utilise une classe Java écrit [p.age] pour appeler [p.getAge()], et [p.age = 56] pour appeler [p.setAge(56)]. On remarque aussi que la classe et ses méthodes sont [final] : nous verrons avec l'héritage qu'en Kotlin, classes et méthodes sont finales par défaut.

L'écriture [p.age = 56] ressemble à l'affectation d'un champ public. La différence est importante : parce qu'elle passe par un modifieur, on peut, sans rien changer au code des utilisateurs de la classe, donner plus tard à la propriété un modifieur qui contrôle les valeurs reçues. C'est l'objet du paragraphe suivant. En Kotlin, on n'écrit donc jamais d'accesseurs ni de modifieurs à la Java : on déclare des propriétés.

4.1.15. Des modifieurs qui vérifient les valeurs reçues

L'intérêt des modifieurs, par rapport à des champs publics, est de pouvoir contrôler les valeurs affectées aux champs : un modifieur peut vérifier la validité de la valeur reçue et lancer une exception si elle est invalide. Kotlin permet de donner à une propriété un accesseur ([get]) et un modifieur ([set]) personnalisés. Considérons le programme [Classes07.kt] :

// des modifieurs qui vérifient la validité des valeurs reçues
package classes07

fun main() {
    val p = Personne("Jean", "Michelin", 34)
    println("p=(${p.prenom},${p.nom},${p.age})")
    p.age = 56
    println("p=(${p.prenom},${p.nom},${p.age})")
    try {
        p.age = -4
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    // constructeur sans paramètres puis affectation des propriétés
    val p2 = Personne()
    p2.age = 7
    p2.prenom = "Arthur"
    p2.nom = "Martin"
    println("p2=(${p2.prenom},${p2.nom},${p2.age})")
    try {
        val p3 = Personne()
        p3.prenom = "Paul"
        p3.nom = " "
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    // le constructeur vérifie lui aussi les valeurs reçues
    try {
        val p4 = Personne("", "Durand", 20)
        println("p4=(${p4.prenom},${p4.nom},${p4.age})")
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
}

// la classe a un constructeur principal sans paramètres
class Personne() {
    // des propriétés dotées d'un modifieur (set) personnalisé
    var prenom = ""
        set(value) {
            // prénom valide ?
            require(value.isNotBlank()) { "prénom ($value) invalide" }
            // field désigne le champ qui mémorise la valeur de la propriété
            field = value
        }

    var nom = ""
        set(value) {
            // nom valide ?
            require(value.isNotBlank()) { "nom ($value) invalide" }
            field = value
        }

    var age = 0
        set(value) {
            // âge valide ?
            require(value >= 0) { "âge ($value) invalide" }
            field = value
        }

    // constructeur secondaire : il passe par les modifieurs pour profiter de leurs vérifications
    constructor(prenom: String, nom: String, age: Int) : this() {
        this.prenom = prenom
        this.nom = nom
        this.age = age
    }
}

Les trois propriétés ont un modifieur personnalisé qui vérifie la valeur reçue :

  • lignes 39-44 : la propriété [prenom]. Sa déclaration est suivie, sur les lignes suivantes, de la définition de son modifieur, [set(value) { ... }]. Le paramètre [value] est la valeur affectée à la propriété (le nom [value] est conventionnel, comme en C#) ; son type est celui de la propriété. La propriété n'a pas d'accesseur personnalisé : elle garde l'accesseur généré, qui rend la valeur mémorisée ;
  • ligne 42 : la fonction [require(condition) { message }], vue au chapitre précédent, lance une exception [IllegalArgumentException], l'exception prévue pour signaler un paramètre invalide, si la condition est fausse. Le message, calculé par la lambda entre accolades, n'est construit que dans ce cas. La fonction [isNotBlank] rend [true] si la chaîne contient au moins un caractère autre qu'un espace. Il n'y a pas à tester la valeur [null] comme en Java : la propriété est de type [String], et non [String?], et le compilateur interdit de lui affecter [null] ;
  • ligne 44 : dans un accesseur ou un modifieur, le mot clé [field] désigne le champ qui mémorise la valeur de la propriété (on l'appelle le « backing field »). Il ne faut pas écrire [prenom = value] : ce serait un appel du modifieur lui-même, qui s'appellerait indéfiniment ;
  • lignes 47-58 : les propriétés [nom] et [age] sont construites sur le même modèle ;
  • ligne 37 : la classe a un constructeur primaire sans paramètres. Les valeurs initiales des propriétés ([""] et 0) sont affectées directement aux champs, sans passer par les modifieurs ;
  • lignes 62-65 : un constructeur secondaire qui initialise l'objet via les modifieurs. Il profite ainsi des vérifications faites par les modifieurs, qui ne sont écrites qu'à un endroit. Ici, [this.prenom = prenom] est l'appel du modifieur de la propriété [prenom] avec la valeur du paramètre [prenom].

Pourquoi ne pas avoir écrit [class Personne(prenom: String, nom: String, age: Int)] avec des propriétés initialisées par [var age = age] ? Parce que l'initialisation d'une propriété à sa déclaration affecte directement le champ, sans appeler le modifieur : un âge de -4 serait accepté sans erreur. Pour vérifier les valeurs reçues par le constructeur primaire, on écrit les vérifications dans un bloc [init], ou, comme ici, on fait passer les valeurs par les modifieurs.

En Java, les modifieurs appelés par le constructeur devaient être déclarés [final] : sinon, une classe dérivée aurait pu les redéfinir, et le constructeur de la classe parent aurait appelé la nouvelle version avant même que le constructeur de la classe dérivée ait été exécuté. En Kotlin, les classes et leurs membres sont finaux par défaut : la précaution est inutile.

Résultats :

1
2
3
4
5
6
7
> gradlew -q run -Pmain=classes07.Classes07Kt
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)
âge (-4) invalide
p2=(Arthur,Martin,7)
nom ( ) invalide
prénom () invalide

La ligne 3 des résultats montre que l'affectation [p.age = -4] (ligne 10) a été refusée par le modifieur : [ex.message] est le message de l'exception. Lignes 15-18, une personne est créée avec le constructeur sans paramètres puis initialisée par ses modifieurs. Lignes 21-23, le nom formé d'un espace est refusé (ligne 5 des résultats). Lignes 29-30, le constructeur à trois paramètres refuse le prénom vide (ligne 6 des résultats).

Java n'a pas d'initialiseur d'objet, syntaxe qui permettrait de fixer plusieurs propriétés dans l'expression de création de l'objet elle-même. Kotlin non plus, mais il offre deux solutions équivalentes : les paramètres nommés et à valeur par défaut des constructeurs, et la fonction [apply]. Nous les présentons dans les deux paragraphes suivants.

4.1.16. Les propriétés en lecture seule et les paramètres facultatifs

Une propriété déclarée avec [val] doit recevoir une valeur une fois et une seule, à sa déclaration, dans le constructeur primaire ou dans un bloc [init] ; elle ne peut plus être modifiée ensuite. Le compilateur le vérifie. C'est l'équivalent du champ [final] de Java : la propriété n'a qu'un accesseur, pas de modifieur. Le programme [Classes08.kt] présente plusieurs cas, ainsi que les paramètres à valeur par défaut et les arguments nommés :

// propriétés en lecture seule, modifieur privé, propriété calculée,
// paramètres obligatoires et facultatifs, arguments nommés
package classes08

fun main() {
    val p = Personne("Jean", "Dupont", 30, "Angers")
    println(p.identite)
    // le prénom est en lecture / écriture
    p.prenom = "Jacques"
    // l'âge n'est modifiable qu'à l'intérieur de la classe
    p.vieillir()
    println(p.identite)
    // les lignes suivantes sont refusées par le compilateur
    // p.nom = "Durand"      // nom est une propriété val
    // p.age = 40            // le modifieur de age est privé
    // p.identite = "?"      // identite est une propriété val (calculée)

    // un paramètre obligatoire et un paramètre facultatif
    val c1 = Contact("jean.dupont@exemple.fr")
    println("Contact : ${c1.email}, ${c1.telephone}")
    // des arguments nommés, dans un ordre quelconque
    val c2 = Contact(telephone = "02 41 00 00 00", email = "paul.martin@exemple.fr")
    println("Contact : ${c2.email}, ${c2.telephone}")
    // Contact(null) est refusé par le compilateur ; un email vide est refusé à l'exécution
    try {
        val c3 = Contact(" ")
        println("Contact : ${c3.email}")
    } catch (ex: IllegalArgumentException) {
        println("Erreur : ${ex.message}")
    }
}

class Personne(var prenom: String, val nom: String, age: Int, val ville: String) {
    // l'âge est lisible par tous, mais son modifieur est privé
    var age = age
        private set

    // une propriété calculée : elle n'a pas de champ, son accesseur calcule sa valeur
    val identite: String
        get() = "[$prenom, $nom, $age, $ville]"

    // l'âge n'est modifié que par les méthodes de la classe
    fun vieillir() {
        age++
    }
}

// l'email est obligatoire, le téléphone facultatif : son paramètre a une valeur par défaut
class Contact(val email: String, val telephone: String = "inconnu") {
    // vérification exécutée à la construction de l'objet
    init {
        require(email.isNotBlank()) { "l'email est obligatoire" }
    }
}
  • ligne 33 : le prénom est une propriété [var], en lecture et écriture ; le nom et la ville sont des propriétés [val], en lecture seule. Le constructeur leur donne une valeur, qui ne pourra plus changer. Le paramètre [age], sans [val] ni [var], n'est qu'un paramètre du constructeur ;
  • lignes 35-36 : la propriété [age] est initialisée avec le paramètre [age]. Elle est lisible par tous mais son modifieur est privé ([private set]) : elle n'est modifiable que par les méthodes de la classe, comme [vieillir] (lignes 43-44). Le modifieur garde son comportement par défaut : il n'a pas besoin de corps ;
  • lignes 39-40 : une propriété calculée. Elle a un accesseur personnalisé, [get() = ...], qui calcule sa valeur à chaque lecture à partir des autres propriétés ; elle n'a pas de champ pour mémoriser une valeur. Pour l'utilisateur de la classe, rien ne la distingue d'une autre propriété : [p.identite] (ligne 7). C'est l'équivalent de la méthode [getIdentite()] de la version Java ;
  • lignes 14-16 : trois instructions que le compilateur refuse, pour les raisons données en commentaires. Si on les décommente, le compilateur signale (on a aussi décommenté l'instruction [Contact(null)] évoquée ligne 24) :
Classes08.kt:14:7: error: 'val' cannot be reassigned.
    p.nom = "Durand"      // nom est une propriété val
      ^^^
Classes08.kt:15:7: error: cannot access 'age': it is private in 'classes08.Personne'.
    p.age = 40            // le modifieur de age est privé
      ^^^
Classes08.kt:16:7: error: 'val' cannot be reassigned.
    p.identite = "?"      // identite est une propriété val (calculée)
      ^^^^^^^^
Classes08.kt:24:13: error: null cannot be a value of a non-null type 'String'.
    Contact(null)
            ^^^^
  • ligne 49 : la classe [Contact] a une propriété obligatoire, l'email, et une propriété facultative, le téléphone. Le caractère facultatif du téléphone s'exprime par une valeur par défaut du paramètre [telephone] du constructeur : [val telephone: String = "inconnu"]. Si l'argument est omis, comme ligne 19, le paramètre prend cette valeur. En Java, qui n'a pas de paramètres facultatifs, il fallait un second constructeur, chaîné au premier ; en Kotlin, un seul constructeur suffit. Les valeurs par défaut s'utilisent de la même façon pour les paramètres de toute fonction ;
  • ligne 22 : les arguments sont nommés : [telephone = "02 41 00 00 00"]. On peut alors les donner dans un ordre quelconque, et l'appel est plus lisible lorsque plusieurs paramètres ont le même type. Les arguments nommés permettent aussi de n'omettre qu'un paramètre facultatif parmi d'autres : avec [f(a: Int = 0, b: Int = 0)], on peut écrire [f(b = 5)] ;
  • lignes 51-52 : le caractère obligatoire de l'email s'exprime d'abord par le type du paramètre : [String] n'admet pas la valeur [null], et l'appel [Contact(null)] est refusé à la compilation (dernière erreur ci-dessus). En Java, il fallait le vérifier à l'exécution ([Objects.requireNonNull]). Reste à refuser un email vide : c'est le rôle du bloc [init], exécuté à la construction de l'objet. Ligne 26, un email formé d'un espace est refusé.

Résultats :

1
2
3
4
5
6
> gradlew -q run -Pmain=classes08.Classes08Kt
[Jean, Dupont, 30, Angers]
[Jacques, Dupont, 31, Angers]
Contact : jean.dupont@exemple.fr, inconnu
Contact : paul.martin@exemple.fr, 02 41 00 00 00
Erreur : l'email est obligatoire

On remarquera combien les déclarations sont courtes : la classe [Personne], qui occupait une cinquantaine de lignes en Java avec ses accesseurs et son modifieur, en occupe une quinzaine en Kotlin.

4.1.17. Les fonctions apply et also à la place des modifieurs fluides

En Java, lorsqu'un objet a beaucoup de champs facultatifs, les constructeurs se multiplient et les appels de modifieurs s'allongent. Une technique répandue consiste à faire rendre l'objet courant ([this]) par chaque modifieur : les appels peuvent alors être enchaînés, on parle de méthodes « fluides » : [new Personne().setPrenom("jean").setNom("Dupont").setAge(30)]. En Kotlin, les paramètres à valeur par défaut et les arguments nommés rendent la technique bien moins utile. Et lorsqu'on veut fixer plusieurs propriétés d'un objet juste après sa création, la bibliothèque standard fournit des fonctions de portée (« scope functions »), utilisables avec n'importe quel objet, dont [apply] et [also]. Le programme [Classes09.kt] les illustre, et présente au passage les propriétés [lateinit] :

// à la place des modifieurs « fluides » : les fonctions de portée apply et also
// une propriété lateinit
package classes09

fun main() {
    // apply exécute le bloc avec l'objet comme objet courant (this), puis rend l'objet
    val p = Personne().apply {
        prenom = "  jean "
        nom = "Dupont"
        age = 30
    }
    println(p.identite)
    try {
        p.nom = ""
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    try {
        p.age = -4
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    println(p.identite)
    // also exécute le bloc avec l'objet comme paramètre (it), puis rend l'objet
    val p2 = Personne().apply { prenom = "Arthur"; nom = "Martin"; age = 7 }
        .also { println("Création de ${it.identite}") }
    // lecture d'une propriété lateinit qui n'a pas encore reçu de valeur
    try {
        println(p2.ville)
    } catch (ex: UninitializedPropertyAccessException) {
        println(ex.message)
    }
    p2.ville = "Angers"
    println("${p2.identite} habite ${p2.ville}")
}

class Personne {
    // propriétés vérifiées par leur modifieur
    var prenom = ""
        set(value) {
            field = verifier(value, "prénom")
        }

    var nom = ""
        set(value) {
            field = verifier(value, "nom")
        }

    var age = 0
        set(value) {
            require(value >= 0) { "âge ($value) invalide" }
            field = value
        }

    // une propriété sans valeur initiale : elle recevra sa valeur plus tard
    lateinit var ville: String

    // identité : le type de la propriété est déduit de son accesseur
    val identite get() = "[$prenom, $nom, $age]"
}

// vérification d'une chaîne obligatoire : elle est rendue sans ses espaces de début et de fin
// (fonction de haut niveau privée : elle n'est visible que dans ce fichier)
private fun verifier(valeur: String, libelle: String): String {
    require(valeur.isNotBlank()) { "$libelle ($valeur) invalide" }
    return valeur.trim()
}
  • lignes 7-10 : [objet.apply { ... }] exécute le bloc entre accolades avec [objet] comme objet courant : dans le bloc, [prenom = " jean "] signifie [this.prenom = " jean "], où [this] est la nouvelle personne. Puis [apply] rend l'objet lui-même, qui est affecté à [p]. L'écriture tient lieu d'initialiseur d'objet ; chaque affectation passe par le modifieur de la propriété et donc par ses vérifications ;
  • lignes 39-46 : les modifieurs du prénom et du nom affectent au champ la valeur rendue par la fonction [verifier] ;
  • lignes 64-66 : les vérifications du prénom et du nom, identiques, sont factorisées dans la fonction [verifier]. Elle n'utilise aucune propriété d'une personne : nous en avons fait une fonction de haut niveau, déclarée [private], donc visible uniquement dans le fichier [Classes09.kt]. En Java, il fallait en faire une méthode statique privée de la classe. La fonction [trim] rend la chaîne débarrassée de ses espaces de début et de fin ;
  • ligne 59 : le type de la propriété calculée [identite], [String], est déduit de l'expression de son accesseur ;
  • lignes 25-26 : [objet.also { ... }] exécute le bloc en lui passant l'objet en paramètre, désigné par [it], puis rend l'objet. On l'utilise pour une action annexe, comme ici un affichage de suivi, sans interrompre l'enchaînement des appels. Ici, [apply] rend la personne, [also] l'affiche puis la rend à son tour, et elle est affectée à [p2]. Ligne 25, les trois affectations du bloc [apply] sont écrites sur une ligne, séparées par des points-virgules ;
  • ligne 56 : une propriété d'un type non [null] doit en principe recevoir une valeur à sa création. Le mot clé [lateinit] (« initialisation tardive ») lève cette obligation : la propriété [ville] recevra sa valeur plus tard (ligne 33). Si on la lit avant, une exception [UninitializedPropertyAccessException] est lancée (lignes 29-31). [lateinit] ne s'applique qu'à une propriété [var] d'un type objet non [null] ([String], mais pas [Int] ni [String?]) et sans accesseur ni modifieur personnalisé. On l'utilise lorsque la valeur est fournie après la construction de l'objet, par exemple par un cadriciel d'injection de dépendances comme Spring (chapitre 6) ou par une méthode d'initialisation d'un test. À l'intérieur de la classe, l'expression [::ville.isInitialized] indique si la propriété a reçu une valeur.

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=classes09.Classes09Kt
[jean, Dupont, 30]
nom () invalide
âge (-4) invalide
[jean, Dupont, 30]
Création de [Arthur, Martin, 7]
lateinit property ville has not been initialized
[Arthur, Martin, 7] habite Angers

Le prénom « jean », fourni entouré d'espaces (ligne 8), a été mémorisé sans eux (ligne 1 des résultats). Les tentatives de modification invalides ont été refusées et la personne n'a pas changé (lignes 2-4 des résultats). La ligne 6 des résultats est le message de l'exception [UninitializedPropertyAccessException].

Il existe trois autres fonctions de portée : [let], vue au chapitre précédent avec les valeurs [null], [run] et [with]. Elles diffèrent par la façon dont le bloc désigne l'objet ([this] ou [it]) et par ce qu'elles rendent (l'objet ou le résultat du bloc) :

Fonction
Objet dans le bloc
Résultat
Usage typique
[apply]
[this]
l'objet
configurer un objet qu'on vient de créer
[also]
[it]
l'objet
action annexe (suivi, vérification) dans un enchaînement
[let]
[it]
le résultat du bloc
traiter une valeur non [null] : [x?.let { ... }]
[run]
[this]
le résultat du bloc
calculer une valeur à partir d'un objet
[with]
[this]
le résultat du bloc
[with(objet) { ... }] : plusieurs appels sur un même objet

Pour construire des objets complexes, Java utilise souvent le patron de conception « Builder » : une classe auxiliaire, dotée de méthodes fluides, recueille les valeurs puis construit l'objet final. En Kotlin, un constructeur avec des paramètres nommés et à valeur par défaut le remplace dans la plupart des cas.

4.1.18. Les membres de classe : l'objet compagnon

Supposons qu'on veuille compter le nombre d'objets [Personne] créés dans une application. On peut soi-même gérer un compteur mais on risque d'oublier les objets temporaires qui sont créés ici ou là. Il semble plus sûr d'inclure dans le constructeur de la classe [Personne] une instruction incrémentant un compteur. Ce compteur ne peut pas être une propriété ordinaire, puisque chaque objet a ses propres propriétés : c'est une propriété de la classe elle-même et non celle d'une instance particulière de cette classe.

Java déclare un tel membre avec le mot clé [static]. Kotlin n'a pas de mot clé [static] : les membres de classe sont déclarés dans un objet compagnon ([companion object { ... }]), un objet unique, attaché à la classe, déclaré dans le corps de celle-ci. Ses membres s'utilisent avec le nom de la classe : [Personne.nbPersonnes], [Personne.inconnue()]. Voici un exemple [Classes10.kt] :

// membres de classe : l'objet compagnon (companion object) ; un objet singleton (object)
package classes10

fun main() {
    val p1 = Personne("Jean", "Dupont", 30)
    val p2 = Personne(p1)
    Personne(p1)
    Journal.ecrire("Nombre de personnes créées : ${Personne.nbPersonnes}")
    // une méthode de classe
    val p3 = Personne.inconnue()
    p3.identifie()
    p2.identifie()
    Journal.ecrire("Nombre de personnes créées : ${Personne.nbPersonnes}")
}

class Personne(private val prenom: String, private val nom: String, private val age: Int) {
    // constructeur secondaire de copie
    constructor(p: Personne) : this(p.prenom, p.nom, p.age)

    // le bloc init fait partie du constructeur principal : toute construction incrémente le compteur
    init {
        nbPersonnes++
    }

    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }

    // l'objet compagnon : ses membres sont ceux de la classe elle-même
    companion object {
        // propriété de classe : lisible par tous, modifiable par la seule classe Personne
        var nbPersonnes = 0L
            private set

        // méthode de classe
        fun inconnue() = Personne("?", "?", 0)
    }
}

// un objet singleton : la classe Journal et son unique instance sont déclarées ensemble
object Journal {
    // l'état de l'objet : le numéro du dernier message écrit
    private var numero = 0

    fun ecrire(message: String) {
        numero++
        println("[$numero] $message")
    }
}
  • lignes 31-37 : l'objet compagnon de la classe [Personne] ;
  • lignes 33-34 : la propriété de classe [nbPersonnes], initialisée à 0 (de type [Long] grâce au suffixe [L]). Elle est lisible par tous ([Personne.nbPersonnes], ligne 8), mais son modifieur est privé : seule la classe [Personne] peut la modifier. Il est inutile d'écrire, comme en Java, un champ privé et une méthode d'accès statique [getNbPersonnes()] ;
  • ligne 37 : une méthode de classe. Elle s'appelle avec le nom de la classe : [Personne.inconnue()] (ligne 10). Une méthode de l'objet compagnon n'a pas d'objet [Personne] courant : elle ne peut pas utiliser directement les propriétés d'une personne. Une méthode qui crée et rend un objet de sa classe, comme celle-ci, est appelée une fabrique (« factory ») ;
  • lignes 21-22 : le bloc [init] incrémente le compteur. Il fait partie du constructeur primaire, et le constructeur de copie, chaîné au constructeur primaire, l'exécute donc aussi.

Le dernier élément du programme est un objet singleton, [Journal], déclaré par le mot clé [object] (lignes 42-48) :

  • une déclaration [object Journal { ... }] déclare à la fois une classe et son unique instance, créée automatiquement lors de sa première utilisation. Il n'y a pas de constructeur : on ne peut pas créer d'autre objet [Journal]. On utilise l'objet par son nom : [Journal.ecrire(...)] ;
  • un objet a un état, ici le numéro du dernier message écrit, conservé d'un appel à l'autre ;
  • un objet compagnon est un cas particulier d'objet singleton : celui qui est attaché à une classe.

Résultats :

1
2
3
4
5
> gradlew -q run -Pmain=classes10.Classes10Kt
[1] Nombre de personnes créées : 3
[?, ?, 0]
[Jean, Dupont, 30]
[2] Nombre de personnes créées : 4

Le programme a bien compté l'objet temporaire créé ligne 7. Le journal a numéroté ses deux messages. La compilation produit une classe pour l'objet compagnon, [Personne$Companion], à côté des classes [Personne], [Journal] et [Classes10Kt] ; une méthode de l'objet compagnon est ainsi, pour la JVM, une méthode d'instance de l'objet [Personne.Companion]. Pour qu'un programme Java puisse l'appeler comme une méthode statique ([Personne.inconnue()] plutôt que [Personne.Companion.inconnue()]), on l'annote avec [@JvmStatic].

Nous utilisons depuis le début des fonctions qui, en Java, seraient des méthodes statiques : [println], [readln]… En Kotlin, ce sont des fonctions de haut niveau, déclarées hors de toute classe. C'est la façon Kotlin d'écrire ce que Java appelle une classe utilitaire, comme la classe [Math] : un fichier de fonctions de haut niveau (la bibliothèque standard offre ainsi [kotlin.math.sqrt], [kotlin.math.PI]), ou, si l'on veut regrouper des fonctions sous un nom, un [object]. L'objet compagnon est réservé à ce qui concerne vraiment la classe : fabriques, constantes, compteurs.

4.1.19. Un tableau de personnes

Un objet est une donnée comme une autre et à ce titre plusieurs objets peuvent être rassemblés dans un tableau [Classes11.kt] :

// un tableau de personnes
package classes11

fun main() {
    // un tableau de 3 références, toutes initialisées à null
    val amis = arrayOfNulls<Personne>(3)
    amis[0] = Personne("Jean", "Dupont", 30)
    amis[1] = Personne("Sylvie", "Vartan", 52)
    amis[2] = Personne("Neil", "Armstrong", 66)
    // affichage : les éléments sont de type Personne?
    for (ami in amis) {
        ami?.identifie()
    }
}

class Personne(private val prenom: String, private val nom: String, private val age: Int) {
    // identité de la personne
    fun identifie() {
        println("[$prenom, $nom, $age]")
    }
}
  • ligne 6 : crée un tableau de 3 éléments. La fonction [arrayOfNulls<Personne>(3)] initialise ces éléments avec la valeur [null], c'est-à-dire qu'ils ne référencent aucun objet : le type du tableau est donc [Array<Personne?>], un tableau de références qui peuvent valoir [null]. Par abus de langage, on parle de tableau d'objets alors que ce n'est qu'un tableau de références d'objets. La création du tableau, qui est un objet lui-même, ne crée aucun objet du type de ses éléments : il faut le faire ensuite ;
  • lignes 7-9 : création des 3 objets de type [Personne] ;
  • lignes 11-12 : affichage du contenu du tableau [amis]. La variable [ami] est de type [Personne?] : le compilateur exige l'appel sécurisé [ami?.identifie()], qui ne fait rien si [ami] vaut [null].

Résultats :

1
2
3
4
> gradlew -q run -Pmain=classes11.Classes11Kt
[Jean, Dupont, 30]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]

Le type [Personne?] des éléments rappelle qu'un tableau créé vide en Java contient des [null]. On l'évite en Kotlin en créant le tableau avec ses éléments, comme le montre le paragraphe suivant.

4.1.20. L'inférence de type et l'initialisation des tableaux

Le programme [Classes12.kt] présente les écritures qui allègent la création des objets et des tableaux :

// inférence de type et initialisation des tableaux
package classes12

fun main() {
    // le type de la variable est déduit de l'expression qui l'initialise
    val p1 = Personne("Jean", "Dupont", 30)
    val p2 = Personne()
    p2.prenom = "Arthur"
    p2.nom = "Martin"
    p2.age = 7
    // un tableau créé et initialisé par une liste de valeurs
    val amis = arrayOf(
        p1,
        p2,
        Personne("Sylvie", "Vartan", 52),
        Personne("Neil", "Armstrong", 66),
    )
    for (ami in amis) {
        println(ami.identite)
    }
    // un tableau créé à la volée, passé en paramètre
    affiche(arrayOf(Personne("Pauline", "Leduc", 12), p1))
    // un tableau dont les éléments sont calculés par une lambda à partir de leur indice
    val enfants = Array(3) { i -> Personne("Enfant${i + 1}", "Martin", i + 2) }
    affiche(enfants)
}

// une fonction qui reçoit un tableau de personnes
fun affiche(personnes: Array<Personne>) {
    for (p in personnes) {
        println("Affiche : ${p.identite}")
    }
}

// toutes les propriétés ont une valeur par défaut : Personne() est possible
class Personne(var prenom: String = "", var nom: String = "", var age: Int = 0) {
    // identité
    val identite get() = "[$prenom, $nom, $age]"
}
  • ligne 6 : le type de la variable, [Personne], est déduit de l'expression qui l'initialise. La variable n'en est pas moins typée : [p1] ne pourra référencer qu'une personne. C'est l'écriture habituelle en Kotlin depuis le début du cours ; l'équivalent Java est le mot clé [var]. Kotlin va plus loin que Java : l'inférence s'applique aussi aux propriétés ([identite], ligne 38) et au type de retour des fonctions dont le corps est une expression ;
  • ligne 36 : toutes les propriétés ont une valeur par défaut. Le constructeur primaire peut donc être appelé sans arguments, [Personne()] (ligne 7), et la personne reçoit ensuite ses propriétés (lignes 8-10). Il est inutile de déclarer un constructeur sans paramètres ;
  • lignes 12-16 : un tableau créé et rempli par la fonction [arrayOf(...)], qui reçoit les valeurs des éléments. La taille du tableau est le nombre de valeurs ; son type, [Array<Personne>], est déduit du type des valeurs : les éléments ne peuvent pas valoir [null]. Une virgule finale (ligne 16) est tolérée dans une liste d'arguments, comme dans toute liste de paramètres : elle facilite l'ajout d'un élément ;
  • ligne 18 : la variable de boucle [ami] est de type [Personne] : pas d'appel sécurisé cette fois ;
  • ligne 22 : un tableau créé à la volée, passé en paramètre. On parle de tableau anonyme : il n'est référencé par aucune variable de la fonction [main]. Contrairement à Java, il n'y a pas d'écriture différente selon que le tableau est créé dans une déclaration ou ailleurs ;
  • ligne 24 : le constructeur [Array(taille) { i -> ... }] crée un tableau dont chaque élément est calculé par la lambda à partir de son indice [i] ;
  • lignes 29-31 : une fonction de haut niveau qui reçoit un tableau de personnes.

Résultats :

> gradlew -q run -Pmain=classes12.Classes12Kt
[Jean, Dupont, 30]
[Arthur, Martin, 7]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]
Affiche : [Pauline, Leduc, 12]
Affiche : [Jean, Dupont, 30]
Affiche : [Enfant1, Martin, 2]
Affiche : [Enfant2, Martin, 3]
Affiche : [Enfant3, Martin, 4]

Nous utiliserons peu les tableaux d'objets : le chapitre suivant présente les listes ([List], [MutableList]), plus souples ([listOf(p1, p2)] s'écrit comme [arrayOf(p1, p2)]).

4.2. L'héritage par l'exemple

4.2.1. Généralités

Nous abordons ici la notion d'héritage. Le but de l'héritage est de « personnaliser » une classe existante pour qu'elle satisfasse à nos besoins. Supposons qu'on veuille créer une classe [Enseignant] : un enseignant est une personne particulière. Il a des attributs qu'une autre personne n'aura pas : la matière qu'il enseigne par exemple. Mais il a aussi les attributs de toute personne : prénom, nom et âge. Un enseignant fait donc pleinement partie de la classe [Personne] mais a des attributs supplémentaires. Plutôt que d'écrire une classe [Enseignant] à partir de rien, on préfère reprendre l'acquis de la classe [Personne] qu'on adapte au caractère particulier des enseignants. C'est le concept d'héritage qui nous permet cela.

Pour exprimer que la classe [Enseignant] hérite de la classe [Personne], on écrit [class Enseignant(...) : Personne(...)] : le nom de la classe parent suit le caractère deux-points, comme en C#, là où Java écrit [extends Personne].

[Personne] est appelée la classe parent (ou mère, ou de base, ou superclasse) et [Enseignant] la classe dérivée (ou fille, ou sous-classe). Un objet [Enseignant] a toutes les qualités d'un objet [Personne] : il a les mêmes propriétés et les mêmes méthodes. Ces propriétés et méthodes de la classe parent ne sont pas répétées dans la définition de la classe fille : on se contente d'indiquer les propriétés et méthodes ajoutées par la classe fille. Une classe Kotlin ne peut dériver que d'une seule classe parent.

Une différence importante avec Java : en Kotlin, une classe est finale par défaut, c'est-à-dire qu'on ne peut pas en dériver. Pour autoriser la dérivation, la classe parent doit être déclarée [open] (« ouverte »). C'est un choix délibéré des concepteurs du langage : une classe doit être conçue pour être dérivée, et celui qui l'écrit le dit explicitement.

Les exemples de ce paragraphe sont deux programmes de plusieurs fichiers, dans les paquetages [heritage1] et [heritage2]. Dans le paquetage [heritage1], la classe [Personne] est définie comme suit [Personne.kt] :

package heritage1

// open : la classe Personne peut avoir des classes dérivées
open class Personne(prenom: String, nom: String, age: Int) {
    // propriétés vérifiées par leur modifieur
    var prenom = ""
        set(value) {
            field = verifier(value, "prénom")
        }

    var nom = ""
        set(value) {
            field = verifier(value, "nom")
        }

    var age = 0
        set(value) {
            require(value >= 0) { "âge ($value) invalide" }
            field = value
        }

    // identité de la personne
    val identite get() = "[$prenom, $nom, $age]"

    // construction : initialisation via les modifieurs, comptage, suivi
    init {
        this.prenom = prenom
        this.nom = nom
        this.age = age
        nbPersonnes++
        println("Constructeur Personne(String, String, Int)")
    }

    companion object {
        // nombre de personnes créées
        var nbPersonnes = 0L
            private set
    }
}

// vérification d'une chaîne obligatoire
private fun verifier(valeur: String, libelle: String): String {
    require(valeur.isNotBlank()) { "$libelle ($valeur) invalide" }
    return valeur
}
  • ligne 4 : la classe est ouverte ([open]) : on peut en dériver. Le constructeur primaire a trois paramètres, qui ne sont pas des propriétés (pas de [val] ni de [var]) ;
  • lignes 6-19 : trois propriétés dont le modifieur vérifie la valeur reçue (voir le programme [Classes07.kt]). Les vérifications du prénom et du nom, identiques, sont factorisées dans la fonction privée [verifier] (lignes 42-44), comme dans le programme [Classes09.kt] ;
  • ligne 23 : la méthode [identifie] des premiers exemples a été remplacée par la propriété calculée [identite], qui rend l'identité de la personne au lieu de l'afficher ;
  • lignes 26-31 : le bloc [init] du constructeur primaire initialise l'objet via les modifieurs des propriétés, incrémente le compteur de personnes et affiche un message de suivi. Il est placé après les déclarations des propriétés : les initialisations des propriétés et les blocs [init] sont exécutés dans l'ordre où ils sont écrits, et une propriété doit avoir été initialisée (ici avec [""] ou 0) avant d'être utilisée ;
  • lignes 34-37 : le compteur de personnes du paragraphe sur l'objet compagnon.

Nous créons une classe [Enseignant] héritant de la classe [Personne] [Enseignant.kt] :

package heritage1

// la classe Enseignant dérive de la classe Personne :
// son constructeur principal passe trois de ses paramètres au constructeur de la classe parent
class Enseignant(prenom: String, nom: String, age: Int, var section: Int) :
    Personne(prenom, nom, age) {

    // suivi
    init {
        println("Constructeur Enseignant(String, String, Int, Int)")
    }
}

La classe [Enseignant] ajoute aux propriétés et méthodes de la classe [Personne] :

  • lignes 5-6 : la classe [Enseignant] dérive de la classe [Personne]. L'en-tête est écrit sur deux lignes pour tenir dans la page ;
  • ligne 5 : une propriété [section], déclarée dans le constructeur primaire ([var section: Int]), qui est le numéro de la section à laquelle appartient l'enseignant dans le corps des enseignants (une section par discipline en gros). La propriété est en lecture et écriture : elle remplace l'accesseur et le modifieur de la version Java ;
  • lignes 9-10 : un bloc [init] qui affiche un message de suivi.

4.2.2. Construction d'un objet Enseignant

Une classe fille n'hérite pas des constructeurs de sa classe parent. Elle doit définir ses propres constructeurs. L'en-tête de la classe [Enseignant] est le suivant :

class Enseignant(prenom: String, nom: String, age: Int, var section: Int) :
    Personne(prenom, nom, age) {

Le constructeur primaire reçoit quatre paramètres [prenom], [nom], [age], [section] et en passe trois ([prenom], [nom], [age]) à sa classe parent, ici la classe [Personne], avec l'écriture [: Personne(prenom, nom, age)] (ligne 2). On sait que cette classe a un constructeur [Personne(String, String, Int)] qui va permettre de construire une personne avec les paramètres passés. Une fois la construction de la partie [Personne] terminée (initialisations et blocs [init] de la classe [Personne]), la construction de l'objet [Enseignant] se poursuit par l'initialisation de sa propriété [section] et l'exécution de ses propres blocs [init]. L'écriture [: Personne(prenom, nom, age)] correspond à l'instruction [super(prenom, nom, age);] du constructeur Java ; si la classe dérivée n'a pas de constructeur primaire, chacun de ses constructeurs secondaires appelle le constructeur de la classe parent par [constructor(...) : super(...)].

En résumé, le constructeur d'une classe dérivée :

  • passe à sa classe parent les paramètres dont celle-ci a besoin pour se construire ;
  • utilise les autres paramètres pour initialiser les propriétés qui lui sont propres.

On aurait pu préférer initialiser directement les propriétés [prenom], [nom] et [age] dans la classe [Enseignant], avec des instructions comme [this.prenom = prenom]. C'est possible ici, car ces propriétés sont publiques et modifiables, mais ce serait une mauvaise solution, et ce serait impossible si elles étaient privées : les membres privés de la classe [Personne] ne sont accessibles qu'aux méthodes de la classe [Personne]. Tous les autres objets, y compris des objets de classes dérivées comme ici, doivent passer par des membres publics (ou protégés, [protected]). Surtout, la partie [Personne] de l'objet doit de toute façon être construite par un constructeur de la classe [Personne]. Si on écrit [: Personne()], sans arguments, le compilateur signale que les paramètres du constructeur de la classe parent manquent :

1
2
3
4
5
6
7
8
9
Enseignant.kt:6:5: error: no value passed for parameter 'prenom'.
    Personne() {
    ^^^^^^^^
Enseignant.kt:6:5: error: no value passed for parameter 'nom'.
    Personne() {
    ^^^^^^^^
Enseignant.kt:6:5: error: no value passed for parameter 'age'.
    Personne() {
    ^^^^^^^^

Utiliser le constructeur de la classe parent est donc la bonne solution et c'est la méthode habituelle : lors de la construction d'un objet fils, on appelle d'abord le constructeur de l'objet parent puis on complète les initialisations propres cette fois à l'objet fils ([section] dans notre exemple).

Enfin, si on oublie le mot clé [open] devant la classe [Personne], la dérivation est refusée :

1
2
3
Enseignant.kt:6:5: error: this type is final, so it cannot be extended.
    Personne(prenom, nom, age) {
    ^^^^^^^^

4.2.3. Les méthodes héritées

La classe [Enseignant] ne définit pas de propriété [identite]. L'expression [e.identite], où [e] est un enseignant, désigne donc la propriété [identite] héritée de la classe [Personne] : elle est publique et devient par héritage une propriété publique de la classe [Enseignant]. Il en est de même pour les méthodes. Le programme de test [Main.kt] du paquetage [heritage1] est le suivant :

package heritage1

fun main() {
    // un enseignant
    val e = Enseignant("Lucile", "Dumas", 56, 61)
    // la propriété identite héritée de la classe Personne
    println(e.identite)
    // la propriété section propre à la classe Enseignant
    println("section=${e.section}")
    // une personne
    val p = Personne("Jean", "Dupont", 30)
    // la fonction affiche reçoit une Personne ou un objet d'une classe dérivée
    affiche(e)
    affiche(p)
    // la méthode toString héritée de la classe Any
    println(e.toString())
    println(p.toString())
    println("Nombre de personnes créées : ${Personne.nbPersonnes}")
}

// affiche l'identité de p
fun affiche(p: Personne) {
    println(p.identite)
}
  • ligne 5 : création d'un enseignant ;
  • ligne 7 : affichage de son identité, obtenue par la propriété [identite] héritée de la classe [Personne] ;
  • ligne 9 : la propriété [section], propre à la classe [Enseignant] ;
  • lignes 13-14 : appel de la fonction [affiche] (lignes 22-23) avec un enseignant puis une personne. Nous y revenons au paragraphe suivant ;
  • lignes 16-17 : appel de la méthode [toString] des deux objets. Nous y revenons également.

Résultats :

> gradlew -q run -Pmain=heritage1.MainKt
Constructeur Personne(String, String, Int)
Constructeur Enseignant(String, String, Int, Int)
[Lucile, Dumas, 56]
section=61
Constructeur Personne(String, String, Int)
[Lucile, Dumas, 56]
[Jean, Dupont, 30]
heritage1.Enseignant@2f0e140b
heritage1.Personne@7440e464
Nombre de personnes créées : 2

On voit que :

  • un objet [Personne] (ligne 1 des résultats) a été construit avant l'objet [Enseignant] (ligne 2) ;
  • [e.identite] a donné l'identité de la partie [Personne] de l'enseignant, sans sa section (ligne 3) ;
  • le compteur de personnes compte aussi l'enseignant (ligne 10) : un enseignant est une personne.

Pour obtenir l'identité complète d'un enseignant, la classe [Enseignant] doit définir sa propre propriété [identite]. C'est ce que fait le paquetage [heritage2].

4.2.4. Le polymorphisme

Considérons une lignée de classes : C0 ← C1 ← C2 ← … ← Cn, où Ci ← Cj indique que la classe Cj est dérivée de la classe Ci. Cela entraîne que la classe Cj a toutes les caractéristiques de la classe Ci plus d'autres. Soient des objets Oi de type Ci. Il est légal d'écrire [Oi = Oj] avec j > i. En effet, par héritage, la classe Cj a toutes les caractéristiques de la classe Ci plus d'autres. Donc un objet Oj de type Cj contient en lui un objet de type Ci. L'opération [Oi = Oj] fait que la variable Oi, de type Ci, référence l'objet Oj : on ne pourra utiliser au travers d'elle que les membres définis dans la classe Ci.

Le fait qu'une variable Oi de classe Ci puisse en fait référencer non seulement un objet de la classe Ci mais tout objet dérivé de la classe Ci est appelé polymorphisme : la faculté pour une variable de référencer différents types d'objets.

Prenons un exemple et considérons la fonction [affiche] du programme précédent :

1
2
3
fun affiche(p: Personne) {
    println(p.identite)
}

On peut aussi bien lui passer une [Personne] qu'un [Enseignant] : dans ce dernier cas, le paramètre formel [p] de type [Personne] reçoit une valeur de type [Enseignant]. Comme le type [Enseignant] dérive du type [Personne], c'est légal. L'expression [p.identite] a utilisé ici la propriété [identite] de la classe [Personne], la seule qui existe dans le programme : pour l'enseignant (ligne 6 des résultats), on n'obtient que l'identité de sa partie « Personne ». Voyons ce qui se passe lorsque la classe [Enseignant] a sa propre propriété [identite].

4.2.5. La redéfinition de méthodes et la liaison dynamique

Dans le paquetage [heritage2], la classe [Personne] est celle du paquetage [heritage1] avec deux changements :

1
2
3
4
5
    // identité de la personne : open, elle peut être redéfinie dans une classe dérivée
    open val identite get() = "[$prenom, $nom, $age]"

    // redéfinition de la méthode toString de la classe Any
    override fun toString() = identite
  • ligne 2 : la propriété [identite] est déclarée [open] : elle peut être redéfinie dans une classe dérivée. En Kotlin, les méthodes et les propriétés sont finales par défaut, comme les classes : seuls les membres [open] d'une classe [open] peuvent être redéfinis ;
  • lignes 4-5 : une redéfinition de la méthode [toString], étudiée au paragraphe suivant.

Dans la classe [Enseignant], la propriété [identite] est redéfinie comme suit :

package heritage2

// une classe Kotlin est finale par défaut : on ne peut pas dériver de la classe Enseignant
class Enseignant(prenom: String, nom: String, age: Int, var section: Int) :
    Personne(prenom, nom, age) {

    // suivi
    init {
        println("Constructeur Enseignant(String, String, Int, Int)")
    }

    // la propriété identite redéfinit celle de la classe parent
    override val identite get() = "Enseignant[${super.identite},$section]"
}
  • ligne 13 : la propriété [identite] redéfinit (en anglais, on dit « override ») celle de la classe parent : elle a le même nom et le même type. Elle s'appuie sur la propriété [identite] de sa classe parent, désignée par [super.identite], pour obtenir la partie « Personne » puis complète avec la section, qui est propre à la classe [Enseignant]. On redéfinit de la même façon une méthode : même nom, mêmes paramètres, même type de résultat, et le mot clé [override] ;
  • ligne 13 : le mot clé [override] est obligatoire. Il joue le rôle de l'annotation [@Override] de Java, qui, elle, est facultative : si la propriété ne redéfinissait rien (faute de frappe dans le nom, type différent), le compilateur signalerait l'erreur au lieu de créer silencieusement un nouveau membre. Inversement, une redéfinition sans [override] est refusée :
1
2
3
Enseignant.kt:13:9: error: 'identite' hides member of supertype 'Personne' and needs an 'override' modifier.
    val identite get() = "Enseignant[${super.identite},$section]"
        ^^^^^^^^
  • et si la propriété [identite] de la classe [Personne] n'est pas déclarée [open], c'est la redéfinition qui est refusée :
1
2
3
Enseignant.kt:13:5: error: 'identite' in 'Personne' is final and cannot be overridden.
    override val identite get() = "Enseignant[${super.identite},$section]"
    ^^^^^^^^
  • ligne 3 : la classe [Enseignant] n'est pas déclarée [open] : elle est finale, ce que nous expliquons un peu plus loin.

Soit un objet enseignant [E]. Cet objet contient en son sein un objet [Personne] :

Image

La propriété [identite] est définie à la fois dans la classe [Enseignant] et dans sa classe mère [Personne]. Le choix de la version utilisée par [p.identite] est fait à l'exécution, d'après le type réel de l'objet référencé par [p], et non à la compilation d'après le type déclaré de la variable. On parle de liaison dynamique. Pour exécuter [o.m()], la machine virtuelle cherche une méthode [m] dans l'ordre suivant :

  • dans la classe de l'objet [o] ;
  • dans sa classe mère s'il en a une ;
  • dans la classe mère de sa classe mère si elle existe ;
  • etc.

Il en est de même pour une propriété, dont la lecture est l'appel de son accesseur, une méthode. L'héritage permet donc de redéfinir dans la classe fille des membres de même signature que dans la classe mère. C'est ce qui permet d'adapter la classe fille à ses propres besoins. Le programme [Main.kt] du paquetage [heritage2] est le suivant :

package heritage2

fun main() {
    // un enseignant
    val e = Enseignant("Lucile", "Dumas", 56, 61)
    affiche(e)
    // une personne
    val p = Personne("Jean", "Dupont", 30)
    affiche(p)
    // vu comme une Personne, l'enseignant reste un enseignant
    val pe: Personne = e
    println(pe.identite)
    println((e as Personne).identite)
    // println(Any?) affiche le résultat de la méthode toString de l'objet
    println(e)
    println(p)
    // un tableau de personnes de types différents
    val personnes = arrayOf(p, e)
    for (personne in personnes) {
        println("${personne::class.simpleName} : $personne")
    }
}

// affiche l'identité de p
fun affiche(p: Personne) {
    println(p.identite)
}

Résultats :

> gradlew -q run -Pmain=heritage2.MainKt
Constructeur Personne(String, String, Int)
Constructeur Enseignant(String, String, Int, Int)
Enseignant[[Lucile, Dumas, 56],61]
Constructeur Personne(String, String, Int)
[Jean, Dupont, 30]
Enseignant[[Lucile, Dumas, 56],61]
Enseignant[[Lucile, Dumas, 56],61]
Enseignant[[Lucile, Dumas, 56],61]
[Jean, Dupont, 30]
Personne : [Jean, Dupont, 30]
Enseignant : Enseignant[[Lucile, Dumas, 56],61]

Cette fois-ci, ligne 3 des résultats, la fonction [affiche] a bien affiché l'identité complète de l'enseignant. Les lignes 11-13 du programme montrent que, vu au travers d'une variable de type [Personne] ou converti en [Personne] par l'opérateur [as] (l'équivalent de l'opérateur [(Personne)] de Java), l'enseignant reste un enseignant : c'est toujours la propriété [identite] de la classe [Enseignant] qui est utilisée (lignes 6-7 des résultats). La conversion change le type de la référence, pas celui de l'objet.

Sur ce point, Kotlin est plus proche de C# que de Java. En C#, une méthode n'est redéfinissable que si elle est déclarée virtuelle ([virtual]), et la redéfinition est signalée par [override] ; en Kotlin, [open] joue le rôle de [virtual]. En Java, au contraire, toutes les méthodes sont redéfinissables par défaut, sauf celles déclarées [final]. Dans tous les cas, une fois la méthode redéfinie, la liaison est dynamique.

4.2.6. La classe Any et la méthode toString

La classe [Any] est la classe « mère » de toutes les classes Kotlin. Ainsi lorsqu'on écrit [class Personne], on écrit implicitement [class Personne : Any()]. Elle correspond à la classe [java.lang.Object] de Java : à l'exécution, sur la JVM, c'est d'ailleurs [Object].

La classe [Any] définit trois méthodes, toutes [open], dont héritent tous les objets :

  • [toString(): String] : rend une représentation textuelle de l'objet. Par défaut, le nom complet de la classe de l'objet, suivi du caractère [@] et d'un code de hachage écrit en hexadécimal ;
  • [equals(other: Any?): Boolean] : indique si l'objet est « égal » à l'objet [other]. Par défaut, il ne l'est qu'à lui-même : la méthode compare les références, comme l'opérateur [===]. C'est cette méthode qu'appelle l'opérateur [==] ;
  • [hashCode(): Int] : rend un code de hachage de l'objet, utilisé par les dictionnaires et les ensembles (chapitre suivant). Les méthodes [equals] et [hashCode] sont étudiées plus loin.

La méthode [getClass()] de la classe [Object] de Java n'est pas une méthode de [Any] : en Kotlin, l'expression [objet::class] rend la description Kotlin de la classe réelle de l'objet (de type [KClass]), dont la propriété [simpleName] est le nom de la classe ; [objet::class.java], ou [objet.javaClass], rend la description Java ([Class]).

C'est la méthode [toString] de la classe [Any] qu'ont utilisée les appels [e.toString()] et [p.toString()] du paquetage [heritage1] : [heritage1.Enseignant@2f0e140b] et [heritage1.Personne@7440e464] (lignes 8-9 des résultats ; les nombres changent d'une exécution à l'autre). Bien que nous n'ayons pas redéfini la méthode [toString] dans les classes [Personne] et [Enseignant], la méthode [toString] de la classe [Any] a été capable d'afficher le nom réel de la classe de l'objet.

Dans le paquetage [heritage2], la classe [Personne] redéfinit la méthode [toString] : [override fun toString() = identite]. Le mot clé [override] est là aussi obligatoire. Revenons au programme [Main.kt] du paquetage [heritage2] :

1
2
3
4
5
6
7
8
    // println(Any?) affiche le résultat de la méthode toString de l'objet
    println(e)
    println(p)
    // un tableau de personnes de types différents
    val personnes = arrayOf(p, e)
    for (personne in personnes) {
        println("${personne::class.simpleName} : $personne")
    }

Ligne 2, la fonction [println(message: Any?)] accepte un paramètre de type [Any?], donc, puisque [Any] est la classe mère de toutes les classes, n'importe quel objet, ici un [Enseignant]. Elle écrit sur la console le résultat de la méthode [toString] de l'objet. La liaison étant dynamique, c'est la méthode [toString] de l'objet réel qui est utilisée, ici celle héritée de la classe [Personne]. Celle-ci rend [identite], propriété redéfinie : pour l'enseignant [e], c'est donc la propriété [identite] de la classe [Enseignant] qui est utilisée, ce que montre la ligne 8 des résultats. Il est ainsi inutile de redéfinir [toString] dans la classe [Enseignant].

Le même mécanisme s'applique aux gabarits de chaînes : dans ["... : $personne"] (ligne 7), l'objet [personne] est converti en chaîne par sa méthode [toString]. La boucle de la ligne 6 parcourt un tableau de références [Personne] qui désignent en réalité des objets de types différents : [personne::class.simpleName] rend le nom de la classe réelle de chaque objet (lignes 10-11 des résultats).

4.2.7. Les classes finales, ouvertes et scellées

La classe [Enseignant] du paquetage [heritage2] est déclarée [class Enseignant(...) : Personne(...)], sans le mot clé [open] : elle est finale. La déclaration [class EnseignantChercheur(...) : Enseignant(...)] serait refusée par le compilateur avec le message déjà rencontré : [this type is final, so it cannot be extended]. En Java, c'est l'inverse : une classe est ouverte par défaut et on la rend finale avec le mot clé [final]. Le choix de Kotlin évite de dériver par mégarde d'une classe qui n'a pas été conçue pour cela. Il en est de même des membres : une méthode ou une propriété n'est redéfinissable que si elle est déclarée [open]. Un membre qui en redéfinit un autre ([override]) est lui-même redéfinissable dans les classes dérivées suivantes, sauf s'il est déclaré [final override].

Entre la classe ouverte à toutes les dérivations et la classe finale, Kotlin offre, comme Java depuis la version 17, une possibilité intermédiaire : la classe scellée ([sealed]), dont toutes les classes dérivées directes sont connues à la compilation. Le programme [Classes13.kt] l'illustre :

// classes finales, classes ouvertes et classes scellées
package classes13

fun main() {
    val personnes = arrayOf(
        Enseignant("Lucile", "Dumas", 61),
        Etudiant("Paul", "Martin", "informatique"),
        Vacataire("Marc", "Petit"),
        Intervenant("Lise", "Morel", "Airbus"),
    )
    for (p in personnes) {
        println("${p::class.simpleName} : ${p.identite}")
    }
    // when sur une classe scellée : le compilateur sait que tous les cas sont traités
    for (p in personnes) {
        println("${p.prenom} est ${description(p)}")
    }
    // les sous-classes directes autorisées de la classe scellée Personne
    println("Personne est scellée : ${Personne::class.java.isSealed}")
    for (c in Personne::class.java.permittedSubclasses) {
        println("sous-classe autorisée : ${c.simpleName}")
    }
}

// une expression when sans branche else
fun description(p: Personne): String = when (p) {
    is Enseignant -> "enseignant(e) de la section ${p.section}"
    is Etudiant -> "étudiant(e) en ${p.filiere}"
    is Vacataire -> "vacataire"
}

// une classe scellée : ses classes dérivées directes sont toutes connues à la compilation
sealed class Personne(val prenom: String, val nom: String) {
    open val identite get() = "[$prenom, $nom]"
}

// une classe dérivée est finale par défaut
class Enseignant(prenom: String, nom: String, val section: Int) : Personne(prenom, nom) {
    override val identite get() = "${super.identite}, section $section"
}

class Etudiant(prenom: String, nom: String, val filiere: String) : Personne(prenom, nom) {
    override val identite get() = "${super.identite}, filière $filiere"
}

// open : la hiérarchie est de nouveau ouverte à partir de cette classe
open class Vacataire(prenom: String, nom: String) : Personne(prenom, nom)

// une classe dérivée de Vacataire : autorisée, Vacataire étant ouverte
class Intervenant(prenom: String, nom: String, val entreprise: String) : Vacataire(prenom, nom) {
    override val identite get() = "${super.identite}, entreprise $entreprise"
}
  • ligne 33 : la classe [Personne] est scellée. Ses classes dérivées directes doivent être déclarées dans le même paquetage et le même module qu'elle (dans un fichier quelconque de ce paquetage). Il n'y a pas, comme en Java, de liste des classes autorisées ([permits]) : ce sont toutes les classes du paquetage qui en dérivent directement, ici [Enseignant], [Etudiant] et [Vacataire]. Une classe scellée est implicitement abstraite (voir plus loin : on ne peut pas créer d'objets de cette classe, seulement de ses classes dérivées) et son constructeur est protégé : il n'est destiné qu'aux classes dérivées ;
  • ligne 34 : la propriété [identite] est ouverte : les classes dérivées peuvent la redéfinir ;
  • lignes 38-43 : les classes [Enseignant] et [Etudiant] sont finales, par défaut. En Java, une classe dérivée d'une classe scellée doit obligatoirement être déclarée [final], [sealed] ou [non-sealed] ; en Kotlin, la règle par défaut suffit ;
  • ligne 47 : la classe [Vacataire] est ouverte ([open]) : elle rouvre la hiérarchie, toute classe peut en dériver. C'est l'équivalent du [non-sealed] de Java. Une troisième possibilité est de la déclarer elle-même [sealed]. La classe n'a pas de corps : il n'y a rien à ajouter à la classe [Personne] ;
  • ligne 50 : la classe [Intervenant] dérive de [Vacataire], ce qui est autorisé ;
  • lignes 5-9 : le type du tableau, [Array<Personne>], est déduit par le compilateur : c'est le type commun le plus précis des quatre objets ;
  • lignes 26-29 : l'expression [when], vue au chapitre précédent, teste ici le type de la personne avec l'opérateur [is]. Dans chaque branche, le compilateur sait que [p] est du type testé : [p.section] est accepté dans la branche [is Enseignant] sans conversion explicite. C'est le transtypage intelligent (« smart cast »), présenté plus en détail à la fin du chapitre. Surtout, l'expression [when] n'a pas de branche [else] : la classe [Personne] étant scellée, le compilateur sait que toute personne est un [Enseignant], un [Etudiant] ou un [Vacataire], et que les trois cas sont traités. Un intervenant est un vacataire : il est traité par la troisième branche ;
  • lignes 19-21 : la description Java de la classe ([Personne::class.java]) indique si elle est scellée ([isSealed]) et donne ses classes dérivées directes autorisées ([permittedSubclasses]). Le compilateur Kotlin produit pour une classe scellée les mêmes informations que le compilateur Java : pour la JVM, la classe [Personne] est une classe Java scellée.

Résultats :

> gradlew -q run -Pmain=classes13.Classes13Kt
Enseignant : [Lucile, Dumas], section 61
Etudiant : [Paul, Martin], filière informatique
Vacataire : [Marc, Petit]
Intervenant : [Lise, Morel], entreprise Airbus
Lucile est enseignant(e) de la section 61
Paul est étudiant(e) en informatique
Marc est vacataire
Lise est vacataire
Personne est scellée : true
sous-classe autorisée : Enseignant
sous-classe autorisée : Etudiant
sous-classe autorisée : Vacataire

Si on écrit une classe [Artiste] dérivée de [Personne] dans un autre paquetage, ici le paquetage [autres], le compilateur signale :

1
2
3
Artiste.kt:6:46: error: a class can only extend a sealed class or interface declared in the same package.
class Artiste(prenom: String, nom: String) : Personne(prenom, nom)
                                             ^^^^^^^^

Et si on supprime la branche [is Vacataire] de l'expression [when], le compilateur signale que tous les cas ne sont plus traités :

1
2
3
Classes13.kt:26:40: error: 'when' expression must be exhaustive. Add the 'is Vacataire' branch or an 'else' branch.
fun description(p: Personne): String = when (p) {
                                       ^^^^

À quoi sert une classe scellée ? Elle permet à l'auteur d'une hiérarchie de classes d'en garder la maîtrise : il sait que toute [Personne] est un [Enseignant], un [Etudiant] ou un [Vacataire]. Le compilateur le sait aussi, et l'exploite dans l'expression [when] : si l'on ajoute plus tard une quatrième classe dérivée de [Personne], toutes les expressions [when] qui ne la traitent pas sont signalées à la compilation. Une branche [else] aurait, au contraire, masqué l'oubli. Le mécanisme s'applique également aux interfaces ([sealed interface]), présentées plus loin, et c'est en Kotlin une façon très courante de modéliser un ensemble fermé de cas : les résultats possibles d'une opération (succès, échec), les états d'un écran, les messages échangés par deux programmes…

4.3. La surcharge des opérateurs

Certains langages, comme C# ou C++, permettent de redéfinir la signification des opérateurs ([+], [==], [<]…) pour une classe. Java ne le permet pas : il faut écrire des méthodes, [a.add(b)] au lieu de [a + b], [s1.equals(s2)] au lieu de [s1 == s2], [a.compareTo(b) < 0] au lieu de [a < b]. Kotlin le permet, d'une façon simple et encadrée : chaque opérateur correspond à une méthode de nom fixé, et il suffit de déclarer une méthode de ce nom, précédée du mot clé [operator], pour que l'opérateur soit utilisable avec les objets de la classe. L'expression [a + b] est ainsi traduite par le compilateur en [a.plus(b)]. On ne peut pas inventer de nouveaux opérateurs, ni changer leur priorité. Les principales conventions sont les suivantes :

Expression
Traduite en
Remarque
[a + b], [a - b], [a * b], [a / b], [a % b]
[a.plus(b)], [a.minus(b)], [a.times(b)], [a.div(b)], [a.rem(b)]
opérateurs arithmétiques
[-a], [+a], [!a]
[a.unaryMinus()], [a.unaryPlus()], [a.not()]
opérateurs unaires
[a++], [a--]
[a.inc()], [a.dec()]
la méthode rend la nouvelle valeur
[a += b]
[a.plusAssign(b)], ou sinon [a = a.plus(b)]
de même pour [-=], [*=]…
[a == b], [a != b]
[a?.equals(b) ?: (b === null)]
[equals] est héritée de [Any] : on la redéfinit avec [override]
[a < b], [a <= b], [a > b], [a >= b]
[a.compareTo(b) < 0]…
[compareTo] rend un entier négatif, nul ou positif
[b in a], [b !in a]
[a.contains(b)], [!a.contains(b)]
appartenance
[a[i]], [a[i] = v]
[a.get(i)], [a.set(i, v)]
indexeurs (paragraphe suivant)
[a(x)]
[a.invoke(x)]
un objet « appelé » comme une fonction
[a..b]
[a.rangeTo(b)]
intervalles
[for (x in a)]
[a.iterator()]
itération

Les opérateurs [===] et [!==], qui comparent les références, ne peuvent pas être redéfinis. Les classes de la bibliothèque standard de Kotlin utilisent ces conventions, et, grâce aux fonctions d'extension (présentées plus loin dans ce chapitre), Kotlin en dote aussi des classes du JDK comme [BigDecimal]. Dans une classe Java, une méthode qui a le nom et la signature attendus ([compareTo], [get]…) est d'ailleurs utilisable comme opérateur, sans mot clé [operator].

4.3.1. Un exemple

Le paquetage [listes] contient une classe [ListeDePersonnes], une liste de personnes, dont les méthodes définissent les opérateurs [+], [+=], [in], l'itération et les indexeurs. Les classes [Personne] et [Enseignant] de ce paquetage sont celles du paquetage [heritage2], simplifiées : les propriétés sont en lecture seule ([val]), il n'y a plus de vérifications, de messages de suivi ni de compteur. La classe [Personne] y gagne un opérateur de comparaison [Personne.kt] :

package listes

open class Personne(val prenom: String, val nom: String, val age: Int) {
    // identité de la personne
    open val identite get() = "[$prenom, $nom, $age]"

    override fun toString() = identite

    // les opérateurs de comparaison <, <=, >, >= : les personnes sont comparées par âge
    operator fun compareTo(autre: Personne) = age.compareTo(autre.age)
}
  • ligne 10 : la méthode [compareTo], déclarée [operator], définit les quatre opérateurs [<], [<=], [>] et [>=] entre deux personnes : [p1 < p2] est traduit en [p1.compareTo(p2) < 0]. La méthode doit rendre un entier négatif si l'objet courant précède l'objet [autre], nul s'ils sont équivalents, positif sinon. Ici, les personnes sont comparées par âge : la méthode [compareTo] des entiers fait le travail.

Les personnes sont rangées dans une liste modifiable, de type [MutableList<Personne>] (les listes sont étudiées au chapitre suivant). Plutôt que de faire hériter la classe [ListeDePersonnes] d'une classe de liste, nous plaçons la liste dans une propriété privée de la classe. On dit qu'on utilise la composition plutôt que l'héritage : la classe [ListeDePersonnes] n'expose ainsi que les opérations qu'elle a choisies. De la liste, nous n'utilisons que les éléments suivants :

  • la méthode [l.add(p)] qui ajoute à la liste [l] la personne [p], et la méthode [l.addAll(l2)] qui lui ajoute tous les éléments de la liste [l2] ;
  • la propriété [l.size] qui donne le nombre d'éléments de la liste [l], et la propriété [l.indices], l'intervalle [0..size-1] de ses indices ;
  • la notation [l[i]] qui désigne l'élément n° i de la liste [l], en lecture et en écriture : les listes de Kotlin ont des indexeurs ;
  • la méthode [l.iterator()] qui rend un itérateur sur les éléments de la liste, et la méthode [l.joinToString(...)] qui construit une chaîne à partir de ses éléments.

La définition de la classe [ListeDePersonnes] est la suivante [ListeDePersonnes.kt] :

package listes

class ListeDePersonnes {
    // les personnes de la liste
    private val personnes = mutableListOf<Personne>()

    // nombre de personnes dans la liste
    val size get() = personnes.size

    // l'opérateur + : rend une nouvelle liste contenant les personnes
    // de la liste courante et la personne p
    operator fun plus(p: Personne): ListeDePersonnes {
        val resultat = ListeDePersonnes()
        resultat.personnes.addAll(personnes)
        resultat.personnes.add(p)
        return resultat
    }

    // l'opérateur += : ajoute p à la liste courante
    operator fun plusAssign(p: Personne) {
        personnes.add(p)
    }

    // l'opérateur in : la liste contient-elle une personne portant le nom indiqué ?
    operator fun contains(nom: String) = indexOf(nom) != -1

    // l'opérateur d'itération : permet d'écrire for (p in liste)
    operator fun iterator(): Iterator<Personne> = personnes.iterator()

    // les indexeurs : liste[i] en lecture et en écriture
    operator fun get(i: Int) = personnes[i]

    operator fun set(i: Int, p: Personne) {
        personnes[i] = p
    }

    // un second indexeur en lecture : liste[nom] rend la personne portant ce nom, ou null
    operator fun get(nom: String): Personne? {
        val i = indexOf(nom)
        return if (i != -1) personnes[i] else null
    }

    // position de la personne portant le nom indiqué, -1 si elle n'existe pas
    fun indexOf(nom: String): Int {
        for (i in personnes.indices) {
            if (personnes[i].nom == nom) {
                return i
            }
        }
        return -1
    }

    // toString : rend (personne1,personne2,...)
    override fun toString() = personnes.joinToString(",", "(", ")")
}
  • ligne 5 : la liste des personnes. La fonction [mutableListOf<Personne>()] crée une liste modifiable vide d'éléments de type [Personne]. Le mot clé [val] indique que la propriété désignera toujours la même liste ; la liste elle-même pourra changer de contenu. Aucune importation n'est nécessaire : les listes font partie de la bibliothèque standard de Kotlin, importée automatiquement ;
  • ligne 8 : le nombre de personnes, sous la forme d'une propriété calculée ;
  • lignes 12-16 : la méthode [plus] définit l'opérateur [+] pour l'opération [l + p], où [l] est de type [ListeDePersonnes] et [p] de type [Personne] ou dérivé. Elle crée une nouvelle liste (ligne 13), y recopie les personnes de la liste courante (ligne 14) puis ajoute la personne [p] (ligne 15). Comme pour l'opérateur [+] des nombres ou des chaînes, les opérandes ne sont pas modifiés. La liste [resultat] étant de même classe, la méthode a accès à sa propriété privée [resultat.personnes]. La liste rendue est de type [ListeDePersonnes], ce qui permet d'enchaîner les opérations comme dans [l + p1 + p2] ;
  • lignes 20-21 : la méthode [plusAssign] définit l'opérateur [+=] : elle modifie la liste courante, en lui ajoutant la personne [p], et ne rend rien ;
  • ligne 25 : la méthode [contains] définit l'opérateur [in] : ["leduc" in l] est traduit en [l.contains("leduc")]. Le paramètre est ici un nom ; c'est la méthode [indexOf], étudiée au paragraphe suivant, qui recherche la personne ;
  • ligne 28 : la méthode [iterator] permet de parcourir la liste avec une boucle [for (p in l)]. Elle rend l'itérateur de la liste privée, sous le type [Iterator<Personne>], qui ne permet que la lecture des éléments ;
  • ligne 54 : nous redéfinissons la méthode [toString] afin d'afficher une liste de personnes sous la forme (personne1,personne2,...) où personnei est lui-même le résultat de la méthode [toString] de la personne. La méthode [joinToString(séparateur, préfixe, suffixe)] des listes construit une chaîne à partir des éléments successifs, convertis en chaînes par leur méthode [toString], en les séparant par le séparateur indiqué (ici la virgule) et en l'encadrant par un préfixe et un suffixe (ici les parenthèses).

On notera que la classe [ListeDePersonnes] n'a pas de constructeur. Elle a donc le constructeur par défaut, sans paramètres, qui ne fait rien d'autre qu'initialiser la propriété [personnes] avec la valeur indiquée à sa déclaration. Les lignes 30-50 sont étudiées au paragraphe suivant.

Le programme de test [Main.kt] commence ainsi :

package listes

import java.math.BigDecimal

fun main() {
    // --- les opérateurs des chaînes et des nombres BigDecimal
    val s1 = "jean"
    val s2 = StringBuilder("je").append("an").toString()
    println("s1 == s2 : ${s1 == s2}, s1 === s2 : ${s1 === s2}")
    println("s1 < \"paul\" : ${s1 < "paul"}, s1.compareTo(\"paul\") : ${s1.compareTo("paul")}")
    val a = BigDecimal("0.10")
    val b = BigDecimal("0.20")
    println("a + b = ${a + b}, a * b = ${a * b}, -a = ${-a}, a < b : ${a < b}")

    // --- l'opérateur compareTo de la classe Personne
    val jean = Personne("jean", "martin", 10)
    val pauline = Personne("pauline", "leduc", 12)
    println("jean < pauline : ${jean < pauline}, jean >= pauline : ${jean >= pauline}")

    // --- l'opérateur +
    // une liste de personnes
    var l = ListeDePersonnes()
    // ajout de personnes
    l = l + jean + pauline
    // affichage
    println("l=$l")
    // l + p rend une nouvelle liste : l n'est pas modifiée
    val l2 = l + Personne("louis", "durand", 8)
    println("l=$l")
    println("l2=$l2")

    // --- l'opérateur +=
    val l3 = l
    // += modifie la liste référencée par l3 (et par l), sans créer de nouvelle liste
    l3 += Enseignant("camille", "germain", 27, 60)
    println("l=$l")
    // l et l3 référencent toujours le même objet
    println("l3 === l : ${l3 === l}")

    // --- les opérateurs in et !in, l'itération
    println("\"leduc\" in l : ${"leduc" in l}, \"xx\" !in l : ${"xx" !in l}")
    for (p in l) {
        print("${p.prenom} ")
    }
    println("(${l.size} personnes)")
  • lignes 7-10 : deux chaînes de même contenu mais distinctes : la chaîne [s2] est construite à l'exécution. L'opérateur [] compare les contenus (il appelle [equals]*), l'opérateur *[=] les références. En Kotlin, on compare donc deux chaînes avec [==], ce qui est une erreur classique en Java. L'opérateur [<] compare deux chaînes dans l'ordre des codes de leurs caractères : il appelle la méthode [compareTo], qui rend ici un nombre négatif car « jean » précède « paul » ;
  • lignes 11-13 : la classe [BigDecimal] du JDK (nombres décimaux exacts, que nous utiliserons pour les montants) n'a que des méthodes : [add], [multiply], [negate], [compareTo]. La bibliothèque standard de Kotlin lui ajoute les opérateurs [+], [*], [-] unaire… : on écrit [a + b] au lieu de [a.add(b)], [-a] au lieu de [a.negate()]. Les opérateurs de comparaison, eux, utilisent directement la méthode [compareTo] de [BigDecimal]. L'importation de la ligne 3 est nécessaire : [BigDecimal] est une classe du JDK ;
  • lignes 16-18 : les opérateurs de comparaison définis par la méthode [compareTo] de la classe [Personne] ;
  • ligne 22 : création d'une liste de personnes [l] vide. La variable est déclarée [var] : elle va changer de valeur ;
  • ligne 24 : ajout de deux personnes avec l'opérateur [+], qui rend chaque fois une nouvelle liste ;
  • ligne 28 : [l + p] crée une nouvelle liste [l2] ; la liste [l] n'est pas modifiée ;
  • ligne 35 : ajout d'un enseignant avec l'opérateur [+=], c'est-à-dire la méthode [plusAssign] ;
  • ligne 41 : les opérateurs [in] et [!in] ;
  • lignes 42-45 : le parcours de la liste par une boucle [for], grâce à la méthode [iterator], et la propriété [size] ;
  • ligne 26 et suivantes : un gabarit de chaîne contenant un objet utilise la méthode [toString] redéfinie de la classe [ListeDePersonnes].

Résultats (début) :

> gradlew -q run -Pmain=listes.MainKt
s1 == s2 : true, s1 === s2 : false
s1 < "paul" : true, s1.compareTo("paul") : -6
a + b = 0.30, a * b = 0.0200, -a = -0.10, a < b : true
jean < pauline : true, jean >= pauline : false
l=([jean, martin, 10],[pauline, leduc, 12])
l=([jean, martin, 10],[pauline, leduc, 12])
l2=([jean, martin, 10],[pauline, leduc, 12],[louis, durand, 8])
l=([jean, martin, 10],[pauline, leduc, 12],Enseignant[[camille, germain, 27],60])
l3 === l : true
"leduc" in l : true, "xx" !in l : true
jean pauline camille (3 personnes)

Pourquoi deux opérateurs, alors que [l = l + p] produit le même contenu que [l += p] ? L'opérateur [+] crée une nouvelle liste, y recopie toutes les personnes de [l], puis [l] désigne cette nouvelle liste et l'ancienne est abandonnée au ramasse-miettes. L'opérateur [+=], défini par [plusAssign], ajoute la personne à la liste existante, sans copie. La ligne 33 mémorise une seconde référence [l3] sur la liste [l] ; après l'opération [l3 += ...], les deux références désignent toujours le même objet (ligne 9 des résultats), et la personne ajoutée apparaît dans [l] (ligne 8), ce qui prouve que la liste n'a pas été recréée. Pour une grosse liste, ou dans une boucle, la différence de performances est importante. Le choix entre les deux styles se retrouve dans la bibliothèque : les objets [String] et [BigDecimal], immuables, rendent de nouveaux objets ; les listes modifiables et les [StringBuilder] se modifient.

Pourquoi avoir écrit [l3 += ...] et non [l += ...] ? Parce que [l] est une variable [var] : pour elle, [l += p] pourrait signifier aussi bien [l.plusAssign(p)] que [l = l.plus(p)], et le compilateur refuse de choisir :

1
2
3
4
5
Main.kt:35:7: error: ambiguity between assign operator candidates:
fun plus(p: Personne): ListeDePersonnes
fun plusAssign(p: Personne): Unit
    l += Enseignant("camille", "germain", 27, 60)
      ^^

Avec une variable [val], comme [l3], seule la première traduction est possible. En pratique, une classe définit l'un ou l'autre : [plus] pour une classe immuable, [plusAssign] pour une classe modifiable. Les listes de Kotlin suivent cette règle : une [List] (en lecture seule) a l'opérateur [+], une [MutableList] a l'opérateur [+=] ; pour une [MutableList] référencée par une variable [var], le compilateur signale la même ambiguïté.

Il ne faut pas abuser de la surcharge des opérateurs : elle n'a de sens que si l'opérateur garde sa signification habituelle (additionner, comparer, appartenir…). Une méthode au nom explicite reste préférable dans les autres cas.

4.4. Les indexeurs : les opérateurs get et set

Nous continuons ici à utiliser la classe [ListeDePersonnes]. Si [l] est un objet [ListeDePersonnes], nous souhaitons pouvoir désigner la personne n° i de la liste [l] aussi bien en lecture qu'en écriture, avec la notation des tableaux [l[i]]. C# appelle cela un indexeur. Java ne le permet pas : la notation [[i]] y est réservée aux tableaux, et l'on écrit des méthodes [get(int i)] et [set(int i, valeur)]. En Kotlin, ces deux méthodes, déclarées [operator], définissent la notation [l[i]] : [l[i]] est traduit en [l.get(i)] et [l[i] = p] en [l.set(i, p)]. C'est ainsi que les listes et les dictionnaires de Kotlin s'utilisent comme des tableaux.

    // les indexeurs : liste[i] en lecture et en écriture
    operator fun get(i: Int) = personnes[i]

    operator fun set(i: Int, p: Personne) {
        personnes[i] = p
    }

    // un second indexeur en lecture : liste[nom] rend la personne portant ce nom, ou null
    operator fun get(nom: String): Personne? {
        val i = indexOf(nom)
        return if (i != -1) personnes[i] else null
    }

    // position de la personne portant le nom indiqué, -1 si elle n'existe pas
    fun indexOf(nom: String): Int {
        for (i in personnes.indices) {
            if (personnes[i].nom == nom) {
                return i
            }
        }
        return -1
    }
  • ligne 2 : la méthode [get] rend la personne n° i de la liste privée [personnes], elle-même désignée par son indexeur ;
  • lignes 4-5 : la méthode [set] lui affecte la personne [p] ;
  • lignes 9-11 : un second indexeur, en lecture seule (il n'y a pas de méthode [set(String, Personne)]). L'index n'est pas forcément un entier : [l["martin"]] rend la personne portant le nom « martin », ou [null] si cette personne n'est pas dans la liste. Le type du résultat, [Personne?], le dit. Les deux méthodes [get] sont des surcharges : le compilateur choisit d'après le type de l'index. Un indexeur peut aussi avoir plusieurs paramètres : [m[i, j]] est traduit en [m.get(i, j)] ;
  • lignes 15-21 : on veut pouvoir également obtenir la position dans la liste de la personne portant un nom donné, ou -1 si cette personne n'est pas dans la liste. Le nom [indexOf] est celui de la méthode équivalente des listes et des chaînes. La boucle parcourt les indices de la liste ([personnes.indices]) ; les noms sont comparés avec l'opérateur [==] (ligne 17).

Le programme de test se poursuit de la façon suivante :

    // --- les indexeurs : les opérateurs get et set
    // changement de l'élément n° 1
    l[1] = Personne("franck", "gallon", 5)
    // affichage de l'élément n° 1
    println("l[1]=${l[1]}")
    // affichage de la liste l
    println("l=$l")
    // recherche de personnes par leur nom, avec indexOf
    val noms = arrayOf("martin", "germain", "xx")
    for (nom in noms) {
        val inom = l.indexOf(nom)
        if (inom != -1) {
            println("Personne($nom)=${l[inom]}")
        } else {
            println("Personne($nom) n'existe pas")
        }
    }
    // la même recherche avec l'indexeur l[nom]
    for (nom in noms) {
        println("l[\"$nom\"]=${l[nom] ?: "n'existe pas"}")
    }
}
  • ligne 3 : appel de la méthode [set] ;
  • ligne 5 : appel de la méthode [get] ;
  • ligne 11 : appel de la méthode [indexOf] ;
  • ligne 13 : appel de la méthode [get] avec un indice entier ;
  • ligne 20 : appel de la méthode [get] avec un nom. L'opérateur [?:] remplace le résultat [null] par un message.

Résultats (fin) :

1
2
3
4
5
6
7
8
9
...
l[1]=[franck, gallon, 5]
l=([jean, martin, 10],[franck, gallon, 5],Enseignant[[camille, germain, 27],60])
Personne(martin)=[jean, martin, 10]
Personne(germain)=Enseignant[[camille, germain, 27],60]
Personne(xx) n'existe pas
l["martin"]=[jean, martin, 10]
l["germain"]=Enseignant[[camille, germain, 27],60]
l["xx"]=n'existe pas

4.5. Valeur et référence

4.5.1. Les types de base et les classes

D'autres langages, comme C ou C#, proposent, à côté des classes, des structures : des types dont les variables contiennent directement les données, et non une référence vers un objet. Java n'en a pas : il distingue les huit types primitifs ([int], [double], [boolean]…), dont les variables contiennent la valeur elle-même, et les types référence (classes, tableaux…), dont les variables contiennent une référence vers un objet. Kotlin n'a pas non plus de structures, et, pour le programmeur, il n'a même pas de types primitifs : tout est objet, [Int], [Double] ou [Boolean] sont des classes, dont on appelle les méthodes ([10.toString()], [age.coerceAtLeast(0)]). Mais le compilateur, nous l'avons vu au chapitre précédent, traduit les types de base [Int], [Long], [Double], [Boolean], [Char]… par les types primitifs de la JVM ([int], [long]…) chaque fois que c'est possible : une variable [Int] contient alors la valeur elle-même. Le programme [Classes14.kt] met en évidence la différence avec une classe :

// types de base et classes : copie de valeur, copie de référence
package classes14

fun main() {
    // deux variables de type Int
    val age1 = 10
    var age2 = age1
    println("age1=$age1, age2=$age2")
    // age2 est modifiée
    age2 = 30
    // vérification age1 et age2
    println("age1=$age1, age2=$age2")

    // un objet op1
    val op1 = CPersonne()
    op1.nom = "paul"
    op1.age = 10
    println("op1=CPersonne(${op1.nom},${op1.age})")
    // un objet op2
    val op2 = op1
    println("op2=CPersonne(${op2.nom},${op2.age})")
    // op2 est modifié
    op2.nom = "nicole"
    op2.age = 30
    // vérification op1 et op2
    println("op1=CPersonne(${op1.nom},${op1.age})")
    println("op2=CPersonne(${op2.nom},${op2.age})")

    // une copie explicite de l'objet
    val op3 = CPersonne()
    op3.nom = op1.nom
    op3.age = op1.age
    op3.age = 50
    println("op1=CPersonne(${op1.nom},${op1.age})")
    println("op3=CPersonne(${op3.nom},${op3.age})")
}

// classe CPersonne : deux propriétés publiques modifiables
class CPersonne {
    var nom = ""
    var age = 0
}
  • lignes 39-41 : une classe avec deux propriétés publiques modifiables : [nom], [age].

Résultats :

1
2
3
4
5
6
7
8
9
> gradlew -q run -Pmain=classes14.Classes14Kt
age1=10, age2=10
age1=10, age2=30
op1=CPersonne(paul,10)
op2=CPersonne(paul,10)
op1=CPersonne(nicole,30)
op2=CPersonne(nicole,30)
op1=CPersonne(nicole,30)
op3=CPersonne(nicole,50)

La déclaration [val age1 = 10] (ligne 6) réserve un emplacement mémoire qui contient l'entier 10 : la valeur de [age1] est l'entier lui-même. Dans le cas de la classe, la création de l'objet ([nom], [age]) se fait explicitement par l'appel du constructeur [CPersonne()] (ligne 15). L'objet est placé dans une zone de la mémoire gérée par le ramasse-miettes, le tas (« heap »), et la valeur de [op1] est l'adresse (la référence) de cet objet.

Lorsque dans le programme on écrit [var age2 = age1] (ligne 7), la valeur de [age1], l'entier 10, est recopiée dans [age2] : c'est une recopie de valeur. Modifier [age2] ne modifie pas [age1] (ligne 2 des résultats). Considérons maintenant l'instruction [val op2 = op1] (ligne 20). La valeur de [op1] est recopiée dans [op2], mais comme cette valeur est l'adresse de l'objet, celui-ci n'est pas dupliqué : [op1] et [op2] désignent le même objet. Si on modifie l'objet pointé par [op2], celui pointé par [op1] est modifié puisque c'est le même (lignes 5-6 des résultats). Pour obtenir un second objet, il faut le créer et y recopier les propriétés du premier (lignes 30-32) : modifier la copie ne modifie pas l'original (lignes 7-8 des résultats).

Image

On retiendra donc de ces explications que :

  • la valeur d'une variable d'un type de base ([Int], [Double]…) est la donnée elle-même ;
  • la valeur d'une variable d'un type classe est l'adresse de l'objet pointé ;
  • l'affectation et le passage de paramètres recopient toujours la valeur de la variable : la donnée pour un type de base, la référence pour un objet.

Le compilateur ne peut pas toujours utiliser un type primitif de la JVM. Une variable de type [Int?] doit pouvoir valoir [null], ce qu'un [int] ne sait pas faire, et un élément d'une liste [List<Int>] doit être un objet (les génériques de la JVM n'acceptent que des types référence, voir plus loin) : dans ces cas, l'entier est placé dans un objet [java.lang.Integer], ce que Java appelle l'« autoboxing ». Le programmeur Kotlin n'a pas à s'en soucier, à une exception près : il ne doit pas comparer deux nombres avec l'opérateur [===], qui comparerait les références des objets [Integer]. Le compilateur le signale par un avertissement ([identity equality for arguments of types 'Int?' and 'Int?' is prohibited]). Les nombres se comparent avec [==], qui compare toujours les valeurs.

Pour représenter une petite donnée composée (un point, une couleur, une date, un montant) qu'on manipule comme une valeur, on utilise des classes immuables : leurs objets ne peuvent pas être modifiés après leur création. Partager une référence vers un objet immuable est sans danger : personne ne peut le modifier, et l'objet se comporte alors comme une valeur. Deux outils complètent le dispositif : la méthode [equals], appelée par l'opérateur [==], qui compare deux objets par leur contenu, et les classes de données ([data class]), qui déclarent en une ligne une classe immuable dotée de cette méthode. C'est l'objet des deux paragraphes suivants.

4.5.2. L'égalité de deux objets : equals et hashCode

Nous l'avons vu, l'opérateur [===] appliqué à deux références indique si elles désignent le même objet. L'opérateur [] appelle la méthode [equals], et celle qui est héritée de [Any]* fait la même chose que *[=]. Pour que deux objets distincts de même contenu soient considérés comme égaux, une classe redéfinit la méthode [equals], et, obligatoirement avec elle, la méthode [hashCode]. Le programme [Classes15.kt] le montre :

// l'égalité de deux objets : equals et hashCode
package classes15

import java.util.Objects

fun main() {
    val p1 = Personne("paul", 10)
    val p2 = Personne("paul", 10)
    val p3 = Personne("nicole", 30)
    // === compare les références, == compare les valeurs (méthode equals)
    println("p1 === p2 : ${p1 === p2}, p1 == p2 : ${p1 == p2}, p1 == p3 : ${p1 == p3}")
    // deux objets égaux ont le même code de hachage
    println("p1.hashCode() == p2.hashCode() : ${p1.hashCode() == p2.hashCode()}")
    // un ensemble (Set) ne contient pas deux objets égaux
    val personnes = hashSetOf(p1, p2, p3)
    println("taille de l'ensemble : ${personnes.size}")
    println("l'ensemble contient (paul,10) : ${Personne("paul", 10) in personnes}")
    // l'opérateur == accepte la valeur null
    val inconnue: Personne? = personnes.find { it.nom == "pierre" }
    println("inconnue == p1 : ${inconnue == p1}, inconnue == null : ${inconnue == null}")
}

class Personne(val nom: String, val age: Int) {
    // deux personnes sont égales si elles ont mêmes nom et âge
    override fun equals(other: Any?): Boolean {
        // même objet ?
        if (this === other) {
            return true
        }
        // other est-il une Personne (et non null) ?
        if (other !is Personne) {
            return false
        }
        // comparaison des propriétés : other est désormais connu comme une Personne
        return age == other.age && nom == other.nom
    }

    // code de hachage cohérent avec equals
    override fun hashCode() = Objects.hash(nom, age)

    override fun toString() = "Personne($nom,$age)"
}
  • lignes 25-35 : la méthode [equals] redéfinit celle de la classe [Any]. Son paramètre est de type [Any?] : on peut comparer une personne à n'importe quel objet, et même à [null]. La méthode suit un schéma classique :
    • lignes 27-28 : un objet est égal à lui-même ;
    • lignes 31-32 : l'expression [other !is Personne] est vraie si [other] ne référence pas un objet de type [Personne] (ou d'une classe dérivée), en particulier s'il vaut [null]. On rend alors [false] ;
    • ligne 35 : on compare les propriétés. Après le test de la ligne 31, le compilateur sait que [other] est une [Personne] : on peut utiliser [other.age] sans conversion. C'est le transtypage intelligent (« smart cast ») déjà rencontré avec l'expression [when]. Les noms sont comparés avec [==], qui appelle la méthode [equals] des chaînes ;
  • ligne 39 : la méthode [hashCode] rend un entier calculé à partir des mêmes propriétés, ici par la méthode [Objects.hash] du JDK (d'où l'importation de la ligne 4). La règle est impérative : deux objets égaux au sens de [equals] doivent avoir le même code de hachage. Les dictionnaires et les ensembles (chapitre suivant) rangent les objets d'après ce code et ne fonctionneraient plus si la règle n'était pas respectée ;
  • ligne 11 : [===] compare les références, [==] les contenus. En Java, on écrirait [p1.equals(p2)] ; en Kotlin, on écrit [p1 == p2], qui est plus lisible ;
  • lignes 15-17 : un ensemble [HashSet], créé par la fonction [hashSetOf], ne contient jamais deux objets égaux : l'ajout de [p2], égal à [p1], est sans effet, et la recherche d'une nouvelle personne ([paul, 10]) avec l'opérateur [in] aboutit ;
  • lignes 19-20 : la fonction [find] des collections rend le premier élément qui vérifie la condition donnée par la lambda, ou [null] s'il n'y en a pas (les lambdas sont étudiées au chapitre 7). La variable [inconnue] vaut donc [null]. L'opérateur [==] accepte un opérande [null] : [a == b] est traduit en [a?.equals(b) ?: (b === null)]. Il n'y a pas d'exception [NullPointerException] à craindre, contrairement à l'appel Java [a.equals(b)] lorsque [a] vaut [null].

Résultats :

1
2
3
4
5
6
> gradlew -q run -Pmain=classes15.Classes15Kt
p1 === p2 : false, p1 == p2 : true, p1 == p3 : false
p1.hashCode() == p2.hashCode() : true
taille de l'ensemble : 2
l'ensemble contient (paul,10) : true
inconnue == p1 : false, inconnue == null : true

Écrire [equals] et [hashCode] à la main est fastidieux et source d'erreurs ; IntelliJ IDEA sait les générer (menu « Code / Generate… »). Les classes de données, elles, les fournissent automatiquement.

4.5.3. Les objets immuables

Le programme [Classes16.kt] présente trois façons de définir une donnée immuable :

// objets immuables : une classe immuable, une classe de données, une classe valeur
package classes16

fun main() {
    val a = Point(1, 2)
    // a.x = 5   // refusé : x est une propriété val
    val b = a.deplacer(3, 4)
    println("a=$a, b=$b")

    // une classe de données : toString, equals, hashCode, copy... sont générés
    val rouge = Couleur(255, 0, 0)
    val rouge2 = Couleur(255, 0, 0)
    println("rouge=$rouge, r=${rouge.r}")
    println("rouge == rouge2 : ${rouge == rouge2}, rouge === rouge2 : ${rouge === rouge2}")
    // une copie modifiée : la méthode copy et des arguments nommés
    val rose = rouge.copy(v = 128, b = 128)
    println("rouge=$rouge, rose=$rose")
    // la déstructuration d'un objet d'une classe de données
    val (r, v, bleu) = rose
    println("r=$r, v=$v, b=$bleu")

    // une classe valeur : un Int enveloppé et vérifié
    val age = Age(30)
    println("age=$age, age.valeur + 1 = ${age.valeur + 1}")
    try {
        val age2 = Age(-4)
        println("age2=$age2")
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
}

// une classe immuable : propriétés val, pas de modifieur ; elle est finale par défaut
class Point(val x: Int, val y: Int) {
    // on ne modifie pas le point : on en rend un nouveau
    fun deplacer(dx: Int, dy: Int) = Point(x + dx, y + dy)

    override fun toString() = "($x,$y)"
}

// une classe de données : une classe immuable déclarée en une ligne
data class Couleur(val r: Int, val v: Int, val b: Int)

// une classe valeur : à l'exécution, un objet Age est le plus souvent un simple int
@JvmInline
value class Age(val valeur: Int) {
    init {
        require(valeur >= 0) { "âge ($valeur) invalide" }
    }
}
  • lignes 34-38 : une classe immuable écrite à la main. Ses propriétés sont en lecture seule ([val]) ; elle est finale, comme toute classe Kotlin par défaut, pour qu'aucune classe dérivée ne puisse lui ajouter un état modifiable. Pour « déplacer » un point, la méthode [deplacer] rend un nouveau point (ligne 36). L'instruction [a.x = 5] (ligne 6) serait refusée par le compilateur : ['val' cannot be reassigned] ;
  • ligne 42 : une classe de données ([data class]). L'en-tête [data class Couleur(val r: Int, val v: Int, val b: Int)] déclare une classe dotée de trois propriétés, d'un constructeur à trois paramètres, et des méthodes [toString], [equals] et [hashCode], qui utilisent toutes les propriétés du constructeur primaire, ainsi que des méthodes [copy] et [component1], [component2], [component3] présentées ci-dessous. C'est l'équivalent Kotlin du record de Java, apparu bien avant lui ; les propriétés étant déclarées avec [val], la classe est immuable ;
  • ligne 13 : une propriété se lit par son nom, [rouge.r] ;
  • ligne 14 : deux objets de même contenu sont égaux au sens de [==], bien que distincts ;
  • ligne 16 : une classe de données étant immuable, on ne la modifie pas : on crée une copie dont certaines propriétés ont changé. C'est le rôle de la méthode [copy] générée, dont chaque paramètre a pour valeur par défaut la valeur actuelle de la propriété : avec des arguments nommés, on ne donne que les propriétés qui changent. En Java, il faut écrire à la main des méthodes « wither » ([avecV(int v)]…) pour obtenir le même résultat ;
  • ligne 19 : la déstructuration, vue au chapitre précédent avec les paires : les trois variables reçoivent les valeurs des trois propriétés, dans l'ordre du constructeur. Elle s'appuie sur les méthodes [component1], [component2], [component3] générées. Le nom des variables est libre : nous avons écrit [bleu] car [b] est déjà utilisé ;
  • lignes 45-48 : une classe valeur ([value class]), annotée [@JvmInline]. Elle a une seule propriété, ici un entier, qu'elle « enveloppe » pour lui donner un type propre et des vérifications : un [Age] n'est pas un [Int] quelconque, une fonction qui attend un [Age] n'acceptera pas un poids ou un nombre d'enfants, et un âge négatif est refusé par le bloc [init] (ligne 26). L'intérêt de la classe valeur est qu'à l'exécution, le compilateur la remplace le plus souvent par la valeur enveloppée : une variable de type [Age] est un simple [int] de la JVM, sans objet créé dans le tas. On a ainsi la sûreté d'un type distinct sans son coût. Une classe valeur est immuable ; elle dispose de [toString], [equals] et [hashCode] générés, comme une classe de données.

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=classes16.Classes16Kt
a=(1,2), b=(4,6)
rouge=Couleur(r=255, v=0, b=0), r=255
rouge == rouge2 : true, rouge === rouge2 : false
rouge=Couleur(r=255, v=0, b=0), rose=Couleur(r=255, v=128, b=128)
r=255, v=128, b=128
age=Age(valeur=30), age.valeur + 1 = 31
âge (-4) invalide

Le [toString] généré pour une classe de données affiche le nom de la classe et ses propriétés (ligne 2 des résultats) ; il en est de même pour la classe valeur (ligne 6). Retenons ici qu'une classe de données est la façon la plus concise de déclarer une petite donnée immuable : c'est ce type que nous utiliserons pour les tranches du barème de l'impôt dans l'application exemple de ce chapitre. Les classes de données sont étudiées en détail plus loin.

4.6. Les interfaces

4.6.1. Un premier exemple

Une interface est un ensemble de déclarations de méthodes (et, en Kotlin, de propriétés) qui forme un contrat. Une classe qui décide d'implémenter une interface s'engage à fournir une implémentation de tous les membres abstraits définis dans l'interface. C'est le compilateur qui vérifie cette implémentation.

Voici par exemple la définition (simplifiée) de l'interface [Iterator<T>] de la bibliothèque standard de Kotlin, qui permet de parcourir les éléments d'une collection : [interface Iterator<out T> { operator fun next(): T; operator fun hasNext(): Boolean }]. Les méthodes de l'interface ne sont définies que par leurs signatures : elles n'ont pas de corps et sont implicitement publiques et abstraites (le mot clé [out] sera expliqué avec les génériques, le mot clé [operator] indique que la boucle [for] peut utiliser ces méthodes). Ce sont les classes qui implémentent l'interface qui donnent du code à ses méthodes. Une classe [C] déclare qu'elle implémente l'interface en la citant après le caractère deux-points, comme une classe parent mais sans parenthèses (une interface n'a pas de constructeur) : [class C : Iterator<String>], et elle doit alors définir les deux méthodes [override fun hasNext(): Boolean { ... }] et [override fun next(): String { ... }]. Là où Java distingue [extends] (héritage) et [implements] (implémentation), Kotlin, comme C#, utilise le même signe deux-points ; le mot clé [override] est obligatoire pour implémenter un membre d'interface, comme pour redéfinir une méthode de la classe parent.

Une convention répandue (mais non universelle : ni le JDK, ni la bibliothèque de Kotlin ne la suivent) fait commencer le nom d'une interface par la lettre I. Les exemples qui suivent forment le paquetage [statistiques] (dossier [src/main/kotlin/statistiques]). Considérons l'interface suivante [IStats.kt] :

package statistiques

// les statistiques d'une série de valeurs
interface IStats {
    // moyenne de la série : une propriété abstraite
    val moyenne: Double

    // écart-type de la série
    val ecartType: Double
}

L'interface [IStats] présente :

  • une propriété [moyenne] : la moyenne d'une série de valeurs ;
  • une propriété [ecartType] : l'écart-type de cette série.

On notera qu'il n'est nulle part précisé de quelle série de valeurs il s'agit. Il peut s'agir de la moyenne des notes d'une classe, de la moyenne mensuelle des ventes d'un produit particulier, de la température moyenne dans un lieu donné… C'est le principe des interfaces : on suppose l'existence de membres dans l'objet mais pas celle de données particulières.

Là où l'interface Java déclarait deux méthodes [double moyenne()] et [double ecartType()], l'interface Kotlin déclare deux propriétés abstraites [val moyenne: Double] et [val ecartType: Double]. Une propriété d'interface ne dit rien de la façon dont la valeur est obtenue : une classe d'implémentation peut la mémoriser dans un champ, la calculer à chaque lecture par un accesseur [get], ou la calculer à la première lecture puis la mémoriser. C'est ce que montrent les deux classes qui suivent. Une propriété [val] d'interface ne promet qu'une lecture ; une propriété [var] promettrait en plus une écriture.

Une première classe d'implémentation de l'interface [IStats] pourrait être une classe servant à mémoriser les notes des élèves d'une classe dans une matière donnée. Un élève est caractérisé par la classe de données [Eleve] suivante [Eleve.kt] :

1
2
3
4
package statistiques

// un élève
data class Eleve(val prenom: String, val nom: String)

L'élève est identifié par son prénom et son nom. La classe de données (« data class »), présentée plus haut (paragraphe « Les objets immuables ») et étudiée en détail plus loin, le fait en une ligne : elle remplace le record de la version Java. Une note est caractérisée par la classe de données [Note] suivante [Note.kt] :

1
2
3
4
5
6
package statistiques

// la note d'un élève
data class Note(val eleve: Eleve, val valeur: Double) {
    override fun toString() = "[${eleve.prenom},${eleve.nom},$valeur]"
}

La note est définie par l'élève noté et la note elle-même. La classe redéfinit la méthode [toString] générée par le compilateur pour afficher une note sous la forme [prénom,nom,valeur]. On notera la forme [fun f() = expression] : une fonction dont le corps est une seule expression peut s'écrire avec le signe [=], sans accolades ni [return], le type du résultat étant alors déduit de l'expression.

Les notes de tous les élèves dans une matière donnée sont rassemblées dans la classe [TableauDeNotes] suivante [TableauDeNotes.kt] :

package statistiques

import kotlin.math.pow
import kotlin.math.sqrt

// des notes rangées dans un tableau, fixé à la construction
class TableauDeNotes(val matiere: String, val notes: Array<Note>) : IStats {
    // implémentation des propriétés de l'interface IStats :
    // la moyenne et l'écart-type sont calculés une fois pour toutes, à la construction
    override val moyenne: Double =
        if (notes.isNotEmpty()) notes.sumOf { it.valeur } / notes.size else -1.0

    override val ecartType: Double =
        if (notes.isNotEmpty()) sqrt(notes.sumOf { (it.valeur - moyenne).pow(2) } / notes.size) else -1.0

    override fun toString() = "matière=$matiere, notes=${notes.contentToString()}"
}
  • ligne 7 : la classe [TableauDeNotes] implémente l'interface [IStats], citée après le deux-points. Elle doit donc implémenter les propriétés [moyenne] et [ecartType]. Son constructeur primaire déclare deux propriétés en lecture seule : la matière dont l'objet mémorise les notes et le tableau des notes ;
  • lignes 10-14 : l'implémentation des deux propriétés de l'interface, précédées du mot clé [override]. On suppose ici que les notes transmises au constructeur ne bougeront plus par la suite. Aussi calcule-t-on tout de suite la moyenne et l'écart-type de ces notes, que l'on mémorise dans les champs des propriétés : les initialiseurs des propriétés sont exécutés à la construction de l'objet, dans l'ordre où elles sont écrites, ce qui permet au calcul de l'écart-type d'utiliser la moyenne déjà calculée. En Java, il fallait quatre champs [final] et un constructeur pour obtenir le même résultat ;
  • ligne 11 : [if] est une expression en Kotlin : elle remplace l'opérateur [condition ? valeur1 : valeur2] de Java et donne la valeur -1 à la moyenne d'un tableau vide. La fonction d'extension [sumOf] des tableaux et des collections fait la somme des valeurs que la lambda [{ it.valeur }] calcule pour chaque élément (les lambdas sont étudiées au chapitre 7 ; [it] y désigne l'élément courant) : elle remplace la boucle [for] de cumul de la version Java ;
  • ligne 14 : [x.pow(2)] calcule x², [sqrt] la racine carrée. Ce sont des fonctions du paquetage [kotlin.math], importées lignes 3-4 ; elles remplacent les méthodes statiques [Math.pow] et [Math.sqrt] de Java (qui restent utilisables) ;
  • ligne 16 : la méthode [toString] utilise la fonction d'extension [contentToString] des tableaux, qui rend leurs éléments entre crochets, séparés par des virgules, chacun étant converti en chaîne par sa méthode [toString], ici celle de la classe [Note]. C'est l'équivalent de la méthode statique [Arrays.toString] de Java. Le [toString] d'un tableau Kotlin, comme celui d'un tableau Java, n'affiche que son type et un code de hachage.

Dans la classe précédente, les notes étaient enregistrées dans un tableau. Il n'était pas possible d'ajouter une nouvelle note après construction de l'objet [TableauDeNotes]. Nous proposons maintenant une seconde implémentation de l'interface [IStats], appelée [ListeDeNotes], où cette fois les notes sont enregistrées dans une liste, avec possibilité d'ajouter des notes après construction initiale de l'objet [ListeDeNotes] [ListeDeNotes.kt] :

package statistiques

import kotlin.math.pow
import kotlin.math.sqrt

// des notes rangées dans une liste, à laquelle on peut ajouter des notes
class ListeDeNotes(val matiere: String, notes: List<Note>) : IStats {
    // la liste modifiable, privée : une copie de la liste reçue
    private val _notes = notes.toMutableList()

    // la vue publique, en lecture seule, de cette liste
    val notes: List<Note>
        get() = _notes

    // moyenne et écart-type mémorisés (null : pas encore calculés)
    private var moyenneCache: Double? = null
    private var ecartTypeCache: Double? = null

    // ajout d'une note
    fun ajouter(note: Note) {
        _notes.add(note)
        // la moyenne et l'écart-type devront être recalculés
        moyenneCache = null
        ecartTypeCache = null
    }

    // moyenne des notes : calculée à la première demande
    override val moyenne: Double
        get() = moyenneCache ?: calculerMoyenne().also { moyenneCache = it }

    // écart-type des notes : calculé à la première demande
    override val ecartType: Double
        get() = ecartTypeCache ?: calculerEcartType().also { ecartTypeCache = it }

    private fun calculerMoyenne(): Double =
        if (_notes.isEmpty()) -1.0 else _notes.sumOf { it.valeur } / _notes.size

    private fun calculerEcartType(): Double {
        if (_notes.isEmpty()) return -1.0
        val m = moyenne
        return sqrt(_notes.sumOf { (it.valeur - m).pow(2) } / _notes.size)
    }

    override fun toString() = "matière=$matiere, notes=$_notes"
}
  • ligne 7 : la classe [ListeDeNotes] implémente l'interface [IStats]. Le paramètre [notes] du constructeur n'est pas précédé de [val] : c'est un simple paramètre, qui n'est utilisé que pour initialiser la propriété de la ligne 9 ;
  • ligne 9 : les notes sont mises maintenant dans une liste modifiable ([MutableList<Note>]), copie de la liste reçue par la fonction d'extension [toMutableList]. Kotlin distingue les collections en lecture seule ([List]) et les collections modifiables ([MutableList]) ; la copie garantit que l'appelant ne pourra pas modifier, dans notre dos, la liste mémorisée par l'objet (voir le chapitre 5) ;
  • lignes 12-13 : la liste est exposée sous le type [List<Note>], en lecture seule : le code extérieur peut lire les notes, pas en ajouter sans passer par la méthode [ajouter]. Ce couple d'une propriété privée [_notes] et d'une propriété publique [notes] de type plus restreint est un idiome courant de Kotlin, appelé propriété d'appui (« backing property ») ;
  • lignes 20-24 : on peut désormais ajouter une note à celles déjà mémorisées. La moyenne et l'écart-type ne sont plus calculés à la construction mais à la demande, dans les accesseurs des propriétés de l'interface : [moyenne] (lignes 28-29) et [ecartType] (lignes 32-33). Une fois calculées, ces valeurs sont mémorisées pour ne pas être recalculées à chaque demande : on dit qu'elles sont mises en cache. L'ajout d'une note invalide le cache ;
  • lignes 16-17 : le cache. Son type [Double?] est un type nullable (chapitre 3) : la valeur [null] y signifie « pas encore calculé ». Là où Java utilisait la classe enveloppe [Double] pour disposer de la valeur [null], Kotlin ajoute simplement un point d'interrogation au type ;
  • ligne 29 : l'accesseur se lit ainsi : la valeur en cache si elle n'est pas [null] (opérateur Elvis [?:]), sinon le résultat de [calculerMoyenne()], que la fonction [also] mémorise au passage dans le cache avant de le rendre. [x.also { ... }] exécute le bloc avec [it = x] puis rend [x] : elle sert à greffer une action secondaire (ici la mise en cache) sur une expression. Le compilateur sait que le résultat de l'expression n'est jamais [null] : il est de type [Double], comme l'exige l'interface ;
  • lignes 35-41 : les deux calculs, écrits avec [sumOf]. On notera, ligne 39, le style « sortie rapide » : la fonction rend -1 tout de suite si la liste est vide ;
  • ligne 44 : le [toString] d'une liste affiche ses éléments entre crochets, sans qu'il soit besoin d'une fonction particulière.

Un programme de test pourrait être le suivant [Main.kt] :

package statistiques

fun main() {
    // quelques élèves et leurs notes d'anglais
    val eleves = arrayOf(Eleve("Paul", "Martin"), Eleve("Maxime", "Germain"), Eleve("Berthine", "Samin"))
    val notes = arrayOf(Note(eleves[0], 14.0), Note(eleves[1], 16.0), Note(eleves[2], 18.0))
    // qu'on enregistre dans un objet TableauDeNotes
    val anglais = TableauDeNotes("anglais", notes)
    // affichage moyenne et écart-type
    afficheStats(anglais)
    // les mêmes notes dans un objet ListeDeNotes
    val francais = ListeDeNotes("français", notes.toList())
    afficheStats(francais)
    // on ajoute une note
    francais.ajouter(Note(Eleve("Jérôme", "Jaric"), 10.0))
    afficheStats(francais)
    // une variable peut avoir pour type une interface
    var stats: IStats = anglais
    println("Moyenne d'anglais : ${stats.moyenne}")
    stats = francais
    println("Moyenne de français : ${stats.moyenne}")
}

// affichage de la moyenne et de l'écart-type d'un objet implémentant IStats
fun afficheStats(valeurs: IStats) {
    println("$valeurs, Moyenne=${valeurs.moyenne}, Ecart-type=${valeurs.ecartType}")
}
  • ligne 5 : création d'un tableau d'élèves par la fonction [arrayOf] ;
  • ligne 6 : création d'un tableau de notes. Les notes sont écrites [14.0] et non [14] : Kotlin ne convertit pas implicitement un [Int] en [Double], et le constructeur de [Note] attend un [Double] ;
  • ligne 8 : un objet [TableauDeNotes] dont on affiche la moyenne et l'écart-type ligne 10 ;
  • ligne 12 : un objet [ListeDeNotes] dont on affiche la moyenne et l'écart-type ligne 13. La fonction d'extension [toList] transforme le tableau en liste ; la classe [ListeDeNotes] en fait elle-même une copie modifiable. En Java, il fallait écrire [new ArrayList<>(List.of(notes))] ;
  • ligne 15 : ajout d'une nouvelle note, puis nouvel affichage ;
  • lignes 25-26 : la fonction [afficheStats] reçoit pour paramètre un type [IStats], donc un type interface. Cela signifie que le paramètre effectif peut être tout objet implémentant l'interface [IStats]. Quand on utilise une donnée ayant le type d'une interface, on n'utilise que les membres de l'interface implémentés par la donnée. On fait abstraction du reste. On a là une propriété proche du polymorphisme vu pour les classes. Si un ensemble de classes Ci non liées entre elles par héritage (on ne peut donc pas utiliser le polymorphisme de l'héritage) présente un ensemble de méthodes de même signature, il peut être intéressant de regrouper ces méthodes dans une interface I qu'implémenteraient toutes les classes concernées. Des instances de ces classes Ci peuvent alors être utilisées comme paramètres effectifs de fonctions admettant un paramètre formel de type I, c'est-à-dire des fonctions n'utilisant que les membres des objets Ci définis dans l'interface I et non les propriétés et méthodes particulières des différentes classes Ci ;
  • lignes 18-21 : une variable peut être du type d'une interface. Elle peut alors référencer tout objet d'une classe implémentant l'interface. Le compilateur ne permet l'accès qu'aux membres de l'interface (et à ceux de la classe [Any], comme [toString]). Le type doit ici être écrit : sans lui, le compilateur aurait déduit le type [TableauDeNotes] de la valeur initiale, et l'affectation de la ligne 20 aurait été refusée.

Le paquetage contient deux fonctions [main] et [afficheStats] de haut niveau, écrites dans le fichier [Main.kt] : la classe JVM qui les contient s'appelle donc [statistiques.MainKt].

Résultats :

1
2
3
4
5
6
> gradlew -q run -Pmain=statistiques.MainKt
matière=anglais, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Moyenne=16.0, Ecart-type=1.632993161855452
matière=français, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Moyenne=16.0, Ecart-type=1.632993161855452
matière=français, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0], [Jérôme,Jaric,10.0]], Moyenne=14.5, Ecart-type=2.958039891549808
Moyenne d'anglais : 16.0
Moyenne de français : 14.5

Dans un gabarit de chaîne, un nombre [Double] est converti en chaîne par la méthode [Double.toString] de la JVM, qui écrit toujours le nombre avec un point décimal et au moins une décimale (16.0), quelle que soit la langue du poste. Pour un affichage à la française, on utilise ["%.2f".format(x)], comme dans l'exemple suivant.

Notons enfin que l'implémentation d'interfaces peut être multiple, c'est-à-dire qu'on peut écrire [class ClasseDerivee : ClasseDeBase(), I1, I2, ..., In], où les Ij sont des interfaces : une classe ne dérive que d'une classe (dont on appelle le constructeur, d'où les parenthèses), mais peut implémenter autant d'interfaces qu'elle le souhaite. Une interface peut elle-même dériver d'une ou plusieurs autres interfaces : [interface I3 : I1, I2].

4.6.2. Les méthodes d'interface avec un corps

Une interface Kotlin peut fournir une implémentation par défaut de certaines de ses méthodes : il suffit de leur donner un corps, sans mot clé particulier (Java exige le mot clé [default]). Une classe qui implémente l'interface peut alors utiliser cette implémentation ou fournir la sienne. Cela permet notamment d'ajouter une méthode à une interface déjà publiée sans obliger toutes les classes qui l'implémentent à être modifiées. Une propriété d'interface peut de même avoir un accesseur [get] par défaut. Une interface peut aussi contenir des fonctions privées. En revanche, Kotlin n'a pas de membres statiques ([static]), pas plus dans les interfaces que dans les classes : leur rôle est tenu par l'objet compagnon ([companion object]), vu plus haut, qu'une interface peut avoir comme une classe. Le programme [Classes17.kt] en donne un exemple :

package classes17

import kotlin.math.PI

// interfaces : méthodes avec un corps par défaut, membres « statiques » (companion object),
// fonctions privées, implémentation de plusieurs interfaces
fun main() {
    val formes = arrayOf(Carre(2.0), Cercle(1.0), IForme.carreUnite())
    for (forme in formes) {
        println(forme.description())
    }
    // la méthode par défaut est héritée par la classe Carre
    val carre = Carre(3.0)
    println(carre.description())
    // une classe qui implémente deux interfaces
    val carreRouge = CarreColore(1.5, "rouge")
    println(carreRouge.description())
    println("nom=${carreRouge.nom}, couleur=${carreRouge.couleur}")
}

interface IForme {
    // méthode abstraite : à implémenter
    fun surface(): Double

    // méthode avec une implémentation par défaut
    fun description(): String = "${this::class.simpleName} de surface ${formater(surface())}"

    // propriété avec un accesseur par défaut (une interface n'a pas de champ)
    val nom: String
        get() = "forme"

    // fonction privée : utilisable par les seules méthodes de l'interface
    private fun formater(valeur: Double) = "%.2f".format(valeur)

    // l'objet compagnon de l'interface : ses membres s'appellent avec le nom de l'interface
    companion object {
        fun carreUnite(): IForme = Carre(1.0)
    }
}

// Carre utilise l'implémentation par défaut de description
open class Carre(private val cote: Double) : IForme {
    override fun surface() = cote * cote
}

// Cercle fournit sa propre implémentation de description
class Cercle(private val rayon: Double) : IForme {
    override fun surface() = PI * rayon * rayon

    override fun description() = "Cercle de rayon %s et de surface %.2f".format(rayon, surface())
}

// une seconde interface, qui a elle aussi une méthode description par défaut
interface IColoree {
    // propriété abstraite : à implémenter
    val couleur: String

    fun description(): String = "objet $couleur"
}

// une classe dérivée de Carre qui implémente aussi IColoree :
// elle hérite de deux méthodes description et doit donc la redéfinir
class CarreColore(cote: Double, override val couleur: String) : Carre(cote), IColoree {
    override fun description() = "${super<Carre>.description()}, ${super<IColoree>.description()}"

    override val nom = "carré coloré"
}
  • lignes 21-37 : l'interface [IForme] :
    • ligne 23 : une méthode abstraite [surface], à implémenter ;
    • ligne 26 : une méthode [description] dotée d'une implémentation par défaut. Celle-ci peut utiliser les autres méthodes de l'interface ([surface], [formater]). L'expression [this::class.simpleName] rend le nom simple de la classe de l'objet ([Carre]) : [this::class] est la classe Kotlin ([KClass]) de l'objet, l'équivalent de [getClass()] en Java ;
    • lignes 29-30 : une propriété dotée d'un accesseur par défaut. Une interface n'a pas d'état : elle ne peut pas mémoriser de valeur, et une propriété d'interface n'a donc jamais de champ. Elle est soit abstraite, soit calculée par un accesseur ;
    • ligne 33 : une fonction privée, utilisable par les seules méthodes de l'interface. ["%.2f".format(valeur)] formate le nombre avec deux décimales, en respectant les conventions de la langue du poste, ici la virgule décimale française. La fonction d'extension [format] des chaînes est l'équivalent Kotlin de [String.format] ;
    • lignes 36-37 : l'objet compagnon de l'interface. Sa méthode [carreUnite] s'appelle avec le nom de l'interface : [IForme.carreUnite()] (ligne 8), comme la méthode statique de la version Java. C'est une fabrique ;
  • lignes 42-43 : la classe [Carre] n'implémente que [surface] : elle utilise la méthode [description] par défaut. Elle est déclarée [open] parce qu'une classe en dérive plus loin. En Java, la classe [Carre] devait être déclarée [static] parce que l'interface crée des carrés ; en Kotlin, les classes déclarées dans un fichier sont toutes de haut niveau et la question ne se pose pas ;
  • lignes 47-50 : la classe [Cercle] fournit sa propre méthode [description]. Le format [%s] y insère le rayon converti en chaîne par [Double.toString] (« 1.0 »), le format [%.2f] la surface avec deux décimales. La constante [PI] vient du paquetage [kotlin.math] ;
  • ligne 14 : la méthode par défaut est héritée par la classe [Carre] comme n'importe quelle méthode : on peut l'appeler sur une variable de type [Carre] ;
  • lignes 54-58 : une seconde interface, avec une propriété abstraite [couleur] et une méthode [description] par défaut, de même signature que celle de [IForme] ;
  • lignes 63-66 : la classe [CarreColore] dérive de [Carre] (qui implémente [IForme]) et implémente [IColoree]. Elle hérite donc de deux méthodes [description] par défaut. Le compilateur exige alors que la classe redéfinisse la méthode, sans quoi il refuse la classe (voir plus bas). La nouvelle méthode peut appeler l'une ou l'autre des versions héritées par la syntaxe [super<Type>.methode()], où [Type] est la classe parent ou l'interface dont on veut la version : ligne 64, la méthode combine les deux. Java écrit [I1.super.methode()] ;
  • ligne 63 : la propriété abstraite [couleur] de l'interface [IColoree] est implémentée directement dans le constructeur primaire, par [override val couleur: String] ;
  • ligne 66 : la propriété [nom] de l'interface [IForme], qui avait un accesseur par défaut, est redéfinie par une propriété dotée d'un champ : la classe, elle, peut mémoriser une valeur.

Résultats :

1
2
3
4
5
6
7
> gradlew -q run -Pmain=classes17.Classes17Kt
Carre de surface 4,00
Cercle de rayon 1.0 et de surface 3,14
Carre de surface 1,00
Carre de surface 9,00
CarreColore de surface 2,25, objet rouge
nom=carré coloré, couleur=rouge

Voici les erreurs qu'obtient le compilateur lorsqu'une classe [C] implémente deux interfaces [I1] et [I2] dotées chacune d'une méthode [fun f() = ...] sans la redéfinir, et lorsqu'une interface tente d'initialiser une propriété ([val compteur: Int = 0]) :

Erreur17.kt:9:1: error: class 'C' must override 'f' because it inherits multiple interface methods for it.
Erreur17.kt:12:25: error: property initializers in interfaces are prohibited.

Contrairement à une classe abstraite, une interface n'a donc pas d'état : elle ne peut pas déclarer de propriété mémorisée. Les constantes, qui en Java sont des champs [static final] de l'interface, se placent en Kotlin dans son objet compagnon ([const val]).

Pour les lecteurs qui viennent de Java, signalons enfin qu'une méthode d'interface Kotlin dotée d'un corps est compilée (depuis Kotlin 2.2) en une méthode [default] de l'interface Java générée : une classe Java peut donc implémenter une interface Kotlin sans réécrire ces méthodes.

4.7. Les classes abstraites

Une classe abstraite est une classe qu'on ne peut pas instancier. Il faut créer des classes dérivées qui, elles, pourront être instanciées. On peut utiliser des classes abstraites pour factoriser le code d'une lignée de classes. Examinons le cas suivant, qui forme le paquetage [utilisateurs] [Utilisateur.kt] :

package utilisateurs

// une classe abstraite : on ne peut pas l'instancier
abstract class Utilisateur protected constructor(
    private val login: String,
    private val motDePasse: String,
) {
    // le rôle de l'utilisateur, obtenu par la méthode abstraite identifie
    // utilisateur non identifié : exception
    private val role: String = identifie() ?: throw ExceptionUtilisateurInconnu("[$login,$motDePasse]")

    override fun toString() = "Utilisateur[$login,$motDePasse,$role]"

    // identification : rend le rôle de l'utilisateur ou null s'il n'est pas reconnu
    protected abstract fun identifie(): String?
}
  • ligne 4 : la classe est déclarée abstraite avec le mot clé [abstract]. Une classe abstraite est implicitement ouverte à l'héritage : inutile d'ajouter [open] ;
  • lignes 4-6 : le constructeur primaire de la classe [Utilisateur]. Cette classe mémorise des informations sur l'utilisateur d'une application web. Celle-ci a divers types d'utilisateurs authentifiés par un login et un mot de passe (lignes 5-6). Ces deux informations sont vérifiées auprès d'un annuaire LDAP pour certains utilisateurs, auprès d'une base de données pour d'autres, etc. Pour donner une visibilité au constructeur primaire, on doit écrire explicitement le mot clé [constructor] après le nom de la classe : [protected constructor(...)]. Le constructeur est [protected] : il est destiné aux constructeurs des classes dérivées. La virgule qui suit le dernier paramètre (ligne 6) est permise : elle facilite l'ajout ultérieur d'un paramètre ;
  • ligne 10 : les informations sont vérifiées par une méthode [identifie]. Parce que la méthode d'identification n'est pas connue à ce niveau, elle est déclarée abstraite (ligne 15) avec le mot clé [abstract]. Une méthode abstraite n'a pas de corps : ce sont les classes dérivées qui lui en donneront un. Une classe qui a une méthode abstraite doit elle-même être déclarée abstraite. La méthode [identifie] rend une chaîne de caractères précisant le rôle de l'utilisateur (en gros ce qu'il a le droit de faire), ou [null] si l'utilisateur n'est pas reconnu : son type est donc [String?] ;
  • ligne 10 : si l'utilisateur n'est pas reconnu, une exception est lancée. L'opérateur Elvis [?:] donne pour valeur à l'expression le rôle rendu par [identifie] s'il n'est pas [null], et exécute sinon l'expression de droite, ici [throw ExceptionUtilisateurInconnu(...)]. En Kotlin, [throw] est une expression (de type [Nothing]), ce qui permet cette écriture compacte. La propriété [role] est de type [String], non nullable : le compilateur sait qu'elle ne peut pas valoir [null]. Aucune clause [throws] n'est nécessaire : Kotlin n'a pas d'exceptions contrôlées (chapitre 3) ;
  • ligne 12 : la méthode [toString] qui identifie une instance de la classe.

On suppose ici que le développeur veut avoir la maîtrise de la construction des instances de la classe [Utilisateur] et des classes dérivées, peut-être parce qu'il veut être sûr qu'une exception d'un certain type est lancée si l'utilisateur n'est pas reconnu. Les classes dérivées pourront s'appuyer sur ce constructeur. Elles devront pour cela fournir la méthode [identifie]. On notera que l'initialisation de la propriété [role], qui fait partie de la construction de l'objet, appelle une méthode redéfinie par les classes dérivées alors que celles-ci n'ont pas encore exécuté leur propre initialisation : la méthode [identifie] ne doit donc pas utiliser de propriétés de la classe dérivée, qui ne sont pas encore initialisées (une propriété non nullable pourrait même y valoir [null] !). C'est une pratique à manier avec précaution. Le compilateur Kotlin ne la signale pas ; c'est IntelliJ IDEA qui la souligne, par une inspection du code (« Leaking 'this' in constructor of non-final class »). Ici, c'est voulu et sans danger, car les méthodes [identifie] des classes dérivées n'utilisent aucune propriété.

La classe [ExceptionUtilisateurInconnu] est la suivante :

1
2
3
4
package utilisateurs

// une exception propre à l'application
class ExceptionUtilisateurInconnu(message: String) : Exception(message)
  • ligne 4 : elle dérive de la classe [Exception] (un alias de [java.lang.Exception]) et a un unique constructeur qui admet pour paramètre un message d'erreur, aussitôt passé au constructeur de la classe parent. Une ligne suffit. En Java, une classe dérivée d'[Exception] est une exception contrôlée : le compilateur vérifie que toute méthode qui peut la lancer la déclare par une clause [throws]. Kotlin ne fait pas cette vérification : toutes les exceptions s'y comportent comme les exceptions non contrôlées de Java. Si une méthode Kotlin doit être appelée depuis du code Java qui veut intercepter l'exception, on la signale à Java par l'annotation [@Throws(ExceptionUtilisateurInconnu::class)]. Le compilateur Kotlin n'exige pas non plus de numéro de version [serialVersionUID].

Nous dérivons maintenant la classe [Utilisateur] dans la classe fille [Administrateur] :

package utilisateurs

class Administrateur(login: String, motDePasse: String) : Utilisateur(login, motDePasse) {
    // identification
    override fun identifie(): String {
        // identification LDAP
        // ...
        return "admin"
    }
}
  • ligne 3 : le constructeur primaire se contente de passer à sa classe parent les paramètres qu'il reçoit : [: Utilisateur(login, motDePasse)]. Les paramètres [login] et [motDePasse] ne sont pas précédés de [val] : ce sont de simples paramètres, les propriétés correspondantes étant déclarées dans la classe parent ;
  • lignes 5-8 : la méthode [identifie] de la classe [Administrateur]. On suppose qu'un administrateur est identifié par un annuaire LDAP. Cette méthode implémente la méthode abstraite [identifie] de sa classe parent. Elle garde l'accès [protected] de la méthode qu'elle redéfinit : en Kotlin, une méthode redéfinie hérite par défaut de la visibilité de la méthode redéfinie (elle pourrait l'élargir, pas la restreindre). Son type de résultat est [String] et non [String?] : une méthode redéfinie peut rendre un type plus précis que la méthode qu'elle redéfinit, et un [String] est un cas particulier de [String?].

Les classes [Observateur] et [Inconnu] dérivent elles aussi de la classe [Utilisateur]. Elles ne diffèrent de la classe [Administrateur] que par leur méthode [identifie]. Celle de la classe [Observateur] suppose qu'un observateur est identifié par une base de données et rend le rôle « observateur » :

1
2
3
4
5
6
    // identification
    override fun identifie(): String {
        // identification par une base de données
        // ...
        return "observateur"
    }

Celle de la classe [Inconnu] rend la référence [null] pour indiquer que l'utilisateur n'a pas été reconnu ; son type de résultat est donc [String?] :

1
2
3
4
5
6
    // identification
    override fun identifie(): String? {
        // utilisateur pas connu
        // ...
        return null
    }

Au final, les objets [Administrateur], [Observateur] et [Inconnu] sont initialisés par le même code, celui de la classe parent [Utilisateur]. Celui-ci utilise la méthode [identifie] que ces classes fournissent. Un programme de test pourrait être le suivant [Main.kt] :

package utilisateurs

fun main() {
    try {
        println(Observateur("observer", "mdp1"))
        println(Administrateur("admin", "mdp2"))
        println(Inconnu("xx", "yy"))
    } catch (e: ExceptionUtilisateurInconnu) {
        println("Utilisateur non connu : ${e.message}")
    }
    // val u = Utilisateur("x", "y")   // refusé : la classe Utilisateur est abstraite
}

On notera que lignes 5 et 6, c'est la méthode [Utilisateur.toString()] qui est utilisée par la fonction [println]. Ligne 7, le constructeur lance une exception [ExceptionUtilisateurInconnu], interceptée ligne 8 : l'objet n'est pas créé et rien n'est affiché par [println]. Le bloc [try] n'est pas exigé par le compilateur, comme il l'était en Java : sans lui, l'exception arrêterait le programme. La ligne 11, si on la décommente, est refusée par le compilateur, qui donne deux raisons :

Main.kt:11:13: error: cannot access 'constructor(login: String, motDePasse: String): Utilisateur': it is protected in 'utilisateurs.Utilisateur'.
Main.kt:11:13: error: cannot create an instance of an abstract class.

on ne peut pas appeler un constructeur protégé hors des classes dérivées, et surtout on ne peut pas créer d'objet d'une classe abstraite.

Résultats :

1
2
3
4
> gradlew -q run -Pmain=utilisateurs.MainKt
Utilisateur[observer,mdp1,observateur]
Utilisateur[admin,mdp2,admin]
Utilisateur non connu : [xx,yy]

Une classe abstraite ressemble à une interface : toutes deux déclarent des membres que d'autres classes devront implémenter. Mais une classe abstraite peut avoir un état (des propriétés mémorisées), des constructeurs, et du code commun à ses classes dérivées ; en contrepartie, une classe ne peut dériver que d'une seule classe abstraite, alors qu'elle peut implémenter plusieurs interfaces. On combine souvent les deux : une interface définit le contrat, une classe abstraite qui l'implémente factorise le code commun à plusieurs implémentations. C'est ce que fera l'application exemple de ce chapitre. Rappelons enfin que la classe scellée ([sealed class]), vue plus haut, est une classe abstraite dont les classes dérivées sont toutes connues du compilateur : nous l'utiliserons plus loin avec l'expression [when].

4.8. Les classes, interfaces et fonctions génériques

4.8.1. Classes et fonctions génériques

Supposons qu'on veuille écrire une classe [PaireEntiers] qui mémorise deux entiers et sait les échanger : deux propriétés [var premier: Int] et [var second: Int], et une méthode [echanger()] qui permute leurs valeurs au moyen d'une variable temporaire [val temp]. Si on voulait ensuite une paire de chaînes, puis une paire de personnes, on écrirait deux autres classes [PaireChaines] et [PairePersonnes], identiques à la précédente au type près : [String] ou [Personne] au lieu de [Int].

Les classes, interfaces et fonctions génériques répondent au besoin de code qui ne diffère que par le type de certaines données. Kotlin les a depuis sa première version ; elles reposent sur les génériques de la JVM, apparus avec Java 5 (2004), dont elles partagent les forces et certaines limites (l'effacement de type, plus loin), avec quelques améliorations notables (la variance de déclaration, les types réifiés). On notera que Kotlin, comme Java, n'a pas de passage de paramètres par référence : une fonction ne peut pas échanger les valeurs de deux variables de l'appelant, mais elle peut échanger deux éléments d'un objet, une paire ou un tableau.

Le programme [Classes18.kt] présente une classe générique, une fonction générique et une seconde classe générique :

package classes18

// classes et fonctions génériques
fun main() {
    // --- une classe générique Paire<T>
    // Int
    val pi = Paire<Int>(1, 2)
    pi.echanger()
    println("pi=$pi")
    // String : le type T est déduit des paramètres effectifs
    val ps = Paire("s1", "s2")
    ps.echanger()
    println("ps=$ps")
    // Personne
    val p1 = Personne("jean", "clu", 20)
    val p2 = Personne("pauline", "dard", 55)
    val pp = Paire(p1, p2)
    pp.echanger()
    println("pp=$pp")
    // le premier élément est de type Personne : pas de conversion
    val premiere: Personne = pp.premier
    println("premier élément : $premiere")

    // --- une fonction générique echanger<T>
    val entiers = arrayOf(1, 2, 3)
    echanger<Int>(entiers, 0, 2)
    println("entiers=${entiers.contentToString()}")
    // le compilateur déduit T du type des paramètres effectifs
    val personnes = arrayOf(p1, p2)
    echanger(personnes, 0, 1)
    println("personnes=${personnes.contentToString()}")

    // --- une classe générique avec un état : une pile d'éléments de type T
    val pile = Pile<Personne>()
    pile.empiler(p1)
    pile.empiler(p2)
    println("La pile contient ${pile.taille} personnes")
    val sommet = pile.depiler()
    println("Personne dépilée : $sommet, il en reste ${pile.taille}")
    // pile.empiler("x")   // refusé : la pile n'accepte que des objets Personne
}

// une classe générique : une paire de deux éléments de type T
class Paire<T>(premier: T, second: T) {
    // deux propriétés lisibles de l'extérieur, modifiables par la seule classe
    var premier = premier
        private set
    var second = second
        private set

    // on échange les deux éléments
    fun echanger() {
        val temp = premier
        premier = second
        second = temp
    }

    override fun toString() = "($premier,$second)"
}

// une fonction générique de haut niveau :
// elle échange les éléments n° i et j d'un tableau d'éléments de type T
fun <T> echanger(tableau: Array<T>, i: Int, j: Int) {
    val temp = tableau[i]
    tableau[i] = tableau[j]
    tableau[j] = temp
}

// une pile d'éléments de type T
class Pile<T> {
    // les éléments de la pile
    private val elements = mutableListOf<T>()

    // nombre d'éléments
    val taille: Int
        get() = elements.size

    // ajout d'un élément au sommet de la pile
    fun empiler(element: T) {
        elements.add(element)
    }

    // retrait de l'élément au sommet de la pile
    fun depiler(): T {
        check(elements.isNotEmpty()) { "pile vide" }
        return elements.removeLast()
    }
}

// une personne
data class Personne(val prenom: String, val nom: String, val age: Int) {
    override fun toString() = "[$prenom, $nom, $age]"
}
  • lignes 44-58 : la classe [Paire] est paramétrée par un type noté [T], placé entre chevrons après le nom de la classe. On peut lui donner le nom que l'on veut ; par convention, c'est une lettre majuscule ([T] pour type, [E] pour élément, [K] et [V] pour clé et valeur). Ce type [T] est ensuite utilisé dans la classe comme n'importe quel type : pour les paramètres du constructeur, les deux propriétés et la variable temporaire [temp] (ligne 53), dont le compilateur déduit le type [T]. On dit que la classe [Paire] est une classe générique ;
  • lignes 46-49 : deux propriétés initialisées par les paramètres du constructeur, de même nom. Elles sont déclarées [var] parce que la méthode [echanger] les modifie, mais leur accesseur [set] est privé ([private set]) : le code extérieur peut lire [pp.premier], il ne peut pas l'affecter. C'est l'équivalent des champs privés et des seuls accesseurs [getPremier] et [getSecond] de la version Java ;
  • lignes 52-55 : l'échange des deux éléments. Les amateurs d'idiomes Kotlin l'écrivent parfois en une ligne : [premier = second.also { second = premier }] ([also] rend la valeur de [second] après avoir exécuté l'affectation du bloc). La version avec une variable temporaire est plus lisible ;
  • ligne 7 : lorsqu'on utilise une classe générique paramétrée par des types T1, T2, …, ces derniers peuvent être précisés entre chevrons. Ici, [Paire<Int>] est une paire d'entiers. Contrairement à Java, qui exige [Paire<Integer>] et refuse [Paire<int>], Kotlin n'a qu'un type [Int] : c'est le compilateur qui utilise la classe enveloppe [java.lang.Integer] de la JVM là où un objet est nécessaire, comme ici ;
  • lignes 11 et 17 : le plus souvent, on ne précise pas le type : le compilateur le déduit des paramètres effectifs du constructeur, ici [String] puis [Personne]. C'est l'équivalent de l'opérateur « diamant » [new Paire<>(...)] de Java, en plus concis ;
  • ligne 21 : la propriété [premier] d'une [Paire<Personne>] est de type [Personne], sans conversion ;
  • lignes 63-66 : une fonction générique de haut niveau. Le paramètre de type [<T>] est déclaré entre le mot clé [fun] et le nom de la fonction. En Java, il fallait une classe [Generic2] pour héberger la méthode statique ; en Kotlin, une fonction peut exister hors de toute classe. Elle échange les éléments n° i et j d'un tableau d'éléments de type [T]. Le type [Array<T>] est le tableau d'objets de Kotlin (l'équivalent de [T[]] en Java) ;
  • ligne 26 : on appelle la fonction [echanger] en précisant entre [<>] le type [T]. En fait, le compilateur est capable de déduire [T] du type des paramètres effectifs. Aussi l'écriture de la ligne 30 est-elle légale, et c'est celle qu'on utilise en pratique. On notera que [arrayOf(1, 2, 3)] crée un [Array<Int>] (un tableau d'objets [Integer]), auquel s'applique la fonction ; un tableau [IntArray] (créé par [intArrayOf]), qui est un tableau d'entiers primitifs [int[]], ne serait pas accepté : comme en Java, un paramètre de type ne peut pas désigner un type primitif de la JVM ;
  • lignes 70-86 : une classe générique avec un état : une pile d'éléments de type [T]. On empile au sommet de la pile, on dépile depuis le sommet. Les éléments sont rangés dans une liste générique modifiable [MutableList<T>] (ligne 72), créée par la fonction générique [mutableListOf<T>()], elle-même paramétrée par le type [T] de la pile. Le nombre d'éléments est une propriété calculée [taille]. La fonction [removeLast] retire et rend le dernier élément de la liste. Une pile vide provoque une exception [IllegalStateException], l'exception prévue pour une opération demandée à un objet qui n'est pas dans l'état voulu : c'est précisément celle que lance la fonction [check(condition) { message }] de Kotlin lorsque la condition est fausse (ligne 85) ;
  • ligne 34 : une pile de personnes. Ici, le type doit être précisé, puisque rien ne permet au compilateur de le déduire. Le compilateur garantit qu'elle ne contiendra que des personnes : l'instruction de la ligne 40, décommentée, est refusée ([argument type mismatch: actual type is 'String', but 'Personne' was expected]). La méthode [depiler] rend un objet de type [Personne], sans conversion ;
  • lignes 91-92 : la personne est une classe de données dont on a redéfini la méthode [toString].

Résultats :

1
2
3
4
5
6
7
8
9
> gradlew -q run -Pmain=classes18.Classes18Kt
pi=(2,1)
ps=(s2,s1)
pp=([pauline, dard, 55],[jean, clu, 20])
premier élément : [pauline, dard, 55]
entiers=[3, 2, 1]
personnes=[[pauline, dard, 55], [jean, clu, 20]]
La pile contient 2 personnes
Personne dépilée : [pauline, dard, 55], il en reste 1

4.8.2. Les bornes des paramètres génériques

Dans la classe [Paire<T>], le compilateur ne sait rien du type [T] : il n'autorise sur les objets de type [T] que les méthodes de la classe [Any] ([toString], [equals], [hashCode]), et encore, à travers l'opérateur [?.] : par défaut, [T] peut en effet être un type nullable ! La borne implicite d'un paramètre générique est [Any?]. Pour pouvoir utiliser d'autres méthodes, on borne le paramètre générique en indiquant après le caractère deux-points le type dont il doit dériver :

Borne
Signification
[<T : Classe>]
T doit être la classe indiquée ou une classe dérivée
[<T : Interface>]
T doit implémenter l'interface indiquée
[<T> ... where T : I1, T : I2]
plusieurs bornes : elles sont listées dans une clause [where], placée avant le corps de la fonction ou de la classe
[<T : Comparable<T>>]
une borne qui utilise T lui-même : T doit être comparable à T
[<T : Any>]
T doit être un type non nullable

La syntaxe est celle de l'héritage : Java écrit [<T extends Classe>] et [<T extends Classe & I1 & I2>], C# [where T : Classe]. Comme en Java, il n'existe pas de borne pour exiger qu'il ait un constructeur sans paramètres : on ne peut pas écrire [T()] dans une classe générique (voir plus loin l'effacement de type). Le programme [Classes19.kt] utilise une borne :

package classes19

import kotlin.random.Random

// les bornes des paramètres génériques
fun main() {
    // --- T peut être n'importe quel type, y compris Int et un type nullable
    val pi = Paire(1, 2)
    println("pi=$pi")
    val pn = Paire<String?>("a", null)
    println("pn=$pn")

    // --- la borne T : IValeur
    // ordonner des paires d'objets de type Aleatoire
    repeat(5) {
        val paire = Paire(Aleatoire(), Aleatoire())
        print("Avant --> $paire")
        ordonner(paire)
        println("   Après --> $paire")
    }
    // ordonner une paire d'objets de type Chaine
    val chaines = Paire(Chaine("xxxxxxxxxxxxxx"), Chaine("xx"))
    println("Avant --> $chaines")
    ordonner(chaines)
    println("Après --> $chaines")
    // ordonner(pi)   // erreur : Int n'implémente pas IValeur
}

// une paire de deux éléments de type T
class Paire<T>(premier: T, second: T) {
    var premier = premier
        private set
    var second = second
        private set

    fun echanger() {
        val temp = premier
        premier = second
        second = temp
    }

    override fun toString() = "($premier,$second)"
}

// le type T doit implémenter IValeur
fun <T : IValeur> ordonner(paire: Paire<T>) {
    // si le 1er élément est plus grand que le 2e, on échange les éléments
    if (paire.premier.valeur > paire.second.valeur) {
        paire.echanger()
    }
}

// un objet ayant une valeur entière
interface IValeur {
    val valeur: Int
}

// un objet ayant une valeur aléatoire
class Aleatoire : IValeur {
    // valeur de l'objet
    override val valeur = generateur.nextInt(100)

    override fun toString() = valeur.toString()

    companion object {
        // générateur de nombres aléatoires partagé par toutes les instances
        // (initialisé avec une graine fixe pour que les résultats soient reproductibles)
        private val generateur = Random(2026)
    }
}

// un objet dont la valeur est la longueur d'une chaîne
class Chaine(private val s: String) : IValeur {
    override val valeur: Int
        get() = s.length

    override fun toString() = s
}
  • ligne 8 : une paire d'entiers. Contrairement à la version Java, il n'y a pas ici d'erreur à montrer : [Paire<Int>] est permis, et le compilateur emploie la classe [Integer] dans le code généré ;
  • ligne 10 : la borne implicite étant [Any?], le type [T] peut être un type nullable : la paire [pn] contient une chaîne et la valeur [null]. Pour l'interdire, on aurait déclaré [class Paire<T : Any>] ;
  • lignes 46-49 : la fonction générique [ordonner] exige que le type [T] implémente l'interface [IValeur] (lignes 54-55). Celle-ci a une propriété [valeur], utilisée ligne 48, qui donne la valeur de l'objet de type [T]. Sans la borne, le compilateur refuserait l'expression [paire.premier.valeur] : un type [T] quelconque n'a pas de propriété [valeur]. Les deux éléments de la paire ne sont échangés que si la valeur du premier est supérieure à celle du second : la fonction [ordonner] range les deux éléments par valeur croissante ;
  • lignes 59-68 : la classe [Aleatoire] implémente l'interface [IValeur]. La valeur d'une instance est un nombre aléatoire entre 0 et 99 (ligne 61), fourni par un générateur de nombres aléatoires de la classe [kotlin.random.Random] partagé par toutes les instances : il est placé dans l'objet compagnon de la classe (ligne 68), qui joue le rôle du champ statique de la version Java. Ce générateur est initialisé avec une « graine » fixe : il produit alors toujours la même suite de nombres, ce qui rend les résultats reproductibles. Ce n'est pas le même algorithme que celui de la classe [java.util.Random] : la suite obtenue n'est donc pas celle de la version Java. Sans besoin de reproductibilité, on utilise le générateur par défaut [Random.Default] (par exemple [Random.nextInt(100)]), ou, pour des usages de sécurité, la classe [java.security.SecureRandom] ;
  • lignes 73-77 : la classe [Chaine] implémente elle aussi l'interface [IValeur] : la valeur d'une instance est la longueur de la chaîne de caractères passée au constructeur, calculée par l'accesseur de la propriété ;
  • lignes 15-19 : des paires d'objets de type [Aleatoire] sont ordonnées. La fonction [repeat(5) { ... }] exécute cinq fois le bloc ;
  • lignes 22-25 : une paire d'objets de type [Chaine] est ordonnée ;
  • ligne 26 : si on décommente cette ligne, le compilateur signale que le type [Int] ne convient pas.

Résultats :

> gradlew -q run -Pmain=classes19.Classes19Kt
pi=(1,2)
pn=(a,null)
Avant --> (67,8)   Après --> (8,67)
Avant --> (73,41)   Après --> (41,73)
Avant --> (28,53)   Après --> (28,53)
Avant --> (26,93)   Après --> (26,93)
Avant --> (31,39)   Après --> (31,39)
Avant --> (xxxxxxxxxxxxxx,xx)
Après --> (xx,xxxxxxxxxxxxxx)

Voici les erreurs obtenues à la compilation une fois la ligne 26 décommentée :

Classes19.kt:26:5: error: cannot infer type for type parameter 'T'. Specify it explicitly.
Classes19.kt:26:14: error: argument type mismatch: actual type is 'Paire<Int>', but 'Paire<uninferred T (of fun <T : IValeur> ordonner)>' was expected.

Le compilateur a tenté de déduire le type [T] : la paire impose [T] = [Int], la borne impose que [T] implémente [IValeur] ; les deux contraintes étant incompatibles, il ne trouve pas de type [T] (« uninferred T »). Si l'on précise soi-même le type, [ordonner<Int>(pi)], le message est plus direct : [type argument is not within its bounds: type parameter 'T (of fun <T : IValeur> ordonner)' must be subtype of 'IValeur', but actual: 'Int'].

4.8.3. Des interfaces génériques : Comparable<T> et Comparator<T>

Pour illustrer la notion d'interface générique, nous allons trier une liste de personnes selon différents critères. Kotlin offre pour cela, sur les listes modifiables (et sur les tableaux), des fonctions qui trient la collection sur place, et, sur toutes les collections, des fonctions qui rendent une nouvelle liste triée sans toucher à l'originale. Nous en utiliserons plusieurs :

  • [liste.sort()] trie la liste selon l'ordre naturel de ses éléments : ceux-ci doivent implémenter l'interface [Comparable<T>], dont l'unique méthode [operator fun compareTo(other: T): Int] compare l'objet courant à l'objet [other]. C'est l'interface [java.lang.Comparable] de Java, vue par Kotlin ;
  • [liste.sortWith(comparateur)] trie la liste selon l'ordre défini par un objet comparateur, qui implémente l'interface [Comparator<T>] (un alias de [java.util.Comparator]), dont la méthode [fun compare(a: T, b: T): Int] compare deux objets ;
  • [liste.sortedWith(comparateur)], [liste.sortedBy { ... }], [liste.sortedByDescending { ... }] rendent une nouvelle liste triée.

Les méthodes [compareTo] et [compare] suivent la même règle : elles rendent un nombre positif si x > y, 0 si x == y, un nombre négatif si x < y. C'est au développeur de donner une signification aux opérateurs <, ==, >. Par exemple, si [p1] et [p2] sont deux objets [Personne], on pourra dire que p1 > p2 si le nom de [p1] suit le nom de [p2] dans l'ordre alphabétique. On aura alors un tri croissant selon le nom des personnes. Si on veut un tri selon l'âge, on dira que p1 > p2 si l'âge de [p1] est supérieur à l'âge de [p2]. Pour avoir un tri dans l'ordre décroissant, il suffit d'inverser le signe des résultats.

Nous en savons assez pour trier une liste de personnes. Le programme [Classes20.kt] est le suivant :

package classes20

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

// les interfaces génériques Comparable<T> et Comparator<T> : trier une liste de personnes
fun main() {
    val personnes = mutableListOf(
        Personne("claude", "pollon", 25),
        Personne("valentine", "germain", 35),
        Personne("paul", "germain", 32),
    )
    // affichage
    affiche("Liste à trier", personnes)
    // tri selon l'ordre naturel des personnes (Comparable) : l'âge
    personnes.sort()
    affiche("Liste après le tri selon l'âge", personnes)
    // tri selon les nom et prénom (Comparator)
    personnes.sortWith(CompareNoms())
    affiche("Liste après le tri selon les nom et prénom", personnes)
    // tri selon l'âge décroissant, avec un objet anonyme
    personnes.sortWith(object : Comparator<Personne> {
        override fun compare(p1: Personne, p2: Personne) = p2.age.compareTo(p1.age)
    })
    affiche("Liste après le tri selon l'âge décroissant", personnes)
    // l'opérateur > utilise la méthode compareTo de Comparable
    println("${personnes[0]} > ${personnes[1]} : ${personnes[0] > personnes[1]}")
    // des comparateurs fabriqués par compareBy et thenBy ; sortedWith rend une nouvelle liste
    val collator = Collator.getInstance(Locale.FRENCH)
    val parNoms = compareBy<Personne, String>(collator) { it.nom }.thenBy(collator) { it.prenom }
    affiche("Nouvelle liste triée selon les nom et prénom", personnes.sortedWith(parNoms))
    // un tri selon une seule propriété
    affiche("Nouvelle liste triée selon le prénom", personnes.sortedBy { it.prenom })
    // la liste d'origine n'a pas changé
    affiche("Liste d'origine", personnes)
}

// affichage d'une liste de personnes
fun affiche(texte: String, personnes: List<Personne>) {
    println(texte + "-".repeat(50 - texte.length))
    for (p in personnes) {
        println(p)
    }
}

// une personne, comparable à une autre personne
class Personne(val prenom: String, val nom: String, val age: Int) : Comparable<Personne> {
    // l'ordre naturel des personnes : l'ordre des âges
    override fun compareTo(other: Personne) = age.compareTo(other.age)

    override fun toString() = "[$prenom, $nom, $age]"
}

// comparaison des noms et prénoms de deux personnes
class CompareNoms : Comparator<Personne> {
    // comparaison de chaînes selon les règles de la langue française
    private val collator = Collator.getInstance(Locale.FRENCH)

    override fun compare(p1: Personne, p2: Personne): Int {
        // on compare les noms
        val i = collator.compare(p1.nom, p2.nom)
        if (i != 0) {
            return i
        }
        // égalité des noms : on compare les prénoms
        return collator.compare(p1.prenom, p2.prenom)
    }
}
  • lignes 8-11 : la liste modifiable de personnes. La version Java triait un tableau ; les mêmes fonctions [sort] et [sortWith] existent pour les tableaux Kotlin ;
  • lignes 47-51 : la classe [Personne] implémente l'interface générique [Comparable<Personne>] : ses objets sont comparables à d'autres personnes. Sa méthode [compareTo] (ligne 49) définit l'ordre naturel des personnes, ici l'ordre des âges. La méthode [a.compareTo(b)] des entiers rend un nombre négatif, nul ou positif selon que [a] est inférieur, égal ou supérieur à [b] : c'est exactement ce qu'attend la méthode [compareTo] (Java écrit [Integer.compare(a, b)]). Le paramètre s'appelle [other] dans l'interface ; on garde ce nom en la redéfinissant, faute de quoi le compilateur, qui permet les arguments nommés, émettrait un avertissement ;
  • ligne 16 : le tri selon l'ordre naturel ;
  • ligne 19 : le tri de la liste de personnes selon les nom et prénom. Le paramètre de la fonction [sortWith] est une instance de la classe [CompareNoms] implémentant l'interface générique [Comparator<Personne>] ;
  • lignes 55-66 : la classe [CompareNoms]. Sa méthode [compare] implémente la méthode [compare(a: T, b: T)] de l'interface [Comparator<T>], avec ici [T] = [Personne]. Les chaînes ne sont pas comparées par leur méthode [compareTo], qui suit l'ordre des codes des caractères (les majuscules avant les minuscules, les lettres accentuées après le z), mais par un objet [java.text.Collator] (ligne 57) qui applique les règles de tri de la langue française. Kotlin utilise ici directement une classe du JDK ;
  • lignes 22-24 : le tri selon l'âge décroissant. Le comparateur est cette fois un objet anonyme : l'expression [object : Comparator<Personne> { ... }] déclare une classe sans nom qui implémente l'interface [Comparator<Personne>] et crée aussitôt une instance de cette classe. C'est l'équivalent de la classe anonyme de Java ([new Comparator<Personne>() { ... }]). Le corps de l'objet fournit la méthode [compare]. Un objet anonyme évite de déclarer une classe qui ne sert qu'une fois ;
  • ligne 27 : la méthode [compareTo] de l'interface [Comparable] est déclarée [operator] : les opérateurs [<], [>], [<=], [>=] sont donc utilisables sur des personnes ([p1 > p2] est traduit en [p1.compareTo(p2) > 0]), comme on l'a vu plus haut avec la surcharge des opérateurs ;
  • lignes 29-30 : la bibliothèque de Kotlin fabrique des comparateurs : [compareBy(comparateur) { it.nom }] rend un comparateur de personnes qui compare leurs noms (la valeur calculée par la lambda) au moyen du comparateur indiqué, ici le [Collator] ; [thenBy(comparateur) { it.prenom }] lui adjoint un second critère, utilisé en cas d'égalité sur le premier. Les deux types de [compareBy] (l'élément et la clé) sont précisés entre chevrons, le compilateur ne pouvant pas les déduire de la lambda seule. Sans comparateur particulier, on écrit simplement [compareBy<Personne> { it.nom }.thenBy { it.prenom }] : les clés sont alors comparées selon leur ordre naturel. Le comparateur [parNoms] remplace avantageusement la classe [CompareNoms] ;
  • ligne 31 : la fonction [sortedWith] rend une nouvelle liste triée par le comparateur ; la liste [personnes] n'est pas modifiée ;
  • ligne 33 : la fonction [sortedBy] trie selon la clé calculée par la lambda, ici le prénom, dans son ordre naturel ([sortedByDescending] donnerait l'ordre décroissant) ;
  • ligne 35 : la liste d'origine n'a pas été modifiée par les fonctions [sortedWith] et [sortedBy] ;
  • ligne 40 : la fonction [repeat] répète une chaîne : la ligne de titre est complétée par des tirets jusqu'à 50 caractères.

Résultats :

> gradlew -q run -Pmain=classes20.Classes20Kt
Liste à trier-------------------------------------
[claude, pollon, 25]
[valentine, germain, 35]
[paul, germain, 32]
Liste après le tri selon l'âge--------------------
[claude, pollon, 25]
[paul, germain, 32]
[valentine, germain, 35]
Liste après le tri selon les nom et prénom--------
[paul, germain, 32]
[valentine, germain, 35]
[claude, pollon, 25]
Liste après le tri selon l'âge décroissant--------
[valentine, germain, 35]
[paul, germain, 32]
[claude, pollon, 25]
[valentine, germain, 35] > [paul, germain, 32] : true
Nouvelle liste triée selon les nom et prénom------
[paul, germain, 32]
[valentine, germain, 35]
[claude, pollon, 25]
Nouvelle liste triée selon le prénom--------------
[claude, pollon, 25]
[paul, germain, 32]
[valentine, germain, 35]
Liste d'origine-----------------------------------
[valentine, germain, 35]
[paul, germain, 32]
[claude, pollon, 25]

La version Java présentait à cette occasion trois sortes de classes déclarées à l'intérieur d'une autre. Kotlin a les mêmes, avec des noms différents : une classe déclarée dans une autre est par défaut imbriquée (l'équivalent de la classe [static class] de Java : elle n'est liée à aucun objet de la classe englobante) ; une classe interne, liée à un objet de la classe englobante, est déclarée avec le mot clé [inner] ([inner class]) ; enfin, l'expression [object : ...] crée un objet anonyme. Nous verrons au chapitre 7 qu'on peut faire encore plus court qu'un objet anonyme pour une interface qui n'a qu'une méthode abstraite, comme [Comparator] : une lambda, [personnes.sortWith { p1, p2 -> p2.age.compareTo(p1.age) }], ou, comme nous venons de le faire, un comparateur fabriqué à partir d'une lambda : [personnes.sortWith(compareByDescending { it.age })].

4.8.4. La variance, les projections et l'effacement de type

Une [MutableList<Int>] n'est pas une [MutableList<Number>], bien que [Int] dérive de [Number] : si elle l'était, on pourrait ajouter un [Double] à une liste d'entiers au travers d'une variable de type [MutableList<Number>]. On dit que le type [MutableList<T>] est invariant : une fonction qui reçoit une [MutableList<Number>] refuse une [MutableList<Int>]. En revanche, une liste en lecture seule [List<Int>] est une [List<Number>] : comme on ne peut rien y ajouter, le danger précédent n'existe pas, et tout entier lu dans la liste est bien un nombre. On dit que le type [List<T>] est covariant.

C'est une différence importante avec Java. En Java, toutes les interfaces génériques sont invariantes, et c'est à chaque utilisation qu'il faut écrire des jokers (« wildcards ») : [List<? extends Number>] pour une liste qu'on lit, [List<? super Integer>] pour une liste qu'on remplit. Kotlin permet d'indiquer la variance une fois pour toutes, à la déclaration de la classe ou de l'interface : c'est la variance de déclaration. L'interface [List] est déclarée [interface List<out E>] : le mot clé [out] signifie que le type [E] n'apparaît qu'en « sortie » (résultat des méthodes, propriétés [val]), ce qui rend le type covariant. Symétriquement, l'interface [Comparable] est déclarée [interface Comparable<in T>] : le type [T] n'y apparaît qu'en « entrée » (paramètre de [compareTo]), ce qui rend le type contravariant : un [Comparable<Number>] est un [Comparable<Int>] (qui sait comparer tout nombre sait comparer un entier). Le compilateur vérifie que la classe respecte ce qu'elle déclare.

Pour les types invariants comme [MutableList], Kotlin offre aussi des projections à l'utilisation, l'équivalent des jokers de Java :

Kotlin
Java
Signification
[MutableList<out Number>]
[List<? extends Number>]
une liste d'éléments d'un type inconnu dérivé de [Number] : on peut en lire des [Number], pas y ajouter d'élément
[MutableList<in Int>]
[List<? super Integer>]
une liste d'éléments d'un type inconnu dont [Int] dérive ([Int], [Number], [Any]) : on peut y ajouter des [Int]
[MutableList<*>]
[List<?>]
une liste d'éléments d'un type inconnu (projection étoilée) : on peut en lire des [Any?]

Le programme [Classes21.kt] illustre la variance, les projections et une particularité des génériques de la JVM, l'effacement de type, ainsi que la réponse partielle qu'y apporte Kotlin, les paramètres de type réifiés :

package classes21

// variance (out, in), projections, projection étoilée, effacement de type et types réifiés
fun main() {
    val entiers: List<Int> = listOf(1, 2, 3)
    val reels: List<Double> = listOf(1.5, 2.25)
    // List<out E> est covariante : une List<Int> est une List<Number>
    println("Somme d'entiers : ${somme(entiers)}")
    println("Somme de réels : ${somme(reels)}")
    // MutableList<in Int> : une liste modifiable qui peut recevoir des Int
    val nombres = mutableListOf<Number>()
    ajouterEntiers(nombres, 3)
    nombres.add(2.5)
    println("nombres=$nombres, somme=${somme(nombres)}")
    // MutableList<out Number> : une liste modifiable dont on ne fait que lire les éléments
    val entiersModifiables = mutableListOf(10, 20)
    println("Somme d'une liste modifiable : ${sommeModifiable(entiersModifiables)}")
    // une borne récursive : T doit être comparable à lui-même
    println("max : ${max(listOf("paul", "jean", "valentine"))}, ${max(entiers)}")
    // la variance de déclaration dans nos propres classes
    val boite: Boite<Number> = Boite(5)
    println("boîte : ${boite.contenu}")
    // la projection étoilée : une liste d'éléments d'un type inconnu
    afficherTaille(listOf("a", "b"))
    afficherTaille(entiersModifiables)
    // l'effacement de type : à l'exécution, une liste est une liste
    val l1 = ArrayList<String>()
    val l2 = ArrayList<Int>()
    println("même classe : ${l1.javaClass == l2.javaClass} (${l1.javaClass.name})")
    // les paramètres de type réifiés des fonctions inline
    val melange = listOf(1, "deux", 3.0, "quatre", 5)
    println("chaînes : ${compter<String>(melange)}, entiers : ${compter<Int>(melange)}")
    println("les chaînes : ${melange.filterIsInstance<String>()}")
    println("nom du type : ${nomDuType<Double>()}")
}

// somme des éléments d'une liste de nombres (List est covariante)
fun somme(valeurs: List<Number>): Double {
    var somme = 0.0
    for (valeur in valeurs) {
        somme += valeur.toDouble()
    }
    return somme
}

// somme des éléments d'une liste modifiable de nombres (MutableList est invariante)
fun sommeModifiable(valeurs: MutableList<out Number>): Double {
    // valeurs.add(1)   // refusé : le type exact des éléments est inconnu
    return valeurs.sumOf { it.toDouble() }
}

// ajout des entiers 1, 2, ..., n à une liste capable de recevoir des Int
fun ajouterEntiers(liste: MutableList<in Int>, n: Int) {
    for (i in 1..n) {
        liste.add(i)
    }
}

// le plus grand élément d'une liste non vide
fun <T : Comparable<T>> max(elements: List<T>): T {
    var max = elements.first()
    for (element in elements) {
        if (element > max) {
            max = element
        }
    }
    return max
}

// une classe covariante : T n'apparaît qu'en sortie (résultat de méthode, propriété val)
class Boite<out T>(val contenu: T)

// une liste dont on ignore le type des éléments
fun afficherTaille(liste: List<*>) {
    println("liste de ${liste.size} éléments, premier élément : ${liste.firstOrNull()}")
}

// le type T est connu à l'exécution grâce à reified (fonction inline)
inline fun <reified T> compter(elements: List<Any>) = elements.count { it is T }

inline fun <reified T : Any> nomDuType() = T::class.simpleName
  • lignes 38-43 : la fonction [somme] accepte une [List<Number>], donc, parce que [List] est covariante, une liste d'éléments de n'importe quel type dérivé de [Number] : [Int], [Double], [BigDecimal]… Elle lit les éléments comme des [Number] et utilise leur méthode [toDouble]. Kotlin, comme Java, n'a pas d'arithmétique générique : on ne peut pas écrire [a + b] pour deux objets de type [T] ou [Number], d'où le passage par [Double]. Là où Java devait écrire [List<? extends Number>], le simple type [List<Number>] suffit ;
  • lignes 8-9 : la même fonction additionne une liste de [Int] et une liste de [Double]. [listOf(...)] crée une liste en lecture seule ;
  • lignes 47-49 : une liste modifiable, elle, est invariante. Pour accepter une [MutableList<Int>] (ligne 17), la fonction déclare une projection [MutableList<out Number>]. Elle peut lire les éléments comme des [Number], mais l'ajout d'un élément (ligne 48) est refusé : le compilateur ne sait pas si c'est une liste d'[Int] ou de [Double] ([receiver type 'MutableList<out Number>' contains out projection which prohibits the use of 'fun add(element: E): Boolean']) ;
  • lignes 53-55 : la fonction [ajouterEntiers] accepte toute liste modifiable capable de recevoir des [Int], ici une [MutableList<Number>] (ligne 11). On retient la règle « producteur : [out], consommateur : [in] » : une liste dont on lit les éléments s'écrit avec [out], une liste qu'on remplit avec [in]. Les mots clés de Kotlin disent directement ce que Java exprime par [extends] et [super] ;
  • lignes 60-67 : la fonction générique [max] rend le plus grand élément d'une liste. La borne [T : Comparable<T>] exige que les éléments soient comparables entre eux ; elle s'applique aux chaînes comme aux entiers (ligne 19). Java doit écrire [T extends Comparable<? super T>] pour accepter aussi un type dont c'est la classe parent qui est comparable ; en Kotlin, l'interface [Comparable] étant contravariante ([in T]), [T : Comparable<T>] suffit. La fonction [first] rend le premier élément de la liste et l'opérateur [>] appelle [compareTo]. (La bibliothèque de Kotlin offre déjà la fonction [maxOf] et, sur les collections, [max()].) ;
  • ligne 71 : notre propre classe covariante. Le type [T] n'y apparaît qu'en sortie, comme type d'une propriété [val]. Ligne 21, une [Boite<Int>] est donc acceptée là où l'on attend une [Boite<Number>]. Si l'on ajoute à la classe une méthode [fun remplacer(nouveau: T)], où [T] apparaît en entrée, le compilateur refuse la classe : [type parameter 'T' is declared as 'out' but occurs in 'in' position in type 'T (of class Boite<out T>)'] ;
  • lignes 74-75 : la fonction [afficherTaille] accepte une liste d'éléments d'un type quelconque : [List<*>]. Elle ne peut rien supposer sur ses éléments, qu'elle lit comme des [Any?]. La fonction [firstOrNull] rend le premier élément, ou [null] si la liste est vide ;
  • lignes 27-29 : à l'exécution, une [ArrayList<String>] et une [ArrayList<Int>] sont deux objets de la même classe [java.util.ArrayList] ([javaClass] rend la classe Java d'un objet). Le compilateur vérifie les types génériques, puis les efface : le code compilé ne connaît que des [ArrayList] d'[Object], et des conversions sont insérées là où c'est nécessaire. Cet « effacement de type » (« type erasure »), choisi par Java en 2004 pour que le code générique reste compatible avec le code existant, s'applique aussi à Kotlin, qui produit du code pour la même JVM. Il explique plusieurs limites : on ne peut pas écrire [T()] ni tester [o is List<String>] ([cannot check for instance of erased type 'List<String>']) ; on peut en revanche tester [o is List<*>] ;
  • lignes 79-81 : Kotlin contourne en partie l'effacement de type pour les fonctions [inline]. Le code d'une fonction [inline] est recopié par le compilateur à chaque endroit où elle est appelée (nous y reviendrons au chapitre 7) : à cet endroit, le type [T] est connu. Si le paramètre de type est déclaré [reified] (« réifié »), la fonction peut alors l'utiliser comme un vrai type : tester [it is T] (ligne 79), ou obtenir sa classe [T::class] (ligne 81). Sans [reified], le test [it is T] est refusé : [cannot check for instance of erased type 'T (of fun <T> compter)'] ;
  • lignes 31-34 : utilisation des fonctions réifiées. On précise le type entre chevrons : [compter<String>(melange)] compte les chaînes de la liste. La bibliothèque de Kotlin utilise beaucoup ce mécanisme, par exemple dans la fonction [filterIsInstance<String>()], qui rend les éléments d'une collection qui sont des chaînes, sous le type [List<String>].

Résultats :

> gradlew -q run -Pmain=classes21.Classes21Kt
Somme d'entiers : 6.0
Somme de réels : 3.75
nombres=[1, 2, 3, 2.5], somme=8.5
Somme d'une liste modifiable : 30.0
max : valentine, 3
boîte : 5
liste de 2 éléments, premier élément : a
liste de 2 éléments, premier élément : 10
même classe : true (java.util.ArrayList)
chaînes : 2, entiers : 2
les chaînes : [deux, quatre]
nom du type : Double

Contrairement à d'autres langages comme C#, où les interfaces peuvent déclarer des membres statiques abstraits pour écrire des calculs génériques, Kotlin ne permet pas d'exiger d'un type [T] qu'il possède une méthode de « classe » : les membres de l'objet compagnon d'une interface s'appellent avec le nom de l'interface et ne sont pas hérités par les classes qui l'implémentent.

4.9. Les paquetages

Pour écrire une ligne à l'écran, nous utilisons la fonction [println(...)]. Si nous regardons sa définition dans la documentation de la bibliothèque de Kotlin (kotlinlang.org/api), nous découvrons qu'elle fait partie du paquetage (« package ») [kotlin.io]. Cela signifie que son nom complet est [kotlin.io.println] et qu'on pourrait écrire [kotlin.io.println(...)]. Un paquetage Kotlin contient des classes, mais aussi des fonctions, des propriétés et des objets de haut niveau. Plusieurs paquetages sont importés automatiquement dans tout fichier Kotlin : [kotlin.*] ([Any], [String], [Int]…), [kotlin.io.*] ([println], [readln]), [kotlin.collections.*] ([List], [listOf]…), [kotlin.text.*] ([StringBuilder], [Regex]…), [kotlin.ranges.*], [kotlin.sequences.*], [kotlin.comparisons.*] ([compareBy]…), [kotlin.annotation.*], et, sur la JVM, [java.lang.*] et [kotlin.jvm.*]. Pour les autres, on utilise une clause [import], placée en tête de fichier, après la déclaration du paquetage :

  • [import java.text.Collator] importe la classe [Collator] du paquetage [java.text] (sans point-virgule final, comme toutes les instructions Kotlin) ;
  • [import java.util.*] importe à la demande toutes les déclarations publiques du paquetage [java.util] ;
  • [import kotlin.math.sqrt] importe une fonction de haut niveau, qu'on peut alors utiliser sans son paquetage : [sqrt(2.0)]. Kotlin n'a pas besoin de la clause [import static] de Java : on importe de la même façon une classe, une fonction, une propriété, et même un membre statique d'une classe Java ([import java.lang.Math.PI]) ;
  • [import espacesdenoms.e2.Personne as PersonneE2] importe une déclaration sous un autre nom, un alias, valable dans ce seul fichier.

Lorsque le compilateur rencontre un nom simple, il le cherche dans le paquetage courant et parmi les déclarations importées. Une clause [import] n'ajoute rien au programme : elle évite seulement d'écrire les noms complets.

Lorsqu'on crée une classe, on la place dans un paquetage. Le but des paquetages est d'éviter les conflits de noms entre classes, lorsque celles-ci sont distribuées par exemple. Considérons deux entreprises E1 et E2 distribuant des classes empaquetées respectivement dans les archives [e1.jar] et [e2.jar]. Soit un client C qui achète ces deux ensembles de classes, dans lesquels les deux entreprises ont défini toutes deux une classe [Personne]. Si l'entreprise E1 prend soin de créer ses classes dans un paquetage appelé [e1] et l'entreprise E2 dans un paquetage appelé [e2], les deux classes [Personne] s'appelleront alors [e1.Personne] et [e2.Personne]. Le client pourra importer l'une d'elles et désigner l'autre par son nom complet, ou lui donner un alias. Le paquetage permet de lever l'ambiguïté. Par convention, les noms de paquetages sont en minuscules et commencent par le nom de domaine inversé de l'organisation qui les publie ([com.mysql.cj.jdbc], [org.hibernate], [kotlinx.coroutines]…) ; pour nos exemples, nous nous contenterons de noms courts.

Pour placer les déclarations d'un fichier dans un paquetage, on écrit en tête du fichier la déclaration [package nom]. C'est ce que nous faisons depuis le début de ce cours : chaque programme est dans un paquetage à son nom. Contrairement à Java, Kotlin n'impose pas de correspondance entre paquetages et dossiers, mais il la recommande, et IntelliJ IDEA signale un fichier mal rangé : les fichiers source du paquetage [espacesdenoms.e1] sont rangés dans un dossier [espacesdenoms/e1] à partir de la racine des sources ([src/main/kotlin]). Le paquetage [espacesdenoms] et ses deux sous-paquetages illustrent ces notions :

1
2
3
4
5
6
src/main/kotlin/espacesdenoms/
  Main.kt               le programme principal (paquetage espacesdenoms)
  e1/
    Personne.kt         la classe espacesdenoms.e1.Personne
  e2/
    Personne.kt         la classe espacesdenoms.e2.Personne

La première classe [Personne] [e1/Personne.kt] :

1
2
3
4
5
6
7
8
9
// les déclarations de ce fichier appartiennent au paquetage espacesdenoms.e1
package espacesdenoms.e1

class Personne(private val prenom: String, private val nom: String) {
    override fun toString() = "e1.Personne[$prenom, $nom]"
}

// une fonction de haut niveau appartient elle aussi à un paquetage
fun saluer(personne: Personne) = "Bonjour $personne"
  • ligne 2 : la déclaration du paquetage. Toutes les déclarations du fichier lui appartiennent : la classe [Personne] et la fonction de haut niveau [saluer] (ligne 9), dont le nom complet est [espacesdenoms.e1.saluer]. Sur la JVM, cette fonction est placée dans une classe [espacesdenoms.e1.PersonneKt], nommée d'après le fichier.

La seconde [e2/Personne.kt] :

// les déclarations de ce fichier appartiennent au paquetage espacesdenoms.e2
package espacesdenoms.e2

class Personne(val nom: String) {
    override fun toString() = "e2.Personne[$nom]"
}

// une constante visible dans tout le module, et seulement dans le module
internal const val VERSION = "e2-1.0"

// une fonction visible dans ce seul fichier
private fun outil() = "outil privé"
  • ligne 4 : une classe [Personne] homonyme de la précédente, mais dans un autre paquetage ;
  • ligne 9 : une constante de visibilité [internal] : elle n'est accessible qu'aux déclarations du même module. Pour Kotlin, un module est un ensemble de fichiers compilés ensemble : un projet (ou sous-projet) Gradle, un module IntelliJ IDEA… Ici, c'est le projet [04-classes] ;
  • ligne 12 : une fonction de haut niveau privée : elle n'est accessible qu'aux déclarations de son propre fichier. Une tentative de l'importer depuis [Main.kt] est refusée : [cannot access 'fun outil(): String': it is private in file].

Les deux classes sont publiques, la visibilité par défaut de Kotlin (une classe Java sans le mot clé [public] n'est accessible qu'aux classes de son propre paquetage). Résumons les quatre visibilités d'une déclaration de haut niveau :

Visibilité
Accessible depuis
[public] (défaut)
partout
[internal]
le même module
[private]
le même fichier
[protected]
(n'a de sens que pour les membres d'une classe : la classe et ses classes dérivées)

Kotlin n'a pas d'équivalent de la visibilité « paquetage » de Java, la visibilité par défaut des déclarations Java sans modifieur : le paquetage n'est en Kotlin qu'un espace de noms, et c'est le module qui délimite ce qu'une bibliothèque montre à ses utilisateurs. Le programme principal [Main.kt] est le suivant :

package espacesdenoms

// importation d'une classe d'un paquetage
import espacesdenoms.e1.Personne
// importation d'une fonction de haut niveau
import espacesdenoms.e1.saluer
// importation d'une classe sous un autre nom
import espacesdenoms.e2.Personne as PersonneE2
// importation d'une constante interne au module
import espacesdenoms.e2.VERSION
// importation d'une fonction de la bibliothèque de Kotlin
import kotlin.math.sqrt

fun main() {
    // Personne désigne espacesdenoms.e1.Personne, qui a été importée
    val p1 = Personne("Jean", "Dupont")
    // le nom complet d'une classe lève toute ambiguïté
    val p2 = espacesdenoms.e2.Personne("Durand")
    // l'alias PersonneE2 désigne la classe espacesdenoms.e2.Personne
    val p3 = PersonneE2("Martin")
    println("$p1 $p2 $p3")
    // une fonction de haut niveau du paquetage espacesdenoms.e1
    println(saluer(p1))
    // nom complet et paquetage d'une classe
    println("${Personne::class.qualifiedName}, ${PersonneE2::class.qualifiedName}")
    println("paquetage de la classe Personne : ${Personne::class.java.packageName}")
    // le module JPMS qui contient une classe
    println("e1.Personne est dans un module nommé : ${Personne::class.java.module.isNamed}")
    println("${String::class.java.name} est dans le module ${String::class.java.module.name}")
    // StringBuilder est connu sans importation
    val sb = StringBuilder("abc")
    println(sb.append("def"))
    // sqrt est connue grâce à l'importation
    println("sqrt(2)=${sqrt(2.0)}")
    // une déclaration internal est accessible dans tout le module
    println("version=$VERSION")
}
  • ligne 4 : importation de la classe [espacesdenoms.e1.Personne] ;
  • ligne 6 : importation de la fonction de haut niveau [saluer], comme on importe une classe ;
  • ligne 8 : importation de la classe [espacesdenoms.e2.Personne] sous le nom [PersonneE2]. Dans ce fichier, le nom [PersonneE2] désigne cette classe. Java n'a pas d'alias : on ne peut pas y donner un autre nom à une classe. (Kotlin permet aussi de déclarer un alias de type pour tout le paquetage, avec [typealias PersonneE2 = espacesdenoms.e2.Personne] : nous en avons vu des exemples, [Comparator] et [Exception] étant des alias de types Java) ;
  • ligne 10 : importation de la constante [internal], permise puisque [Main.kt] est dans le même module ;
  • ligne 12 : importation de la fonction [sqrt] du paquetage [kotlin.math] ;
  • ligne 16 : [Personne] désigne la classe [espacesdenoms.e1.Personne], qui a été importée ;
  • ligne 18 : le nom complet de la classe [espacesdenoms.e2.Personne] lève toute ambiguïté ;
  • ligne 20 : l'alias donne le même résultat, plus lisiblement ;
  • ligne 23 : appel de la fonction importée ;
  • lignes 25-26 : la propriété [qualifiedName] de la description Kotlin d'une classe ([Personne::class]) rend son nom complet, préfixé par son paquetage. La propriété [java] rend la description Java de la classe ([java.lang.Class]), dont la méthode [getPackageName], vue en Kotlin comme une propriété [packageName], rend le nom du paquetage ;
  • lignes 28-29 : la méthode [getModule] de la classe Java rend le module JPMS qui contient la classe (voir ci-dessous) ;
  • lignes 31-32 : la classe [StringBuilder] est connue sans importation (c'est un alias, défini dans le paquetage [kotlin.text], de la classe [java.lang.StringBuilder]) ;
  • ligne 34 : utilisation de la fonction importée ;
  • ligne 36 : utilisation de la constante [internal].

Résultats :

> gradlew -q run -Pmain=espacesdenoms.MainKt
e1.Personne[Jean, Dupont] e2.Personne[Durand] e2.Personne[Martin]
Bonjour e1.Personne[Jean, Dupont]
espacesdenoms.e1.Personne, espacesdenoms.e2.Personne
paquetage de la classe Personne : espacesdenoms.e1
e1.Personne est dans un module nommé : false
java.lang.String est dans le module java.base
abcdef
sqrt(2)=1.4142135623730951
version=e2-1.0

Si on remplace les importations de [Personne] par les deux importations à la demande [import espacesdenoms.e1.*] et [import espacesdenoms.e2.*], sans l'alias, le nom [Personne] devient ambigu. Les deux importations s'annulent et le compilateur ne trouve plus la classe :

Main.kt:7:14: error: unresolved reference 'Personne'.

On notera qu'une importation avec alias masque le nom d'origine dans le fichier : avec la clause [import espacesdenoms.e2.Personne as PersonneE2], le nom [Personne] ne peut plus désigner que la classe du paquetage [e1], même si les deux paquetages sont importés à la demande.

4.9.1. Les modules

Le mot module a deux sens pour un développeur Kotlin. Nous venons de voir le premier : le module Kotlin, ensemble de fichiers compilés ensemble, qui délimite la visibilité [internal]. Le second est celui de Java : depuis Java 9, les paquetages sont regroupés en modules (« Java Platform Module System », JPMS). Le JDK est découpé en modules : [java.base] contient les paquetages fondamentaux ([java.lang], [java.util], [java.math], [java.io]…), [java.sql] l'accès aux bases de données, [java.net.http] le client HTTP, etc. Un module JPMS déclare, dans un fichier [module-info.java], son nom, les modules dont il a besoin ([requires java.sql;]) et les paquetages qu'il rend accessibles aux autres modules ([exports impots.metier;]). Un projet Kotlin peut en déclarer un (le fichier [module-info.java], écrit en Java, est placé à côté des sources Kotlin, et doit alors requérir le module [kotlin.stdlib]). Les classes d'une application qui ne déclare pas de module JPMS, comme les nôtres, appartiennent au module sans nom (ligne 5 des résultats), qui a accès à tous les modules. C'est le cas de la plupart des applications, et de toutes celles de ce cours : les modules JPMS intéressent surtout les auteurs de bibliothèques et du JDK lui-même.

4.10. Les classes de données

Beaucoup de classes ne servent qu'à transporter des données : une personne, une note, une tranche d'impôt… Pour elles, on écrit toujours le même code : des propriétés en lecture seule, une méthode [toString] qui les affiche, et les méthodes [equals] et [hashCode]. Le constructeur primaire de Kotlin évite déjà d'écrire les champs, le constructeur et les accesseurs ; le mot clé [data], placé devant [class], demande en plus au compilateur de générer le reste. Les classes de données (« data classes ») existent depuis la première version de Kotlin (2016) ; Java les a rattrapées, avec ses records, en 2021. Nous les avons rencontrées plus haut (paragraphe « Les objets immuables »), et à plusieurs reprises depuis ; le programme [Classes22.kt] les présente plus complètement :

package classes22

// les classes de données (data class)
fun main() {
    val r1 = Personne("Jean", "Dupont", 30)
    val r2 = Personne("Jean", "Dupont", 30)
    // toString généré par le compilateur
    println(r1)
    // les propriétés sont celles déclarées par le constructeur primaire
    println("prénom=${r1.prenom}, identité=${r1.identite()}")
    // égalité par valeur : r1 et r2 sont deux objets différents mais égaux
    println("r1 == r2 : ${r1 == r2}, r1 === r2 : ${r1 === r2}, hashCode égaux : ${r1.hashCode() == r2.hashCode()}")
    // les propriétés val sont en lecture seule
    // r1.age = 31   // refusé : age est une propriété val
    // une copie modifiée, avec la méthode copy et un argument nommé
    val r3 = r1.copy(age = 31)
    println(r3)
    println("r1 == r3 : ${r1 == r3}")
    // déstructuration en variables, grâce aux fonctions component1(), component2()...
    val (prenom, nom, age) = r1
    println("prénom=$prenom, nom=$nom, âge=$age")
    println("component2()=${r1.component2()}")

    // la même chose avec une classe ordinaire
    val c1 = PersonneC("Jean", "Dupont", 30)
    val c2 = PersonneC("Jean", "Dupont", 30)
    println(c1)
    println("c1 == c2 : ${c1 == c2}")

    // deux classes de données qui implémentent la même interface
    val e = Enseignant("Lucile", "Dumas", 56, 61)
    println(e)
    println(e.identite())
    // l'égalité tient compte du type : une Personne n'est pas égale à un Enseignant
    val pe = Personne("Lucile", "Dumas", 56)
    println("pe == e : ${pe == e}")
    // le type commun : l'interface AvecIdentite
    val identites = listOf(pe, e)
    for (i in identites) {
        println(i.identite())
    }
}

// une interface implémentée par les classes de données
interface AvecIdentite {
    fun identite(): String
}

// une classe de données : prenom, nom, age sont ses propriétés
data class Personne(val prenom: String, val nom: String, val age: Int) : AvecIdentite {
    // on peut ajouter des méthodes
    override fun identite() = "[$prenom, $nom, $age]"
}

// une classe de données ne peut pas être dérivée (elle est finale)
data class Enseignant(val prenom: String, val nom: String, val age: Int, val section: Int) : AvecIdentite {
    override fun identite() = "Enseignant[[$prenom, $nom, $age],$section]"
}

// une classe ordinaire équivalente... ou presque
class PersonneC(val prenom: String, val nom: String, val age: Int)
  • lignes 50-52 : la classe de données [Personne]. La déclaration [data class Personne(val prenom: String, val nom: String, val age: Int)] génère, à partir des propriétés déclarées dans le constructeur primaire :
    • trois propriétés en lecture seule [prenom], [nom], [age] (ce que fait aussi une classe ordinaire), lues par leur nom : [r1.prenom] (ligne 10) ;
    • une méthode [toString] qui affiche le nom de la classe et ses propriétés (ligne 1 des résultats) ;
    • les méthodes [equals] et [hashCode], qui comparent les valeurs des propriétés et non les références ;
    • une méthode [copy], qui crée une copie de l'objet dont on peut changer certaines propriétés ;
    • des méthodes [component1], [component2], [component3], qui rendent les propriétés dans l'ordre de leur déclaration et permettent la déstructuration ;
  • le constructeur primaire d'une classe de données doit avoir au moins un paramètre, et tous ses paramètres doivent être des propriétés ([val] ou [var]). On peut déclarer des propriétés [var] : la classe de données est alors modifiable. C'est permis mais déconseillé, car un objet dont le [hashCode] change est perdu dans un ensemble ou un dictionnaire qui le contient : on réserve les classes de données aux données immuables ;
  • ligne 52 : une classe de données peut, comme une classe, recevoir des méthodes supplémentaires et implémenter des interfaces (ligne 50) ;
  • ligne 12 : [r1] et [r2] sont deux objets distincts ([===], l'égalité des références, rend [false]) mais ils sont égaux au sens de [==], qui appelle [equals], car leurs propriétés ont les mêmes valeurs. Leurs codes de hachage sont donc égaux. Pour une classe ordinaire, [equals] compare les références (ligne 28) ;
  • ligne 14 : décommentée, cette ligne est refusée : ['val' cannot be reassigned] ;
  • ligne 16 : une classe de données immuable ne se modifie pas : on en crée une copie modifiée. La méthode [copy] générée a un paramètre par propriété, dont la valeur par défaut est la valeur de la propriété dans l'objet copié : avec un argument nommé, on ne précise que les propriétés qui changent. C'est l'expression [with] qui manquait à Java, où il faut écrire à la main des méthodes « wither » ([avecAge(31)]) ;
  • ligne 20 : la déstructuration : les propriétés de [r1] sont extraites dans les variables [prenom], [nom] et [age]. Le compilateur traduit cette déclaration par [val prenom = r1.component1()], [val nom = r1.component2()], etc. : la correspondance se fait par la position et non par le nom (ligne 22, la méthode [component2] appelée directement). On peut ignorer une propriété avec le caractère [_] : [val (prenom, _, age) = r1]. C'est l'équivalent du motif de record de Java 21 ([r1 instanceof Personne(String prenom, String nom, int age)]) ; nous retrouverons la déstructuration avec le filtrage par motif ;
  • lignes 56-57 : une classe de données est finale : elle ne peut pas être dérivée (le mot clé [open] y est interdit). Contrairement à un record Java, elle peut en revanche elle-même dériver d'une classe ouverte, abstraite ou scellée. Pour donner un type commun aux classes [Personne] et [Enseignant], on leur fait implémenter une même interface, ici [AvecIdentite] (lignes 45-46). La classe [Enseignant] reprend donc les propriétés de la personne ;
  • ligne 36 : l'égalité des classes de données tient compte du type réel des objets : une [Personne] n'est jamais égale à un [Enseignant], même si leurs propriétés communes sont égales ;
  • lignes 38-40 : l'interface commune permet de traiter les deux classes de façon polymorphe. Le type de la liste, [List<AvecIdentite>], est déduit par le compilateur : c'est le type commun le plus précis des deux éléments ;
  • ligne 61 : une classe ordinaire qui ressemble à la classe [Personne]. Grâce au constructeur primaire, elle tient elle aussi en une ligne… mais l'égalité par valeur, [toString], [copy] et la déstructuration ne sont pas fournis. En Java, il fallait en plus écrire les champs, le constructeur et les accesseurs.

Résultats :

> gradlew -q run -Pmain=classes22.Classes22Kt
Personne(prenom=Jean, nom=Dupont, age=30)
prénom=Jean, identité=[Jean, Dupont, 30]
r1 == r2 : true, r1 === r2 : false, hashCode égaux : true
Personne(prenom=Jean, nom=Dupont, age=31)
r1 == r3 : false
prénom=Jean, nom=Dupont, âge=30
component2()=Dupont
classes22.PersonneC@4554617c
c1 == c2 : false
Enseignant(prenom=Lucile, nom=Dumas, age=56, section=61)
Enseignant[[Lucile, Dumas, 56],61]
pe == e : false
[Lucile, Dumas, 56]
Enseignant[[Lucile, Dumas, 56],61]

On notera que la méthode [toString] de la classe [PersonneC] est celle de la classe [Any] : le nom complet de la classe suivi d'un code de hachage, qui change d'une exécution à l'autre (ligne 8 des résultats), et que l'égalité des deux objets [PersonneC] est fausse (ligne 9). Le format du [toString] d'une classe de données, [Personne(prenom=Jean, nom=Dupont, age=30)], diffère légèrement de celui d'un record Java, [Personne[prenom=Jean, nom=Dupont, age=30]].

Résumons les différences entre une classe ordinaire et une classe de données :

 
Classe
Classe de données
égalité [==] ([equals])
même objet (sauf redéfinition)
mêmes valeurs des propriétés du constructeur primaire, même type
[toString]
nom de la classe et code de hachage
nom de la classe et propriétés
propriétés, constructeur
déclarés par le constructeur primaire
déclarés par le constructeur primaire (au moins une propriété, que des propriétés)
état
modifiable ou non
immuable avec [val] (recommandé) ; d'autres propriétés peuvent être déclarées dans le corps
copie modifiée
à écrire
[copy(prop = valeur)] générée
déstructuration
à écrire ([operator fun componentN])
générée
héritage
d'une classe, de plusieurs interfaces ; ouverte avec [open]
d'une classe, de plusieurs interfaces ; toujours finale

On utilise une classe de données pour une donnée définie par sa valeur (une personne lue dans une base, un message échangé sur le réseau, une tranche d'impôt) et une classe ordinaire pour un objet doté d'une identité et d'un comportement (un service de calcul, une connexion, une liste modifiable). Nous verrons au chapitre 11 que les entités Hibernate, qui ont une identité, ne sont pas des classes de données. Attention : une classe de données aux propriétés [val] est « superficiellement » immuable : si une propriété référence un objet modifiable, une [MutableList] par exemple, le contenu de cette liste reste modifiable. On utilise alors des propriétés de type [List], en lecture seule, initialisées avec une copie ([liste.toList()]). Pour les lecteurs qui viennent de Java : une classe de données est compilée en une classe Java ordinaire et finale (et non en un record) ; l'annotation [@JvmRecord] permet, si nécessaire, de la compiler en un vrai record Java.

4.11. Les constructeurs des classes de données

Le constructeur primaire d'une classe de données se contente de recopier ses paramètres dans les propriétés. On peut cependant vouloir vérifier ou normaliser ces paramètres. Java offre pour cela le constructeur compact des records, qui peut modifier les paramètres avant leur mémorisation. Kotlin n'a pas d'équivalent exact : les paramètres d'une classe de données sont ses propriétés, mémorisées telles quelles. On dispose en revanche des outils de toute classe : le bloc [init], pour vérifier, les constructeurs secondaires, et les fabriques de l'objet compagnon, pour normaliser. Le programme [Classes23.kt] les illustre :

package classes23

import kotlin.math.sqrt

// les constructeurs des classes de données : validation, constructeur secondaire, fabrique
fun main() {
    val p = Personne.creer("  Jean ", "Dupont", 30)
    println(p.identite())
    // une classe de données est immuable : vieillir rend une nouvelle personne
    val p2 = p.vieillir()
    println("${p.identite()} -> ${p2.identite()}")
    // un constructeur secondaire
    val inconnu = Personne("Martin")
    println(inconnu.identite())
    // les vérifications de la fabrique et du bloc init
    try {
        Personne.creer("Paul", " ", 20)
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    try {
        Personne.creer("Paul", "Martin", -4)
    } catch (ex: IllegalArgumentException) {
        println(ex.message)
    }
    // une classe de données avec une propriété calculée et un objet compagnon
    val pt = Point(3.0, 4.0)
    println("pt=$pt, distance à l'origine : ${pt.distance}, origine=${Point.ORIGINE}")
}

// le constructeur primaire est privé : on crée une personne avec la fabrique creer
// ou avec le constructeur secondaire ; copy est privée comme lui (@ConsistentCopyVisibility)
@ConsistentCopyVisibility
data class Personne private constructor(val prenom: String, val nom: String, val age: Int) {
    // le bloc init vérifie l'objet, quelle que soit la façon dont il est construit
    init {
        require(age >= 0) { "âge ($age) invalide" }
    }

    // un constructeur secondaire doit appeler le constructeur primaire
    constructor(nom: String) : this("?", verifier(nom, "nom"), 0)

    // une méthode qui rend une nouvelle personne
    fun vieillir() = copy(age = age + 1)

    fun identite() = "[$prenom, $nom, $age]"

    companion object {
        // la fabrique : elle vérifie et normalise les paramètres avant la construction
        fun creer(prenom: String, nom: String, age: Int) =
            Personne(verifier(prenom, "prénom"), verifier(nom, "nom"), age)

        // une fonction privée de vérification
        private fun verifier(valeur: String, libelle: String): String {
            require(valeur.isNotBlank()) { "$libelle ($valeur) invalide" }
            return valeur.trim()
        }
    }
}

data class Point(val x: Double, val y: Double) {
    // une propriété déclarée dans le corps de la classe : elle ne fait pas partie
    // des propriétés utilisées par toString, equals, hashCode, copy
    val distance: Double
        get() = sqrt(x * x + y * y)

    companion object {
        val ORIGINE = Point(0.0, 0.0)
    }
}
  • lignes 36-37 : le bloc [init] s'exécute à chaque construction, après l'initialisation des propriétés du constructeur primaire. On y vérifie l'objet : un âge négatif est refusé. La fonction [require(condition) { message }] lance une exception [IllegalArgumentException] avec le message calculé par la lambda si la condition est fausse : c'est l'exception prévue pour un paramètre invalide. Tous les constructeurs d'une classe passent par le constructeur primaire, et donc par le bloc [init] : il n'existe aucun moyen de créer une [Personne] d'âge négatif, pas même avec [copy], qui appelle elle aussi le constructeur primaire ;
  • ligne 34 : le bloc [init] ne peut pas modifier les propriétés [val] du constructeur primaire, qui sont déjà initialisées. Pour normaliser les paramètres (ici, ôter les espaces qui entourent le prénom et le nom), on rend le constructeur primaire privé ([private constructor]) et on offre à sa place une fabrique : la fonction [creer] de l'objet compagnon (lignes 50-51). Elle vérifie et normalise les paramètres avant d'appeler le constructeur primaire, qu'elle a le droit d'appeler puisqu'elle fait partie de la classe. On l'appelle avec le nom de la classe : [Personne.creer(" Jean ", "Dupont", 30)] (ligne 7) ;
  • ligne 33 : la méthode [copy] générée appelle le constructeur primaire ; publique, elle permettrait de contourner la fabrique ([p.copy(prenom = " x ")]). L'annotation [@ConsistentCopyVisibility] demande au compilateur de lui donner la visibilité du constructeur primaire, ici privée. Sans elle, Kotlin 2.4 émet l'avertissement [non-public primary constructor is exposed via the generated 'copy()' method of the 'data' class], en précisant que la visibilité de la méthode [copy] générée changera dans une version future (elle aura alors celle du constructeur) ;
  • ligne 41 : un constructeur secondaire, qui ne reçoit que le nom. Comme tout constructeur secondaire d'une classe dotée d'un constructeur primaire, il doit appeler celui-ci avec [: this(...)] ; il peut, pour calculer les paramètres, appeler les fonctions de l'objet compagnon, même privées ([verifier]). Un constructeur secondaire peut être public quand le constructeur primaire est privé ;
  • ligne 44 : une classe de données est immuable : vieillir, c'est rendre une nouvelle personne, que crée la méthode [copy] (privée, mais utilisable dans la classe) ;
  • lignes 54-56 : une fonction privée de vérification, dans l'objet compagnon. [isNotBlank()] est vrai si la chaîne contient autre chose que des espaces ; [trim()] rend la chaîne débarrassée de ses espaces de début et de fin. Le message d'erreur est construit par un gabarit de chaîne, dans la lambda passée à [require] : il n'est calculé qu'en cas d'erreur ;
  • lignes 61-68 : une classe de données peut avoir, en plus des propriétés de son constructeur primaire, des propriétés déclarées dans son corps, ici la propriété calculée [distance]. Celles-ci ne participent ni à [toString], ni à [equals], ni à [hashCode], ni à [copy]. Elle peut aussi avoir un objet compagnon, ici avec la propriété [ORIGINE], équivalent du champ statique de la version Java. (Contrairement à un record, une classe de données peut même avoir des propriétés mémorisées dans son corps, mais ce n'est guère utile pour une donnée immuable.)

Résultats :

1
2
3
4
5
6
7
> gradlew -q run -Pmain=classes23.Classes23Kt
[Jean, Dupont, 30]
[Jean, Dupont, 30] -> [Jean, Dupont, 31]
[?, Martin, 0]
nom ( ) invalide
âge (-4) invalide
pt=Point(x=3.0, y=4.0), distance à l'origine : 5.0, origine=Point(x=0.0, y=0.0)

Le prénom, fourni entouré d'espaces (ligne 7), a été mémorisé sans eux (lignes 1 et 2 des résultats). Le bloc [init] est le moyen idiomatique de garantir qu'un objet est toujours valide ; une fabrique, avec un constructeur privé, permet en plus de normaliser les données.

4.12. Les fonctions d'extension

Comment ajouter une méthode à une classe qu'on ne peut pas modifier, la classe [String] par exemple ? Java n'a pas de réponse dans le langage : on y écrit des méthodes statiques, rassemblées dans une classe utilitaire ([Arrays], [Collections], [Objects], [Math]…), et on les appelle avec le nom de leur classe, en leur passant l'objet en paramètre : [Outils.initiales(p)]. Kotlin, comme C# avec ses méthodes d'extension, propose les fonctions d'extension : des fonctions déclarées hors de la classe, qui s'utilisent comme des méthodes de la classe étendue : [p.initiales()]. Nous en utilisons depuis le début de ce cours : [toInt], [sumOf], [contentToString], [toMutableList], [repeat], [also]… sont des fonctions d'extension de la bibliothèque de Kotlin, qui enrichit ainsi les classes du JDK ([String], les tableaux, les collections) sans les modifier. Le programme [Classes24.kt] montre comment écrire les siennes :

package classes24

import java.time.DayOfWeek
import java.time.LocalDate

// les fonctions et propriétés d'extension
fun main() {
    val p = Personne("jean", "dupont", 30)
    // les fonctions d'extension s'appellent comme des méthodes de l'objet
    println(p.initiales())
    println("jean".premiereMajuscule())
    println("${p.nomComplet} est majeur : ${p.estMajeur}")
    println("\"il fait beau\" a ${"il fait beau".nbMots()} mots")
    // une fonction de l'objet compagnon de la classe elle-même
    val inconnue = Personne.inconnue()
    println("${inconnue.nomComplet} est majeur : ${inconnue.estMajeur}")
    // une extension d'une classe Java
    val date = LocalDate.of(2026, 10, 4)
    println("le $date est un jour de week-end : ${date.estWeekEnd}")
    // une extension d'un type nullable : appelable sur null
    val absente: Personne? = null
    println("${p.description()} / ${absente.description()}")
    // la résolution des extensions est statique : c'est le type déclaré qui compte
    val forme: Forme = Carre()
    println("forme.nom()=${forme.nom()}, Carre().nom()=${Carre().nom()}")
    // ...alors que l'appel d'une méthode redéfinie est polymorphe
    println("forme.categorie()=${forme.categorie()}")
}

// une personne
data class Personne(val prenom: String, val nom: String, val age: Int) {
    companion object {
        // fabrique : une personne inconnue
        fun inconnue() = Personne("?", "?", 0)
    }
}

// des fonctions d'extension de la classe Personne
fun Personne.initiales() = "${prenom[0].uppercaseChar()}.${nom[0].uppercaseChar()}."

// des propriétés d'extension (calculées : une extension ne peut pas mémoriser de valeur)
val Personne.estMajeur: Boolean
    get() = age >= 18

val Personne.nomComplet: String
    get() = "${prenom.premiereMajuscule()} ${nom.uppercase()}"

// des fonctions d'extension de la classe String
fun String.premiereMajuscule() = if (isEmpty()) this else this[0].uppercaseChar() + substring(1)

fun String.nbMots() = if (isBlank()) 0 else trim().split(Regex("\\s+")).size

// une propriété d'extension d'une classe du JDK
val LocalDate.estWeekEnd: Boolean
    get() = dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY

// une fonction d'extension d'un type nullable
fun Personne?.description() = if (this == null) "personne absente" else "personne $prenom"

// résolution statique des extensions
open class Forme {
    open fun categorie() = "forme"
}

class Carre : Forme() {
    override fun categorie() = "carré"
}

fun Forme.nom() = "une forme"

fun Carre.nom() = "un carré"
  • ligne 39 : une fonction d'extension de la classe [Personne]. Le nom de la fonction est préfixé par le type étendu, appelé le type receveur ([Personne.]). Dans le corps de la fonction, [this] désigne l'objet sur lequel la fonction est appelée, le receveur, et ses membres sont accessibles sans préfixe, comme dans une méthode : [prenom], [nom]. [prenom[0]] rend le premier caractère de la chaîne, et [uppercaseChar()] le met en majuscule ;
  • lignes 42-46 : deux propriétés d'extension. Elles se déclarent comme les fonctions, avec un type receveur, et doivent avoir un accesseur [get] : une extension n'ajoute rien à l'objet, elle ne peut donc pas mémoriser de valeur (une propriété d'extension n'a pas de champ et ne peut pas avoir d'initialiseur). On les lit comme des propriétés : [p.estMajeur], [p.nomComplet] (ligne 12) ;
  • lignes 49-51 : deux extensions de la classe [String], utilisées comme des méthodes de toute chaîne : ["jean".premiereMajuscule()]. Dans [premiereMajuscule], [this] est la chaîne elle-même. La fonction [nbMots] découpe la chaîne avec la fonction [split], à laquelle on passe une expression régulière ([Regex]) : ["\\s+"] désigne une suite d'un ou plusieurs caractères d'espacement (les expressions régulières sont étudiées au chapitre 5) ;
  • lignes 54-55 : une propriété d'extension d'une classe Java du JDK, [java.time.LocalDate]. Une extension s'applique à n'importe quel type, y compris une classe Java ou une classe finale. On notera que la méthode [getDayOfWeek] de la classe Java est vue par Kotlin comme une propriété [dayOfWeek] ;
  • ligne 58 : une extension d'un type nullable, [Personne?]. Elle peut être appelée sur une référence [null] sans l'opérateur [?.] (ligne 22) : c'est elle qui teste si [this] vaut [null]. Après le test, le compilateur sait que [this] n'est pas [null] dans la branche [else] et autorise l'accès à [prenom] (c'est le transtypage intelligent, que le paragraphe suivant présente plus en détail). La fonction [toString()] de la bibliothèque de Kotlin, appelable sur une référence nulle, est déclarée ainsi : [fun Any?.toString(): String] ;
  • lignes 8-13 : les fonctions et propriétés d'extension s'appellent comme des membres : [p.initiales()] et non [Outils.initiales(p)] ;
  • lignes 32-34 : lorsqu'on peut modifier le type, on y place naturellement ses méthodes : la fabrique [inconnue] est une fonction de l'objet compagnon de la classe [Personne] (ligne 15) ;
  • lignes 61-71 : une classe [Forme] et sa classe dérivée [Carre], dotées chacune d'une méthode [categorie] redéfinie et d'une fonction d'extension [nom]. Ligne 25, la variable [forme], déclarée de type [Forme], référence un [Carre] : l'appel [forme.nom()] exécute pourtant l'extension de [Forme]. La résolution des extensions est statique : le compilateur choisit la fonction d'après le type déclaré de la variable, et non d'après le type réel de l'objet à l'exécution. Ligne 27, la méthode [categorie], elle, est polymorphe : c'est celle de la classe [Carre] qui est exécutée.

Résultats :

> gradlew -q run -Pmain=classes24.Classes24Kt
J.D.
Jean
Jean DUPONT est majeur : true
"il fait beau" a 3 mots
? ? est majeur : false
le 2026-10-04 est un jour de week-end : true
personne jean / personne absente
forme.nom()=une forme, Carre().nom()=un carré
forme.categorie()=carré

Ce comportement s'explique par la façon dont le compilateur traduit une fonction d'extension : en une méthode statique, dont le premier paramètre est le receveur. La fonction [fun Personne.initiales()] du fichier [Classes24.kt] devient la méthode Java [public static final String initiales(Personne $this$initiales)] de la classe [classes24.Classes24Kt] (la propriété d'extension [estMajeur] devient de même une méthode statique [getEstMajeur(Personne)]), et l'appel [p.initiales()] devient [Classes24Kt.initiales(p)]. Autrement dit, Kotlin écrit pour nous la classe utilitaire de Java, et rend son usage plus lisible. Un programme Java qui utilise une bibliothèque Kotlin appelle d'ailleurs ainsi ses extensions. Il en découle plusieurs règles :

  • une extension n'a accès qu'aux membres visibles du receveur : elle ne voit pas ses membres privés ou protégés ;
  • une extension ne peut pas être redéfinie dans une classe dérivée : elle n'est pas polymorphe ;
  • si la classe a un membre de même signature, c'est toujours le membre qui est appelé, et le compilateur avertit que l'extension ne sert à rien ([this extension is shadowed by a member: 'fun g(): Int' defined in 'C']) ;
  • pour être utilisée dans un autre paquetage, une extension doit être importée, comme une fonction de haut niveau ([import outils.initiales]).

Les fonctions d'extension sont l'un des traits les plus caractéristiques de Kotlin. On les utilise pour enrichir des types qu'on ne maîtrise pas, mais aussi pour séparer, dans ses propres classes, le cœur de la classe (ses membres) des fonctions utilitaires qui ne s'en servent que de l'extérieur. Nous verrons au chapitre 7 les lambdas avec receveur, qui reposent sur le même principe ([buildString { append(...) }]).

4.13. Le filtrage par motif sur les objets

L'expression [when] ne se limite pas aux valeurs simples (nombres, chaînes, énumérations) : elle s'applique aussi aux objets. On peut tester le type d'un objet, ajouter une condition, et utiliser l'objet dans le bon type sans conversion explicite, le tout en une seule expression. Kotlin n'a pas, à proprement parler, de « motifs » comme Java 21 : il obtient l'essentiel du même résultat avec l'opérateur [is], le transtypage intelligent (« smart cast ») et la déstructuration. Le programme [Classes25.kt] utilise une interface scellée et trois classes de données qui l'implémentent :

package classes25

// le filtrage par motif sur les objets : is, transtypage intelligent, when, déstructuration
fun main() {
    val objets: List<Any?> = listOf(
        Particulier("Jean", "Dupont", 30),
        Particulier("Lise", "Morel", 12),
        Enseignant("Lucile", "Dumas", 56, 61),
        Enseignant("Marc", "Petit", 44, 27),
        Etudiant("Paul", "Martin", 19, "informatique"),
        "une chaîne",
        42,
        null,
    )

    // 1 - le test de type is et le transtypage intelligent
    for (o in objets) {
        if (o is Personne) {
            // ici o est une Personne
            println("${o.prenom} est de type ${o::class.simpleName}")
        }
    }

    // 2 - le test de type suivi d'une condition
    for (o in objets) {
        if (o is Personne && o.age >= 18) {
            println("${o.prenom} est majeur")
        }
    }

    // 3 - when sur le type de l'objet, avec gardes et déstructuration
    for (o in objets) {
        println(decrire(o))
    }

    // 4 - un when exhaustif sur une interface scellée
    for (o in objets) {
        if (o is Personne) {
            println("${o.prenom} : ${categorie(o)}")
        }
    }

    // 5 - les transtypages explicites as et as?
    val premier = objets[0] as Personne
    val chaine = objets[0] as? String
    println("premier=${premier.prenom}, chaine=$chaine")
    try {
        val s = objets[0] as String
        println(s)
    } catch (ex: ClassCastException) {
        println("ClassCastException : ${ex.message}")
    }
}

fun decrire(o: Any?): String = when (o) {
    is Enseignant if o.section == 27 -> "${o.prenom} enseigne l'informatique"
    is Enseignant -> "${o.prenom} enseigne dans la section ${o.section}"
    is Etudiant -> {
        val (prenom, _, _, filiere) = o
        "$prenom étudie $filiere"
    }
    is Personne if o.age < 18 -> "${o.prenom} est mineur"
    is Personne -> "${o.prenom} a ${o.age} ans"
    is String -> "une chaîne de ${o.length} caractères"
    is Int if o > 0 -> "l'entier positif $o"
    null -> "la référence null"
    else -> "un objet inconnu"
}

// pas de else : le compilateur sait que les trois cas couvrent toutes les personnes
fun categorie(p: Personne) = when (p) {
    is Particulier -> "particulier"
    is Enseignant -> "enseignant"
    is Etudiant -> "étudiant"
}

// une interface scellée et les trois classes de données qui l'implémentent
sealed interface Personne {
    val prenom: String
    val nom: String
    val age: Int
}

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

data class Enseignant(
    override val prenom: String,
    override val nom: String,
    override val age: Int,
    val section: Int,
) : Personne

data class Etudiant(
    override val prenom: String,
    override val nom: String,
    override val age: Int,
    val filiere: String,
) : Personne
  • lignes 78-97 : une interface scellée [Personne] ([sealed interface]), implémentée par trois classes de données [Particulier], [Enseignant] et [Etudiant]. Comme une classe scellée (paragraphe « Les classes finales, ouvertes et scellées »), une interface scellée ne peut être implémentée que par des classes du même paquetage et du même module : le compilateur connaît donc toutes ses implémentations, sans qu'il faille les énumérer comme le fait la clause [permits] de Java. L'interface déclare les propriétés abstraites [prenom], [nom] et [age] ; les classes de données les implémentent par des propriétés de leur constructeur primaire, précédées de [override] ;
  • lignes 5-13 : une liste d'objets de types divers, y compris un entier et la référence [null]. Son type est [List<Any?>] : [Any?] est le type le plus général de Kotlin, qui accepte tout objet et la valeur [null] ;
  • ligne 18 : l'expression [o is Personne] est vraie si [o] référence un objet de type [Personne] (elle est fausse si [o] vaut [null]). Dans le bloc qui suit, le compilateur sait que [o] est une [Personne] : il autorise [o.prenom] sans conversion. C'est le transtypage intelligent : on teste le type d'une variable, et la variable elle-même change de type là où le test est garanti vrai. Java 16 obtient le même résultat en déclarant une nouvelle variable ([o instanceof Personne p]) ; Kotlin n'en a pas besoin. Le transtypage intelligent s'applique aux variables locales [val], aux variables [var] non modifiées entre le test et l'utilisation, et aux propriétés [val] du même module sans accesseur personnalisé : le compilateur doit être sûr que la valeur n'a pas pu changer entre le test et l'utilisation ;
  • ligne 26 : le transtypage intelligent vaut aussi dans la suite de la condition, après [&&] ;
  • lignes 55-67 : l'expression [when] avec sujet essaie les branches dans l'ordre et rend la valeur associée à la première qui convient :
    • ligne 56 : un enseignant de la section 27. Dans une branche [is Type], le sujet [o] est transtypé intelligemment ; la clause [if] ajoute une condition, appelée garde (Kotlin 2.2), l'équivalent du [when] des motifs de Java ;
    • ligne 57 : tout autre enseignant. L'ordre est important : placé avant le précédent, ce cas l'aurait rendu inaccessible. Le compilateur Kotlin ne le signale pas (Java le refuse) : la branche ne serait simplement jamais choisie ;
    • lignes 58-60 : un étudiant, dont les propriétés sont extraites par une déstructuration : [val (prenom, _, _, filiere) = o] appelle [component1()] et [component4()] de la classe de données ([_] ignore une propriété). C'est l'équivalent du motif de record de Java ([case Etudiant(var prenom, _, _, var filiere)]), en deux temps : le test de type, puis la déstructuration. Une branche peut contenir un bloc ; sa valeur est celle de la dernière expression du bloc ;
    • lignes 62-63 : une personne mineure, puis toute autre personne ;
    • ligne 64 : une chaîne, dont on peut utiliser la propriété [length] ;
    • ligne 65 : un entier positif. On teste le type [Int], et non [Integer] comme en Java ;
    • ligne 66 : le cas [null], une simple valeur, comparée au sujet par [==]. Sans ce cas, la référence nulle tomberait dans la branche [else] : un [when] sur une référence nulle ne lance jamais d'exception ;
    • ligne 67 : la branche [else] accepte tout le reste. Sans elle, le compilateur signalerait que l'expression [when] ne traite pas tous les cas : une expression [when], qui rend une valeur, doit être exhaustive ;
  • lignes 71-74 : un [when] sur une [Personne], sans [else]. Il est pourtant exhaustif : l'interface étant scellée, le compilateur sait qu'une personne est un [Particulier], un [Enseignant] ou un [Etudiant] ;
  • lignes 44-51 : les transtypages explicites. [o as Personne] convertit [o] en [Personne] et lance une exception [ClassCastException] si l'objet n'est pas une personne (ligne 48) ; c'est l'équivalent de [(Personne) o] en Java. [o as? String] est le transtypage « sûr » : il rend [null] au lieu de lancer l'exception, ce qui se combine bien avec l'opérateur Elvis ([o as? String ?: "défaut"]). Avec le transtypage intelligent, on en a rarement besoin.

Résultats :

> gradlew -q run -Pmain=classes25.Classes25Kt
Jean est de type Particulier
Lise est de type Particulier
Lucile est de type Enseignant
Marc est de type Enseignant
Paul est de type Etudiant
Jean est majeur
Lucile est majeur
Marc est majeur
Paul est majeur
Jean a 30 ans
Lise est mineur
Lucile enseigne dans la section 61
Marc enseigne l'informatique
Paul étudie informatique
une chaîne de 10 caractères
l'entier positif 42
la référence null
Jean : particulier
Lise : particulier
Lucile : enseignant
Marc : enseignant
Paul : étudiant
premier=Jean, chaine=null
ClassCastException : class classes25.Particulier cannot be cast to class java.lang.String (classes25.Particulier is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')

Si on supprime le cas [Etudiant] de la fonction [categorie], le compilateur le signale :

Classes25.kt:71:30: error: 'when' expression must be exhaustive. Add the 'is Etudiant' branch or an 'else' branch.

C'est le grand intérêt de l'association des types scellés et de l'expression [when] : si on ajoute une quatrième classe à la hiérarchie, le compilateur désigne tous les [when] à compléter. On notera aussi que le test [is] correspond aux types qui implémentent l'interface : la boucle du test de type (lignes 16-20) a retenu les trois sortes de personnes. Le [when] sur les types remplace avantageusement les cascades de [if (o is ...)] ; il ne doit cependant pas remplacer le polymorphisme : quand le comportement dépend du type de l'objet et que l'on maîtrise la hiérarchie de classes, une méthode redéfinie dans chaque classe reste souvent la solution orientée objet. Le filtrage par [when] convient bien aux hiérarchies de données (classes de données scellées) sur lesquelles on veut écrire des traitements extérieurs à ces données.

4.14. La gestion de la valeur null

En Java, toute variable de type référence peut valoir [null], et l'utilisation d'une référence nulle provoque l'exception [NullPointerException], la plus fréquente des erreurs d'exécution. Nous avons vu au chapitre 3 la réponse de Kotlin : le compilateur distingue les types qui peuvent valoir [null] ([String?]) de ceux qui ne le peuvent pas ([String]), et n'autorise l'accès aux membres d'une référence nullable qu'après un test, ou au travers des opérateurs [?.], [?:] et [!!]. Là où Java recourt à la classe [Optional<T>], aux méthodes [Objects.requireNonNull] et [Objects.requireNonNullElse], ou à des annotations comme celles de la bibliothèque JSpecify ([@Nullable], [@NullMarked]), Kotlin s'appuie sur le système de types lui-même. Nous approfondissons ici ces notions dans le cadre des classes : propriétés nullables, propriétés initialisées tardivement, et frontière avec le code Java. Le programme [Classes26.kt] reprend l'exemple de la version Java :

package classes26

// la gestion de la valeur null dans les classes : types nullables, ?., ?:, ?.let, requireNotNull, !!,
// lateinit, types plateformes
fun main() {
    val jean = Personne("Jean", "Dupont")
    jean.secondPrenom = "Marie"
    val personnes = listOf(jean, Personne("Paul", "Martin"))
    for (p in personnes) {
        // une valeur par défaut lorsque la référence est null : l'opérateur Elvis
        val second = p.secondPrenom ?: "(aucun)"
        // l'appel sûr ?. : null si la référence est null
        val longueur = p.secondPrenom?.length
        println("${p.prenom} : second prénom=$second, longueur=$longueur")
    }

    // une fonction qui peut ne rien trouver : elle rend un Personne?
    var trouvee = chercher(personnes, "Durand")
    println("Durand trouvé : ${trouvee != null}")
    println(trouvee?.prenom ?: "Durand : personne non trouvée")
    // on teste la référence avant de l'utiliser : transtypage intelligent
    trouvee = chercher(personnes, "Martin")
    if (trouvee != null) {
        println("Martin : ${trouvee.prenom}")
    }
    // ?.let : le bloc n'est exécuté que si la référence n'est pas null
    chercher(personnes, "Dupont")?.let { it.secondPrenom = "Louise" }
    chercher(personnes, "Durand")?.let { it.secondPrenom = "Louise" }
    println(jean.secondPrenom)
    // requireNotNull : on affirme que la référence n'est pas null, sinon une exception est lancée
    val martin = requireNotNull(chercher(personnes, "Martin")) { "Martin est introuvable" }
    println(martin.prenom)
    try {
        requireNotNull(chercher(personnes, "Durand")) { "Durand est introuvable" }
    } catch (ex: IllegalArgumentException) {
        println("Erreur : ${ex.message}")
    }
    // l'opérateur !! : une exception NullPointerException si la référence est null
    try {
        val lise = Personne("Lise", "Morel")
        println(lise.secondPrenom!!.length)
    } catch (ex: NullPointerException) {
        println("Erreur : $ex")
    }

    // une propriété lateinit : initialisée après la construction
    val paul = personnes[1]
    println("courriel de Paul initialisé : ${paul.aUnCourriel}")
    try {
        println(paul.courriel)
    } catch (ex: UninitializedPropertyAccessException) {
        println("Erreur : ${ex.message}")
    }
    paul.courriel = "paul.martin@exemple.fr"
    println("courriel de Paul : ${paul.courriel}, initialisé : ${paul.aUnCourriel}")

    // les types plateformes : une méthode Java peut rendre null
    val variable = System.getenv("VARIABLE_INEXISTANTE")
    println("variable=$variable, longueur=${variable?.length}")
    try {
        val valeur: String = System.getenv("VARIABLE_INEXISTANTE")
        println(valeur.length)
    } catch (ex: NullPointerException) {
        println("Erreur : ${ex.message}")
    }
}

// recherche d'une personne par son nom : null si elle n'est pas trouvée
fun chercher(personnes: List<Personne>, nom: String): Personne? = personnes.firstOrNull { it.nom == nom }

class Personne(val prenom: String, val nom: String) {
    // propriété facultative : null signifie « pas de second prénom »
    var secondPrenom: String? = null

    // propriété obligatoire, mais initialisée après la construction
    lateinit var courriel: String

    // la propriété lateinit a-t-elle été initialisée ?
    val aUnCourriel: Boolean
        get() = this::courriel.isInitialized
}
  • lignes 71-80 : la classe [Personne] a deux propriétés obligatoires, de type [String], initialisées par le constructeur primaire, et une propriété facultative [secondPrenom], de type [String?] : [null] signifie « pas de second prénom ». Le constructeur n'a pas besoin de vérifier ses paramètres comme le faisait la version Java avec [Objects.requireNonNull] : c'est le compilateur qui refuse un appel [Personne(null, "Morel")] ([null cannot be a value of a non-null type 'String']). L'erreur est détectée avant même l'exécution ;
  • ligne 11 : l'opérateur Elvis [?:] rend la valeur de gauche si elle ne vaut pas [null], celle de droite sinon. C'est l'équivalent de [Objects.requireNonNullElse(valeur, défaut)] ;
  • ligne 13 : l'appel sûr [?.] rend [null] si la référence vaut [null], et sinon la longueur de la chaîne : [longueur] est de type [Int?]. L'accès direct [p.secondPrenom.length] est refusé par le compilateur : [only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'] ;
  • ligne 69 : la fonction [chercher] rend la personne cherchée, ou [null] si elle ne la trouve pas : son type de résultat, [Personne?], avertit l'appelant que le résultat peut manquer, et le compilateur l'oblige à traiter ce cas pour utiliser la personne. C'est le rôle que joue [Optional<Personne>] en Java, sans objet supplémentaire. La fonction d'extension [firstOrNull] rend le premier élément de la liste qui vérifie la condition, ou [null] ;
  • ligne 19 : on teste la présence d'une valeur en la comparant à [null] (l'équivalent de [isPresent()]) ;
  • ligne 20 : la combinaison [?.] puis [?:] transforme la personne éventuellement présente en son prénom, et fournit une valeur à défaut. C'est l'équivalent de [trouvee.map(Personne::getPrenom).orElse(...)] ;
  • lignes 23-24 : après le test [trouvee != null], le transtypage intelligent fait de [trouvee] une [Personne] dans le bloc : on accède directement à [trouvee.prenom]. Le transtypage intelligent fonctionne ici bien que [trouvee] soit une variable [var], car elle n'est pas modifiée entre le test et l'utilisation ;
  • lignes 27-28 : [x?.let { ... }] n'exécute le bloc que si [x] ne vaut pas [null] ; dans le bloc, [it] désigne la valeur, de type non nullable. C'est l'équivalent de [ifPresent]. Dupont existe et reçoit un nouveau second prénom ; Durand n'existe pas et l'instruction ne fait rien ;
  • ligne 31 : [requireNotNull(valeur) { message }] rend la valeur si elle ne vaut pas [null], sous un type non nullable, et lance sinon une exception [IllegalArgumentException] avec le message indiqué (lignes 34-36). On l'utilise quand l'absence de valeur est une erreur : c'est l'équivalent de [orElseThrow]. Sa sœur [checkNotNull] lance une exception [IllegalStateException] ;
  • lignes 40-41 : l'opérateur [!!] affirme que la référence ne vaut pas [null] et lance l'exception [NullPointerException] si elle la vaut. Ici, l'exception n'a pas de message : on préfère [requireNotNull] ou [checkNotNull], qui en donnent un. L'opérateur [!!] est à réserver aux cas où l'on est sûr de soi… et où le compilateur ne peut pas le savoir ;
  • ligne 76 : une propriété [lateinit] (« initialisée tardivement »). Une propriété de type non nullable doit normalement être initialisée à la construction. Mais certaines valeurs ne sont connues qu'après : une dépendance injectée par Spring (chapitre 6), un objet préparé par une méthode d'initialisation de test, etc. Plutôt que de déclarer la propriété nullable, et de devoir écrire [?.] ou [!!] à chaque utilisation, on la déclare [lateinit var] : elle est de type [String] et s'utilise comme telle. Une propriété [lateinit] doit être une [var] d'un type non nullable et non primitif ([lateinit var n: Int] est interdit) ;
  • lignes 50-51 : la lecture d'une propriété [lateinit] avant son initialisation lance l'exception [UninitializedPropertyAccessException], dont le message nomme la propriété ; c'est plus clair qu'une [NullPointerException] survenue plus loin ;
  • lignes 79-80 : l'expression [this::courriel.isInitialized] indique si la propriété a été initialisée. [this::courriel] est une référence de propriété (chapitre 7) ; ce test n'est permis que dans la classe qui déclare la propriété ;
  • lignes 58-62 : la frontière avec Java. La méthode Java [System.getenv(nom)] rend la valeur d'une variable d'environnement, ou [null] si elle n'existe pas ; mais rien dans sa signature Java ne le dit au compilateur Kotlin. Son résultat a un type plateforme, noté [String!] dans les messages du compilateur et dans IntelliJ IDEA : un type dont Kotlin ignore s'il est nullable, et qu'il laisse le développeur utiliser comme un [String] ou comme un [String?], à ses risques. Ligne 58, la variable [variable] a le type plateforme, et on la traite prudemment avec [?.]. Ligne 61, on affirme qu'elle n'est pas nulle en la déclarant [String] : le compilateur insère alors une vérification, et l'exception [NullPointerException] est lancée dès l'affectation, avec un message qui nomme la méthode coupable ([getenv(...) must not be null]), plutôt que plus tard, lors d'une utilisation. Lorsque le code Java est annoté ([@Nullable], [@NonNull] de JSpecify ou de JetBrains), Kotlin en tient compte et le type n'est plus un type plateforme : c'est le cas d'une bonne partie de Spring Framework 7, que nous utiliserons.

Résultats :

> gradlew -q run -Pmain=classes26.Classes26Kt
Jean : second prénom=Marie, longueur=5
Paul : second prénom=(aucun), longueur=null
Durand trouvé : false
Durand : personne non trouvée
Martin : Paul
Louise
Paul
Erreur : Durand est introuvable
Erreur : java.lang.NullPointerException
courriel de Paul initialisé : false
Erreur : lateinit property courriel has not been initialized
courriel de Paul : paul.martin@exemple.fr, initialisé : true
variable=null, longueur=null
Erreur : getenv(...) must not be null

La classe [Optional] de Java reste utilisable en Kotlin, quand une bibliothèque Java en rend une (la fonction d'extension [getOrNull()] du paquetage [kotlin.jvm.optionals] la convertit alors en type nullable), mais on ne l'utilise pas dans le code Kotlin : un type nullable fait la même chose, vérifié par le compilateur, sans créer d'objet. Dans ce cours, on déclare nullables les seules valeurs facultatives, on préfère les objets toujours complets (construits avec toutes leurs valeurs obligatoires) aux objets remplis progressivement, on réserve [lateinit] aux propriétés initialisées par un cadriciel, et l'on traite avec prudence les valeurs venues de Java.

4.15. Application exemple - V2

On reprend le calcul de l'impôt déjà étudié dans le chapitre précédent (version 1) et on le traite maintenant en utilisant des classes et des interfaces. Rappelons le problème. On se propose d'écrire un programme permettant de calculer l'impôt d'un contribuable. On se place dans le cas simplifié d'un contribuable n'ayant que son seul salaire à déclarer (barème 2004 pour les revenus 2003, conservé tel quel : il s'agit d'un exercice) :

  • on calcule le nombre de parts du salarié : nbParts = nbEnfants / 2 + 1 s'il n'est pas marié, nbEnfants / 2 + 2 s'il est marié, où nbEnfants est son nombre d'enfants ;
  • s'il a au moins trois enfants, il a une demi-part de plus ;
  • on calcule son revenu imposable R = 0,72 × S où S est son salaire annuel ;
  • on calcule son quotient familial QF = R / nbParts ;
  • on calcule son impôt I à l'aide du tableau suivant :
limite
coeffR
coeffN
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

Chaque ligne a trois champs. Pour calculer l'impôt I, on recherche la première ligne où QF <= limite. Par exemple, si QF = 5000 on trouvera la ligne (8382 ; 0,068 ; 291,09). L'impôt I est alors égal à 0,068 × R - 291,09 × nbParts. Si QF est tel que la relation QF <= limite n'est jamais vérifiée, alors ce sont les coefficients de la dernière ligne qui sont utilisés, ce qui donne l'impôt I = 0,481 × R - 9505,54 × nbParts.

L'application forme le paquetage [impotsv2]. Les classes du calcul sont rangées dans un sous-paquetage [impotsv2.impots] :

1
2
3
4
5
6
7
src/main/kotlin/impotsv2/
  Main.kt                   le programme de dialogue avec l'utilisateur
  impots/
    TrancheImpot.kt         une tranche du barème
    IImpot.kt               l'interface du calcul de l'impôt
    AbstractImpot.kt        une classe abstraite implémentant IImpot
    HardwiredImpot.kt       une classe dérivée, avec des données écrites en dur

Tout d'abord, nous définissons un type capable d'encapsuler une ligne du tableau précédent [TrancheImpot.kt] :

1
2
3
4
5
6
package impotsv2.impots

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)

Nous utilisons une classe de données :

  • parce qu'une tranche est une petite donnée sans identité, définie par sa seule valeur, et qu'elle n'a aucune raison d'être modifiée une fois créée : ses propriétés sont des [val] ;
  • parce que le compilateur génère [toString], l'égalité, [copy] et la déstructuration à partir d'une seule ligne.

Les propriétés sont de type [BigDecimal] (paquetage [java.math], d'où l'importation de la ligne 3) : Kotlin utilise directement cette classe du JDK. Les types [Float] et [Double] représentent les nombres en binaire : un nombre décimal comme 0,068 n'y a pas de représentation exacte, et les calculs accumulent de petites erreurs d'arrondi. La classe [BigDecimal] représente exactement les nombres décimaux : c'est le type à utiliser pour les montants. En Java, son inconvénient est l'absence d'opérateurs : on y écrit [a.add(b)], [a.multiply(b)], [a.compareTo(b)]. La bibliothèque de Kotlin lui ajoute, par des fonctions d'extension déclarées [operator], les opérateurs [+], [-], [*], [/], [%] et le moins unaire ; et comme [BigDecimal] implémente [Comparable], les opérateurs de comparaison [<], [<=]… s'appliquent aussi. Nous allons en profiter, avec une exception, la division.

Puis nous définissons une interface [IImpot] capable de calculer l'impôt [IImpot.kt] :

1
2
3
4
5
6
package impotsv2.impots

interface IImpot {
    // calcul de l'impôt d'un contribuable
    fun calculer(marie: Boolean, nbEnfants: Int, salaire: Int): Int
}
  • ligne 5 : la méthode de calcul de l'impôt à partir de trois données : l'état marié ou non du contribuable, son nombre d'enfants, son salaire annuel. Elle rend l'impôt arrondi à l'euro inférieur.

Ensuite, nous définissons une classe abstraite implémentant cette interface [AbstractImpot.kt] :

package impotsv2.impots

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) + (if (marie) 2 else 1).toBigDecimal()
        if (nbEnfants >= 3) {
            nbParts += BigDecimal("0.5")
        }
        // calcul du revenu imposable et du quotient familial
        val revenu = BigDecimal("0.72") * salaire.toBigDecimal()
        val quotient = revenu.divide(nbParts, MathContext.DECIMAL128)
        // recherche de la tranche d'impôt : la dernière sert si aucune limite ne convient
        val tranches = tranchesImpot
        val tranche = tranches.dropLast(1).firstOrNull { quotient <= it.limite } ?: tranches.last()
        // calcul de l'impôt
        return (revenu * tranche.coeffR - nbParts * tranche.coeffN).toInt()
    }
}
  • ligne 1 : la classe appartient au paquetage [impotsv2.impots] ; son fichier est rangé dans le dossier [impotsv2/impots] ;
  • ligne 6 : la classe [AbstractImpot] implémente l'interface [IImpot] ;
  • ligne 9 : les données du barème, fournies par une propriété abstraite protégée (la version Java utilisait une méthode abstraite [getTranchesImpot()]). La classe [AbstractImpot] ne sait pas d'où viennent ces données (écrites dans le code, lues dans un fichier, une base de données…). Elle en laisse le soin aux classes dérivées. C'est pourquoi elle est déclarée abstraite, ce qui en interdit toute instanciation. La propriété abstraite oblige les classes dérivées à fournir les tranches : le compilateur le vérifie. Son type [List<TrancheImpot>] est celui d'une liste en lecture seule : la classe ne peut pas modifier le barème qu'on lui fournit ;
  • lignes 12-25 : l'implémentation de la méthode [calculer] de l'interface [IImpot]. Les classes dérivées n'auront pas à réécrire cette méthode. La classe [AbstractImpot] sert ainsi de classe de factorisation des classes dérivées : on y met ce qui est commun à toutes ;
  • ligne 14 : le nombre de parts. La fonction d'extension [toBigDecimal()] convertit un entier en [BigDecimal] (comme [BigDecimal.valueOf(n)]) ; la division par [BigDecimal.TWO] est exacte (3 / 2 = 1,5), contrairement à la division entière [nbEnfants / 2] (3 / 2 = 1). L'expression [if (marie) 2 else 1] vaut 2 pour un contribuable marié, 1 sinon. L'addition s'écrit avec l'opérateur [+]. La division, elle, est faite par la méthode [divide] de Java, et non par l'opérateur [/] : l'opérateur [/] de Kotlin sur les [BigDecimal] garde le nombre de décimales du dividende et arrondit le résultat ; [3.toBigDecimal() / BigDecimal.TWO] vaut 2, et non 1,5 ! C'est un piège connu, qu'il vaut mieux éviter en écrivant toujours les divisions de [BigDecimal] avec [divide], qui oblige à réfléchir à la précision voulue ;
  • lignes 15-16 : la demi-part supplémentaire. Un [BigDecimal] est immuable : [nbParts += x] est traduit en [nbParts = nbParts + x], qui crée un nouvel objet affecté à la variable ; c'est pourquoi [nbParts] est une [var] ;
  • lignes 19-20 : le revenu imposable et le quotient familial. On crée les constantes décimales à partir de chaînes ([BigDecimal("0.72")]) pour qu'elles soient exactes. La division d'un [BigDecimal] peut avoir un nombre infini de décimales (1 / 3) : on précise la précision voulue, ici [MathContext.DECIMAL128] (34 chiffres significatifs) ;
  • ligne 23 : la recherche de la tranche, en une ligne. [tranches.dropLast(1)] rend la liste des tranches sauf la dernière ; [firstOrNull { quotient <= it.limite }] y cherche la première dont la limite est supérieure ou égale au quotient familial (l'opérateur [<=] appelle [compareTo]), ou rend [null] s'il n'y en a pas ; l'opérateur Elvis donne alors la dernière tranche ([tranches.last()]). La version Java utilisait une boucle [for] et un [break] ; les deux écritures sont équivalentes ;
  • ligne 25 : le calcul de l'impôt, écrit avec les opérateurs [*] et [-], beaucoup plus lisible que [revenu.multiply(tranche.coeffR()).subtract(nbParts.multiply(tranche.coeffN()))]. La fonction [toInt] tronque le résultat à sa partie entière.

Une classe implémentant l'interface [IImpot] peut être construite en dérivant la classe [AbstractImpot]. C'est ce que nous faisons maintenant [HardwiredImpot.kt] :

package impotsv2.impots

class HardwiredImpot : AbstractImpot() {
    // les tranches d'impôt, construites à partir des données écrites en dur
    override val tranchesImpot: List<TrancheImpot> = LIMITES.indices.map { i ->
        TrancheImpot(LIMITES[i].toBigDecimal(), COEFF_R[i].toBigDecimal(), COEFF_N[i].toBigDecimal())
    }

    companion object {
        // données du calcul de l'impôt (barème 2004 sur les revenus 2003), écrites « en dur »
        private val LIMITES = doubleArrayOf(4962.0, 8382.0, 14753.0, 23888.0, 38868.0, 47932.0, 0.0)
        private val COEFF_R = doubleArrayOf(0.0, 0.068, 0.191, 0.283, 0.374, 0.426, 0.481)
        private val COEFF_N = doubleArrayOf(0.0, 291.09, 1322.92, 2668.39, 4846.98, 6883.66, 9505.54)
    }
}
  • lignes 9-13 : la classe [HardwiredImpot] définit en dur (« hardwired ») les données nécessaires au calcul de l'impôt. Elles sont placées dans l'objet compagnon : elles sont partagées par toutes les instances, comme les champs [static final] de la version Java. Ce sont des tableaux de [Double] ([DoubleArray], créés par [doubleArrayOf]) ; leurs éléments sont écrits [4962.0] et non [4962], faute de conversion implicite d'un [Int] en [Double]. Par convention, les noms des constantes sont écrits en majuscules ;
  • lignes 5-6 : l'implémentation de la propriété abstraite de la classe parent, initialisée à la construction de l'objet. [LIMITES.indices] est l'intervalle des indices du tableau (0..6) ; la fonction [map] construit la liste des résultats de la lambda pour chacun d'eux, ici une tranche par indice. La fonction d'extension [Double.toBigDecimal()] convertit un [Double] en [BigDecimal] en passant par sa représentation décimale la plus courte : [0.068.toBigDecimal()] vaut exactement 0,068, comme [BigDecimal.valueOf(0.068)] en Java. Il ne faut pas utiliser le constructeur [BigDecimal(0.068)], qui reproduit toutes les décimales de la valeur binaire approchée (0.068000000000000004884981308350688…) ;
  • la liste rendue par [map] est exposée sous le type [List<TrancheImpot>], en lecture seule : aucune classe ne pourra modifier le barème au travers de cette propriété (la version Java faisait une copie non modifiable avec [List.copyOf]). La propriété redéfinie garde la visibilité [protected] de la propriété abstraite.

Le chapitre suivant ajoutera d'autres classes dérivées de [AbstractImpot], qui liront les tranches dans un fichier texte ou JSON : elles n'auront qu'à fournir la propriété [tranchesImpot].

Un programme de test pourrait être le suivant [Main.kt] :

// programme interactif de calcul de l'impôt
// l'utilisateur tape trois données au clavier : marié nbEnfants salaire
// le programme affiche alors l'impôt à payer
package impotsv2

import impotsv2.impots.HardwiredImpot
import impotsv2.impots.IImpot

// le message d'aide
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()

fun main() {
    // création d'un objet IImpot
    val impot: IImpot = HardwiredImpot()

    // boucle de saisie
    while (true) {
        // on demande les paramètres du calcul de l'impôt
        print("Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : ")
        val parametres = readlnOrNull()
        // fin de l'entrée standard ou ligne vide : on s'arrête
        if (parametres.isNullOrBlank()) {
            break
        }
        // vérification du nombre de champs de la ligne saisie
        val champs = parametres.trim().split(Regex("\\s+"))
        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
        val nbEnfants = entierPositif(champs[1])
        if (nbEnfants == null) {
            println("$SYNTAXE\nArgument NbEnfants incorrect : tapez un entier positif ou nul")
            continue
        }
        // salaire
        val salaire = entierPositif(champs[2])
        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")
    }
}

// rend l'entier positif ou nul représenté par la chaîne, null si la chaîne n'en représente pas un
fun entierPositif(chaine: String): Int? = chaine.toIntOrNull()?.takeIf { it >= 0 }

Le programme ci-dessus permet à l'utilisateur de faire des simulations répétées de calcul d'impôt.

  • lignes 4-7 : le programme principal est dans le paquetage [impotsv2] ; il importe les deux types du sous-paquetage [impotsv2.impots] dont il a besoin. Le commentaire d'en-tête peut précéder la déclaration du paquetage ;
  • lignes 10-15 : le message d'aide est une propriété de haut niveau, privée au fichier, écrite sous la forme d'une chaîne brute délimitée par trois guillemets : la chaîne peut s'étendre sur plusieurs lignes. La fonction [trimIndent()] retire l'indentation commune des lignes, ainsi que la première et la dernière ligne si elles sont vides : la chaîne ne se termine donc pas par un passage à la ligne. Une propriété [const val] n'est pas possible ici, la valeur étant calculée par une fonction ;
  • ligne 19 : création d'un objet [impot] implémentant l'interface [IImpot]. Cet objet est obtenu par instanciation d'un type [HardwiredImpot], un type qui implémente l'interface [IImpot]. On notera qu'on n'a pas laissé le compilateur déduire le type [HardwiredImpot] de la variable [impot] : on lui a donné le type [IImpot]. En écrivant cela, on indique qu'on ne s'intéresse qu'à la méthode [calculer] de l'objet [impot] et pas au reste. Pour utiliser les données d'un fichier, il suffira de changer cette seule ligne ;
  • lignes 22-56 : la boucle des simulations de calcul de l'impôt ;
  • lignes 24-25 : les trois paramètres nécessaires à la méthode [calculer] sont demandés en une seule ligne tapée au clavier. [print] affiche le message sans passer à la ligne, puis [readlnOrNull()] lit une ligne ; elle rend [null] si l'entrée standard est fermée ;
  • ligne 27 : la fonction d'extension [isNullOrBlank], définie sur le type [String?], est vraie si la chaîne vaut [null] ou ne contient que des espaces : le programme s'arrête alors. Après ce test, le compilateur sait que [parametres] n'est pas [null] (la fonction l'indique au compilateur par un « contrat ») : il autorise l'appel [parametres.trim()] sans [?.] ;
  • ligne 31 : la fonction [split] décompose la ligne, débarrassée de ses espaces de début et de fin, en mots séparés par un ou plusieurs caractères d'espacement (expression régulière ["\\s+"]). Elle rend une [List<String>], dont on teste la taille par [size] ;
  • lignes 38-39 : la fonction [lowercase] met la chaîne en minuscules ; les chaînes se comparent avec [==] et [!=], qui comparent leurs contenus ;
  • lignes 44-51 : la fonction [entierPositif] (ligne 61) vérifie que les deux derniers champs sont des entiers positifs ou nuls. Elle rend un [Int?] : [null] signale une erreur, là où la version Java rendait la valeur -1. [toIntOrNull()] rend [null] si la chaîne ne représente pas un entier (au lieu de lancer une exception comme [Integer.parseInt]), et [takeIf { it >= 0 }] rend la valeur si elle vérifie la condition, [null] sinon. Après les tests [nbEnfants == null] et [salaire == null], suivis de [continue], le compilateur sait que les deux variables ne valent pas [null] : elles sont de type [Int] dans la suite de la boucle ;
  • ligne 56 : appel de la méthode [calculer] de l'objet [impot] implémentant l'interface [IImpot].

Voici un exemple d'exécution du programme, lancé dans le dossier du projet [04-classes]. Les saisies, fournies par redirection de l'entrée standard, ont été réinsérées dans l'affichage aux endroits où le programme les lit, comme elles apparaissent lors d'une saisie au clavier :

> gradlew -q run -Pmain=impotsv2.MainKt
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 : q s d f
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 : o -1 60000
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument NbEnfants 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 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 :

Les valeurs de contrôle sont bien retrouvées : 4282 euros pour un contribuable marié avec deux enfants et un salaire annuel de 60000 euros (ligne 25 des résultats), 2959 euros avec trois enfants (ligne 27). Un contribuable non marié sans enfant paie 11519 euros pour le même salaire (ligne 29). Ce sont les résultats de la version Java.

Par rapport à la version 1, le calcul est désormais isolé dans des classes, derrière une interface : le programme de dialogue ne connaît que l'interface [IImpot]. Les versions suivantes exploiteront cette séparation : la version 3 (chapitre suivant) changera l'origine des données en dérivant de nouvelles classes de [AbstractImpot], puis l'application sera organisée en couches (chapitre 6).