ARC y zombies en Swift
Arturo Rivas Arias
🧠 ARC (Automatic Reference Counting) lleva con nosotros desde el primer día en Swift, pero la mayoría de desarrolladores solo conoce su capa exterior: los objetos se retienen, se liberan y se destruyen cuando el contador llega a cero. Lo que muy pocos saben es que el modelo interno ha cambiado de forma significativa desde las primeras versiones del lenguaje. Esos cambios no son detalles de implementación irrelevantes: explican por qué ciertas decisiones de diseño tienen un coste, y por qué el runtime se comporta de una manera u otra en situaciones límite.
🏛️ En las primeras versiones de Swift, el diseño era deliberadamente compacto. Cada objeto almacenaba su contador de referencias fuertes y su contador de referencias débiles directamente dentro de su propia memoria, sin estructuras externas ni registros globales. Incrementar o decrementar una referencia era una operación puramente local: se leía y se escribía un campo en la cabecera del propio objeto. Las referencias débiles también apuntaban directamente al objeto, sin ningún nivel de indirección adicional.
☠️ El comportamiento más llamativo de aquel modelo era lo que ocurría cuando desaparecía la última referencia fuerte. Si en ese momento no quedaban referencias débiles, el objeto se destruía y su memoria se liberaba de inmediato. Pero si aún existían referencias débiles, el runtime tomaba un camino diferente: ejecutaba el deinit del objeto y marcaba su estado como destruido, pero mantenía la memoria asignada. El objeto se convertía en un zombie: su vida útil había terminado, pero su almacenamiento seguía ocupado porque había punteros débiles apuntando a su dirección de memoria.
// Simulación del comportamiento zombie en el modelo original
// El objeto AudioPlayer ha terminado su ciclo de vida lógico,
// pero su memoria permanece hasta que se libera la última referencia débil.
final class AudioPlayer {
let trackName: String
init(trackName: String) {
self.trackName = trackName
}
deinit {
// Se ejecuta cuando cae la última referencia fuerte,
// pero en el modelo antiguo la memoria puede seguir viva.
print("Player liberado: \(trackName)")
}
}
class PlaybackCoordinator {
weak var currentPlayer: AudioPlayer?
}
func demonstrateZombie() {
let coordinator = PlaybackCoordinator()
do {
let player = AudioPlayer(trackName: "Intro Theme")
coordinator.currentPlayer = player
// player sale del scope → deinit se llama
// pero en el modelo antiguo, la memoria aguantaba hasta que
// coordinator.currentPlayer era leído y anulado
}
// Aquí coordinator.currentPlayer es nil,
// y solo en este acceso se decrementaba el contador de débiles
// y se liberaba la memoria del zombie
_ = coordinator.currentPlayer
}
⚠️ Aquel modelo tenía ventajas reales: era simple, no requería estructuras adicionales y el principal caso de uso era extremadamente sencillo y directo. Sin embargo, sus limitaciones se fueron haciendo evidentes con el tiempo. El problema más visible era el consumo de memoria. Si un objeto grande tenía referencias débiles apuntándole, su almacenamiento completo permanecía asignado incluso después de su destrucción lógica, hasta que alguien accedía a esas referencias débiles. En objetos con ciclos de vida complicados, esto se traducía en un crecimiento silencioso e inesperado de memoria.
🔒 El segundo problema era la concurrencia. Las referencias débiles se anulaban de forma lazy, es decir, en el momento en que eran leídas. Eso significaba que múltiples hilos podían interactuar con el mismo objeto zombie de formas difíciles de controlar. A medida que la concurrencia en Swift evolucionó, este aspecto del diseño se convirtió en un obstáculo real para garantizar que todo funcionaba de forma correcta.
🧬 Swift 4 introdujo un cambio estructural: las side tables. En lugar de obligar a todos los objetos a llevar los metadatos de referencias débiles de forma inline (dentro de su propia estructura), el runtime adoptó una estructura externa y además opcional. En el caso base, un objeto recién creado sigue siendo tan compacto como antes: sus contadores fuertes (y los unowned) viven directamente en su cabecera, sin ninguna asignación adicional. El cambio de comportamiento se activa únicamente cuando se crea la primera referencia débil al objeto.
// Representación simplificada del encabezado de un objeto Swift en el heap
struct HeapObject {
Metadata *metadata; // Puntero al tipo
InlineRefCounts refCounts; // Contadores inline
};
// La side table, creada solo cuando aparece la primera referencia débil
struct SideTable {
HeapObject *object; // Puntero de vuelta al objeto
RefCounts counts; // Contadores movidos fuera del objeto
};
🔀 En ese momento, el campo refCounts del objeto deja de contener contadores y pasa a contener un puntero a la side table. El runtime usa un flag en ese mismo campo para distinguir entre los dos modos. Las referencias débiles ya no apuntan directamente al objeto: apuntan a la side table. Esto separa el ciclo de vida del objeto del ciclo de vida de su contador de referencias débiles.
✅ La consecuencia es inmediata. Cuando desaparece la última referencia fuerte, el objeto se destruye y su memoria se libera inmediatamente, sin importar cuántas referencias débiles sigan existiendo. La side table permanece viva mientras haya referencias débiles que la necesiten, pero es una estructura auxiliar y pequeña en comparación, no el objeto completo. El problema del objeto zombie queda resuelto: lo que sobrevive es solo la información necesaria para responder nil a futuras lecturas de esas referencias débiles.
// Ejemplo: cache de previsualizaciones con referencias débiles
// El DocumentCache no retiene los documentos, solo los observa mientras vivan
final class DocumentPreview {
let documentID: String
let thumbnailData: Data
init(documentID: String, thumbnailData: Data) {
self.documentID = documentID
self.thumbnailData = thumbnailData
}
deinit {
// Con side tables: la memoria de thumbnailData se libera AQUÍ,
// no cuando alguien acceda al cache posteriormente.
print("Preview liberada para documento: \(documentID)")
}
}
final class PreviewCache {
private var entries: [String: WeakBox<DocumentPreview>] = [:]
func store(_ preview: DocumentPreview) {
entries[preview.documentID] = WeakBox(preview)
}
func retrieve(for documentID: String) -> DocumentPreview? {
return entries[documentID]?.value
}
}
// Wrapper auxiliar para almacenar referencias débiles en diccionarios
final class WeakBox<T: AnyObject> {
weak var value: T?
init(_ value: T) { self.value = value }
}
🔍 Acceder a una referencia débil en el modelo moderno implica una secuencia bien definida. El runtime resuelve la side table, obtiene el puntero al objeto, comprueba si sigue vivo e intenta crear una retención temporal antes de devolver el resultado. Esa retención temporal es fundamental: garantiza que el objeto no se destruya entre la lectura del puntero y el uso del valor. Si el objeto ya ha sido destruido, la side table devuelve nil y la referencia débil se considera consumida. Todo esto ocurre con las correctas garantías bajo concurrencia, lo que era imposible con el modelo de zombie.
⚡️ Las referencias unowned tienen un comportamiento diferente al de las débiles y más cercano al de las fuertes en cuanto a representación. Normalmente apuntan directamente al objeto sin pasar por la side table, y no producen un valor opcional. Sin embargo, tampoco son gratuitas: al acceder a una referencia unowned, el runtime intenta promoverla a una referencia fuerte temporal antes de permitir su uso.
// El intento de promoción es lo que provoca el crash en unowned inválidas
// Pseudocódigo de la lógica interna del runtime:
//
// HeapObject *loadUnowned(HeapObject *object) {
// if (!tryRetainStrong(object)) {
// abortUnownedAccess(); // trap controlado, no UB
// }
// return object;
// }
// Ejemplo real: coordinador de navegación que sabe que siempre vive
// más que las pantallas que coordina
protocol Navigatable: AnyObject {
func navigateTo(_ destination: AppDestination)
}
enum AppDestination {
case settings, profile, feed
}
final class SettingsViewController {
// El coordinator siempre vive más que esta pantalla:
// la garantía es estructural y está impuesta por el ciclo de vida de la app
unowned let coordinator: Navigatable
init(coordinator: Navigatable) {
self.coordinator = coordinator
}
func didTapProfile() {
coordinator.navigateTo(.profile)
}
}
🚨 La diferencia entre unowned y weak no es solo cosmética. Si el objeto al que apunta una referencia unowned ya ha sido destruido cuando se accede a ella, el runtime provoca un crash controlado. No es comportamiento indefinido: es un punto de comprobación explícito que expone un modelo de ciclo de vida que era incorrecto. Esto contrasta con el acceso a una referencia débil, que simplemente devuelve nil. La elección entre ambas comunica un contrato: weak dice “es válido que esto sea nil”, unowned dice “garantizo que esto nunca será nil”.
🔩 Existe una variante aún más baja: unowned(unsafe). Esta forma elimina completamente las comprobaciones en tiempo de ejecución y funciona como un puntero crudo al objeto. Si el objeto ha sido destruido, el programa puede continuar con la memoria corrupta o provocar un segfault sin ningún mensaje de error del que tirar. Es la única herramienta de referenciado que rompe las garantías de seguridad de memoria de Swift. Su uso está justificado únicamente en contextos de rendimiento extremo rigurosamente controlados, nunca en código de aplicación ordinario.
📊 A efectos prácticos, las tres formas de referencia tienen perfiles de coste distintos
- Las referencias fuertes son el camino optimizado: una operación atómica sobre un campo en la cabecera del objeto, sin asignaciones ni indirecciones.
- Las referencias débiles introducen un nivel adicional de indirección (la side table), más probabilidad de error de caché del procesador porque la posición de memoria no es contigua, y trabajo adicional en cada lectura para la retención temporal y la comprobación de validez. Una vez que un objeto tiene side table, incluso las operaciones fuertes sobre ese objeto pasan por la indirección.
- Las referencias
unownedevitan la side table en el caso habitual pero siguen requiriendo la comprobación de existencia en cada acceso.
🎯 Para el código cotidiano de iOS, la elección correcta parte por tener clara la propiedad, no de optimizar el rendimiento. Las referencias fuertes son el punto de partida natural para cualquier relación de propiedad. Las referencias débiles encajan cuando el objeto referenciado puede desaparecer de forma independiente y la ausencia es un estado válido: delegates, observers, coordinadores, caches… La opcionalidad no es una molestia sino parte del contrato. Las referencias unowned tienen sentido cuando la relación de ciclo de vida es rígida por construcción y puede mantenerse con certeza a lo largo del tiempo y los refactors. En la práctica, esa certeza es menos común de lo que parece, y un ciclo de vida que parecía obvio puede romperse meses después sin ningún cambio local en la declaración.
🏗️ Una receta sencilla que suele funciona bien: usa referencias fuertes para propiedad, referencias débiles cuando la ausencia es válida, y unowned solo cuando el ciclo de vida es lo suficientemente estricto como para que lo puedas documentar explícitamente en la interfaz del tipo. El runtime de Swift está construido para que ese modelo sea eficiente y seguro. Cuando el código lo refleja con fidelidad, ARC trabaja contigo y no contra tí.