Come avvicinarsi ai wrapper per le proprietà Swift
Pubblicato: 2022-03-11In termini semplici, un wrapper di proprietà è una struttura generica che incapsula l'accesso in lettura e scrittura alla proprietà e aggiunge un comportamento aggiuntivo ad essa. Lo usiamo se dobbiamo vincolare i valori delle proprietà disponibili, aggiungere ulteriore logica all'accesso in lettura/scrittura (come l'utilizzo di database o impostazioni predefinite dell'utente) o aggiungere alcuni metodi aggiuntivi.
Questo articolo riguarda un nuovo approccio Swift 5.1 alle proprietà di wrapping, che introduce una nuova sintassi più pulita.
Vecchio approccio
Immagina di sviluppare un'applicazione e di avere un oggetto che contiene i dati del profilo utente.
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)
Vuoi aggiungere la verifica e-mail: se l'indirizzo e-mail dell'utente non è valido, la proprietà e- email
deve essere nil
. Questo sarebbe un buon caso per utilizzare un wrapper di proprietà per incapsulare questa logica.
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) } }
Possiamo usare questo wrapper nella struttura Account:
struct Account { var firstName: String var lastName: String var email: Email<String> }
Ora, siamo sicuri che la proprietà email può contenere solo un indirizzo email valido.
Tutto sembra a posto, tranne la sintassi.
let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)
Con un wrapper di proprietà, la sintassi per l'inizializzazione, la lettura e la scrittura di tali proprietà diventa più complessa. Quindi, è possibile evitare questa complicazione e utilizzare i wrapper di proprietà senza modifiche alla sintassi? Con Swift 5.1, la risposta è sì.
Il nuovo modo: @propertyWrapper Annotazione
Swift 5.1 fornisce una soluzione più elegante per la creazione di wrapper di proprietà, in cui è consentito contrassegnare un wrapper di proprietà con un'annotazione @propertyWrapper
. Tali wrapper hanno una sintassi più compatta rispetto a quelli tradizionali, risultando in un codice più compatto e comprensibile. L'annotazione @propertyWrapper
ha un solo requisito: l'oggetto wrapper deve contenere una proprietà non statica denominata 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) } }
Per definire tale proprietà avvolta nel codice, dobbiamo usare la nuova sintassi.
@Email var email: String?
Quindi, abbiamo contrassegnato la proprietà con l'annotazione @
email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil
Ottimo, ora sembra migliore rispetto al vecchio approccio. Ma la nostra implementazione del wrapper ha uno svantaggio: non consente un valore iniziale per il valore avvolto.
@Email var email: String? = "[email protected]" //compilation error.
Per risolvere questo problema, dobbiamo aggiungere il seguente inizializzatore al wrapper:
init(wrappedValue value: Value?) { self.value = value }
E questo è tutto.
@Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil
Il codice finale del wrapper è il seguente:
@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) } }
Wrapper configurabili
Facciamo un altro esempio. Stai scrivendo un gioco e hai una proprietà in cui sono memorizzati i punteggi degli utenti. Il requisito è che questo valore sia maggiore o uguale a 0 e minore o uguale a 100. È possibile ottenere ciò utilizzando un wrapper di proprietà.
@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
Questo codice funziona ma non sembra generico. Non puoi riutilizzarlo con vincoli diversi (non 0 e 100). Inoltre, può vincolare solo valori interi. Sarebbe meglio avere un wrapper configurabile in grado di vincolare qualsiasi tipo conforme al protocollo Comparable. Per rendere configurabile il nostro wrapper, dobbiamo aggiungere tutti i parametri di configurazione tramite un inizializzatore. Se l'inizializzatore contiene un attributo wrappedValue
(il valore iniziale della nostra proprietà), deve essere il primo parametro.

@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 } } }
Per inizializzare una proprietà avvolta, definiamo tutti gli attributi di configurazione tra parentesi dopo l'annotazione.
@Constrained(0...100) var scores: Int = 0
Il numero di attributi di configurazione è illimitato. È necessario definirli tra parentesi nello stesso ordine dell'inizializzatore.
Ottenere l'accesso al wrapper stesso
Se è necessario accedere al wrapper stesso (non al valore avvolto), è necessario aggiungere un carattere di sottolineatura prima del nome della proprietà. Ad esempio, prendiamo la nostra struttura Account.
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>)
Abbiamo bisogno dell'accesso al wrapper stesso per utilizzare la funzionalità aggiuntiva che abbiamo aggiunto ad esso. Ad esempio, vogliamo che la struttura dell'account sia conforme al protocollo Equatable. Due account sono uguali se i loro indirizzi e-mail sono uguali e gli indirizzi e-mail devono essere senza distinzione tra maiuscole e minuscole.
extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }
Funziona, ma non è la soluzione migliore perché dobbiamo ricordarci di aggiungere un metodo minuscolo() ogni volta che confrontiamo le e-mail. Un modo migliore sarebbe rendere la struttura dell'e-mail equiparabile:
extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }
e confronta i wrapper invece dei valori avvolti:
extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }
Valore previsto
L'annotazione @propertyWrapper
fornisce un altro zucchero di sintassi: un valore proiettato. Questa proprietà può avere qualsiasi tipo tu voglia. Per accedere a questa proprietà, è necessario aggiungere un prefisso $
al nome della proprietà. Per spiegare come funziona, utilizziamo un esempio dal framework Combine.
Il wrapper della proprietà @Published
crea un editore per la proprietà e lo restituisce come valore previsto.
@Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher
Come puoi vedere, utilizziamo un messaggio per accedere alla proprietà avvolta e un $messaggio per accedere al publisher. Cosa dovresti fare per aggiungere un valore previsto al tuo wrapper? Niente di speciale, basta dichiararlo.
@propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }
Come notato in precedenza, la proprietà projectedValue
può avere qualsiasi tipo in base alle tue esigenze.
Limitazioni
La nuova sintassi dei wrapper delle proprietà sembra buona ma contiene anche diverse limitazioni, le principali sono:
- Non possono partecipare alla gestione degli errori. Il valore avvolto è una proprietà (non un metodo) e non possiamo contrassegnare getter o setter come
throws
. Ad esempio, nel nostro esempio diEmail
, non è possibile generare un errore se un utente tenta di impostare un'e-mail non valida. Possiamo restituirenil
o arrestare in modo anomalo l'app con una chiamatafatalError()
, che potrebbe essere inaccettabile in alcuni casi. - Non è consentito applicare più wrapper alla proprietà. Ad esempio, sarebbe meglio avere un wrapper
@CaseInsensitive
separato e combinarlo con un wrapper@Email
invece di rendere il wrapper@Email
insensibile alle maiuscole e alle maiuscole. Ma costruzioni come queste sono vietate e portano a errori di compilazione.
@CaseInsensitive @Email var email: String?
Come soluzione alternativa per questo caso particolare, possiamo ereditare il wrapper Email
dal wrapper CaseInsensitive
. Tuttavia, anche l'ereditarietà ha dei limiti: solo le classi supportano l'ereditarietà ed è consentita solo una classe base.
Conclusione
Le annotazioni @propertyWrapper
semplificano la sintassi dei wrapper delle proprietà e possiamo operare con le proprietà avvolte allo stesso modo di quelle ordinarie. Questo rende il tuo codice, come sviluppatore Swift, più compatto e comprensibile. Allo stesso tempo, ha diversi limiti di cui dobbiamo tenere conto. Spero che alcuni di essi vengano corretti nelle future versioni di Swift.
Se desideri saperne di più sulle proprietà di Swift, consulta i documenti ufficiali.