Dans ce chapitre, nous irons encore plus loin dans l’étude du fonctionnement de Kotlin et de son approche sur les classes.
Une histoire d’héritage
Comme vous devez le savoir, en Java, vous pouvez hériter ( extends
) de n’importe quelle classe et redéfinir ( override
) ses méthodes à moins que celle-ci soit explicitement "protégée" par le mot-clé final
: on dit qu’une classe Java, ainsi que l’ensemble de ses méthodes, sont "ouvertes" (ou "open") par défaut.
Cela peut s’avérer très pratique, mais aussi une source de problèmes importante. En effet, si par défaut, en Java, une classe peut avoir des sous-classes (via l'héritage), vous prenez le risque qu’un développeur redéfinisse ( override
) une des méthodes de la classe parente d’une manière inattendue (ou du moins d’une manière que vous n’avez pas prévue !).
Ainsi, cela peut entraîner des changements de comportement entre la classe parent et les possibles classes enfants, ce qui ne respecte pas vraiment les principes de la programmation orientée objet.
D’ailleurs, Joshua Bloch, l’auteur de "Effective Java" (un des livres les plus respectés et recommandés dans l’univers Java), indiquait :
Design and document for inheritance or else prohibit it.
En français, cela donne quelque chose comme : "Concevez et documentez (ndlr : vos classes) pour l’héritage ou interdisez-le." En clair, nous devrions à chaque fois interdire l’héritage des classes non pensées pour être héritées, notamment grâce au mot-clé final
, afin de les rendre "fermées" à l’héritage.
D’accord, mais pourquoi tu nous racontes tout cela ?
Eh bien, en Kotlin, toutes les classes et leurs méthodes sont "fermées" par défaut. Vous ne pourrez donc pas hériter d’une classe ou redéfinir ses méthodes sans l’autorisation explicite d’un développeur. :)
Ainsi, le développeur devra indiquer dans son code explicitement, grâce au mot-clé open
, si une classe ou une méthode peut être "ouverte" à l’héritage.
Par exemple, imaginons que nous ayons créé la classe Button.java
(modélisant un simple bouton) :
public class Button{
public void show(){
// ... very complex processing to show button
}
public void hide(){
// ... very complex processing to hide button
}
}
... et qu’un second développeur (que nous appellerons "Toto") crée la classe CircularButton.java
(représentant un bouton rond), héritant de la classe Button.java
:
public class CircularButton extends Button{
@Override
public void show(){
super.show();
// ... adding some effects
}
@Override
public void hide(){
// ... adding some effects
}
}
Notre classe Button contient deux méthodes, show
et hide
, créées dans le but d’afficher et de masquer notre bouton. Jusqu’ici, tout va bien. :)
Puis, Toto arrive, et se dit qu’il aimerait bien créer un bouton rond. Naturellement, il crée une classe CircularButton
héritant de la classe Button
, afin de récupérer un comportement de bouton déjà créé.
Cependant, Toto souhaite modifier le comportement des méthodes show
et hide
: il décide donc de les redéfinir afin, notamment, d’ajouter des animations.
Toto, étant très tête-en-l’air, oublie d’appeler la méthode originale ( super.hide()
) lors de la redéfinition de la méthode hide
: le comportement de cette méthode se voit alors complètement changé ! La méthode show()
voit aussi son comportement changé, même si ce n’est qu’un simple ajout d’animation.
Comme vous n’avez pas forcément de visibilité sur le code de Toto, vous ne savez pas que ce dernier a modifié le comportement des méthodes show
et hide
en y ajoutant des animations.
Bien plus tard, vous décidez de faire évoluer les méthodes hide()
et show()
en ajoutant… des animations ! Toto verra donc son bouton rond exécuter des animations EN PLUS de celles qu’il aura déjà créées précédemment… Bref, vous comprenez la panique ! ;)
Tout aurait été plus simple si les méthodes show
et hide
avaient été déclarées en final
dès le début : Toto n’aurait jamais pu les redéfinir (et aurait dû créer ses propres méthodes).
C’est pour cette raison précise que les classes et leurs méthodes sont maintenant "fermées" en Kotlin. Voici l’équivalent en Kotlin, de manière plus "sécurisée" :
Commençons par la classe Button
:
open class Button {
fun show(){ }
fun hide(){ }
}
Nous avons utilisé le mot-clé open
devant le mot-clé class
afin d’indiquer explicitement que cette classe pourra être utilisée comme classe parente (via l’héritage). Cependant, nous n’autorisons pas les méthodes show
et hide
à être redéfinies, afin d’éviter que d’autres développeurs (comme Toto ;) ) modifient sans le vouloir leur comportement.
Ainsi, Toto recréa en Kotlin la classe CircularButton
:
Comme vous pouvez le voir, en Kotlin, nous utilisons les deux points :
en remplacement du mot-clé extends
en Java, suivi du nom de la classe parente ( Button
). Petite subtilité tout de même, nous devons indiquer à Kotlin quel constructeur utiliser. Nous reviendrons sur ce point d’ici quelques minutes. ;)
Cependant, lorsque Toto souhaite maintenant redéfinir les méthodes show
et hide
, une belle erreur s’affiche en indiquant que cela est impossible, car ces méthodes sont considérées comme étant final
! Eh oui, comme nous l’avons vu plus haut, par défaut, toutes les classes et méthodes sont "fermées" en Kotlin, à moins que nous les "ouvrions" explicitement grâce au mot-clé open
.
D’ailleurs, voici un petit récapitulatif des modificateurs d’accès disponibles en Kotlin (à ne pas confondre avec les modificateurs de visibilité vus dans un précédent chapitre !) :
final
: Classe/Méthode/Propriété ne pouvant pas être redéfinie. C’est l’état par défaut de tous les éléments en Kotlin.open
: Classe/Méthode/Propriété pouvant être redéfinie. Ce modificateur d’accès doit être indiqué explicitement.abstract
: Classe/Méthode/Propriété devant être redéfinie. Ce modificateur d’accès peut être utilisé uniquement dans des classes abstraites.
Et bien entendu, le mot-clé override
sera utilisé pour redéfinir un élément d’une classe parente (ou d’une interface).
Plutôt efficace, non ? :)
Des constructeurs multiples
J’ai une petite question : est-ce possible de créer plusieurs constructeurs pour une même classe en Kotlin ?
Bien sûr ! Reprenons notre exemple précédent, et étoffons-le un peu…
Tout d’abord, nous avons ajouté une propriété color
de type String
à notre classe Button
directement dans son constructeur primaire. Bon, ça, vous devez maîtriser ! ;)
Cependant, nous avons ajouté également un mystérieux bloc init
...
Euuuuuuh ! À quoi peut bien servir ce bloc ? :waw:
En fait, vous avez déjà remarqué que la syntaxe pour créer le constructeur primaire (ou principal) d’une classe était très épurée ! Peut-être trop pour certains cas… notamment les cas où nous souhaitons réaliser une action spéciale après qu’un objet soit construit. Eh bien, c’est le rôle du bloc init
.;) Celui-ci sera exécuté à chaque fois que la classe sera instanciée (qu’un objet de cette classe sera créé, si vous préférez).
Nous avons également modifié la classe CircularButton
afin de lui rajouter un constructeur contenant un paramètre qui sera directement utilisé par le constructeur primaire de la classe parente Button
. Simple, efficace. :D
Effectivement ! Et si maintenant, je souhaite créer un second constructeur, vide cette fois-ci, à ma classe Button
, comment cela se passe-t-il ?
Très simple ! Pour cela, nous utiliserons le mot-clé constructor
:
Ce mot-clé vous permettra de créer un constructeur dit "secondaire". En pratique, cette syntaxe sera surtout utilisée dans certains cas spécifiques, notamment en termes d'interopérabilité entre une classe parente écrite en Java possédant plusieurs constructeurs, et une classe enfant écrite en Kotlin. Voici un exemple en Android, avec la classe parente View
:
import andoid.content.Content
import andoid.util.AttributeSet
import andoid.view.View
class CustomView : View {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
}
Nous utilisons dans cet exemple des constructeurs secondaires en Kotlin pour "matcher" avec les différents constructeurs de la classe View
.
Pour terminer, comme nous l’avons vu dans un précédent chapitre, il serait beaucoup plus "propre" dans notre cas actuel de ne PAS utiliser de "second constructeur", mais plutôt la notion de "paramètre par défaut" et de "paramètre nommé" :
En plus, cette approche est beaucoup plus évolutive ! ;)
Des classes originales !
Des classes pour nos données
En Java (et dans tout langage de programmation orientée objet), il est très habituel de créer des classes représentant nos modèles de données, comme la classe User
représentant un utilisateur de notre programme.
En Kotlin, cela pourrait donner quelque chose comme ça :
class User(var email: String, var password: String, var isConnected: Boolean)
Jusque-là, pas de souci ! Cependant, très souvent, nous effectuons des actions répétitives sur ce genre de classe, comme :
Afficher son contenu sous forme de texte via la méthode
toString()
(dans une console de log, par exemple).Comparer deux objets de même type (via la méthode
equals()
de sa superclasse).Dupliquer un objet de manière distincte en un second objet (via la méthode
copy()
ouclone()
de sa superclasse).
En Java, cela nous obligeait à redéfinir les méthodes toString()
, hashCode()
, equals()
et copy()
dans le corps de notre classe (certains IDE s’occupaient même de générer ces méthodes pour nous). Mais bon, ça, c’était avant ! :D
En Kotlin, nous allons pouvoir définir une classe comme étant destinée à contenir des modèles de données grâce au mot-clé data
:
data class User(var email: String, var password: String, var isConnected: Boolean)
Grâce à ce simple mot-clé, le compilateur Kotlin implémentera pour nous les principales méthodes utilisées pour "comparer" et "décrire" un objet contenant des données comme les méthodes toString()
, hashCode()
, equals()
ou encore copy()
:
val user = User("toto@gmail.com", "azerty", true)
val secondUser = User("tata@gmail.com", "youhou", false)
// Description
println(user.toString())
// Comparison
if (secondUser == user) println("Users are equal!")
else println("Users are not equal...")
// Clone
val userCloned = user.copy()
if (userCloned == user) println("Users are equal!")
else println("Users are not equal...")
Plutôt pratique, non ?>_<
Un compagnon de route
Comme nous l’avons vu dans le premier chapitre de cette partie, le mot-clé static
n’existe plus. En effet, nous ne créons plus de méthodes ou de propriétés statiques publiques, mais plutôt des propriétés/fonctions de premier niveau (ou "top-level") créées à l’intérieur d’un fichier plutôt que d’une classe, comme nous le faisions précédemment en Java.
Cependant, celles-ci auront plus un rôle "global" pour notre programme. Imaginons que nous souhaitions créer l’équivalent "à l’intérieur" d’une classe afin d’accéder à l’ensemble de ses membres (propriétés, fonctions, constructeurs), qu’ils soient publics ou même privés : la notion de "top-level" ne pourra pas fonctionner dans ce cas-là !
Eh bien, c’est pour cette raison que Kotlin a créé le principe "d’objet compagnon" (ou "companion object") ! :)
Ce dernier va nous permettre de créer des propriétés ou des méthodes à l’intérieur d’une classe, accessibles même si aucune instance de cette classe n'existe (en d’autres mots, même si aucun objet n’est créé).
Prenons l’exemple ci-dessous :
data class User(var email: String, var password: String, var isConnecetd: Boolean) {
companion object {
fun newInstanceAfterSignUp(email: String, password: String) = User(email, password, isConnected: true)
}
}
// First syntax
val user = User.newInstanceAfterSignUp("toto@gmail.com", "azerty")
// Second syntax
val secondUser = User.Companion.newInstanceAfterSignUp("tata@gmail.com", "youhou")
Nous avons légèrement modifié notre classe User
afin d’y ajouter un objet compagnon. Pour cela, nous utilisons dans le corps de notre classe les mots-clés companion object
: à l’intérieur de cet objet, nous avons créé la méthode newInstanceAfterSignUp
dont l’objectif sera de créer un objet de type User
.
En somme, nous venons de créer une méthode de Factory. ;)
Puis, afin d’appeler cette nouvelle méthode, nous le ferons de la même manière que nous le faisions en Java sur des méthodes statiques publiques. :) Les deux syntaxes utilisées dans l’exemple sont équivalentes.
Voici un exemple nécessitant l’utilisation d’une propriété de premier niveau (ou "top-level property") :
private const val DEFAULT_SCORE = 100 // Top-Level Property
data class User(var email: String, var password: String, var isConnecetd: Boolean, var defaultScore: Int = DEFAULT_SCORE)
plutôt que l’utilisation de l’objet compagnon :
data class User(var email: String, var password: String, var isConnecetd: Boolean, var defaultScore: Int = DEFAULT_SCORE) {
companion object {
private const val DEFAULT_SCORE = 100
}
}
Les deux solutions fonctionnent, mais la première est un peu plus Kotlin-Friendly que la seconde ! ;)
Pour terminer, sachez également qu’un objet compagnon peut avoir un nom et même implémenter des interfaces :
interface ModelFactory<T> { fun newInstanceFromJson(json: String) : T }
data class User(var email: String, var password: String, var isConnecetd: Boolean) {
companion object Factory: ModelFactory<User> {
override fun newInstanceFromJson(json: String): User = ...
}
}
Par exemple, ici, nous avons créé une interface générique appelée ModelFactory
(plus d’informations sur les interfaces en Kotlin à ce lien) qui sera implémentée par l’objet compagnon (portant désormais le nom de Factory
) de notre classe User
.
Cette interface contient symboliquement, et pour l’exemple, une méthode appelée newInstanceFromJson
dont l’objectif sera de retourner un objet User
construit à partir d’un JSON.
Il n'y a pas à dire, Kotlin, c’est vraiment pratique ! 🙌
Un Singleton par-ci, un Singleton par-là !
Il est très probable qu’en tant que développeur Java, vous ayez rencontré au moins une fois la notion de Singleton permettant de limiter l’instanciation d’une classe à un seul et même objet.
On le rencontre très souvent en combinaison avec le patron de conception DAO (design pattern) permettant de centraliser l’accès à certaines données, comme une base de données. :)
C’est vrai, ça ! Mais pourquoi tu nous parles de ça ?
Eh bien, en Kotlin, la création d’un Singleton va s’avérer, comment dire… très simple ! ;)
Dans cet exemple, nous avons imaginé un DAO contenant les requêtes que nous pourrions faire sur une base de données pour la table User
. Ce DAO sera également un Singleton.
Remarquez la différence entre les deux déclarations Java et Kotlin ! Comme à son habitude, Kotlin nous permet d’écrire moins de code, pour un résultat encore plus lisible. :) Afin de créer un Singleton en Kotlin, il vous suffira d’utiliser le mot-clé object
devant le nom de l’objet en question. Pas besoin d’utiliser le mot-clé class
!
Puis, afin d’appeler les différentes fonctions publiques se trouvant à l’intérieur de notre Singleton, il vous suffit de renseigner dans votre code le nom de votre Singleton suivi d’un point .
et du nom de la méthode, un peu comme vous le faisiez avec les méthodes publiques statiques en Java.
Practice Makes Perfect!
La théorie, c'est bien, mais la pratique c'est encore mieux ! Justement, nous vous avons concocté un petit exercice interactif de fin de chapitre pour appliquer toutes ces nouvelles connaissances.
🎓Un petit exercice, ça vous tente ? C'est par ici que ça se passe.
🎁Déjà terminé ? Le corrigé se trouve juste ici.
En résumé
En Kotlin, toutes les classes et leurs méthodes sont "fermées" à l'héritage par défaut (équivalent du mot-clé
final
en Java). Pour les ouvrir à l’héritage, il faut utiliser le mot-cléopen
.L’héritage en Kotlin s’effectue grâce aux deux points
:
suivis du nom de la classe parente et de son constructeur entre parenthèses()
; par exemple :class CircleButton : Button()
.Il est possible d’ajouter un second constructeur (appelé également "constructeur secondaire") en Kotlin grâce au mot-clé
constructor
, ainsi qu’un bloc d’initialisationinit
qui sera appelé une fois que l’objet sera construit.Le mot-clé
data
devant le nom d’une classe l’indique comme pouvant contenir des données (modèles) : le compilateur Kotlin implémentera automatiquement à notre place les principales méthodes utilisées pourcomparer
etdécrire
un objet, comme les méthodestoString()
,hashCode()
,equals()
ou encorecopy()
.Un objet compagnon (ou "companion object") permet de créer des propriétés ou des méthodes à l’intérieur d’une classe, accessibles même si aucune instance de cette classe n'existe (équivalent du mot-clé
static
).Le mot-clé
object
suivi d’un nom permettra de créer automatiquement une classe de type Singleton.
Pour aller plus loin
KotlinLang : Classes and Inheritance, Data Classes, Interface, Object