Observadores de propiedad de Swift
En la década de 1930, Rube Goldberg ya era un nombre familiar; sinónimo de invenciones increíblemente complejas y retorcidas representadas en forma de tiras cómicas, como la de “La servilleta automática”. Por aquel entonces, Albert Einstein popularizó la frase «acción espeluznante a distancia» en su crítica hacia la interpretación de la mecánica cuántica de Niels Bohr, predominante por aquel entonces.
Casi un siglo después, el desarrollo de software se ha convertido en lo que podría considerarse la quintaesencia de un artilugio de Goldberg, extendiéndose incluso por ese reino espeluznante gracias a la computación cuántica.
Como desarrolladores de software, se nos alienta a reducir esas acciónes a distancia en nuestro código cuanto sea posible. Esto puede observarse en patrones con nombres contundentes, como el Principio de responsabilidad única, el Principio de la mínima sorpresa y la Ley de Demeter. Y a pesar de las dudas que puedan despertar estos principios acerca de los efectos secundarios, aún hay cabida, en ocasiones, para que tales efectos ayuden a aclarar, más que a confundir.
El artículo de esta semana se centra en los observadores de propiedad de Swift, los cuales ofrecen, de manera nativa, una alternativa ligera a soluciones más formales como programación funcional reactiva (FRP) con model-view-viewmodel (MVVM).
Hay dos tipos de propiedades en Swift: stored properties, las cuales asocian estado con un objeto, y computed properties, que realizan un cálculo en base a dicho estado. Por ejemplo:
struct S {
// Stored Property
var stored: String = "stored"
// Computed Property
var computed: String {
return "computed"
}
}
Cuando declaras una stored property, tienes la opción de definir observadores mediante bloques de código que se ejecutan cuando se le asigna un valor a la propiedad. El observador will
se ejecuta justo antes de que se asigne el nuevo valor, y did
justo después. Se ejecutan siempre, incluso aunque el valor anterior sea igual que el nuevo.
struct S {
var stored: String {
will Set {
print("se llamó a will Set")
print("stored es ahora igual a \(self.stored)")
print("se asignará \(new Value) a stored")
}
did Set {
print("se llamó a did Set")
print("stored es ahora igual a \(self.stored)")
print("stored tenía previamente el valor \(old Value)")
}
}
}
var s = S(stored: "first")
s.stored = "second"
Por ejemplo, al ejecutar el código anterior se imprimirá por consola el siguiente resultado:
- se llamó a willSet
- stored es ahora igual a first
- se asignará second a stored
- se llamó a didSet
- stored es ahora igual a second
- stored tenía previamente el first
Advertencia: los observadores no se ejecutan si inicializas una propiedad en un método init. Hasta Swift 4.2, puedes salvar esto empaquetando la llamada setter en un bloque
defer
, pero esto es un bug que se arreglará en breve, por lo que no deberías depender de él.
Los observadores de propiedades en Swift han sido parte del lenguaje prácticamente desde sus inicios. Para entender por qué, echemos un vistazo a cómo son las cosas en Objective-C:
Propiedades en Objective-C
En Objective-C, todas las propiedades son, en cierto sentido, computadas. Cada vez que una propiedad se accede mediante la notación del punto, equivale a llamar a su método getter o setter. Esto, a su vez, se compila en un paso de mensaje que ejecuta una función que lee o escribe una variable de instancia.
// El acceso por punto
person.name = @"Johnny";
// ...equivale a
[person set Name:@"Johnny"];
// ... que es compilado a
objc_msg Send(person, @selector(set Name:), @"Johnny");
// ...cuya implementación sintetizada representa
person->_name = @"Johnny";
Los efectos secundarios son, generalmente, algo a evitar en la programación debido a que dificulta la compresión del comportamiento de un programa. Pero muchos desarrolladores de Objective-C han terminado apoyándose en la capacidad de inyectar comportamiento adicional a un getter o setter según sea necesario.
El diseño de Swift ha formalizado estos patrones y creado distinción entre los efectos secundarios derivados del acceso a estado (stored properties) de aquellos que redireccionan acceso a estado (computed properties). Para stored properties, los observadores will
y did
reemplazan el código que de lo contrario incluirías junto con el acceso a la ivar. Para computed properties, los métodos get
y set
reemplazan el código que podrías implementar para propiedades @dynamic
en Objective-C.
Como resultado, obtenemos una semántica más consistente y mayores garantías en mecanismos que interactúan con propiedades, como el Key-Value Observing (KVO) y Key-Value Coding (KVC).
Entonces, ¿qué podemos hacer con observadores en Swift? Aquí tienes un par de ideas a considerar:
Validación / normalización de valores
A veces es necesario aplicar ciertas restricciones sobre qué valores son aceptables para un tipo.
Por ejemplo, si estuvieras desarrollando una app que interactúa con burocracia del gobierno, necesitas asegurar que el usuario no puede enviar un formulario en el que falte algún campo requerido, o que contenga algún valor inválido.
Si, por ejemplo, un formulario requiere que los nombres usen letras mayúsculas sin acentos, podrías usar el observador did
para eliminar tildes y aplicar mayúsculas al nuevo valor:
var name: String? {
did Set {
self.name = self.name?
.applying Transform(.strip Diacritics,
reverse: false)?
.uppercased()
}
}
Por suerte, asignar un valor a una propiedad en el cuerpo de un observador did
no dispara nuevas llamadas, por lo que no creamos un bucle infinito. En cambio esto no pasa con el observador will
; cualquier valor asignado en el cuerpo se sobreescribe inmediatamente cuando a la propiedad se le asigna el new
.
Si bien es cierto que este enfoque puede funcionar para problemas puntuales, repetir su uso es un fuerte indicativo de que la lógica de negocio puede formalizarse en un tipo.
Sería mejor crear un tipo Normalized
que encapsulara los requerimientos de texto del formulario:
struct Normalized Text {
enum Error: Swift.Error {
case empty
case excessive Length
case unsupported Characters
}
static let maximum Length = 32
var value: String
init(_ string: String) throws {
if string.is Empty {
throw Error.empty
}
guard let value = string.applying Transform(.strip Diacritics,
reverse: false)?
.uppercased(),
value.can Be Converted(to: .ascii)
else {
throw Error.unsupported Characters
}
guard value.count < Normalized Text.maximum Length else {
throw Error.excessive Length
}
self.value = value
}
}
Un inicializador que falle o lance excepciones puede indicar errores al que hace la llamada de una manera en que did
no puede. Ahora, si un alborotador como Jøhnny de Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch
viene buscando pelea, ¡podemos darle su merecido!
(Es decir, comunicarle errores de una forma razonable en lugar de fallar de manera silenciosa o permitir información inválida)
Propagando estado dependiente
Otro uso potencial de los observadores es propagar estado a componentes dependientes en un view controller.
Considera el siguiente ejemplo de un modelo Track
y un Track
que lo presenta:
struct Track {
var title: String
var audio URL: URL
}
class Track View Controller: UIView Controller {
var player: AVPlayer?
var track: Track? {
will Set {
self.player?.pause()
}
did Set {
self.title = self.track.title
let item = AVPlayer Item(url: self.track.audio URL)
self.player = AVPlayer(player Item: item)
self.player?.play()
}
}
}
Cuando se asigna un valor a la propiedad track
del view controller, pasa lo siguiente de manera automática:
- Se pausa cualquier pista de audio anterior.
- Se actualiza el
title
del view controller al título de la pista nueva. - Se carga y reproduce la nueva pista de audio.
Fantástico, ¿verdad?
Podrías incluso implementar un comportamiento en cascada a través de múltiples propiedades observadas, al estilo de esta escena de Mousehunt.
Como regla general, los efectos secundarios son algo a evitar en la programación debido a que dificulta la compresión de comportamiento complejo. Tenlo en mente la próxima vez que vayas a utilizar esta nueva herramienta.
Sin embargo, desde la punta afilada de esta tambaleante torre de abstracción que es la programación, puede ser tentador (y quizá, a veces, gratificante), abrazar el caos del sistema. Seguir siempre las reglas es aBohrrido.