ARC en Swift: lo que todo desarrollador debería saber
Arturo Rivas Arias
🧠 ARC (Automatic Reference Counting) es uno de esos temas que todos los desarrolladores Swift dicen conocer, pero pocos entienden de verdad. La superficie es simple: los objetos se liberan cuando su contador de referencias llega a cero. Lo interesante empieza cuando profundizas en cómo el compilador y el runtime colaboran para que eso ocurra de forma eficiente.
⚙️ ARC no es solo un mecanismo en tiempo de compilación, aunque esa sea la simplificación más común. El compilador de Swift analiza tu código e inserta las operaciones de gestión de memoria. Esas operaciones se ejecutan después en tiempo de ejecución. ARC, por tanto, vive entre el análisis estático y la ejecución dinámica. Eso es precisamente lo que lo hace predecible y rápido sin necesitar un recolector de basura al estilo de Java o Go.
🔍 Cuando el compilador procesa una clase, genera internamente llamadas a swift_retain y swift_release en la representación intermedia del código. Para entenderlo, imagina un modelo de red sencillo como este:
final class NetworkSession {
let baseURL: URL
init(baseURL: URL) {
self.baseURL = baseURL
}
deinit {
print("Sesión liberada: \(baseURL)")
}
}
func createSession() {
let session = NetworkSession(baseURL: URL(string: "https://api.example.com")!)
// swift_retain(session) → implícito al crear la referencia
// ... uso de session
// swift_release(session) → implícito al salir del scope
}
El compilador decide dónde colocar esas llamadas basándose en el análisis de vida útil del objeto.
📏 El análisis de vida útil (lifetime analysis) es clave para entender cómo ARC optimiza el consumo de memoria. El compilador no inserta retain/release de forma mecánica alrededor de cada línea: estudia cuándo se usa por última vez cada valor y puede adelantar el release para acortar la vida del objeto. En sistemas grandes, esto tiene un impacto real en la presión sobre el heap (o pila de memoria).
🚀 Una vez insertadas las operaciones, el compilador ejecuta varias pasadas de optimización sobre ellas. Las más relevantes son la eliminación de operaciones redundantes (si dos referencias apuntan al mismo objeto y una es prescindible, el optimizador la elimina), el movimiento de código (acercando el release al último uso real), la fusión de operaciones en bucles y rutas calientes, y el análisis de escape, que permite reducir el overhead de conteo de referencias cuando se puede demostrar que un objeto no escapa de su scope.
✅ Una de las ventajas prácticas de ARC frente a un garbage collector es el determinismo. El deinit se ejecuta exactamente cuando el contador llega a cero, no en algún momento indeterminado del futuro. Esto simplifica mucho el razonamiento sobre recursos como ficheros, conexiones o sockets:
final class FileHandle {
private let path: String
init(path: String) {
self.path = path
print("Abriendo: \(path)")
}
deinit {
print("Cerrando: \(path)")
// El fichero se cierra exactamente aquí, no "cuando el GC quiera"
}
}
func processLog() {
let handle = FileHandle(path: "/var/log/app.log")
// ... lectura del fichero
} // ← deinit se llama aquí, de forma determinista
🔗 Las referencias weak y unowned existen para romper ciclos de retención sin incrementar el contador. La diferencia entre ambas es crítica: weak acepta que el objeto puede ser nil en algún momento y la referencia se pone a nil automáticamente en la deallocación. unowned asume que el objeto siempre estará vivo mientras exista la referencia, y acceder a una referencia unowned inválida provoca un crash. Úsala solo cuando el ciclo de vida esté perfectamente definido.
// Ejemplo con patrón delegado, donde el delegate siempre vive más que el objeto
protocol AnalyticsDelegate: AnyObject {
func didTrackEvent(_ name: String)
}
final class AnalyticsTracker {
// El tracker no debe retener al delegate: ciclo de retención típico
weak var delegate: AnalyticsDelegate?
func track(_ event: String) {
delegate?.didTrackEvent(event)
}
}
🔄 Los closures son la fuente más habitual de ciclos de retención en código real, especialmente en arquitecturas con ViewModels, Combine o async/await con continuaciones almacenadas. El patrón clásico es un objeto que retiene un closure que captura al propio objeto:
final class ImageLoader {
var onLoad: ((UIImage?) -> Void)?
func load(url: URL) {
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self else { return }
let image = data.flatMap(UIImage.init)
self.onLoad?(image)
}.resume()
}
}
Sin [weak self], el closure retiene fuertemente a self, que a su vez retiene onLoad, formando un ciclo que Instruments te mostrará como una fuga de memoria.
🛠️ Detectar leaks en la práctica requiere combinar varias herramientas. El Memory Graph Debugger de Xcode muestra visualmente los ciclos de retención. Instruments con la plantilla Leaks permite capturar fugas en tiempo real. Y desde Xcode 16, el nuevo Malloc Stack Logging mejorado ayuda a trazar exactamente dónde se creó cada objeto que nunca se libera.
🆕 Con Swift 5.9+ y la adopción del macro @Observable, ARC sigue siendo el mecanismo subyacente, pero la forma de razonar sobre los ciclos cambia. Los tipos @Observable son clases bajo el capó, por lo que las mismas reglas aplican. Si almacenas un @Observable en un closure que también retiene al observador, sigues teniendo el mismo riesgo de ciclo. La sintaxis cambia; la física de la memoria, no.
👨💻 Dominar ARC no es solo preparación para entrevistas técnicas: es entender el contrato de memoria que Swift establece con el programador. Cuando algo va mal —una fuga, un crash por acceso inválido, un deinit que no llega nunca— el lugar donde buscar siempre está en la misma intersección: quién retiene a quién, y por qué. ¿Tienes algún ciclo de retención que te haya costado especialmente encontrar?