Découverte de Swift (11)
Suite de ma série de billets sur ma découverte de Swift, mais aussi suite du chapitre sur l'initalisation et les initialisateurs. Aujourd'hui, on s'attaque au délicat sujet de l'initialisation associé à l'héritage.
Commençons par une règle déjà abordée dans le précédent billet : toutes les propriétés stockées d'une classe doivent avoir une valeur affectée à l'issue de l'initialisation d'une instance.
Dans le cas des classes, Swift distingue 2 types d'initialisateurs : – designated initializers – convenience initializers
Initialisateurs designated et convenience
Les initialisateurs de type designated sont les principaux initialisateurs d'une classe. Ils initialisent totalement toutes les propriétés introduites dans cette classe puis appellent un initialisateur de la classe-mère pour poursuivre le processus d'initialisation en remontant l'arbre généalogique de la classe.
Un initialisateur de type designated se déclare avec la syntaxe suivante :
init(parameters) {
statements
}
Toutes les classes doivent avoir au moins un initialisateur de type designated.
Les initialisateurs de type convenience sont les initialisateurs secondaires d'une classe. Ils peuvent appeler un initialisateur de type designated en lui passant par exemple des valeurs par défaut ou en transformant au préalable les informations reçues dans un paramètre d'un autre type que celui attendu par l'initialisateur designated de la classe.
Un initialisateur de type convenience se déclare avec la syntaxe suivante :
convenience init(parameters) {
statements
}
Il n'est pas obligatoire pour une classe d'avoir un initialisateur de type convenience.
Délégation d'initialisation pour les classes
Swift s'appuie sur 3 règles pour gérer les règles de délégation entre initialisateurs : 1. Un initialisateur designated doit appeler un initialisateur designated de sa classe-mère 2. Un initialisateur convenience doit appeler un initialisateur de la même classe 3. Un initialisateur convenience doit appeler, directement ou indirectement, un initialisateur designated
Une façon plus simple de l'exprimer serait : – les initialisateurs designated doivent déléguer vers le haut – les initialisateurs convenience doivent déléguer à l'horizontale
Initialisation en 2 phases
Avec Swift, l'initialisation d'une classe se déroule en 2 phases : 1. De bas en haut, chaque classe de l'arbre généalogique affecte une valeur à chacune des propriétés stockées qu'elle introduit : à la fin de cette phase, on est certain que chaque propriété stockée est initialisée avec une valeur par défaut 2. De haut en bas, chaque classe de l'arbre généalogique peut modifier la valeur d'une propriété stockée à laquelle elle a accès, c'est-à-dure les propriétés stockées qu'elle introduit et celles qu'elle hérite de sa classe-mère
Pour fonctionner, Swift met en place 4 contrôles de sécurité :
1. Un initialisateur designated doit s'assurer que toutes les propriétés stockées introduites par sa classe sont initialisées avant de déléguer à un initialisateur de sa classe-mère
2. Un initialisateur designated doit déléguer à un initialisateur de sa classe-mère avant d'affecter une valeur à une propriété héritée (dans le cas contraire, cette valeur sera écrasée par l'initialisateur de la classe-mère)
3. Un initialisateur convenience doit déléguer à un autre initialisateur avant d'affecter une valeur à une propriété, qu'elle soit introduite dans cette classe ou héritée d'une classe-mère (dans le cas contraire, cette valeur sera écrasée par l'initialisateur designated de la classe)
4. Un initialisateur ne peut appeler aucune méthode d'instance, ne peut lire aucune propriété d'instance, ni faire appeler à la référence self
, jusqu'à ce que la 1ère phase d'initialisation soit terminée
Enchainement des 2 phases d'initialisation
Concrètement, cela se déroule ainsi :
Phase 1
- Un initialisateur designated ou convenience est appelé sur une classe
- Un espace-mémoire est alloué pour une nouvelle instance de cette classe, mais le contenu de cet espace-mémoire n'est pas encore initialisé
- Un initialisateur designated (appelé directement ou par ricochet par un initialisateur convenience) confirme que toutes les propriétés stockées introduites par la classe possèdent bien une valeur ; l'espace-mémoire pour ces propriétés stockées est alors initialisé
- L'initialisateur designated appelle un initialisateur de la classe-mère, qui va réaliser les mêmes opérations pour ses propres propriétés stockées
- Cette logique continue tout le long de l'arbre généalogique de la classe, jusqu'à ce qu'on atteigne la classe de base, qui n'hérite pas d'une autre classe
- Une fois que la classe de base est atteinte, et que son initialisateur designated s'est assuré que toutes ses propriétées stockées possèdent une valeur, l'espace-mémoire de l'instance est totalement initialisé, et la phase 1 est terminée
Phase 2
- En refaisant tout le chemin dans l'autre chose, c'est-à-dire en redescendant tout l'arbre généalogique de la classe de base jusqu'à la classe-fille que l'on souhaite instancier, chaque initialisateur designated a la possibilité de personnaliser l'instance en modifiant les propriétés stockées (les siennes comme celles héritées de ses classes-mères), en appelant des méthodes d'instance, en accédant à la référence
self
, etc. - Les initialisateurs convenience ont également la possibilité de personnaliser l'instance de la même façon
Héritage d'un initialisateur et overriding
Swift ne propose pas d'héritage automatique de l'initialisateur par défaut. Cela évite de créer une instance incomplète d'une classe en appelant l'initialisateur par défaut hérité d'une classe-mère qui ne gère pas une propriété stockée introduite dans la classe-fille.
Il est toutefois possible de redéfinir un initialisateur designated par défaut dans la classe-fille avec le mot-clef override
, pour spécifier explictivement que cet initialisateur réimplémente l'initialisateur de la classe-mère.
Par contre, seuls les initialisateurs designated d'une classe-mère peuvent être appelés par un initialisateur d'une classe-fille, on peut être certain qu'un initialisateur convenience d'une classe-mère ne peut pas être appelé par un initialisateur d'une classe-fille : on ne risque donc pas d'appeler “par erreur” un initialisateur convenience d'une classe-mère, le mot-clef override
n'est donc pas nécessaire pour déclarer un initialisateur convenience, même s'il présente les mêmes paramètres d'un initialisateur convenience d'une classe-mère.
Après toutes ces explications très théoriques, voyons tout cela dans un exemple :
class Vehicle {
var numberofWheels = 0
var description: String {
return "\(numberofWheels) wheel(s)"
}
}
Cette classe Vehicle
définit simplement une propriété stockée pour gérer le nombre de roues, et une propriété calculée, en lecture seule, pour renvoyer une description synthétique dun véhicule.
Comme la classe ne définit aucun initialisateur explicitement, et qu'elle fournit une valeur par défaut pour sa propriété stockée, elle fournit automatiquement un initialisateur par défaut, que l'on peut utiliser pour initialiser une instance :
let vehicle = Vehicle()
print("Vehicle: \(vehicle.description)")
// Prints "Vehicle: 0 wheel(s)"
Définissons maintenant une classe-fille ayant Vehicle
pour classe-mère :
class Bicycle: Vehicle {
override init() {
super.init()
numberofWheels = 2
}
}
let bicycle = Bicycle()
print("Bicycle: \(bicycle.description)")
// Prints "Bicycle: 2 wheel(s)"
Dans la classe Bicycle
, on se contente de déclarer un initialisateur sans paramètre, qui vient redéfinir par le mot-clé override
l'initialisateur par défaut de Vehicle
. Dans cet initialisateur override
, nous appelons tout d'abords l'initialisateur par défaut de Vehicle
(c'est l'instruction super.init()
), avant de pouvoir modifier le nombre de roues, qui est une propriété stockée hétéritée de Vehicle
.
Cas particulier : si l'initialisateur d'une classe-fille ne modifie pas les propriétées stockées de sa classe-mère, et que cette classe-mère possède un initialisateur sans paramètre, il est possible de ne pas appeler explictivement super.init()
, celui-ci sera appelé implicitement après l'initialisation des propriétées stockées introduites dans la classe-fille. Par exemple :
class Hoverboard: Vehicle {
var color: String
init(color : String) {
self.color = color
// super.init() implicitly called here
}
override var description: String {
return "\(super.description) in a beautiful \(color)"
}
}
La classe Hoverboard
hérite de la classe Vehicle
dont elle se distingue par 3 aspects :
– elle introduit une propriété stockée supplémentaire : color
– elle définit un initialisateur qui initialise la propriété color
à partir d'un paramètre reçu, puis appelle implicitement l'initialisateur par défaut de Vehicle
– elle redéfinit la propriété calculée description
pour ajouter la notion de couleur dans la description
Quand on crée une instance de Hoverboard
, on se rend compte que la couleur est bien prise en compte dans la description et que le nombre de routes a été initialisé par l'initialisateur par défaut :
let hoverboard = Hoverboard(color: "silver")
print("Hoverboard : \(hoverboard.description)")
// Prints "Hoverboard : 0 wheel(s) in a beautiful silver"
Héritage automatique d'un initialisateur
Comme indiqué précédemment, une classe-fille n'hérite pas de l'initialisateur par défaut de leur classe-mère.
Cependant, les initilisateurs d'une classe-mère sont automatiquement hérités dans certaines conditions.
Tout d'abord, il faut que la classe-fille fournisse une valeur par défaut à toutes les propriétés stockées qu'elle introduit.
Ensuite, il y a 2 règles complémentaires :
1. Si la classe-fille ne fournit par d'initialisateur par défaut, alors elle hérite automatiquement de tous les initialisateurs designated de sa classe-mère
2. Si la classe-fille fournit une implémentation de tous les initialisateurs designated de sa classe-mère (soit en les héritant par la règle précédente, soit en les réimplémentant par override
), alors elle hérite de tous les initialisateurs convenience de sa classe-mère
Cela permet de réduire le nombre de cas où il est nécessaire de redéfinir un initialisateur, et d'hériter des initialisateurs d'une classe-mère avec un effort limité.
Un exemple concret et complet
Ce billet étant très théorique et dense, je souhaite terminer par un exemple pratique complet.
Nous allons définir des classes Food
, RecipeIngredient
et ShoppingListItem
, liées par héritage, et voir comment leurs initialisateurs intéragissent.
Une classe de base : Food
La classe de base est la classe Food
, elle modélise un aliment comestible, défini par son nom :
class Food {
var name: String
init(name: String) {
self.name = name
}
convenience init() {
self.init(name: "[Unnamed]")
}
}
Outre une propriété stockée pour stocker le nom de l'aliment, la classe Food
fournit 2 initialisateurs :
– un initialisateur designated qui alimente le nom de l'aliment avec le nom reçu en paramètre
– un initialisateur convenience
, sans paramètre, qui appelle l'initialisateur designated avec un nom indéfini
Un test rapide permet de constater que les 2 initialisateurs fonctionnent comme attendu :
let bacon = Food(name: "Bacon")
print(bacon.name)
// Prints "Bacon"
let mystery = Food()
print(mystery.name)
// Prints "[Unnamed]"
Une classe-fille : RecipeIngredient
La deuxième classe, RecipeIngredient
, est une classe-fille de Food
:
class RecipeIngredient: Food {
var quantity: Int
init(name: String, quantity: Int) {
self.quantity = quantity
super.init(name: name)
}
override convenience init(name: String) {
self.init(name: name, quantity: 1)
}
}
Cette classe introduit une nouvelle propriété stockée quantity
pour gérer la quantité de l'ingrédition nécessaire de la recette.
La classe définit un initialisateur designated qui reçoit en paramètre le nom de l'ingrédient et la quantité nécessaire. Cet initialisateur commence par affecter la quantité reçue en paramètre à la propriété stockée quantity
introduire dans cette classe, puis appelle l'initialisateur designated de la classe-mère.
Elle définit également un initialisateur convenience qui ne reçoit en paramètre que le nom de l'ingrédient, et appelle l'initialisateur designated de la même classe avec une quantité par défaut de 1
. Notons que comme cet initialisateur redéfinit avec les mêmes paramètres l'initialisateur designated de sa classe-mère, le mot-clef override
est spécifié.
Un point d'attention ici : la classe-mère RecipeIngredient
fournit une implémentation de tous les initialisateurs designated de sa classe-mère, c'est-à-dire l'initialisateur init(name: String)
, même s'il est défini en convenience dans la classe-fille. Elle hérite de tous les initialisateurs convenience de sa classe-mère, donc de l'initialisateur sans paramètre. Celui-ci fonctionne alors de la même façon, sauf que l'appel de self.init(name: String)
se fait alors sur la version deRecipeIngredient
et non sur celui de Food
Voyons cela concrètement :
let sixEggs = RecipeIngredient(name: "Eggs", quantity: 6)
print("\(sixEggs.quantity) x \(sixEggs.name)")
// Prints "6 x Eggs"
let oneBacon = RecipeIngredient(name: "Bacon")
print("\(oneBacon.quantity) x \(oneBacon.name)")
// Prints "1 x Bacon"
let oneMysteryItem = RecipeIngredient()
print("\(oneMysteryItem.quantity) x \(oneMysteryItem.name)")
// Prints "1 x [Unnamed]"
La première instance, les 6 oeufs, est créé avec l'initialisateur designated de RecipeIngredient
, avec le nom et la quantité transmis explicitement en paramètres.
La deuxième instance, le bacon, est créé avec l'initialisateur convenience de RecipeIngredient
, avec uniquement le nom transmis explictivement en paramètre. Cet initialisateur appelle ensuite l'initialisateur designated avec une quantité de 1
par défaut.
La troisième instance, l'ingrédient mystère, est créé avec l'initialisateur sans paramètre hérité de la classe Food
, celui-ci va créé un ingrédient avec un nom indéterminé puis passer la main à l'initialisateur init(name: String)
de RecipeIngredient
, qui va lui-même appeler l'initialisateur init(name : String, quantity: Int)
avec une quantité par défaut de 1.
Une autre classe-fille : ShoppingListItem
Créons maintenant une troisième classe ShoppingListItem
qui hérite de RecipeItem
:
class ShoppingListItem: RecipeIngredient {
var purchased = false
var description: String {
var output = "\(quantity) x \(name)"
output += purchased ? " ✔" : " ✘"
return output
}
}
Cette classe-fille introduit :
– une nouvelle propriété stockée de type Bool
pour modéliser le fait que l'ingrédient ait été acheté ou pas encore
– une propriété calculée pour restituer une ligne complète de la liste de course, avec la quantité, le nom de l'ingrédient, et le statut acheté ou pas
Cette classe fournit une valeur par défaut à sa nouvelle propriété stockée, et elle ne définit aucun initialisateur : elle hérite donc automatiquement de tous les initialisateurs de sa classe-mère, qu'ils soient designated ou convenience.
Nous pouvons ainsi construire une liste de courses pour le petit-déjeuner à partir des initialisateurs hérités :
var breakfastList = [
ShoppingListItem(),
ShoppingListItem(name: "Bacon"),
ShoppingListItem(name: "Eggs", quantity: 6),
]
for item in breakfastList {
print(item.description)
}
// Prints :
// 1 x [Unnamed] ✘
// 1 x Bacon ✘
// 6 x Eggs ✘
Le premier initialisateur ajoute à la liste un ingrédient indéterminé avec une quantité par défaut de 1
.
Le deuxième initialisateur ajoute à la liste un ingrédient avec le nom fourni et une quantité par défaut de 1
.
Le troisième initialisateur ajoute à la liste un ingrédient avec le nom et la quantité fournis.
Reconnaissons tout de même que cette liste de courses n'est pas très pratique avec cet ingrédient mystère ! Rien de nous empêche au moment de faire nos courses de compléter les informations qu'il nous manque :
breakfastList[0].name = "Orange juice"
breakfastList[0].purchased = true
Nous pouvons accéder aux propriétés de notre ingrédient mystère (le premier de notre liste de courses, d'où l'utilisation de l'index 0
pour y accéder) pour modifier le nom (l'ingrédient mystère était en fait du jus d'orange !) et indiquer que nous l'avons acheté.
Après avoir retrouvé la mémoire dans les rayons du magasin, notre liste est désormais à jour :
for item in breakfastList {
print(item.description)
}
// Prints
// 1 x Orange juice ✔
// 1 x Bacon ✘
// 6 x Eggs ✘
Et la suite ?
J'avais prévu de terminer le chapitre sur l'initialisation avec ce billet, mais cette partie sur les classes était encore plus dense que prévu, j'ai préféré m'arrêter une nouvelle fois.
Je garde pour le prochain billet les quelques subtilités qui terminent le chapitre : il y sera question d'initialisateurs faillibles, d'initialisateurs requis, et d'utilisation d'une fonction ou d'une closure pour définir la valeur par défaut d'une propriété.
Zéro Tech, le blog Tech de Zéro Janvier – @zerojanvier@diaspodon.fr