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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
class
Personne(nom: String
) {
val
nom: String
var
age = 0
private
set
init
{
this
.nom = nom
}
fun
grandir() {
age += 1
}
}
fun
main(args: Array<String
>) {
val
humain = Personne("Jean"
)
println(humain.nom)
println(humain.age)
humain.grandir()
println(humain.age)
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
class
Personne(val
nom: String
, var
age: Int
) {
constructor
(nom: String
) : this
(nom, 0
) {
// Nous pouvons même effectuer d'autres tâches
// d'initialisation ici
println("Dans un constructeur auxiliaire"
)
}
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)
}
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’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 :
- on définit le type de la propriété sans l’initialiser ;
- 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 !"
)
}
- 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 ».
- Ensuite, on peut déclarer chaque paramètre comme étant du type « T ».
- 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 officielleDocumentation officielle sur les génériques (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 :
- les deux-points ;
- le nom de classe de base ;
- 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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
open
class
Vehicule(nombreRoues: Int
, nombrePlaces: Int
)
{
val
nombreRoues: Int
val
nombrePlaces: Int
init
{
this
.nombreRoues = nombreRoues
this
.nombrePlaces = nombrePlaces
}
}
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)
}
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 :
2.
3.
4.
open
class
Vehicule(val
nombreRoues: Int
, val
nombrePlaces: Int
)
class
Velo: Vehicule(4
,4
) {
constructor
(): super
(3
,3
)
}
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 :
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 :
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 » :
Mais ce qui est vraiment pratique avec les interfaces, c’est qu’une classe peut hériter de plusieurs interfaces :
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 :
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 :
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 :
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 :
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 :
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.
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 :
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 :
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 officielledocumentation sur les data classes 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 :
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
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 :
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 :
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 :
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 officielleSurcharge d'opérateurs si vous souhaitez approfondir le sujet.
Soit une classe Fraction et une première tentative infructueuse d’additionner deux fractions :
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 :
Et dans une version plus classique, avec une simple méthode :
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 BaronProfil de Mickael Baron pour la relecture technique et escartefigue pour la relecture orthographique et syntaxique.