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 initializersconvenience 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

Phase 2

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 Foodfournit 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 ShoppingListItemqui 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é.

#swift

Zéro Tech, le blog Tech de Zéro Janvier@zerojanvier@mamot.fr