Cómo acercarse a los contenedores para las propiedades de Swift

Publicado: 2022-03-11

En 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.

Contenedores de propiedades en Swift 5.1

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 @ . El tipo de propiedad debe coincidir con el tipo de contenedor `wrappedValue`. Ahora, puedes trabajar con esta propiedad como con la ordinaria.

 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:

  1. 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 de Email , no es posible generar un error si un usuario intenta configurar un correo electrónico no válido. Podemos devolver nil o bloquear la aplicación con una llamada fatalError() , lo que podría ser inaceptable en algunos casos.
  2. 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.