weak y unowned: romper el ciclo sin romper el programa
Las referencias no propietarias son el vocabulario con el que Swift expresa que una relación existe sin implicar posesión, y elegir entre las dos disponibles no es cuestión de estilo sino de una afirmación verificable sobre tiempos de vida. Esta lección explica la mecánica interna de weak con sus tablas laterales y el coste de cada lectura, describe la contabilidad separada que hace que unowned falle de forma determinista en lugar de leer memoria liberada, y formula la regla que decide cuál corresponde en cada caso junto con el crash exacto que produce equivocarse.
Romper un ciclo consiste en degradar exactamente una arista: convertir una relación de propiedad en una relación de conocimiento. Swift ofrece dos formas de hacerlo y la diferencia entre ellas no es de rendimiento ni de comodidad sintáctica, aunque ambas cosas se noten. weak dice: puede que esto ya no esté, y por eso te lo entrego como opcional. unowned dice: garantizo que esto estará mientras yo esté, y si me equivoco quiero que el programa se detenga. Son dos afirmaciones distintas sobre el mundo, y el compilador no puede comprobar ninguna de las dos: la verificación queda de tu lado, en tiempo de ejecución, con un nil silencioso en un caso y una parada inmediata en el otro.
- Distinguir las tres fuerzas de referencia por lo que afirman sobre el tiempo de vida, no por su sintaxis.
- Explicar la implementación de
weakcon tablas laterales y justificar el coste de cada lectura. - Describir la contabilidad separada de
unownedy el motivo de que su fallo sea determinista. - Aplicar la regla del tiempo de vida para elegir entre ambas y reconocer los casos límite en código asíncrono.
Las tres fuerzas
Una referencia fuerte incrementa el conteo y obliga: mientras yo exista, esto existe. Las otras dos no lo incrementan, y se separan por lo que ocurre cuando el objeto apuntado desaparece. Una referencia weak se pone a nulo automáticamente, lo que exige que sea siempre opcional y siempre var. Una referencia unowned no se pone a nulo, puede ser let y no opcional, y a cambio leerla después de la muerte del objeto detiene el proceso.
final class Cliente {
let nombre: String
var tarjeta: Tarjeta? // fuerte: el cliente posee su tarjeta
init(nombre: String) { self.nombre = nombre }
}
final class Tarjeta {
let numero: UInt64
unowned let titular: Cliente // no opcional: no hay tarjeta sin titular
init(numero: UInt64, titular: Cliente) {
self.numero = numero
self.titular = titular
}
}
Ese unowned let no es una optimización: es una afirmación de dominio. Una tarjeta sin titular no significa nada, así que modelarla con un titular opcional obligaría a todo el código a comprobar una condición imposible. Y como la tarjeta nunca sobrevive a su titular, la garantía que unowned exige es cierta por construcción. Ahora invierte el ejemplo: un cliente puede quedarse sin tarjeta, luego esa arista sí es opcional y sí debería ser weak si además fuese de vuelta.
weak · puede desaparecer
El objeto apuntado puede morir antes que yo. La referencia vale nulo a partir de ese instante. Obligatoriamente opcional y mutable. Lectura más cara. Correcto para delegados, observadores y cualquier captura en trabajo asíncrono de duración indefinida.
unowned · no puede desaparecer
El objeto apuntado vive al menos tanto como yo. Sin optatividad y sin coste de desenvolver. Lectura casi tan barata como una referencia fuerte. Correcto para relaciones de composición estricta entre padre e hijo creadas y destruidas juntas.
Cómo funciona weak por dentro
Una referencia débil no puede apuntar directamente al objeto, porque cuando este muera nadie sabría dónde están todos los punteros que hay que anular. Swift resuelve el problema con una indirección: al crearse la primera referencia débil, el objeto adquiere una tabla lateral, una pequeña estructura auxiliar que guarda el conteo fuerte, el conteo débil y un puntero al objeto. Las referencias débiles apuntan a esa tabla, no a la instancia.
Cuando el conteo fuerte llega a cero se ejecuta deinit y el puntero de la tabla se marca como muerto, pero la tabla sobrevive mientras quede alguna referencia débil viva. Por eso leer una referencia débil no es leer un puntero: es una operación del sistema que consulta la tabla de forma atómica y, si el objeto sigue vivo, lo retiene y te lo devuelve retenido para que no pueda morir mientras lo usas. Esa retención temporal es la razón profunda de un patrón que casi todo el mundo escribe por costumbre.
servicio.cargar { [weak self] resultado in
guard let self else { return } // una sola lectura, un solo retain
self.mostrar(resultado)
self.registrar(resultado) // ya es una referencia fuerte local
}
Sin ese guard, cada self?.algo sería una lectura atómica independiente de la tabla lateral, con su retención y su liberación, y —lo que es peor— con la posibilidad de que la primera tenga éxito y la tercera devuelva nulo si el objeto muere en otro hilo entre medias. Convertir la referencia débil en una fuerte local al principio del bloque no es sintaxis bonita: es lo que da coherencia al cuerpo entero.
De ahí se deduce también la única regla de rendimiento que merece recordarse sobre weak: no leas una referencia débil dentro de un bucle caliente. Cada lectura cuesta operaciones atómicas y, bajo contención, una sincronización sobre la tabla. Léela una vez fuera, o replantea quién debería poseer qué.
unowned y el crash que promete
unowned en su forma segura, que es la predeterminada, mantiene su propia contabilidad. El objeto tiene un conteo de referencias no propietarias además del fuerte, y las dos cosas ocurren en momentos distintos: al llegar el conteo fuerte a cero se ejecuta deinit y se liberan las propiedades, pero la memoria no vuelve al asignador hasta que también el conteo no propietario llega a cero. Entre ambos instantes el objeto es un zombi: existe como bloque reservado, ya no como valor.
Esa fase intermedia tiene un único propósito, y es que el fallo sea determinista. Si tu referencia unowned sobrevive al objeto y la lees, el sistema encuentra el bloque, ve que ya fue destruido y detiene el proceso con un mensaje inequívoco sobre haber intentado leer una referencia no propietaria de un objeto ya liberado. No hay lectura de memoria ajena, no hay valor basura, no hay corrupción diferida que se manifieste tres pantallas más tarde. Es un fallo caro pero honesto.
final class Sesion {
unowned let usuario: Usuario
init(usuario: Usuario) { self.usuario = usuario }
func saludar() { print(usuario.nombre) } // trampa si el usuario ya murio
}
var sesion: Sesion?
do {
let u = Usuario(nombre: "Ana")
sesion = Sesion(usuario: u)
} // el usuario muere aqui: nadie mas lo sostiene
sesion?.saludar() // parada inmediata del proceso
Existe además unowned(unsafe), que renuncia incluso a esa contabilidad y equivale a un puntero crudo sin gestión. No hay zombi, no hay comprobación y no hay parada: hay memoria liberada leída como si fuera válida, es decir, comportamiento indefinido en el peor sentido de la expresión. Es una herramienta legítima en código de muy bajo nivel medido y verificado, y una forma rápida de escribir errores irreproducibles en cualquier otro sitio.
flowchart TB q[Necesito romper una arista del ciclo] --> a[El objeto apuntado puede morir antes que yo] q --> b[El objeto apuntado vive al menos tanto como yo] a --> w[Usar weak: opcional y autoanulable] b --> c[La garantia es cierta por construccion del dominio] c --> u[Usar unowned: sin optatividad ni coste] b --> d[La garantia depende del orden de eventos o de la red] d --> w w --> g[Convertir a fuerte local con guard let self]
La regla del tiempo de vida
Toda la decisión cabe en una frase: usa unowned solo cuando puedas demostrar que el objeto apuntado vive al menos tanto como el que apunta; en cualquier otro caso usa weak. La palabra que hace el trabajo es demostrar. No basta con que sea probable, ni con que en las pruebas nunca haya fallado, ni con que el diseño actual lo garantice: si la garantía depende de un orden de eventos, de una respuesta de red o de la decisión de un usuario, entonces no es una garantía y weak es la respuesta.
Ese criterio explica por qué unowned encaja tan bien en relaciones de composición estricta —el hijo creado por el padre en su inicializador y destruido con él— y tan mal en cualquier cosa asíncrona. Un bloque de finalización que se ejecuta al volver una petición puede llegar después de que la pantalla se haya cerrado; una tarea desprendida puede sobrevivir al objeto que la lanzó; un observador registrado puede recibir su aviso durante el proceso de destrucción de otro. En todos esos casos unowned compra unos nanosegundos y vende una parada del proceso en producción.
Queda un caso que conviene nombrar porque genera dudas legítimas: capturar de forma no propietaria dentro de una tarea estructurada. Mientras la tarea se ejecute dentro del ámbito de un grupo o de un async let, el objeto que la creó sigue vivo por construcción; pero en cuanto la tarea se desprende, la garantía desaparece. La distinción no está en la palabra Task sino en si el tiempo de vida de la tarea está contenido en el del objeto.
final class Pantalla {
private var tarea: Task<Void, Never>?
func cargar() {
tarea = Task { [weak self] in // desprendida del ambito
let datos = await Red.pedir()
guard let self else { return } // la pantalla pudo cerrarse
await self.mostrar(datos)
}
}
deinit { tarea?.cancel() }
}
Fíjate en que la captura débil no sustituye a la cancelación ni al revés. Sin [weak self] la tarea sostiene la pantalla y el deinit no llegaría nunca; sin la cancelación, la tarea seguiría corriendo y consumiendo red después de que la pantalla desapareciese, aunque su resultado ya no se use. Las dos piezas resuelven problemas distintos y por eso aparecen siempre juntas.
Hay un último matiz sobre weak que conviene tener presente al modelar: una referencia débil obliga a que el tipo apuntado tenga identidad. No puedes declararla hacia una estructura, ni hacia un protocolo que no esté restringido con AnyObject, ni hacia un valor. Cuando el compilador rechaza tu weak, la lectura correcta no suele ser buscar un rodeo, sino que la relación que intentabas modelar no era entre entidades.
Poner weak donde bastaba unowned produce ruido: opcionales que nunca son nulos, comprobaciones defensivas y lecturas más caras de lo necesario. Poner unowned donde hacía falta weak produce paradas en producción que además son difíciles de reproducir, porque dependen de tiempos. Pero el error verdaderamente grave es un tercero: usar [weak self] de forma ritual, sin preguntarse si hay ciclo, hasta el punto de que un bloque no escapante o un objeto que nadie más sostiene se vuelva nulo justo cuando debía actuar. Una captura débil también puede convertir un fallo de memoria en un fallo de lógica silencioso, que es peor de diagnosticar.
La costumbre de presentar weak y unowned como técnicas para evitar fugas oculta lo que realmente son: dos maneras de escribir en el código una proposición sobre el orden temporal de dos muertes. unowned afirma que la del objeto apuntado no puede preceder a la del que apunta; weak se abstiene de afirmarlo y por eso está obligada a admitir la nulidad. Que esa proposición sea temporal y no espacial tiene una consecuencia inmediata y poco cómoda: el compilador no puede verificarla, porque el orden de las muertes depende de la ejecución y no del texto. Swift asume esa imposibilidad con una elegancia que merece atención, porque no elige entre seguridad y rendimiento sino que ofrece las dos opciones con su precio explícito. Si no puedes demostrar la afirmación, weak te cobra una indirección, una tabla lateral y una lectura atómica, y a cambio te devuelve un opcional que te obliga a decidir qué hacer cuando la cosa ya no está: el coste está en la ejecución y la carga está en el flujo del programa. Si puedes demostrarla, unowned te cobra cero en el caso normal y te cobra el proceso entero si mentiste: el coste está en la corrección y la carga está en tu razonamiento. Elegir entre ellas es, por tanto, elegir dónde quieres que aparezca el error si te equivocas, y esa es una decisión de ingeniería en el sentido más estricto. Lo que no es aceptable es no elegir, porque escribir weak por superstición convierte un problema de propiedad en un problema de lógica, y escribir unowned por elegancia convierte un problema de propiedad en un informe de fallos.
- Escribe la pareja cliente y tarjeta con
unowned let, comprueba que ambosdeinitse ejecutan y razona por qué la arista de vuelta no necesitaba ser opcional. - Provoca deliberadamente la parada de
unownedguardando el objeto dependiente y dejando morir al principal; captura el mensaje exacto del sistema. - Sustituye ese
unownedporweaky describe qué cambia en el tipo, en el punto de fallo y en el código que lo usa. - Escribe un bloque asíncrono con
[weak self]singuardy otro con él, e identifica las lecturas atómicas de la tabla lateral en cada uno. - Audita un proyecto tuyo buscando
[unowned self]en closures escapantes y justifica por escrito, para cada aparición, la demostración del tiempo de vida o cámbialo.