Comment aborder les wrappers pour les propriétés Swift

Publié: 2022-03-11

En termes simples, un wrapper de propriété est une structure générique qui encapsule l'accès en lecture et en écriture à la propriété et lui ajoute un comportement supplémentaire. Nous l'utilisons si nous devons limiter les valeurs de propriété disponibles, ajouter une logique supplémentaire à l'accès en lecture/écriture (comme l'utilisation de bases de données ou des valeurs par défaut de l'utilisateur), ou ajouter des méthodes supplémentaires.

Enveloppes de propriété dans Swift 5.1

Cet article concerne une nouvelle approche Swift 5.1 des propriétés d'encapsulation, qui introduit une nouvelle syntaxe plus propre.

Ancienne approche

Imaginez que vous développez une application et que vous disposez d'un objet contenant des données de profil utilisateur.

 struct Account { var firstName: String var lastName: String var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email = "[email protected]" print(account.email)

Vous souhaitez ajouter une vérification par e-mail. Si l'adresse e-mail de l'utilisateur n'est pas valide, la propriété email doit être nil . Ce serait un bon cas pour utiliser un wrapper de propriété pour encapsuler cette logique.

 struct Email<Value: StringProtocol> { private var _value: Value? init(initialValue value: Value?) { _value = value } var value: Value? { get { return validate(email: _value) ? _value : nil } set { _value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64}" let pred = NSPredicate(format: "SELF MATCHES %@", regex) return pred.evaluate(with: email) } }

Nous pouvons utiliser ce wrapper dans la structure du compte :

 struct Account { var firstName: String var lastName: String var email: Email<String> }

Maintenant, nous sommes sûrs que la propriété email ne peut contenir qu'une adresse email valide.

Tout semble bon, sauf la syntaxe.

 let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)

Avec un wrapper de propriété, la syntaxe d'initialisation, de lecture et d'écriture de ces propriétés devient plus complexe. Alors, est-il possible d'éviter cette complication et d'utiliser des wrappers de propriété sans changement de syntaxe ? Avec Swift 5.1, la réponse est oui.

La nouvelle façon : @propertyWrapper Annotation

Swift 5.1 fournit une solution plus élégante pour créer des wrappers de propriété, où le marquage d'un wrapper de propriété avec une annotation @propertyWrapper est autorisé. Ces wrappers ont une syntaxe plus compacte par rapport aux traditionnels, ce qui se traduit par un code plus compact et compréhensible. L'annotation @propertyWrapper n'a qu'une seule exigence : votre objet wrapper doit contenir une propriété non statique appelée wrappedValue .

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Pour définir une telle propriété enveloppée dans le code, nous devons utiliser la nouvelle syntaxe.

 @Email var email: String?

Donc, nous avons marqué la propriété avec l'annotation @ . Le type de propriété doit correspondre au type de wrapper "wrappedValue". Maintenant, vous pouvez travailler avec cette propriété comme avec l'ordinaire.

 email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil

Super, ça a l'air mieux maintenant qu'avec l'ancienne approche. Mais notre implémentation de wrapper a un inconvénient : elle n'autorise pas de valeur initiale pour la valeur enveloppée.

 @Email var email: String? = "[email protected]" //compilation error.

Pour résoudre ce problème, nous devons ajouter l'initialiseur suivant au wrapper :

 init(wrappedValue value: Value?) { self.value = value }

Et c'est tout.

 @Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil

Le code final du wrapper est ci-dessous :

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? init(wrappedValue value: Value?) { self.value = value } var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Emballages configurables

Prenons un autre exemple. Vous écrivez un jeu et vous avez une propriété dans laquelle les scores des utilisateurs sont stockés. L'exigence est que cette valeur soit supérieure ou égale à 0 et inférieure ou égale à 100. Vous pouvez y parvenir en utilisant un wrapper de propriété.

 @propertyWrapper struct Scores { private let minValue = 0 private let maxValue = 100 private var value: Int init(wrappedValue value: Int) { self.value = value } var wrappedValue: Int { get { return max(min(value, maxValue), minValue) } set { value = newValue } } } @Scores var scores: Int = 0

Ce code fonctionne mais il ne semble pas générique. Vous ne pouvez pas le réutiliser avec des contraintes différentes (pas 0 et 100). De plus, il ne peut contraindre que des valeurs entières. Il serait préférable d'avoir un wrapper configurable qui peut contraindre tout type conforme au protocole Comparable. Pour rendre notre wrapper configurable, nous devons ajouter tous les paramètres de configuration via un initialiseur. Si l'initialiseur contient un attribut wrappedValue (la valeur initiale de notre propriété), il doit être le premier paramètre.

 @propertyWrapper struct Constrained<Value: Comparable> { private var range: ClosedRange<Value> private var value: Value init(wrappedValue value: Value, _ range: ClosedRange<Value>) { self.value = value self.range = range } var wrappedValue: Value { get { return max(min(value, range.upperBound), range.lowerBound) } set { value = newValue } } }

Pour initialiser une propriété enveloppée, nous définissons tous les attributs de configuration entre parenthèses après l'annotation.

 @Constrained(0...100) var scores: Int = 0

Le nombre d'attributs de configuration est illimité. Vous devez les définir entre parenthèses dans le même ordre que dans l'initialiseur.

Accéder à l'emballage lui-même

Si vous avez besoin d'accéder au wrapper lui-même (et non à la valeur enveloppée), vous devez ajouter un trait de soulignement avant le nom de la propriété. Par exemple, prenons notre structure de compte.

 struct Account { var firstName: String var lastName: String @Email var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email // Wrapped value (String) account._email // Wrapper(Email<String>)

Nous avons besoin d'accéder au wrapper lui-même afin d'utiliser les fonctionnalités supplémentaires que nous lui avons ajoutées. Par exemple, nous voulons que la structure du compte soit conforme au protocole Equatable. Deux comptes sont égaux si leurs adresses e-mail sont identiques, et les adresses e-mail doivent être insensibles à la casse.

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }

Cela fonctionne, mais ce n'est pas la meilleure solution car nous devons nous rappeler d'ajouter une méthode lowercased() partout où nous comparons des emails. Une meilleure façon serait de rendre la structure de l'e-mail équivalente :

 extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }

et comparez les wrappers au lieu des valeurs enveloppées :

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }

Valeur projetée

L'annotation @propertyWrapper fournit un autre sucre de syntaxe - une valeur projetée. Cette propriété peut avoir n'importe quel type que vous voulez. Pour accéder à cette propriété, vous devez ajouter un préfixe $ au nom de la propriété. Pour expliquer comment cela fonctionne, nous utilisons un exemple du framework Combine.

Le wrapper de propriété @Published crée un éditeur pour la propriété et le renvoie en tant que valeur projetée.

 @Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher

Comme vous pouvez le voir, nous utilisons un message pour accéder à la propriété enveloppée et un $message pour accéder à l'éditeur. Que devez-vous faire pour ajouter une valeur projetée à votre emballage ? Rien de spécial, il suffit de le déclarer.

 @propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }

Comme indiqué précédemment, la propriété projectedValue peut avoir n'importe quel type en fonction de vos besoins.

Limites

La syntaxe des nouveaux wrappers de propriété semble bonne mais elle contient également plusieurs limitations, les principales étant :

  1. Ils ne peuvent pas participer à la gestion des erreurs. La valeur enveloppée est une propriété (pas une méthode), et nous ne pouvons pas marquer le getter ou le setter comme throws . Par exemple, dans notre exemple d'e- Email , il n'est pas possible de générer une erreur si un utilisateur essaie de définir un e-mail invalide. Nous pouvons renvoyer nil ou planter l'application avec un appel fatalError() , ce qui peut être inacceptable dans certains cas.
  2. L'application de plusieurs wrappers à la propriété n'est pas autorisée. Par exemple, il serait préférable d'avoir un wrapper @CaseInsensitive séparé et de le combiner avec un wrapper @Email au lieu de rendre le wrapper @Email insensible à la casse. Mais de telles constructions sont interdites et conduisent à des erreurs de compilation.
 @CaseInsensitive @Email var email: String?

Pour contourner ce cas particulier, nous pouvons hériter du wrapper Email du wrapper CaseInsensitive . Cependant, l'héritage a également des limites : seules les classes prennent en charge l'héritage et une seule classe de base est autorisée.

Conclusion

Les annotations @propertyWrapper simplifient la syntaxe des wrappers de propriété et nous pouvons opérer avec les propriétés enveloppées de la même manière qu'avec les propriétés ordinaires. Cela rend votre code, en tant que développeur Swift, plus compact et compréhensible. En même temps, il a plusieurs limites dont nous devons tenir compte. J'espère que certains d'entre eux seront corrigés dans les futures versions de Swift.

Si vous souhaitez en savoir plus sur les propriétés Swift, consultez la documentation officielle.