Découverte de Swift (6)

Au programme de ma découverte de Swift aujourd'hui : les propriétés, un sujet bien plus riche que je l'aurais cru en commençant ce chapitre.

Qu'est ce qu'une propriété ?

Pour définir brièvement ce qu'est une propriété dans Swift, il s'agit de ce qui permet de stocker ou de calculer à la demande une valeur dans une classe, une structure ou une énumération.

Nous allons voir dans la suite comment des fonctionnalités très intéressantes peuvent se cacher derrière cette définition simple en apparence.

Propriétés stockées

Commençons simplement, avec une structure définie avec deux propriétés stockées simples :

struct FixedLengthRange {
    var firstValue: Int
    let length: Int
}

var rangeOfThreeItems = FixedLengthRange(firstValue: 0, length: 3)
// This range represents 3 integer values starting from 0 : 0, 1 ,2

rangeOfThreeItems.firstValue = 6
// This range represents 3 integer values starting from 6 : 6, 7, 8

Rien de bien compliqué ici : la structure modélise une série de nombre entiers consécutifs ; pour cela elle stocke la valeur du premier entier et le nombre d'entiers de la série.

La propriété est accessible en lecture (pour récupérer sa valeur) et en écriture (pour mettre à jour sa valeur, comme nous le faisons dans l'exemple en modifiant la première valeur de la série de 0 à 6)

Propriétés stockées dans une constante

Si on crée une instance d'une structure et qu'on l'affecte à une constante, il est impossible de modifier la valeur de ses propriétés stockées, même si elle sont déclarées comme variables dans la structure.

En reprenant l'exemple de notre FixedLengthRange, en créant cette fois une instance constante :

let rangeOfFourItems = FixedLengthRange(firstValue:0, length: 4)
// This range represents 4 integer values starting from 0 : 0, 1, 2, 3, 4
// rangeOfFourItems.firstValue = 6
// !!! Reports an error, even though firstValue is a variable property, because RangeOfFourItems is a constant

Une tentative de modifier sa propriété firstValue envoie une erreur dès la compilation, alors que la même action fonctionnait lorsque l'instance de la structure était déclarée comme variable.

Propriétés 'lazy'

Une propriété est déclarée lazy si elle n'est pas initialisée avant la première fois qu'elle est utilisée. C'est une fonctionnalité qui peut être utile pour des propriétés qui sont complexes ou coûteuses à initialiser et/ou qui ne sont pas toujours utilisées, comme une fonctionnalité optionnelle.

Un exemple simplifié avec une classe DataManager qui comme son nom l'indique permet de gérer des données. Parmi les fonctionnalités fournies par cette classe, il y a une possibilité d'importer des données à partir d'un fichier. Cette fonctionnalité est gérée dans une propriété de importer qui est une instance d'une classe DataImporter que l'on peut considérer comme lourde ou complexe.

Dans ce cas, le choix a été fait de déclarer cette propriété importer comme lazy, afin d'économiser l'initialisation du DataImporter dans les cas où la fonctionnalité d'import par fichier ne serait pas utilisée. C'est seulement lors de l'utilisation de cette fonctionnalité, lorsque la propriété importer est utilisée pour la première fois, que celle-ci est initialisée.

class DataImporter {
    var fileName = "data.txt"
}

class DataManager {
    lazy var importer = DataImporter()
    var data = [String]()
}

let manager = DataManager()
manager.data.append("Some data")
manager.data.append("Some more data")
// At this point, the DataImporter instance for the importer property has not yet been created

print(manager.importer.fileName)
// The DataImporter instance for the importer property has now been created
// Prints "data.txt"

Propriétés calculées

Une propriété calculée ne stocke aucune valeur, mais met à disposition d'une méthode get pour être calculée et lue à la demande, et éventuellement une méthode set pour réaliser des modifications sur d'autres propriétés stockées sur la base d'une nouvelle valeur.

Un exemple sera plus parlant :

struct Point {
    var x = 0.0, y = 0.0
}

struct Size {
    var width = 0.0, height = 0.0
}

struct Rectangle {
    var origin = Point()
    var size = Size()
    
    var center: Point {
        get {
            let centerX = origin.x + (size.width / 2)
            let centerY = origin.y + (size.height / 2)
            return Point(x: centerX, y: centerY)
        }
        set(newCenter) {
            origin.x = newCenter.x - (size.width / 2)
            origin.y = newCenter.y - (size.height / 2)
        }
    }
}

Nous créons ici deux structures simples Point (avec 2 coordonnées x et y) et Size (avec une largeur et une hauteur). Puis nous créons une structure Rectangle qui définit 3 propriétés : – une propriété stockée origin de type Point : il s'agit du point d'origine du rectangle (son point en bas à gauche) – une propriété stockée size de type Size : il s'agit de la hauteur et de la largeur du rectangle – une propriété calculée center de type Point : il s'agit du centre du rectangle, qui dépend des deux propriétés stockées origine et size

La propriété calculée center met à disposition une méthode get en lecture et une méthode set en écriture : – en lecture, get renvoie le point calculé comme étant le centre du rectangle, en se basant sur le point d'origine et sur la taille du rectangle – en écriture, set reçoit le point qui est le nouveau centre du rectangle, et recalcule automatiquement le nouveau point d'origine du rectangle

Si on met cela à l'épreuve avec un petit test, on se rend que cela fonctionne comme attendu :

var square = Rectangle(origin: Point(x: 0.0, y: 0.0),
                       size: Size(width: 10.0, height: 10.0))
print("square.center is at (\(square.center.x), \(square.center.y))")
// Prints "square.center is at (5.0, 5.0)"

square.center = Point(x: 15.0, y: 15.0)
print("square.center is at (\(square.center.x), \(square.center.y))")
// Prints "square.center is at (15.0, 15.0)"
print("square.origin is at (\(square.origin.x), \(square.origin.y))")
// Prints "square.origin is at (10.0, 10.0)"

On crée un rectangle partant du point d'origine (0, 0) et faisant une taille de 10, 10

Si on interroge le rectangle pour connaitre son centre, il est bien positionné au point (5, 5)

Si on modifie le centre du rectangle pour le positionner au point (15, 15), le point d'origine a été automatiquement recalculé en (10, 10) pour prendre en compte le déplacement du rectangle.

Propriété calculée en lecture seule

Dans notre exemple précédent, la propriété calculée center était accessible à la fois en lecture et en écriture, car le cas d'usage s'y prêtait. Il est toutefois possible de définir une propriété calculée accessible uniquement en lecture.

Par exemple dans le cas suivant :

struct Cuboid {
    var width = 0.0, height = 0.0, depth = 0.0
    var volume: Double {
        return width * height * depth
    }
}

let CuboidOfFourByFiveByTwo = Cuboid(width: 4.0, height: 5.0, depth: 2.0)
print ("The volume of CuboidOfFourByFiveByTwo is \(CuboidOfFourByFiveByTwo.volume)")
// Prints "The volume of CuboidOfFourByFiveByTwo is 40.0"

Nous gérons une forme 3D dans une structure ayant 3 propriétés stockées pour la largeur, la hauteur et la profondeur.

La structure met également à disposition un get pour une propriété calculée permettant de connaître le volume de la forme, en multipliant la taille des 3 dimensions.

Le volume n'est accessible qu'en lecture puisqu'en mise à jour nous serions incapables de l'exploiter : connaître le nouveau volume ne nous dit pas quelle(s) dimension(s) il faudrait modifier.

Observers

Les observers sont des méthodes spéciales qui permettent de réagir lorsque la valeur d'une propriété est mise à jour (même si la nouvelle valeur est identique à l'ancienne).

Il y a deux observers possibles, selon le moment où l'action est souhaitée : – willSet est appelée juste avant que la nouvelle valeur soit stockée : elle reçoit la nouvelle valeur en paramètre sous la forme d'une constante (donc non modifiable) – didSet est appelée juste après que la nouvelle valeur soit stockée : elle reçoit l'ancienne valeur en paramètre sous la forme d'une constante, donc non modifiable ; par contre la nouvelle valeur est modifiable

Prenons un exemple concret avec une classe modélisant un compteur de pas :

class StepCounter {
    var totalSteps = 0 {
        willSet(newTotalSteps) {
            print("About to update totalSteps to \(newTotalSteps) steps")
        }
        didSet {
            if totalSteps > oldValue {
                print("Added \(totalSteps - oldValue) steps")
            }
            else {
                print("No new steps this time")
            }
        }
    }
}

Cette classe se limite à une propriété stockée comptabilisant le nombre total de pas. Deux observers sont définis sur cette propriété : – willSet affiche un message précisant le nouveau total de pas lorsque celui-ci va être mis à jour – didSet affiche un message indiquant le nombre de pas ajoutés, dans le cas où le nouveau total de pas est supérieur à l'ancien, ou dans le cas contraire un message indiquant qu'aucun nouveau pas n'a été ajouté depuis la dernière fois

En action, cela donne ceci :

let stepCounter = StepCounter()
stepCounter.totalSteps = 200
// Prints "About to update totalSteps to 200 steps"
// Prints "Added 200 steps"

stepCounter.totalSteps = 300
// Prints "About to update totalSteps to 300 steps"
// Prints "Added 100 steps"

stepCounter.totalSteps = 300
// Prints "About to update totalSteps to 300 steps"
// Prints "No new steps this time"

Wrappers

Les wrappers permettent d'encapsuler des comportements spécifiques et de les réutiliser pour plusieurs propriétés. Cela me semble un bon moyen de factoriser du code pour des fonctionnalités récurrentes.

Prenons un exemple simple, où nous voulons gérer des formes géométriques dont les dimensions doivent pouvoir être plafonnées à une taille maximale.

Pour cela, nous allons commencer par définir une structure taguée @propertyWrapper pour encapsuler le comportement d'une dimension plafonnée :

@propertyWrapper
struct SmallNumber {
    private var maximum: Int
    private var number: Int
    
    var wrappedValue: Int {
        get { return number}
        set { number = min(newValue, maximum) }
    }
    
    init() {
        maximum = 12
        number = 0
    }
    
    init(wrappedValue: Int) {
        maximum = 12
        number = min(wrappedValue, maximum)
    }
    
    init(wrappedValue: Int, maximum: Int) {
        self.maximum = maximum
        number = min(wrappedValue, maximum)
    }
}

Cette structure contient 2 propriétés stockées privées : – maximum qui contient la valeur maximale que peut contenir ce nombre – number qui contient la valeur actuelle du nombre

A cela s'ajoute une propriété calculée wrappedValue, obligatoire et dont le nom est imposé par le fait de définir la structure comme @propertyWrapper. Cette propriété calculée est servie par : – un get qui se contente de renvoyer la valeur actuelle du nombre (number) – un set qui met à jour la valeur actuelle du nombre, en le plafonnant à la valeur maximale autorisée (si la valeur reçue est supérieure, c'est la valeur maximale autorisée qui est retenue)

La structure se présente avec 3 initialisateurs pour créer une instance en précisant ou pas la valeur actuelle du nombre (0 par défaut si non précisée) et la valeur maximale autorisée (12 par défaut si non précisée)

A partir de cette structure SmallNumber nous pouvons créer une autre structure SmallRectangle qui va en utiliser les fonctionnalités :

struct SmallRectangle {
    @SmallNumber var height: Int = 1
    @SmallNumber(maximum: 9) var width: Int = 2
}

La structure SmallRectangle possède 2 propriétés stockées : – height qui est un Small Number initialisé avec une valeur de 1 ; sa valeur maximale n'est pas précisée à l'initialisation, c'est donc celle par défaut : 12width qui est aussi un SmallNumber mais cette fois-ci initialisé avec une valeur de 2 et une valeur maximale de 9

Si on essaye de modifier plusieurs fois la largeur de ce rectangle, on constate que la valeur maximale de 9 est bien prise en compte :

var rectangle = SmallRectangle()
print("Un rectangle de largeur \(rectangle.width)")
// Prints "Un rectangle de largeur 2"

rectangle.width = 5
print("Un rectangle de largeur \(rectangle.width)")
// Prints "Un rectangle de largeur 5"

rectangle.width = 15
print("Un rectangle de largeur \(rectangle.width)")
// Prints "Un rectangle de largeur 9"

Passer d'une largeur de 2 à 5 ne pose aucun problème, le rectangle est bien modifié en conséquence.

Par contre quand on essaye de modifier la largeur à 15, soit au dessus du maximum autorisé de 9, la largeur est fixée à 9.

Ainsi, nous avons bien encapsulé dans la structure SmallNumber des caractéristiques (valeur maximale) et des comportements (plafonnement à la valeur maximale) que nous pouvons réutiliser pour plusieurs priorités d'autres structures ou classes, avec un niveau de personnalisation intéressant (comme l'illustre le maximum différent entre la hauteur et la largeur de notre rectangle).

Valeur projetée d'un Wrapper

Une autre subtilité des wrappers, c'est la possibilité d'exposer une valeur dite projetée pour une propriété.

Restons sur notre exemple de nombre plafonné à un maximum. Pour simplifier un peu le code et se concentrer sur la notion de valeur projetée, je suis revenu à une définition de la structure où la valeur maximale autorisée n'est pas paramétrable, elle est toujours de 12 :

@propertyWrapper
struct ProjectedSmallNumber {
    private var number: Int
    var projectedValue: Bool
    
    init() {
        self.number = 0
        self.projectedValue = false
    }
    
    var wrappedValue: Int {
        get { return number }
        set {
            if newValue > 12 {
                number = 12
                projectedValue = true
            }
            else {
                number = newValue
                projectedValue = false
            }
        }
    }
}

On retrouve notre propriété privée number qui est la valeur actuelle du nombre. S'ajoute à cela : – une propriété stockée de type Bool nommée projectedValue – un initialisateur par défaut qui initialise number à 0 et projectedValue à false – une propriété calculée wrappedValue avec : – un get qui renvoie toujours la valeur de number – un set dont le fonctionnement diffère la nouvelle valeur souhaitée : – si elle dépasse 12(maximum autorisé) : number est fixé à 12 et projectedValue passe à true – sinon, number est mis à jour avec la nouvelle valeur et projectedValue passe à false

L'objectif de tout cela est de disposer d'un indicateur booléen pour savoir si le nombre a été plafonné ou pas lors de sa dernière mise à jour.

On peut tester cela en utilisant notre structure ProjectedSmallNumber pour la propriété d'une nouvelle structure :

struct SomeStructure {
    @ProjectedSmallNumber var someNumber: Int
}

var someStructure = SomeStructure()

someStructure.someNumber = 4
print("Has the number been limited to its maximum ? \(someStructure.$someNumber)")
// Prints "Has the number been limited to its maximum ? false"

someStructure.someNumber = 55
print("Has the number been limited to its maximum ? \(someStructure.$someNumber)")
// Prints "Has the number been limited to its maximum ? true"

On fait appel à la valeur projetée du ProjectedSmallNumber avec le préfixe $.

Ainsi, dans notre première itération, avec une valeur de 4, $someNumber, qui correspond au booléen projectedValue projeté par ProjectedSmallNumber, est false, cela signifique que la valeur 4 n'a pas été plafonnée.

Par contre, lors de la seconde itération, quand on essaye de mettre à jour le nombre avec une valeur de 55, la valeur projetée renvoie true, avertissant ainsi que la valeur a été plafonnée.

Cela m'a l'air particulièrement puissant, même si j'ai encore du mal à voir comment je vais pouvoir m'en servir concrètement. Ou si je penserai à utiliser ce concept lorsque je pourrais en avoir besoin :–)

Propriétés statiques

Une propriété statique est une particularité : elle a une valeur unique pour toutes les instances d'une structure ou d'une classe. C'est un bon moyen de stocker des données communes à plusieurs instances. Elles se déclarent avec le mot-clé static.

Un exemple concret avec une structure permettant de gérer le volume de plusieurs canaux audios :

struct AudioChannel {
    static let maxPossibleLevel = 10
    static var maxLevelForAllChannels = 0
    
    var currentLevel: Int = 0 {
        didSet {
            if currentLevel > AudioChannel.maxPossibleLevel {
                // cap the new audio level to the max possible level
                currentLevel = AudioChannel.maxPossibleLevel
            }
            if currentLevel > AudioChannel.maxLevelForAllChannels {
                // store this new level as the new overall max level
                AudioChannel.maxLevelForAllChannels = currentLevel
            }
        }
    }
}

Outre une propriété stockée classique currentLevel qui contient le volume de chaque canal au niveau de son instance, la structure présente 2 propriétés statiques : – maxPossibleLevel est une constante statique qui définit le volume maximal autorisé pour tous les canaux audios (10) – maxLevelForAllChannels est une variable statique contient la volume maximal atteint par tous les canaux audios “en circulation” (c'est-à-dire parmi toutes les instances de la structure AudioChannel) ; à l'initialisation sa valeur est 0

Un observer didSet est également déclaré sur la propriété currentLevel. De cette façon, à chaque que le volume d'un AudioChannel (quel qu'il soit) est modifié, les règles codées dans l'observer sont appliquées : – si le nouveau volume est supérieur au volume maximal autorisé (maxPossibleLevel), le nouveau volume est plafonné au maximum autorisé – si le nouveau volume est supérieur au volume maximal actuel atteint par tous les canaux audios “en circulation” (maxLevelForAllChannels), alors ce nouveau volume devient le volume maximal actuel

Concrètement, cela donne ceci :

var leftChannel = AudioChannel()
var rightChannel = AudioChannel()

leftChannel.currentLevel = 7
print(leftChannel.currentLevel)
// Prints "7"
print(AudioChannel.maxLevelForAllChannels)
// Prints "7"

rightChannel.currentLevel = 11
print(rightChannel.currentLevel)
// Prints "10"
print(AudioChannel.maxLevelForAllChannels)
// Prints "10"

Nous créons deux AudioChannel, un pour la gauche, l'autre pour la droite. Par défaut, le volume des 2 canaux est à 0.

Quand nous montons le volume du canal gauche à 7, cette valeur devient à la fois celle du canal gauche et le volume maximal parmi tous les canaux.

Quand nous montons le volume du canal droit à 11, cette valeur est plafonnée à 10 puisqu'il s'agit de la valeur du maximum autorisé dans AudioChannel. 10 devient ainsi le volume du canal droit et le volume maximal parmi tous les canaux.

Et la suite ?

Je dois dire que j'ai été surpris par la richesse de ce chapitre sur les propriétés, et plutôt emballé par les possibilités que cela ouvre. J'espère être aussi enthousiaste après le prochain chapitre, consacré aux méthodes.

#swift

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