Cómo acercarse a los contenedores para las propiedades de Swift
Publicado: 2022-03-11En términos simples, un contenedor de propiedad es una estructura genérica que encapsula el acceso de lectura y escritura a la propiedad y le agrega un comportamiento adicional. Lo usamos si necesitamos restringir los valores de propiedad disponibles, agregar lógica adicional al acceso de lectura/escritura (como usar bases de datos o valores predeterminados del usuario) o agregar algunos métodos adicionales.
Este artículo trata sobre un nuevo enfoque de Swift 5.1 para envolver propiedades, que presenta una sintaxis nueva y más limpia.
Viejo enfoque
Imagine que está desarrollando una aplicación y tiene un objeto que contiene datos de perfil de usuario.
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)
Desea agregar verificación de correo electrónico; si la dirección de correo electrónico del usuario no es válida, la propiedad de email
debe ser nil
. Este sería un buen caso para usar un contenedor de propiedades para encapsular esta lógica.
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) } }
Podemos usar este contenedor en la estructura de la cuenta:
struct Account { var firstName: String var lastName: String var email: Email<String> }
Ahora, estamos seguros de que la propiedad de correo electrónico solo puede contener una dirección de correo electrónico válida.
Todo se ve bien, excepto la sintaxis.
let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)
Con un contenedor de propiedades, la sintaxis para inicializar, leer y escribir dichas propiedades se vuelve más compleja. Entonces, ¿es posible evitar esta complicación y usar contenedores de propiedades sin cambios de sintaxis? Con Swift 5.1, la respuesta es sí.
La nueva forma: anotación @propertyWrapper
Swift 5.1 proporciona una solución más elegante para crear contenedores de propiedad, donde se permite marcar un contenedor de propiedad con una anotación @propertyWrapper
. Dichos envoltorios tienen una sintaxis más compacta en comparación con los tradicionales, lo que da como resultado un código más compacto y comprensible. La anotación @propertyWrapper
solo tiene un requisito: su objeto contenedor debe contener una propiedad no estática llamada 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) } }
Para definir dicha propiedad envuelta en el código, necesitamos usar la nueva sintaxis.
@Email var email: String?
Entonces, marcamos la propiedad con la anotación @
email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil
Genial, se ve mejor ahora que con el enfoque anterior. Pero nuestra implementación de contenedor tiene una desventaja: no permite un valor inicial para el valor envuelto.
@Email var email: String? = "[email protected]" //compilation error.
Para resolver esto, necesitamos agregar el siguiente inicializador al contenedor:
init(wrappedValue value: Value?) { self.value = value }
Y eso es.
@Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil
El código final del contenedor es el siguiente:
@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) } }
Envolturas configurables
Tomemos otro ejemplo. Está escribiendo un juego y tiene una propiedad en la que se almacenan las puntuaciones de los usuarios. El requisito es que este valor debe ser mayor o igual a 0 y menor o igual a 100. Puede lograr esto utilizando un contenedor de propiedades.
@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
Este código funciona pero no parece genérico. No puede reutilizarlo con diferentes restricciones (no 0 y 100). Además, solo puede restringir valores enteros. Sería mejor tener un contenedor configurable que pueda restringir cualquier tipo que se ajuste al protocolo Comparable. Para que nuestro contenedor sea configurable, necesitamos agregar todos los parámetros de configuración a través de un inicializador. Si el inicializador contiene un atributo wrappedValue
(el valor inicial de nuestra propiedad), debe ser el primer parámetro.

@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 } } }
Para inicializar una propiedad envuelta, definimos todos los atributos de configuración entre paréntesis después de la anotación.
@Constrained(0...100) var scores: Int = 0
El número de atributos de configuración es ilimitado. Debe definirlos entre paréntesis en el mismo orden que en el inicializador.
Obtener acceso al envoltorio en sí
Si necesita acceder al propio contenedor (no al valor envuelto), debe agregar un guión bajo antes del nombre de la propiedad. Por ejemplo, tomemos nuestra estructura de cuenta.
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>)
Necesitamos acceso al envoltorio en sí mismo para usar la funcionalidad adicional que le agregamos. Por ejemplo, queremos que la estructura de la cuenta se ajuste al protocolo Equatable. Dos cuentas son iguales si sus direcciones de correo electrónico son iguales y las direcciones de correo electrónico no deben distinguir entre mayúsculas y minúsculas.
extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }
Funciona, pero no es la mejor solución porque debemos recordar agregar un método en minúsculas () cada vez que comparemos correos electrónicos. Una mejor manera sería hacer que la estructura del correo electrónico sea equiparable:
extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }
y compare envoltorios en lugar de valores envueltos:
extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }
Valor Proyectado
La anotación @propertyWrapper
proporciona un azúcar de sintaxis más: un valor proyectado. Esta propiedad puede tener cualquier tipo que desee. Para acceder a esta propiedad, debe agregar un prefijo $
al nombre de la propiedad. Para explicar cómo funciona, usamos un ejemplo del marco Combine.
El contenedor de propiedad @Published
crea un publicador para la propiedad y lo devuelve como un valor proyectado.
@Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher
Como puede ver, usamos un mensaje para acceder a la propiedad envuelta y un mensaje $ para acceder al editor. ¿Qué debe hacer para agregar un valor proyectado a su envoltorio? Nada especial, solo declararlo.
@propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }
Como se señaló anteriormente, la propiedad projectedValue
puede tener cualquier tipo según sus necesidades.
Limitaciones
La sintaxis de los nuevos contenedores de propiedades se ve bien, pero también contiene varias limitaciones, las principales son:
- No pueden participar en el manejo de errores. El valor envuelto es una propiedad (no un método), y no podemos marcar el getter o setter como
throws
. Por ejemplo, en nuestro ejemplo deEmail
, no es posible generar un error si un usuario intenta configurar un correo electrónico no válido. Podemos devolvernil
o bloquear la aplicación con una llamadafatalError()
, lo que podría ser inaceptable en algunos casos. - No se permite aplicar varios envoltorios a la propiedad. Por ejemplo, sería mejor tener un contenedor
@CaseInsensitive
separado y combinarlo con un contenedor@Email
en lugar de hacer que el contenedor@Email
no distinga entre mayúsculas y minúsculas. Pero construcciones como estas están prohibidas y conducen a errores de compilación.
@CaseInsensitive @Email var email: String?
Como solución alternativa para este caso en particular, podemos heredar el contenedor de Email
del contenedor CaseInsensitive
. Sin embargo, la herencia también tiene limitaciones: solo las clases admiten la herencia y solo se permite una clase base.
Conclusión
Las anotaciones @propertyWrapper
simplifican la sintaxis de los envoltorios de propiedades, y podemos operar con las propiedades envueltas de la misma manera que con las ordinarias. Esto hace que su código, como desarrollador de Swift, sea más compacto y comprensible. Al mismo tiempo, tiene varias limitaciones que tenemos que tener en cuenta. Espero que algunos de ellos se rectifiquen en futuras versiones de Swift.
Si desea obtener más información sobre las propiedades de Swift, consulte los documentos oficiales.