Tutoriel sur le langage Kotlin

Notions avancées des classes et autres fonctionnalités (partie 4/4)

Laurent Bernabé

Dans cet article en plusieurs parties, vous pourrez être initiés aux bases du langage Kotlin.

Il vise principalement les développeurs étant familiers avec le langage Java et la POO, même si tout langage orienté objet devrait convenir. Je fais de temps en temps référence à Java pour essayer d’aider à visualiser certains concepts.

C’est la version 1.3.70 de Kotlin qui a été utilisée lors de l’écriture de l’article.

Voici les différentes parties :

Introduction rapide

Bases de la syntaxe

Fonctions et notions sur les classes

Notions avancées sur les classes et diverses fonctionnalités

Cette dernière partie présente les notions avancées des classes ainsi que d’autres fonctionnalités utiles du langage.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Tutoriel sur le langage Kotlin

- Auteur : Laurent Bernabé

- Parution : 10 juillet 2020

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2020 Laurent Bernabe. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Utilisation plus élaborée des classes

- I-A. Constructeur primaire

- I-B. Constructeurs auxiliaires

- I-C. Classes imbriquées

- I-D. Les Objets

- I-E. Les Objets compagnons (companion object)

- I-F. Les fonctions d’extension

- I-G. Les propriétés d’extension

- I-H. Les compagnons d’objet d’extensions

- I-I. Introduction basique aux génériques

- I-I-1. Fonction générique

- I-I-2. Classe générique

- I-I-3. Aller plus loin

- II. L’héritage de classes

- II-A. Bases de l’héritage

- II-A-1. Héritage simple

- II-A-2. La visibilité protected

- II-A-3. Héritage et constructeurs secondaires

- II-B. Héritage plus élaboré

- II-B-1. Héritage de classes abstraites

- II-B-2. Héritage d’interfaces

- II-B-3. Héritage simultané d’une classe et d’interfaces

- II-B-4. Le polymorphisme

- III. Autres notions liées aux classes

- III-A. Objet compagnon

- III-B. Classe anonyme

- III-C. Les classes de données

- III-D. Les classes scellées

- III-E. Les classes énumérations

- III-F. La surcharge d’opérateurs

- IV. Conclusion et remerciements

I - Utilisation plus élaborée des classes

I-A - Constructeur primaire

Dès lors qu’une classe doit disposer d’un unique constructeur (nous verrons le cas de constructeurs multiples dans la section suivante), il faut respecter la syntaxe du constructeur primaire.

1. class Personne(nom: String) {

2. val nom: String

3. var age = 0

4. private set

6. init {

7. this.nom = nom

8. }

10. fun grandir() {

11. age += 1

12. }

14. }

16. fun main(args: Array<String>) {

17. val humain = Personne("Jean")

18. println(humain.nom)

19. println(humain.age)

20. humain.grandir()

21. println(humain.age)

22. }

Ainsi, la liste des paramètres est précisée juste après le nom de classe et entre parenthèses. En outre, pour chaque paramètre, telle la déclaration d’un paramètre de fonction, il faut préciser le nom de la variable avant son type.

Remarquez également qu’on pouvait ne par attribuer de valeur immédiatement à la propriété « nom » (ligne 2), parce que cela a été fait dans le bloc d’initialisation init (lignes 6-8).

Et donc, grâce au constructeur primaire, c’est lors de la construction de l’instance que nous attribuons une valeur pour le nom (ligne 17).

Mais nous pouvons simplifier ce code :

class Personne(val nom: String) {
var age = 0
private set

fun grandir() {
age += 1
}

}

fun main(args: Array<String>) {
val humain = Personne("Jean")
println(humain.nom)
println(humain.age)
humain.grandir()
println(humain.age)
}

Ce que nous avons fait ici :

- nous avons préfixé le paramètre « nom » du constructeur primaire par le mot-clef « val ». De cette manière, Kotlin ajoutera automatiquement une propriété « nom » en lecture seule, et dont la valeur sera celle passée en paramètre lors de la construction de l’instance (ligne 11) ;

- de ce fait, nous avons pu supprimer le bloc init, devenu inutile.

Et si nous faisions de même pour la propriété « age » ?

class Personne(val nom: String, var age: Int = 0) {

fun grandir() {
age += 1
}

}

fun main(args: Array<String>) {
val humain = Personne("Jean")
println(humain.nom)
println(humain.age)
humain.grandir()
println(humain.age)

val deuxiemeHumain = Personne("Alice", 18)
println(deuxiemeHumain.age)
}

Nous avons donc :

- ajouté le paramètre « age » dans le constructeur primaire avec une valeur par défaut ;

- supprimé la propriété « age » de la classe.

En revanche, rien ne nous empêche ici de modifier l’âge depuis l’extérieur. Et si nous avions voulu utiliser une propriété en lecture seule à la place, la méthode « grandir() » n’aurait pas pu fonctionner.

C’est donc l’inconvénient de cette technique : l’impossibilité de restreindre l’accès au setter.

I-B - Constructeurs auxiliaires

Si nous voulons d’autres constructeurs, il faut les déclarer à l’intérieur de la classe et les baser sur le constructeur principal.

1. class Personne(val nom: String, var age: Int) {

3. constructor(nom: String) : this(nom, 0) {

4. // Nous pouvons même effectuer d'autres tâches

5. // d'initialisation ici

6. println("Dans un constructeur auxiliaire")

7. }

9. fun grandir() {

10. age += 1

11. }

13. }

15. fun main(args: Array<String>) {

16. val humain = Personne("Jean")

17. println(humain.nom)

18. println(humain.age)

19. humain.grandir()

20. println(humain.age)

22. val deuxiemeHumain = Personne("Alice", 18)

23. println(deuxiemeHumain.age)

24. }

J’ai volontairement supprimé la valeur par défaut du paramètre « age » dans le constructeur principal afin que l’exemple soit plus pédagogique.

C’est donc le mot-clef constructor qui permet de déclarer un constructeur auxiliaire. De prime abord, sa signature ressemble énormément à la signature d’une fonction. Mais vous avez peut-être remarqué l’appel à this(nom, 0)juste avant les accolades. C’est l’équivalent de l’appel à super() du Java/C++ et qui permet d’abord d’initialiser l’instance en faisant appel au constructeur primaire (donc ici nous initialisons l’âge à zéro). Enfin, il est également possible d’effectuer d’autres instructions après cet appel, grâce au code situé entre les accolades.

I-C - Classes imbriquées

En Kotlin, il est également possible de déclarer des classes imbriquées.

class Externe(val a: Int) {
class Interne(var b: Int) {
fun augmenterB() {
b += 1
}
}
}

fun main(args: Array<String>) {
val externe = Externe(3)
val interne = Externe.Interne(2)

println(externe.a)
println(interne.b)
interne.augmenterB()
println(interne.b)
}

Prêtez bien attention d’ailleurs aux différentes casses (majuscule/minuscule) des différentes instances et classes, en particulier lors de la création de la variable interne (ligne 10).

I-D - Les Objets

Les Objets regroupent des fonctions que l’on peut appeler sans devoir instancier le modèle en question (toutes leurs méthodes et valeurs sont des équivalents des méthodes et valeurs statiques de Java/C++).

Un Objet est en quelque sorte une classe Singleton, pour ceux qui connaissent ce patron de conception.

object Math {
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}

fun main(args: Array<String>) {
println(Math.moyenne(arrayOf(2,10,18,13)))
println(Math.min(arrayOf(3,1,10)))
}

Ainsi, pour accéder aux méthodes « moyenne » et « min », je n’ai pas eu à créer d’instance de la classe « Math ». En revanche, j’accède à ces méthodes « en tant que propriétés de la classe Math ».

Attention toutefois : un objet ne peut être imbriqué que dans un autre objet ou une classe non interne.

I-E - Les Objets compagnons (companion object)

Il est possible d’ajouter des méthodes « statiques » dans une classe grâce à la fonctionnalité « objet compagnon ».

class Personne(val nom: String, age: Int)
{
var age = age
private set

fun grandir()
{
age += 1
}

companion object Math {
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}
}

fun main(args: Array<String>) {
println(Personne.moyenne(arrayOf(2,10,18,13)))
println(Personne.min(arrayOf(3, 12, 8)))
val guillaume = Personne("Guillaume", 15)
println(guillaume.nom)
}

- Je déclare un objet compagnon dans la classe « Personne » : je peux donc directement appeler les méthodes « moyenne » et « min » de la classe « Personne », sans même avoir instancié de variable du modèle « Personne ». Ce sont des méthodes statiques ajoutées à la classe « Personne ».

Nous pouvons même ignorer le nom du compagnon objet dans la classe Personne :

class Personne(val nom: String, age: Int)
{
var age = age
private set

fun grandir()
{
age += 1
}

companion object { // Il n&#8217;y a plus le nom Math
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}
}

fun main(args: Array<String>) {
println(Personne.moyenne(arrayOf(2,10,18,13)))
println(Personne.min(arrayOf(3, 12, 8)))
val guillaume = Personne("Guillaume", 15)
println(guillaume.nom)
}

I-F - Les fonctions d’extension

Les fonctions d’extension permettent d’enrichir une classe déjà existante (mais juste dans le contexte du projet dans lequel on se situe, évidemment).

Vous connaissez déjà certaines fonctions d’extension : les fameuses fonctions map/filter/reduce que nous avons utilisées avec certaines collections.

Ainsi, le type « Array » est basé sur les tableaux natifs de Java, lesquels ne possèdent aucune des trois méthodes.

On peut même, de cette manière, augmenter le type « String », pourtant à l’origine immuable en Java.

fun String.capitalize() : String {
return "${this[0].toUpperCase()}${this.substring(1)}"
}

fun main(args: Array<String>) {
println("hello".capitalize())
}

Ici, j’ai défini une nouvelle méthode « capitalize() » sur la classe « String » :

- c’est presque une fonction classique : il faut juste penser à préfixer le nom de la méthode par le nom de la classe destinatrice (y compris nos propres types) suivi d’un point ;

- à l’intérieur de la fonction, « this » représente l’instance de l’objet sur lequel a été appelée la méthode (pour les non-habitués à Java, c’est un concept de la programmation orientée objet dont nous reparlerons ultérieurement) ;

- évidemment, nous ne sommes pas limités à une fonction sans arguments ;

- de même, nous pouvons utiliser la simple syntaxe d’une expression en valeur de retour.

Un autre exemple intéressant, c’est d’écrire une méthode pour les nombres premiers :

fun Int.estPremier() : Boolean {
return (2 until this).none{this % it == 0}
}

fun main(args: Array<String>) {
for (i in 1..20)
{
println("$i est premier ? ${if (i.estPremier()) "oui" else "non"}")
}
}

Remarquez :

- 2 until 5 représente un intervalle commençant à 2 mais n’incluant pas 5 ;

- la fonction d’ordre supérieur « none » est une méthode présente dans les intervalles et permettant de vérifier qu’aucun élément ne vérifie la condition énoncée.

Une fonction d’extension a évidemment accès à l’ensemble des membres et méthodes publiques de la classe en question.

Mais il est aussi possible de définir une fonction d’extension sur un type « nullable » !

fun Int?.estPair() : Boolean {
if (this == null) return false
return this % 2 == 0
}

fun main(args: Array<String>) {
val i:Int? = null
val j = 3

println(i.estPair())
println(j.estPair())
}

Et donc, comme l’extrait de code précédent le démontre, nous pouvons éventuellement tester si la valeur est « null » afin de réaliser un traitement adéquat.

I-G - Les propriétés d’extension

Tout comme nous pouvons ajouter des méthodes aux classes existantes, nous pouvons ajouter des propriétés.

val String.demiTaille: Int
get() = length / 2

fun main(args: Array<String>) {
println("Hello World !".demiTaille)
}

Il faut absolument respecter cette forme :

1. on définit le type de la propriété sans l’initialiser ;

2. on ajouter un « getter » pour la propriété (d’ailleurs ici, il fait appel à une autre propriété de la classe « String » : length).

Cela fonctionne également avec les génériques, que nous verrons sous peu.

val <T> Array<T>.demiTaille : Int
get() = size / 2

fun main(args: Array<String>) {
println(arrayOf(2,3,4,5,6).demiTaille)
println(arrayOf(1.0,2.0,3.0,4.0,5.0).demiTaille)
}

I-H - Les compagnons d’objet d’extensions

Tout compagnon d’objet de classe peut se voir ajouter des fonctions et valeurs. Le mécanisme est le même que pour les fonctions et propriétés d’extension.

class Vecteur(val dx: Double, val dy: Double) {
companion object {

}
}

// Mince l'auteur original a oublie d'ajouter le vecteur nul en tant que valeur de classe
// Pas grave, je vais le faire moi-même, localement

val Vecteur.Companion.NUL: Vecteur
get() = Vecteur(0.0, 0.0)

fun main(args: Array<String>) {
println(Vecteur.NUL.dx)
println(Vecteur.NUL.dy)
}

Ainsi, j’ai appliqué une propriété d’extension sur le compagnon d’objet de la classe « Vecteur ». Ici, le compagnon d’objet s’appelle tout simplement « Companion », étant donné qu’il est anonyme dans la classe que l’on souhaite étendre. Pour être plus complet, j’aurais d’ailleurs pu ajouter une extension de méthode sur la classe « Vecteur » elle-même afin d’en calculer la norme (la longueur du vecteur).

Nous reparlerons des objets compagnons ultérieurement.

I-I - Introduction basique aux génériques

I-I-1 - Fonction générique

Dans sa forme la plus basique, une fonction générique est une fonction dont tout ou partie des paramètres fonctionne sans restriction sur le type d’argument.

fun <T> afficher(a: T, b: T) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3,2)
afficher(2.0,10.0)
afficher("Hello", "World !")
}

1. Donc, entre le mot-clef « fun » et la signature de la fonction, on insère une liste d’identifiants de types. Ici, il n’y a qu’un réceptacle de type qu’on a nommé « T ».

2. Ensuite, on peut déclarer chaque paramètre comme étant du type « T ».

3. On peut utiliser la fonction avec plusieurs types de données.

Mais nous ne sommes pas limités à exiger un unique type pour la liste des paramètres ni à exiger un type pour l’ensemble des paramètres.

fun <T, U> afficher(a: T, b: U) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 'a')
afficher(2.0, "euros")
afficher("Bonjour", "les codeurs !")
}

L’utilisation de deux String dans le dernier exemple est purement pédagogique.

fun <T> afficher(a: T, b: Int) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 10)
afficher(2.0, 20)
afficher("Bonjour", 30)
}

L’ordre de déclaration des types aussi est libre :

fun <T, U> afficher(a: U, b: T) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 'a')
afficher(2.0, "euros")
afficher("Bonjour", "les codeurs !")
}

Enfin, le choix des noms « T » et « U » n’est nullement obligatoire : il s’agit juste d’une convention.

I-I-2 - Classe générique

Les classes génériques se déclarent et fonctionnent de manière similaire.

class MaPaire<T, U>(val prem: T, val deux: U)

fun main(args: Array<String>) {
val paire = MaPaire(10, "euros")
println(paire.prem)
println(paire.deux)
}

Cette fois-ci, la liste des types à fournir est déclarée en pleine signature de classe : entre le nom de la classe et son constructeur primaire si défini.

I-I-3 - Aller plus loin

Une couverture complète des génériques ne rentre pas dans le cadre de ce tutoriel. Toutefois, je ne peux que vous inviter à aller consulter la documentation officielle (en anglais).

II - L’héritage de classes

En Kotlin, comme dans de nombreux langages, il est possible de bénéficier des fonctionnalités de la Programmation Orientée Objets afin de pouvoir dupliquer valeurs ou comportements entre plusieurs entités à moindres frais.

L’entité classique est la classe, mais pas seulement : il est par exemple possible de combiner plusieurs interfaces en une. (Nous arriverons très vite sur les interfaces.)

II-A - Bases de l’héritage

II-A-1 - Héritage simple

Tout d’abord, un simple rappel sur l’héritage dans la programmation orientée objet.

Prenons, pour illustrer avec un cas de la vie courante, le cas d’un véhicule terrestre. Quand nous parlons de véhicule terrestre, nous pensons à quelque chose :

- avec des roues ;

- une ou plusieurs places où s’installer ;

- …, et j’en passe.

Ce que je viens de décrire, c’est la représentation commune qu’on se fait d’un véhicule terrestre.

Maintenant, nous pouvons diviser les véhicules en plusieurs catégories, disons par exemple :

- les automobiles :

- disposant de moteurs,

- pouvant reculer ;

- les vélos :

- disposant d’un pédalier,

- disposant d’un guidon.

Je viens donc de décrire une relation d’héritage :

- une automobile est un véhicule, avec certaines caractéristiques communes avec celles des vélos, mais aussi des attributs et comportements propres ;

- on peut appliquer le même raisonnement aux vélos.

Une automobile est un véhicule, un vélo est un véhicule.

En Kotlin, nous pouvons donc définir une classe de base « Vehicule » :

open class Vehicule(nombreRoues: Int, nombrePlaces)

Nous avons ajouté le mot-clef « open », qui nous autorisera à dériver la classe « Vehicule », notamment en classes « Velo » et « Automobile ». Justement, définissons-les :

class Velo : Vehicule(2,1)
class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

Il a donc suffi, pour chacune des sous-classes, d’ajouter :

1. les deux-points ;

2. le nom de classe de base ;

3. les valeurs à passer au constructeur primaire de la classe de base.

Ainsi, nous déclarons un « Velo » comme étant un « Vehicule » ayant 2 roues (afin de simplifier notre exemple) et une unique place. Par contre, nous laissons de la flexibilité en ce qui concerne la classe « Automobile ».

Ne l’oubliez pas, le mot-clef open est obligatoire afin de rendre une classe héritable !

Enfin, nous pouvons en créer des instances comme on en a l’habitude.

fun main(args: Array<String>) {
val monVehicule = Vehicule(3, 1)
// println(monVehicule.nombreRoues): erreur ! Sera resolue plus tard
// println(monVehicule.nombrePlaces): erreur ! Sera resolue plus tard
val monVelo = Velo()
// println(monVelo.nombreRoues) : erreur ! Sera resolue plus tard
// println(monVelo.nombrePlaces) : erreur ! Sera resolue plus tard
val maVoiture = Automobile(4,4)
// println(maVoiture.nombreRoues) : erreur ! Sera resolue plus tard
// println(maVoiture.nombrePlaces) : erreur ! Sera resolue plus tard
}

Remarquez également qu’aucune de ces trois classes ne dispose des propriétés « nombreRoues » et « nombrePlaces », ce qui est normal, étant donné qu’on a juste passé ces définitions en tant que valeurs du constructeur primaire de la classe « Vehicule ».

Par contre, il suffit de les transformer en propriétés au sein de la classe « Vehicule », soit en ajoutant des mots-clefs val/var aux paramètres désirés du constructeur primaire (comme nous l’avons vu auparavant), soit en ajoutant des propriétés dédiées à l’intérieur de la classe « Vehicule ».

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)
class Velo : Vehicule(2,1)
class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

fun main(args: Array<String>) {
val monVehicule = Vehicule(3, 1)
println(monVehicule.nombreRoues)
println(monVehicule.nombrePlaces)

val monVelo = Velo()
println(monVelo.nombreRoues)
println(monVelo.nombrePlaces)

val maVoiture = Automobile(4,4)
println(maVoiture.nombreRoues)
println(maVoiture.nombrePlaces)
}

Tout d’abord, remarquez que l’appel au constructeur primaire de la classe de base doit obligatoirement se faire avant le corps de la classe.

L’ajout du mot-clef val dans le constructeur primaire de la classe « Vehicule » a permis de disposer des propriétés voulues dans les trois classes liées hiérarchiquement. Ainsi, il n’a pas été nécessaire de déclarer des propriétés « nombreRoues » et « nombrePlaces » dans aucune des deux classes « Velo » et « Vehicule ».

En ce qui concerne l’autre méthode :

1. open class Vehicule(nombreRoues: Int, nombrePlaces: Int)

2. {

3. val nombreRoues: Int

4. val nombrePlaces: Int

6. init {

7. this.nombreRoues = nombreRoues

8. this.nombrePlaces = nombrePlaces

9. }

10. }

12. class Velo : Vehicule(2,1)

13. class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

15. fun main(args: Array<String>) {

16. val monVehicule = Vehicule(3, 1)

17. println(monVehicule.nombreRoues)

18. println(monVehicule.nombrePlaces)

20. val monVelo = Velo()

21. println(monVelo.nombreRoues)

22. println(monVelo.nombrePlaces)

24. val maVoiture = Automobile(4,4)

25. println(maVoiture.nombreRoues)

26. println(maVoiture.nombrePlaces)

27. }

Cette fois-ci nous avons ajouté manuellement les propriétés dans la classe « Vehicule ». Le mot-clef « this » fait référence à l’instance de la classe « Vehicule » (donc c’est éventuellement aussi une instance de la classe « Velo » ou « Automobile ») dans laquelle nous nous situons, ou plutôt que nous essayons d’initialiser, et ce mot-clef permet ici d’éviter la confusion avec les paramètres du constructeur primaire.

Évidemment, parmi les véhicules automobiles, nous pouvons notamment distinguer :

- les voitures ;

- les deux-roues motorisés.

Et parmi les deux-roues motorisés : les scooters, les motos… rien ne nous limite dans la profondeur de la hiérarchie.

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)

class Velo : Vehicule(2,1)
open class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

class Voiture: Automobile(4,4)

Évidemment, il a fallu ajouter le mot-clef open à la déclaration de la classe « Automobile ».

Enfin, note importante, une classe ne peut hériter que d’une seule classe à la fois. Plus tard, avec les interfaces, nous verrons comment on peut contourner cette limitation.

II-A-2 - La visibilité protected

Dans la section IV-C, nous avons vu les visibilités « public » et « private ». Lorsque nous définissons une relation d’héritage entre classes, la prise en compte des visibilités est légèrement plus complexe :

- un membre ou une fonction « public » dans une classe, le sera aussi dans l’ensemble des classes filles, et ce, quelle que soit la profondeur d’héritage ;

- un membre ou une fonction « private » dans une classe ne sera visible que dans la définition de la classe proprement dite : elle ne sera accessible ni de l’extérieur, ni des classes filles.

Il y a donc une troisième possibilité, plus souple que « private », qui est « protected » :

- une propriété ou fonction déclarée « protected » ne sera pas accessible de l’extérieur ;

- en revanche, elle sera accessible dans les classes filles, quelle qu’en soit la profondeur.

II-A-3 - Héritage et constructeurs secondaires

Dès lors qu’on ajoute des constructeurs secondaires dans une classe fille, la syntaxe simple dans laquelle nous avions simplement suffixé le nom de la classe mère avec les paramètres n’est plus autorisée :

1. open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)

2. class Velo: Vehicule(4,4) {

3. constructor(): super(3,3)

4. }

La ligne 2 provoquera une erreur de compilation. Il y a deux remèdes possibles :

- ajouter un constructeur primaire à la classe fille « Velo » et baser les constructeurs secondaires dessus comme nous l’avons déjà vu ;

- ou bien, s’il n’y a pas de constructeur primaire dans la classe fille « Velo », faire appel pour chaque constructeur secondaire au constructeur primaire de la classe mère grâce au mot-clef super.

(Exemple purement pédagogique.)

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)
class Velo: Vehicule {
constructor(): super(3,3)
constructor(moitieNombrePlaces: Int): super(4, moitieNombrePlaces * 2)
}

fun main(args: Array<String>) {
val velo1 = Velo()
println(velo1.nombrePlaces)
println(velo1.nombreRoues)

val velo2 = Velo(2)
println(velo2.nombrePlaces)
println(velo2.nombreRoues)
}

II-B - Héritage plus élaboré

II-B-1 - Héritage de classes abstraites

Il se peut que, dans la définition de notre hiérarchie de classes, nous ne souhaitions pas que l’utilisateur puisse créer et utiliser des instances de la classe racine (la classe au sommet de la hiérarchie). Cela peut-être notamment utile dans l’utilisation du polymorphisme, que nous verrons ultérieurement.

Voici un cas concret : une hiérarchie de figures géométriques, appelons-la racine « Shape ». Une figure, en soi, ne définit pas grand-chose. En revanche, des triangles, rectangles, carrés…, oui. Nous pouvons donc décider de rendre la classe « Shape » abstraite.

Pour cela, il faut :

- déclarer la classe comme « abstract » ;

- déclarer ce qui doit absolument être redéfini dans les classes filles (propriétés ou fonctions) comme « abstract ».

En revanche, il est possible de définir des propriétés et fonctions par défaut : elles ne seront donc pas déclarées « abstract ».

Voici ce que cela pourrait donner comme code :

Classe abstraite

1. abstract class Figure {

2. abstract val nombreCotes: Int

4. abstract fun perimetre(): Double

5. abstract fun aire(): Double

7. val nom = "Figure"

9. fun afficher() {

10. println("Affichage de la figure sur l’écran")

11. }

12. }

14. class Carre(val longueurCotes: Double): Figure() {

15. override val nombreCotes = 4

17. override fun perimetre(): Double {

18. return 4 * nombreCotes;

19. }

21. override fun aire(): Double {

22. return nombreCotes * longueurCotes;

23. }

24. }

26. fun main() {

27. // Ceci ne compilera pas => val figure0 = Figure();

28. val figure1 = Carre(10.0)

29. println(figure1.perimetre()) // affiche 40.0

30. println(figure1.aire()) // affiche 100.0

31. }

Veuillez donc remarquer :

- il est possible, dans une classe abstraite, d’utiliser des propriétés/méthodes abstraites et non abstraites ;

- il est impossible d’instancier la classe « Figure » ;

- il n’y a aucune obligation d’utiliser le mot-clef open, car tout élément abstrait doit obligatoirement être défini dans toute classe fille destinée à être instanciée. Et ce, une fois de plus, quelle que soit la profondeur. On appelle classe concrète, par opposition à une classe abstraite, une classe qui a l’ensemble de ses éléments définis, et dont on peut donc créer des instances ;

- les classes filles n’ont donc pas pour obligation d’être toutes concrètes et de définir l’ensemble des propriétés/méthodes de leurs classes ancêtres. Seules celles dont on doit pouvoir créer des instances ont cette obligation.

II-B-2 - Héritage d’interfaces

Outre les classes abstraites, une autre notion est fréquemment utilisée dans la programmation orientée objet : la notion d’interfaces.

Les interfaces sont en quelque sorte des classes abstraites, mais ayant l’interdiction de stocker des états. Par exemple, dans notre classe « Shape » de la section précédente, nous avons utilisé un état, la propriété « name ». Et bien dans une interface, cela aurait été impossible.

En revanche, il reste possible de définir des implémentations par défaut de méthodes.

Une version interface de la classe « Shape » pourrait donner :

La version interface de la classe Figure

1. interface Figure {

2. abstract val nombreCotes: Int

4. abstract fun perimetre(): Double

5. abstract fun aire(): Double

7. fun afficher() {

8. println("Affichage de la figure sur l’écran")

9. }

10. }

12. class Carre(val longueurCotes: Double): Figure {

13. override val nombreCotes = 4

15. override fun perimetre(): Double {

16. return 4 * longueurCotes;

17. }

19. override fun area(): Double {

20. return longueurCotes * longueurCotes;

21. }

22. }

24. fun main() {

25. // Ceci ne compilera pas => val figure0 = Figure();

26. val figure1 = Carre(10.0)

27. println(figure1.perimetre())

28. println(figure1.aire())

29. }

Tout d’abord, veuillez remarquer :

- c’est avec le simple mot-clef « interface » qu’on déclare une interface ;

- la propriété « nom » a été supprimée de l’interface « Figure » ;

- il ne faut pas déclarer les parenthèses lorsqu’on hérite de la classe « Figure », les interfaces n’ont pas de constructeur, et pour cause, elles n’ont pas d’état.

J’ajouterais même que, les interfaces n’ayant pas d’état, tout membre d’interface est automatiquement abstrait. Il devient donc superflu de déclarer les éléments « abstract » :

Version simplifiée de l'interface Figure

1. interface Figure {

2. val nombreCotes: Int

4. fun perimetre(): Double

5. fun aire(): Double

7. fun afficher() {

8. println("Affichage de la figure sur l’écran")

9. }

10. }

12. class Carre(val longueurCotes: Double): Carre {

13. override val nombreCotes = 4

15. override fun perimetre(): Double {

16. return 4 * longueurCotes;

17. }

19. override fun aire(): Double {

20. return longueurCotes * longueurCotes;

21. }

22. }

24. fun main() {

25. // Ceci ne compilera pas => val figure0 = Figure();

26. val figure1 = Carre(10.0)

27. println(figure1.perimeter())

28. println(figure1.area())

29. }

Mais ce qui est vraiment pratique avec les interfaces, c’est qu’une classe peut hériter de plusieurs interfaces :

Heriter de plusieurs interfaces

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. class Canard : PeutVoler, PeutNager {

11. }

13. class Poisson : PeutNager {

15. }

19. fun main() {

20. val c = Canard()

21. c.voler()

22. c.nager()

24. val p = Poisson()

25. p.nager()

26. }

II-B-3 - Héritage simultané d’une classe et d’interfaces

Rien ne nous empêche d’hériter d’une classe (abstraite ou non) et d’interfaces en même temps.

Ainsi, nous pouvons adapter l’exemple précédent en définissant une classe commune « Animal », que nous rendons ici abstraite, juste par choix :

Héritage simultané de classe et d'interfaces

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. abstract class Animal

11. class Canard: Animal(), PeutVoler, PeutNager {

13. }

15. class Poisson : Animal(), PeutNager {

17. }

21. fun main() {

22. val c = Canard()

23. c.voler()

24. c.nager()

26. val p = Poisson()

27. p.nager()

28. }

Ainsi un canard peut et voler et nager, un poisson ne peut que nager (si l’on ne considère pas, pour simplifier notre exemple, l’exocet ou autre éventuel poisson volant). Et il n’a pas été nécessaire de définir les caractéristiques plusieurs fois. Juste deux interfaces dont il a fallu hériter convenablement.

Pourquoi avoir rajouté une telle classe « Animal » à la fois abstraite et vide dans cet exemple ? En clair, qu’est-ce que cela nous a apporté ? Justement, la section suivante traite du polymorphisme, où cela prendra plus de sens.

II-B-4 - Le polymorphisme

Encore une notion fréquente de la programmation orientée objet.

En Kotlin, tout comme en Java, il est possible de stocker une variable de classe fille dans une variable du type de l’une de ses classes mères. Plus concrètement, dans notre exemple précédent, nous avions des animaux de type « Canard » et « Poisson ». Et bien nous pouvons stocker une instance de « Canard » ou une instance de « Poisson » dans une variable de type « Animal ». Bien que la classe « Animal » soit abstraite, il est possible de déclarer une variable de type « Animal », c’est juste le fait de créer une instance de « Animal » qui est interdit.

Et cette possibilité nous permet d’effectuer du polymorphisme :

Polymorphisme

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. abstract class Animal {

10. abstract fun bouger()

11. }

13. class Canard: Animal(), PeutVoler, PeutNager {

14. override fun bouger() {

15. voler()

16. nager()

17. }

18. }

20. class Poisson : Animal(), PeutNager {

21. override fun bouger() {

22. nager()

23. }

24. }

28. fun main() {

29. val ferme = arrayOf<Animal>(Canard(), Poisson(), Poisson(), Canard())

30. for (animal in ferme) animal.bouger()

31. }

Tout d’abord, afin de bénéficier du polymorphisme, j’ai ajouté une méthode bouger() dans la classe « Animal », qui est redéfinie par chaque « Animal » afin de bénéficier d’une fonctionnalité commune à l’ensemble des animaux.

Ensuite, dans la fonction « main », j’ai créé un tableau de « Animal ». Et donc, en parcourant chaque élément du tableau, c’est la fonctionnalité bouger() propre au type de la classe  « Animal » qui est appelée.

III - Autres notions liées aux classes

III-A - Objet compagnon

Nous avons déjà rencontré les objets compagnons auparavant dans ce tutoriel lorsque nous souhaitions ajouter des fonctionnalités à des classes existantes(section V-H).

De manière générale, les objets compagnons permettent de définir des propriétés/méthodes comme appartenant à une classe, au lieu de les faire appartenir aux instances de cette classe :

Companion Object

1. class Math {

2. companion object {

3. val PI = 3.1415927

4. fun carre(x: Double) = x*x

5. }

6. }

8. fun main() {

9. println(Math.carre(Math.PI))

10. }

Ainsi, j’ai défini une classe « Math », contenant une constante PI et une fonction carre. Elles sont directement accessibles via la syntaxe Math.. C’est l’équivalent des propriétés et méthodes statiques du langage Java.

Rien ne nous interdit de déclarer un objet compagnon dans une classe normalement prévue pour créer des instances :

Classe standard avec objet compagnon

1. class Livre(val contenu: String, val pages: Int) {

2. companion object {

3. val possesseur = "Moi"

4. }

5. }

7. fun main() {

8. val l = Livre("Je suis un livre", 100)

9. println(l.contenu)

10. // Ceci ne compile pas : println(l.possesseur )

11. println(Livre.possesseur )

12. }

On remarque donc que les éléments définis par un objet compagnon ne restent accessibles que par la syntaxe Classe..

III-B - Classe anonyme

En pratique, de nombreuses fonctions attendent des instances de classes, qui implémentent une interface particulière, en paramètre. Et dans de nombreux cas, nous sommes amenés à créer des instances de telles classes, juste avant d’appeler lesdites fonctions et sans nous en servir une fois cette fonction terminée. En clair, on a créé un objet temporaire juste pour pouvoir satisfaire la fonction.

Par exemple :

Création d'une instance de classe quasi inutile

1. interface Appelable {

2. abstract val nom: String

3. }

5. fun direBonjourA(a: Appelable) {

6. println("Bonjour ${a.nom} !")

7. }

9. class Personne(nom: String): Appelable {

10. override val nom = nom

11. }

13. fun main() {

14. val leo = Personne("Leo")

15. direBonjourA(leo)

16. }

Ici, nous avons créé une variable « leo », juste pour pouvoir la passer à la fonction « direBonjour » (l’exemple n’est pas très représentatif de la problématique, je vous l’accorde).

Les classes anonymes, fonctionnalité qui existe notamment déjà en Java, permettent de contourner cette problématique.

« Interface anonyme »

1. interface Appelable {

2. abstract val nom: String

3. }

5. fun direBonjourA(a: Appelable) {

6. println("Bonjour ${a.nom} !")

7. }

9. fun main() {

10. direBonjourA(object: Appelable {

11. override val nom = "Leo"

12. })

13. }

Ainsi, nous avons pu appeler la fonction « direBonjourA » sans même avoir eu à créer un objet de type « Personne » (qui du coup n’est plus nécessaire). Il s’agit simplement d’un « héritage d’interface » local. Évidemment, les interfaces n’ayant pas de constructeur, il ne faut pas de parenthèses lors de la définition de l’interface anonyme.

Il est possible de faire la même chose si le paramètre attendu est une classe :

« Classe anonyme »

1. abstract class Appelable(val nom: String) {}

3. fun direBonjourA(a: Appelable) {

4. println("Bonjour ${a.nom} !")

5. }

7. fun main() {

8. direBonjourA(object: Appelable("Leo"){

9. })

10. }

Veuillez noter l’appel du constructeur parent lors de la définition de la classe anonyme. Tout comme on l’aurait fait pour définir une classe standard.

Dans les deux cas, classe ou interface, la dénomination officielle est « expression objet ». C’est important, car dans la documentation officielle, c’est sous ce nom qu’on y fait référence.

Enfin, comme dans toute classe, on peut redéfinir de la même manière les méthodes et propriétés de la hiérarchie, voire en ajouter d’autres.

III-C - Les classes de données

Les classes de données (data classes) peuvent souvent nous simplifier beaucoup de choses.

Prenons un exemple simple :

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

Vous avez sans doute remarqué le mot-clef data.

Grâce à cela, nous avons un certain nombre de choses déjà implémentées pour nous, rien que par cet ajout. Entre autres :

- une définition automatique des méthodes « equals » et « hashCode » : ce qui nous permet d’utiliser directement l’opérateur == pour comparer des objets de type « Personne » ;

- une définition automatique de la méthode  toString() : ce qui nous permet d’afficher proprement les objets de type « Personne », avec une commande println par exemple ;

- une définition automatique des méthodes component1, component2 et component3. Ici jusqu’à trois, car nous avons trois paramètres dans notre constructeur. Ces méthodes permettent de déconstruire les objets de type « Personne », comme nous l’avons vu auparavant dans ce tutoriel (Section II-i).

En revanche, il y a des restrictions :

- le constructeur primaire doit avoir au moins un paramètre ;

- tous les paramètres du constructeur primaire doivent être marqués soit comme« val », soit comme « var » ;

- ces classes ne peuvent être ni « open », ni « abstract » (donc non héritables), ni « inner » (donc non internes), ni « sealed ». Nous verrons ce que sont les classes « sealed » dans la section suivante.

Il y a d’autres restrictions, je vous invite à consulter la documentation officielle pour approfondir le sujet.

III-D - Les classes scellées

Lorsque l’on veut être certain qu’une classe ne soit pas héritable à l’infini, mais qu’en revanche seulement un certain nombre de classes en soient héritées, on peut la définir comme « scellée ».

De ce fait, toutes les classes filles doivent être déclarées dans le même fichier que cette classe mère :

Classe scellée

1. sealed class Expression

2. data class Constante(val nombre: Double): Expression()

3. data class Somme(val op1: Expression, val op2: Expression): Expression()

4. object NaN: Expression()

Ici, je me suis contenté d’adapter l’exemple officiel.

Bien évidemment, ici, « NaN » est bien une classe « object » au sens propre du terme et non une classe anonyme.

La classe « Expression » ne peut avoir d’autre classe fille que les classes « Constante », « Somme » et « Nan ». Et l’on peut utiliser le polymorphisme dans une fonction dédiée pour évaluer des instances de la classe « Expression ». Ce n’est pas difficile à imaginer, à l’aide d’un algorithme récursif.

III-E - Les classes énumérations

Les classes énumérations sont des énumérations plus puissantes que dans un certain nombre de langages, dont Java.

Une énumération est un type dont l’ensemble des valeurs a été défini à l’avance. C’est donc équivalent aux classes scellées, mais pour de simples valeurs.

De bonnes notions candidates pour les énumérations peuvent être : les mois de l’année, les points cardinaux, les langues vivantes enseignées dans un établissement…

Un exemple simple pour commencer

Énumération points cardinaux

enum class Direction {
Nord, Est, Sud, Ouest
}

fun main() {
val destination = Direction.Nord
println(when (destination) {
Direction.Nord -> "Je vais au nord"
Direction.Sud -> "Je vais au sud"
else -> "Direction interdite !"
})
}

Prenons maintenant une énumération RGB, qui permettrait d’obtenir du rouge, du vert ou du bleu. Nous pouvons aussi lire la valeur (hexadécimale, mais codée sous forme d’entier) de la couleur choisie :

Énumération avec constructeur primaire

1. enum class RVB(val hexa: Int) {

2. Rouge(0xFF0000),

3. Vert(0x00FF00),

4. Bleu(0x0000FF)

5. }

7. fun main() {

8. val couleur = RVB.Rouge

9. println("0x${Integer.toHexString(color.hexa)}")

10. }

Ainsi, grâce au constructeur primaire de l’énumération RVB, nous pouvons en personnaliser et lire la valeur associée.

Allons un peu plus loin :

Énumération avec constructeur primaire complexe

1. enum class RVB(val r: Int, val g: Int, val b: Int) {

2. rouge(255, 0, 0),

3. vert(0, 255, 0),

4. bleu(0, 0, 255),

5. noir(0,0,0),

6. blanc(255,255,255)

7. }

9. fun main() {

10. val color = RVB.noir

11. println(color.r)

12. }

Une énumération peut aussi avoir des éléments abstraits, que les différentes valeurs doivent alors redéfinir en tant que classes anonymes, certes avec une syntaxe épurée :

Énumération avec élément abstrait

1. enum class Animal {

2. Chien {

3. override fun crier() = "YapYap!"

4. },

5. Chat {

6. override fun crier() = "Miaou!"

7. };

8. abstract fun crier(): String

9. }

10. fun main() {

11. val animal = Animal.Chat

12. println(animal.crier())

13. }

Attention à ne pas oublier le point-virgule après la définition des valeurs !

Veuillez également remarquer que les éléments n’ont pas besoin de la syntaxe Object : {afin de redéfinir la base de l’énumération.

Sachez aussi qu’il est possible, de manière similaire, d’implémenter des interfaces dans une énumération. N’hésitez pas à aller consulter la documentation officielle si besoin.

III-F - La surcharge d’opérateurs

Kotlin permet de surcharger un certain nombre d’opérateurs au sein de nos propres classes.

Ainsi, on pourrait additionner deux dates de calendrier directement avec la notation d1 + d2, on pourrait multiplier deux matrices à l’aide de m1 * m2…

Comprenez bien que tout mécanisme permettant d’augmenter une classe, donc l’héritage ou les fonctions d’extension, permet la surcharge d’opérateurs.

Évidemment, le but ne sera pas d’être exhaustif, mais simplement de vous présenter un simple exemple. Je vous invite donc à vous rendre à la documentation officielle si vous souhaitez approfondir le sujet.

Soit une classe Fraction et une première tentative infructueuse d’additionner deux fractions :

Tentative infructueuse d'additionner deux fractions

1. class Fraction(val numerateur: Int, val denominateur: Int = 1)

3. fun main() {

4. val a = Fraction(2)

5. val b = Fraction(3,2)

7. // Ceci ne compilera pas => println(a+b)

8. }

Une manière élégante de résoudre ce problème consiste à définir l’opérateur + pour la classe Fraction. Mais attention, en Kotlin, les noms des fonctions à redéfinir ne correspondent pas aux noms des opérateurs ! D’où la bonne idée de regarder la documentation officielle.

En l’occurrence, la fonction à redéfinir pour l’addition s’appelle « plus » et prend le 2e opérande de l’addition en paramètre :

Correction avec une extension de fonction

1. class Fraction(val numerateur: Int, val denominateur: Int = 1){

2. override fun toString(): String = "$numerateur/$denominateur"

3. }

5. operator fun Fraction.plus(autre: Fraction): Fraction {

6. return Fraction(numerateur * autre.denominateur + autre.numerateur * denominateur, denominateur * autre.denominateur)

7. }

9. fun main() {

10. val a = Fraction(2)

11. val b = Fraction(3,2)

13. println(a+b)

14. }

Et dans une version plus classique, avec une simple méthode :

Correction avec une simple méthode

1. class Fraction(val numerateur: Int, val denominateur: Int = 1){

2. override fun toString(): String = "$numerateur/$denominateur"

4. operator fun plus(autre: Fraction): Fraction {

5. return Fraction(numerateur * autre.denominateur + autre.numerateur * denominateur, denominateur * autre.denominateur)

6. }

8. }

9. fun main() {

10. val a = Fraction(2)

11. val b = Fraction(3,2)

13. println(a+b)

14. }

Dans les deux cas, vous pouvez remarquer l’utilisation du mot-clef « operator », obligatoire pour que la méthode soit reconnue en tant que surcharge d’opérateur !

IV - Conclusion et remerciements

Dans cette dernière section, nous avons des concepts avancés sur les classes ainsi que diverses fonctionnalités utiles du langage.

J’espère que ce tutoriel vous aura plu, n’hésitez pas à aller consulter la documentation officielle ou d’autres sources si vous voulez aller plus loin.

Je tiens à remercier Mickael Baron pour la relecture technique et escartefigue pour la relecture orthographique et syntaxique.