Découverte de Swift (12)

Après quelques jours de pause, voici la suite et fin du chapitre sur l'initialisation, avec quelques subtilités sympathiques : initialisateurs faillibles, initialisateurs requis, et utilisation d'une fonction ou d'une closure pour définir la valeur par défaut d'une propriété

Initialisateurs faillibles

Il peut parfois être utile de définir une classe, une structure ou uné énumération dont l'initialisation peut échouer. Cet échec peut être dû à des valeurs de paramètres d'initialisation non conformes à une exigence, à l'absence d'une ressource externe nécessaire, ou toute autre raison qui empêche l'initialisation d'aller à son terme.

Pour répondre à ce besoin, il est possible de définir des initialisateurs dits faillibles, dans le sens où ils peuvent échouer. Pour déclarer un initialisateur faillible, il faut le déclarer comme init? au lieu de la syntaxe classique init.

Un initialisateur faillible renvoie un optional du type qu'il est censé initialiser, c'est-à-dire de la classe, de la structure ou de l'énumération pour laquelle il a été défini. Dans le code de cet initialisateur, on utilise return nil pour déclencher l'échec de l'initialisation.

Un exemple fourni en standard dans Swift est celui des initialisateurs par conversion pour les types numériques. Ils proposent un initialisation faillible init(exactly:) qui échoue si la valeur ne peut pas être maintenue exactement lors de la conversion, par exemple un nombre décimal qui est tronqué lors de sa conversion en nombre entier :

let wholeNumber: Double = 12345.0
let pi = 3.14159

if let valueMaintened = Int(exactly: wholeNumber) {
    print("\(wholeNumber) conversion to Int maintains value of \(valueMaintened)")
}
// Prints "12345.0 conversion to Int maintains value of 12345"

let valueChanged = Int(exactly: pi)
if valueChanged == nil {
    print("\(pi) conversion to Int does not maintain value")
}
// Prints "3.14159 conversion to Int does not maintain value"

Dans ce court extrait de code, nous testons cela avec 2 nombres décimaux : l'un peut être converti en entier sans que sa valeur ne soit tronqué, l'autre sera tronqué.

Ce qui fonctionne ici avec le type standard Int peut être reproduit avec une classe créée de toute pièce :

class Animal {
    
    let species: String
    
    init?(species: String) {
        if species.isEmpty { return nil }
        self.species = species
    }
}

let someCreature = Animal(species: "Giraffe")
if let giraffe = someCreature {
    print("An animal was initialized with a species of \(giraffe.species)")
}
// Prints "An animal was initialized with a species of Giraffe"

let anonymousCreature = Animal(species: "")
if anonymousCreature == nil {
    print("The anonymous creature could not be initialized")
}
// Prints "The anonymous creature could not be initialized"

L'initialisation de notre classe Animal peut échouer si l'espèce qui est passée en paramètre est une chaine de caractères vide. Il est alors possible de tester après une tentative d'initialisation si l'instance de la classe a bien été créée (cas de la girafe dans notre exemple) ou pas.

Initialisateur faillible pour une énumération

Il est possible d'utiliser un initialisateur faillible pour sélectionner le case adéquat d'une énumération à partir d'un ou plusieurs paramètre(s). L'initialisateur peut alors échouer si les paramètres fournis ne correspondent à aucun case de l'énumération.

Par exemple, avec l'énumération suivante qui permet de gérer les unités usuelles de température :

enum TemperatureUnit {
    
    case celcius, fahrenheit, kelvin
    
    init?(symbol: Character) {
        switch symbol {
        case "C":
            self = .celcius
        case "F":
            self = .fahrenheit
        case "K":
            self = .kelvin
        default:
            return nil
        }
    }
}

L'énumération gère 3 unités de température : les degrés celcius, fahrenheit et kelvin. L'initialisateur permet de sélectionner la bonne unité à partir de son symbole sur 1 caractère. Si le symbole reçu n'est pas celui d'une des 3 unités, l'initialisateur échoue en renvoyant nil.

On peut ensuite tester facilement un cas passant et un cas en erreur :

let celciusUnit = TemperatureUnit(symbol: "C")
if celciusUnit != nil {
    print("This is a defined temperature unit, so initialization succeeded")
}
// Prints "This is a defined temperature unit, so initialization succeeded"

let unknownUnit = TemperatureUnit(symbol: "X")
if unknownUnit == nil {
    print("This is not a defined temperature unit, so initialization failed")
}
// Prints "This is not a defined temperature unit, so initialization failed"

Initialisateur faillible pour une énumération avec valeur brute

Les énumérations avec valeur brute fournissent automatiquement un initialisateur faillible init?(rawValue:) qui prend en paramètre la valeur brute rawValue, sélectionne le case de l'énumération correspondant à cette valeur brute, ou déclenche une erreur si aucune valeur correspondante n'existe.

On peut réécrire notre exemple TemperatureUnit en utilisant des valeurs brutes de type Character pour gérer le symbole de chaque unité et utiliser directement l'initialisateur faillible par valeur brute :

enum TemperatureUnit: Character {
    
    case celcius = "C",
         fahrenheit = "F",
         kelvin = "K"
}

Par rapport à l'exemple précédent, on a clairement gagné en lisibilité et en simplicité.

Le test fonctionne de la même façon avec notre cas passant et notre cas en erreur :

let celciusUnit = TemperatureUnit(rawValue: "C")
if celciusUnit != nil {
    print("This is a defined temperature unit, so initialization succeeded")
}
// Prints "This is a defined temperature unit, so initialization succeeded"

let unknownUnit = TemperatureUnit(rawValue: "X")
if unknownUnit == nil {
    print("This is not a defined temperature unit, so initialization failed")
}
// Prints "This is not a defined temperature unit, so initialization failed"

Propagation de l'échec de l'initialisation

Un initialisateur faillible d'une classe, d'une structure ou d'une énumération peut déléguer une partie de l'initialisation à un autre initialisateur faillible de la même classe, structure ou énumération. Dans le même esprit, un initialisateur faillible d'une classe peut déléguer à un initialisateur faillible de la classe-mère.

Dans tous les cas, si l'un des initialisateurs dans la chaîne d'initialisation échoue, tout le processus d'initialisation échoue immédiatement et aucune autre code d'initialisation n'est exécuté.

Prenons un exemple avec une classe-mère Product et sa classe-fille Item :

class Product {
    
    let name: String
    
    init?(name: String) {
        if name.isEmpty { return nil}
        self.name = name
    }
}

class CartItem: Product {
    
    let quantity: Int
    
    init?(name: String, quantity: Int) {
        self.quantity = quantity
        super.init(name: name)
    }
}

La classe-mère Product fournit un initialisateur faillible, qui échoue si le nom de produit fourni en paramètre est vide.

La classe-fille CartItem fournit elle aussi un initialisateur faillible init() car elle utilise l'initialisateur faillible de Product, même si elle-même n'échoue pas explicitement en renvoyant nil.

On pourrait réécrire la classe-fille CartItempour qu'elle gère elle aussi un cas d'échec, par exemple si la quantité passée en paramètre est inférieure à 1 :

class CartItem: Product {
    
    let quantity: Int
    
    init?(name: String, quantity: Int) {
        if quantity < 1 { return nil }
        self.quantity = quantity
        super.init(name: name)
    }
}

On peut ensuite tester les 3 cas possibles : – l'initialisation avec succès, en fournissant le nom et la quantité – l'initialisation en échec à cause d'une quantité inférieure à 1 – l'initialisation en échec à cause d'un nom vide

if let twoSocks = CartItem(name: "sock", quantity: 2) {
    print("Item : \(twoSocks.name), quantity : \(twoSocks.quantity)")
}
// Prints "Item : sock, quantity : 2"

if let zeroShirts = CartItem(name: "shirt", quantity: 0) {
    print("Item : \(zeroShirts.name), quantity : \(zeroShirts.quantity)")
}
else {
    print("Unable to initialize zero shirts")
}
// Prints "Unable to initialize zero shirts"

if let oneUnknown = CartItem(name: "", quantity: 1) {
    print("Item : \(oneUnknown.name), quantity : \(oneUnknown.quantity)")
}
else {
    print("Unable to initialize one unknown product")
}
// Prints "Unable to initialize one unknown product"

Overriding d'un initialisateur faillible

Il est possible dans une classe-fille de redéfinir un initialisateur faillible d'une classe-mère, comme c'est le cas pour n'importe quel autre initialisateur. De plus, il est possible de redéfinir cet initialisateur faillible de la classe-mère comme un initialisateur non faillible dans la classe-fille.

Voyons comment cela fonctionne avec un exemple. Commençons par créer une classe Document :

class Document {
    
    var name: String?
    
    init() {}
    
    init?(name: String) {
        if name.isEmpty { return nil }
        self.name = name
    }
}

Cette classe propose : – une propriété stockée name de type String?, c'est-à-dire un optional de type String – un initialisateur sans paramètre qui ne contient aucun code, et laisse donc sa valeur par défaut à la propriété name, à savoir nil` – un initialisateur faillible qui reçoit en paramètre le nom du document : cet initialisateur échoue si le nom est vide

Ainsi, un Documentest valide son nom est renseigné ou s'il n'a pas de nom (nil). Par contre, il n'est pas possible d'avoir un document dont le nom est vide (ce qui est différent de ne pas avoir de nom avec nil).

Créons maintenant une classe-fille AutomaticallyNamedDocument qui hérite de Document :

class AutomaticallyNamedDocument: Document {
    
    override init() {
        super.init()
        self.name = "[Untitled]"
    }
    
    
    override init(name: String) {
        super.init()
        if name.isEmpty {
            self.name = "[Untitled]"
        }
        else {
            self.name = name
        }
    }
    
}

Cette classe redéfinit les 2 initialisateurs de sa classe-mère : – dans le cas de l'initialisateur sans paramètre, elle appelle l'initialisateur équivalent de la classe-mère, puis nomme le document avec un nom par défaut – dans le cas de l'initialisateur avec le nom en paramètre, elle appelle l'initialisateur sans paramètre de la classe-mère, puis affecte le nom, soit par celui qui est reçu en paramètre s'il n'est pas vide, soit par un nom par défaut dans le cas contraire

Ainsi, l'initialisateur init(name:) qui était faillible dans la classe-mère est redéfini en non faillible dans la classe fille, en mettant en place une règle particulière pour s'assurer que le document est toujours nommé.

Une autre façon d'écrire l'initialisateur sans paramètre dans la classe-fille serait de forcer l'optional que constitue l'initialisateur faillible de la classe-mère :

override init() {
	super.init(name: "[Untitled]")!
}

Avec cette syntaxe, le ! à la fin de l'appel de l'initialisateur de la classe-mère signifie que l'on sait que l'initialisateur ne va pas échouer et qu'on peut récupérer tel quel son résultat. En effet dans ce cas précis comme on passe en paramètre un nom de document par défaut non vide, on sait que l'initialisateur ne va pas échouer.

Initialisateurs requis

Le mot-clef required utilisé lors de la déclaration d'un initialisateur dans une classe permet d'indiquer que cet initialisateur est requis, c'est-à-dire qu'il doit être implémenté dans chaque classe-fille de cette classe. Le mot-clef est également utilisé lors de la déclaration d'un initialisateur requis d'une classe-fille pour indiquer que ses propres classes-filles devront aussi implémenter cet initialisateur.

A noter, qu'il n'est pas nécessaire d'utiliser le mot-clef override, normalement utilisé pour réimplémenter une méthode ou un initialisateur d'une classe-mère, dans le cas il s'agit d'un initialisateur required.

La syntaxe est la suivante :

class SuperClass {
	required init() {
	// some init operations
	}
}

class SubClass: SuperClass {
	init() {
	// some init operations
	}
}

Fonction / Closure pour la valeur par défaut d'une propriété

Si déterminer la valeur par défaut d'une propriété stockée nécessite des opérations personnalisées ou de mise au point, il est possible d'utiliser une closure ou une fonction globale pour fournir cette valeur personnalisée :

class SomeClass {
	let someProperty: SomeType = {
		// create a default value for someProperty inside this closure
		return someValue // someValue must be of same type as SomeType
	}
}

De cette façon, à chaque fois qu'une nouvelle instance est créée, la closure ou la fonction est appelée et la valeur qu'elle renvoie est affectée comme valeur par défaut à la propriété en question.

Généralement, ce genre de closure ou de fonction crée une valeur temporaire du même type que la propriété concernée, ajuste cette valeur temporaire pour obtenir le résultat souhaité, et retourne cette valeur pour qu'elle soit utilisée comme valeur par défaut de la propriété.

Nous pouvons mettre cela en pratique en créant une structure pour modéliser un échiquier. Cet échiquier est composé de 8 lignes et 8 colonnes, soit 64 cases. Nous allons modéliser cela par un tableau de 64 éléments. Chaque élément sera un booléen qui est true si la case est noire, false si elle est blanche.

struct ChessBoard {
    
    let boardColors: [Bool] = {
        var temporaryBoard = [Bool]()
        var isBlack = false
        for i in 1...8 {
            for j in 1...8 {
                temporaryBoard.append(isBlack)
                isBlack = !isBlack
            }
            isBlack = !isBlack
        }
        return temporaryBoard
    }()
    
    func squareIsBlackAt(row: Int, column: Int) -> Bool {
        return boardColors[(row * 8) + column]
    }
    
}

Nous utilisons une closure pour définir la valeur par défaut de la priorité boardColors qui est notre tableau de 64 booléens. Ce tableau est construit en bouclant sur les 8 lignes et sur les 8 colonnes, en commençant par une case blanche et en inversant la valeur du booléen à chaque changement de colonne et à chaque changement de ligne.

Quand on crée notre échiquier board avec l'initialisateur par défaut, le tableau boardColors est initialisé avec sa valeur par défaut, déterminé par le résultat de la closure que nous avons indiquée.

let board = ChessBoard()
print(board.squareIsBlackAt(row: 0, column: 1))
// Prints "true"
print(board.squareIsBlackAt(row: 7, column: 7))
// Prints "false"

Il est alors aisé d'utiliser la méthode squareIsBlackAt(row: Int, column: Int) pour interroger l'échiquier et savoir si la case à telle ligne et telle colonne est noire ou blanche.

Et la suite ?

Ainsi s'achève ce long chapitre sur l'initialisation qui a été dense, parfois complexe, mais franchement intéressant. La suite, c'est un chapitre bien plus court sur le processus de désinitialisation. Cela devrait donner un prochain billet plus court et sans doute plus facile à suivre.

#swift

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