Retain cycles
Al terminar: Provocar un ciclo de retención con un closure, detectarlo con leaks y arreglarlo con [weak self].
Al terminar esta lección, Torneo tendrá un Cronometro que se captura a sí mismo a propósito, verás cómo la herramienta leaks lo delata con las palabras ROOT CYCLE, y lo arreglarás con [weak self] hasta que el informe diga 0 leaks.
Importa porque un ciclo de retención no da ningún error ni ningún aviso: el programa funciona, y la memoria de esos objetos no se libera nunca. Temas: el ciclo de una closure guardada que usa self, la lista de captura [weak self] y la herramienta leaks. Los delegados weak, las tareas con self y los tests que vigilan todo esto son de la lección siguiente.
Un ciclo de retención
Un ciclo de retención (retain cycle) ocurre cuando dos objetos se retienen con referencias fuertes el uno al otro. Ninguno llega a contador cero, y ninguno se libera nunca.
La forma que más se ve en la práctica:
- Un objeto guarda una closure en una propiedad (para llamarla más tarde).
- Esa closure usa
self, y para eso lo captura con una referencia fuerte. - Resultado: el objeto retiene la closure, y la closure retiene al objeto.
Un ejemplo ajeno al torneo: una radio que guarda una closure para avisar cuando cambia el volumen. configurarMal tiene el ciclo; configurar lo evita. Se prueban las dos en probar, con dos radios y una referencia débil a cada una (la weak var de ARC, que sirve para preguntar «¿sigue viva?» sin mantenerla viva).
final class Radio {
var alCambiar: (() -> Void)? // una closure guardada en una propiedad
var volumen = 5
func configurarMal() {
alCambiar = { print("volumen \(self.volumen)") } // captura `self` con referencia fuerte
}
func configurar() {
alCambiar = { [weak self] in // lista de captura: `self` débil
guard let self else { return } // si ya no existe, no hace nada
print("volumen \(self.volumen)")
}
}
}
func probar() {
weak var mal: Radio? // weak: mira sin mantener viva la radio
weak var bien: Radio?
do {
let r = Radio()
r.configurarMal()
mal = r
let s = Radio()
s.configurar()
bien = s
} // aquí las dos radios salen de su bloque
print(mal == nil ? "mal: liberada" : "mal: sigue viva")
print(bien == nil ? "bien: liberada" : "bien: sigue viva")
}
probar()Lo que imprime:
mal: sigue viva
bien: liberadaLa primera radio no se libera aunque su bloque terminó: la radio guarda la closure, y la closure guarda la radio. En la segunda, la closure solo mira.
[weak self] es una lista de captura (capture list): dice cómo captura la closure. Con weak, self llega como opcional; guard let self else { return } lo desenvuelve y, si ya no existe, la closure termina sin hacer nada. Es la forma corta del guard let de guard let: guard let self = self else { return } significa lo mismo, y desde Swift 5.7 puedes escribir solo guard let self. Hay una variante, [unowned self]: no cuenta y no es opcional, y sirve si self vive siempre más que la closure; si no, el programa se detiene al usarla (unowned y tests de memoria explica ese fallo).
Cómo reconocer el error
Es configurarMal del ejemplo de arriba. alCambiar es una propiedad de self, así que self retiene la closure; y la closure, al usar self.volumen, retiene a self. En el paso de esta lección lo escribirás con un Cronometro, lo verás delatado por leaks y lo arreglarás igual que configurar.
Una pista en el código: un parámetro marcado @escaping (lo viste en Closures) es una closure que sobrevive a la llamada de la función que la recibe, normalmente porque se guarda en una propiedad. En el Cronometro, avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) guarda aviso dentro de alTick: ahí está la señal de que un ciclo es posible.
Kotlin y Swift, lado a lado
| Kotlin | Swift | |
|---|---|---|
| Una closure guardada que captura al objeto | Ciclo normal: el recolector de basura parte de las raíces (lo que el programa sigue usando: variables globales y las pilas de los hilos) y libera lo que no se alcanza desde ellas, los dos objetos incluidos. No hay nada que arreglar | Ciclo de retención: nunca se libera |
[weak self] | No existe | Necesario en closures guardadas |
Reto · Ejercicio: la alarma que no se apaga
En tu Playground (Tu primer paquete de Swift; reutiliza el mismo y borra lo anterior), escribe una clase final, Alarma, con un nombre, una closure opcional guardada en una propiedad, alSonar, y un aviso al liberarse, «Alarma de Lucía apagada». Su método programar guarda en alSonar una closure que imprime «¡Arriba, Lucía!» con el nombre de la propia alarma, y sonar la llama. En una función, probar, crea la alarma de Lucía, prográmala y hazla sonar. Llama a probar e imprime «fin de la prueba».
Escríbelo primero usando la alarma dentro de la closure sin más cuidado. Lo que sale:
¡Arriba, Lucía!
fin de la pruebaEl aviso de liberación no sale: hay un ciclo. Cambia solo programar para que la closure no mantenga viva la alarma. Lo que debe salir:
¡Arriba, Lucía!
Alarma de Lucía apagada
fin de la pruebaVer solución
final class Alarma {
let nombre: String
var alSonar: (() -> Void)? // la closure se guarda en una propiedad
init(nombre: String) { self.nombre = nombre }
deinit { print("Alarma de \(nombre) apagada") }
func programar() {
alSonar = { [weak self] in // mira a la alarma sin retenerla
guard let self else { return } // si ya no existe, no hace nada
print("¡Arriba, \(self.nombre)!")
}
}
func sonar() { alSonar?() }
}
func probar() {
let alarma = Alarma(nombre: "Lucía")
alarma.programar()
alarma.sonar()
}
probar()
print("fin de la prueba")La versión con el ciclo cambia el cuerpo de programar por alSonar = { print("¡Arriba, \(self.nombre)!") }: la closure usa self sin más cuidado, y el aviso «Alarma de Lucía apagada» no sale nunca. Se hace dentro de una función porque una variable global sigue alcanzable hasta el final del programa y nunca se liberaría, con ciclo o sin él.
Paso · el cronómetro que se captura a sí mismo
Objetivo: crear Cronometro, provocar el ciclo, detectarlo con leaks y arreglarlo. Al terminar, torneo-cli cronometro imprime los tres minutos y leaks dice 0 leaks; con el bug puesto, dice 2 leaks y ROOT CYCLE.
Trabajas en el paquete Torneo, en ~/Marcador-Swift/Torneo. Los comandos de esta lección se escriben en la Terminal, dentro de esa carpeta (cd ~/Marcador-Swift/Torneo).
Crea Cronometro.swift: en el panel izquierdo de Xcode, clic derecho sobre Sources → Torneo → New File from Template… → Swift File → Next, escribe Cronometro y pulsa Create. En un paquete no eliges «target» como en una app: el fichero pertenece a la carpeta donde lo creas. Si prefieres la Terminal, touch Sources/Torneo/Cronometro.swift hace lo mismo.
Piezas nuevas de este paso:
[weak self]yguard let self else { return }: los de arriba.@MainActoren la clase y en la función de la demo (lo explica @MainActor y el hilo principal): el cronómetro marca los minutos que se ven en pantalla, así que es del actor principal. Para llamar aCronometrodesde una función hace falta que la función también lleve@MainActor.import Foundation(ya está en la primera línea demain.swift; daexit),CommandLine.arguments(los textos escritos tras el nombre del programa; el primero es el propio programa, ydropFirst()lo salta) yexit(0): terminar el programa.
Intenta escribirlo tú. Primero el bug; después ejecutas leaks (sección siguiente) y solo entonces pones el arreglo.
Cronometro, una clase pública del actor principal de la que nadie hereda: unminutoque empieza en 0 y solo ella cambia; una closure opcional,alTick, que se guarda;avanzar, que suma un minuto y avisa a la closure; yavisarCadaMinuto, que recibe un aviso con el minuto y lo guarda enalTick(con el bug: la closure usa el propio cronómetro sin cuidado).- Arriba de
main.swift, bajo los dosimport: una función del actor principal,demoCronometro, que crea un cronómetro, le pide avisar de cada minuto con una línea sangrada, avanza tres minutos e imprime la línea de fin; y, solo si el programa recibe el argumentocronometro, ejecuta la demo y termina ahí.
Lo que debes ver al ejecutar la demo:
minuto 1
minuto 2
minuto 3
Fin de la demo: el cronómetro sale de su alcanceIntenta primero. Cuando termines, compara con la solución.Mostrar el código
// El cronómetro del partido: marca los minutos que se verían en pantalla, por eso
// es del actor principal.
@MainActor
public final class Cronometro {
public private(set) var minuto = 0
// La closure se guarda en una propiedad: sin esto no habría ciclo.
public var alTick: (() -> Void)?
public init() {}
// Avanza un minuto y avisa a la closure.
public func avanzar() {
minuto += 1
alTick?()
}
// EL BUG: la closure usa `self` con referencia fuerte y se guarda en `self`.
public func avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) {
alTick = { aviso(self.minuto) }
}
}En main.swift, este bloque va arriba del todo, justo debajo de las dos líneas import Foundation e import Torneo:
// `torneo-cli cronometro` ejecuta SOLO la demo del cronómetro y termina. Sirve para
// mirar la memoria con `leaks --atExit -- .build/debug/torneo-cli cronometro`.
// La demo vive en una función: un objeto guardado en una variable global de
// `main.swift` sigue siendo alcanzable al salir y `leaks` no lo contaría como fuga.
@MainActor
func demoCronometro() {
let reloj = Cronometro()
reloj.avisarCadaMinuto { minuto in print(" minuto \(minuto)") }
for _ in 1...3 { reloj.avanzar() }
print("Fin de la demo: el cronómetro sale de su alcance")
}
if CommandLine.arguments.dropFirst().contains("cronometro") {
demoCronometro()
exit(0)
}Detectarlo con leaks
leaks es una herramienta de macOS que ejecuta un programa y, al terminar (--atExit), busca objetos que nadie puede alcanzar y siguen en memoria. No hay nada que instalar: viene con el sistema (which leaks escribe /usr/bin/leaks). Se usa con el nombre del programa y un argumento: cronometro hace que torneo-cli ejecute solo demoCronometro(). Sin él, leaks analizaría la demo entera del programa y llenaría el informe de líneas que no son ciclos.
Con avisarCadaMinuto con el bug (alTick = { aviso(self.minuto) }) y la demo ya en main.swift, desde ~/Marcador-Swift/Torneo:
xcrun swift build
leaks --atExit -- .build/debug/torneo-cli cronometro.build/debug/torneo-cli es el programa ya compilado, que xcrun swift build deja en la carpeta .build. El -- separa las opciones de leaks (--atExit) del programa que va a ejecutar. Verás algo como esto (recortado; los números de proceso, las direcciones y los bytes varían: son unos 96 bytes):
minuto 1
minuto 2
minuto 3
Fin de la demo: el cronómetro sale de su alcance
Process 50746: 969 nodes malloced for 119 KB
Process 50746: 2 leaks for 96 total leaked bytes.
====
2 (96 bytes) ROOT CYCLE: <Cronometro 0xc77020600> [48]
1 (48 bytes) __strong alTick.context --> ROOT CYCLE: <Closure context 0xc770380f0> [48]
__strong --> CYCLE BACK TO <Cronometro 0xc77020600> [48]Se lee así: el Cronometro retiene (__strong alTick.context) el contexto de la closure, y ese contexto vuelve a retener al Cronometro (CYCLE BACK TO). El informe dice qué objetos forman el ciclo; el fallo está en Cronometro.swift, en la closure.
Lo que recortamos: antes de Process …: … nodes malloced sale una cabecera (Process:, Path:, Date/Time:…); entre 2 leaks y ====, la pila de llamadas (STACK OF 1 INSTANCE…); y al final, la lista de bibliotecas (Binary Images:). No hace falta leerlas. echo $? imprime el código de salida del último comando (0 si terminó bien, otro número si no): leaks da 1 cuando hay fugas.
Si en vez de eso ves 0 leaks, o ya tienes el [weak self] puesto, o leaks no ha podido analizar el programa: confirma que el comando acaba en torneo-cli cronometro y que ejecutas desde ~/Marcador-Swift/Torneo. Si sale leaks: ... No such file or directory, falta el xcrun swift build previo.
Ahora pon el arreglo: en Cronometro.swift, sustituye la función avisarCadaMinuto entera y repite.
// EL BUG: la closure usa `self` con referencia fuerte y se guarda en `self`.
public func avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) {
alTick = { aviso(self.minuto) }
} // La closure mira a `self` sin retenerlo; si ya no existe, no hace nada.
public func avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) {
alTick = { [weak self] in
guard let self else { return }
aviso(self.minuto)
}
}Tras xcrun swift build, leaks dice (los números de proceso, nodos y KB varían):
Process 50796: 967 nodes malloced for 119 KB
Process 50796: 0 leaks for 0 total leaked bytes.echo $? da 0.
Desde Xcode. Este paso se hace en la Terminal. Si ejecutas torneo-cli desde Xcode, pasa el argumento en Product → Scheme → Edit Scheme… → Run → Arguments → Arguments Passed On Launch, con un + y cronometro (el nombre de los menús puede variar entre versiones).
Qué hace cada pieza:
| Pieza | Por qué está así |
|---|---|
public var alTick: (() -> Void)? | La closure se guarda en una propiedad: sin eso no habría ciclo |
[weak self] + guard let self | La closure mira al cronómetro sin retenerlo |
demoCronometro() en una función | Un objeto en una variable global de main.swift sigue alcanzable al salir y leaks no lo contaría |
Punto de control
- Desde
~/Marcador-Swift/Torneo,xcrun swift run torneo-cli cronometroimprimeminuto 1,minuto 2,minuto 3yFin de la demo: el cronómetro sale de su alcance, y termina. leaks --atExit -- .build/debug/torneo-cli cronometro(trasxcrun swift build) dice0 leaks for 0 total leaked bytes.. Con el bug puesto,2 leaks(unos 96 bytes) con unROOT CYCLE: <Cronometro …>.xcrun swift testsigue enTest run with 129 tests in 17 suites passed, yxcrun swift build --build-testssigue sinwarning:nierror:.- Provocaste el ciclo, viste
2 leaks, pusiste[weak self]y viste0 leaks. - Sabes decir por qué
alTickforma un ciclo.
La lección siguiente completa este Cronometro con un delegado weak y una tarea que no lo retiene, y escribe los tests que vigilan el ciclo para siempre.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué forma tiene el ciclo de retención del
Cronometro?2.¿Qué hace
[weak self]seguido deguard let self else { return }?3.En el informe de
leaks, ¿qué indicaROOT CYCLE: <Cronometro …>junto aCYCLE BACK TO?4.¿Por qué una closure guardada que captura al objeto no es un problema en Kotlin?
5.¿Por qué
demoCronometroes una función y no código suelto enmain.swift?