Reservar y liberar a mano: el ciclo completo sin ARC
`allocate`, `initialize`, `deinitialize` y `deallocate` no son cuatro llamadas sino cuatro estados de una máquina que ARC recorría por ti en silencio. Por qué inicializar y actualizar son operaciones distintas, qué hace exactamente `move`, cuándo `deinitialize` no es opcional, y cómo se construye un tipo propietario que no fugue.
En Swift de alto nivel, el nacimiento y la muerte de un valor son un solo acontecimiento indivisible: escribes una declaración y ahí hay un valor; el ámbito termina y ya no hay nada. Esa fusión es tan cómoda que resulta invisible, y por eso desconcierta descubrir que en realidad son dos acontecimientos independientes que ARC se limitaba a sincronizar. Reservar memoria es conseguir un trozo de dirección con el tamaño y la alineación adecuados; inicializarlo es depositar allí un valor válido. Nada obliga a que ocurran a la vez, y las APIs manuales de Swift toman esa separación en serio hasta un extremo que ningún otro lenguaje de uso masivo alcanza: cada una de las cuatro transiciones tiene su propio método, con su propio nombre, y equivocarse de método no da un error de compilación sino una fuga silenciosa o una liberación doble.
- Nombrar los cuatro estados de una región de memoria y la operación que produce cada transición.
- Justificar por qué inicializar y actualizar son operaciones distintas y qué se rompe al confundirlas.
- Decidir cuándo
deinitializees obligatorio y cuándo es una formalidad sin efecto. - Escribir un tipo propietario que reserve, inicialice y libere sin fugar ni siquiera ante un error lanzado.
Cuatro estados y sus transiciones
La región de memoria que manipulas atraviesa una máquina de estados muy pequeña. No reservada. Reservada pero sin inicializar. Inicializada, es decir, conteniendo un valor válido del tipo. Y de vuelta a reservada sin inicializar, y finalmente devuelta al sistema. La ley que gobierna todo lo demás cabe en dos frases: leer memoria reservada pero no inicializada es comportamiento indefinido, y liberar memoria todavía inicializada fuga los recursos que ese valor poseía.
let p = UnsafeMutablePointer<String>.allocate(capacity: 3) // reservada, sin inicializar
// print(p.pointee) // indefinido: aqui no hay ningun String, hay basura
p.initialize(repeating: "vacio", count: 3) // inicializada
p[1] = "hola" // ahora si, escritura normal
p.deinitialize(count: 3) // libera los buffers de los String
p.deallocate() // devuelve la memoria
allocate(capacity:) reserva stride multiplicado por la capacidad, con la alineación que exige el tipo, y devuelve un puntero ligado a ese tipo pero sin inicializar. La ligadura y la inicialización son cosas distintas: la primera dice de qué tipo serán los bytes cuando los haya; la segunda dice que ya los hay. Esa distinción, que suena escolástica, es justo la que hace que la primera línea comentada del ejemplo sea indefinida en vez de simplemente devolver un valor raro.
Inicializar no es asignar
Aquí está el error que más caro se paga, y es un error que el compilador no puede detectar porque ambas operaciones tienen tipos idénticos. initialize asume que la memoria destino no contiene un valor y deposita uno nuevo. update —llamada assign antes de que SE-0370 la renombrara en Swift 5.8— asume que la memoria destino sí contiene un valor válido, lo destruye correctamente y pone otro en su lugar.
let q = UnsafeMutablePointer<String>.allocate(capacity: 1)
q.initialize(to: "primero") // correcto: la memoria estaba vacia
q.update(to: "segundo") // correcto: libera "primero" y guarda "segundo"
// q.initialize(to: "tercero") // FUGA: no libera "segundo", lo pisa
Para tipos triviales —enteros, valores en coma flotante, estructuras compuestas solo de triviales— la diferencia no se nota, porque destruir un Int no hace nada. Para todo lo que gestione recursos —cadenas, arrays, instancias de clase, enumeraciones con carga— la diferencia es exactamente la fuga o la liberación doble. La regla de trabajo es mecánica: si no puedes afirmar con certeza en qué estado está la memoria, no has diseñado bien el tipo que la posee.
La familia se completa con las operaciones de movimiento, que existen porque copiar tiene un coste que a veces es innecesario. move extrae el valor y deja la memoria deinicializada; moveInitialize traslada un rango entero desde otro puntero, dejando el origen deinicializado y el destino inicializado, y es la operación que implementa un crecimiento de búfer sin retenciones ni liberaciones intermedias.
let viejo = UnsafeMutablePointer<String>.allocate(capacity: 2)
viejo.initialize(repeating: "x", count: 2)
let nuevo = UnsafeMutablePointer<String>.allocate(capacity: 4)
nuevo.moveInitialize(from: viejo, count: 2) // traslada sin retener ni liberar
viejo.deallocate() // ya esta deinicializado: NO llamar deinitialize
nuevo.deinitialize(count: 2)
nuevo.deallocate()
stateDiagram-v2 [*] --> NoReservada NoReservada --> SinInicializar: allocate capacity SinInicializar --> Inicializada: initialize Inicializada --> Inicializada: update Inicializada --> SinInicializar: deinitialize o move SinInicializar --> NoReservada: deallocate Inicializada --> Fuga: deallocate directo SinInicializar --> Indefinido: leer pointee
Ligar no es inicializar
allocate devuelve memoria ligada al tipo pero vacía. La ligadura promete qué habrá; la inicialización promete que ya hay algo.
initialize y update no son sinónimos
Una supone destino vacío, la otra destino lleno. Confundirlas no da error de compilación: da fuga o liberación doble.
move traslada sin tocar el contador
moveInitialize evita el par retener y liberar de una copia. A cambio deja el origen deinicializado y ya no debes deinicializarlo.
Un tipo propietario que no fugue
Las cuatro operaciones sueltas son un peligro; encapsuladas en un tipo con dueño único, son una herramienta. El patrón canónico es una clase que reserve en su inicializador y libere en su deinit, de modo que el ciclo manual quede confinado y el resto del programa vuelva a la seguridad de ARC.
final class Bufer<Elemento> {
private let base: UnsafeMutablePointer<Elemento>
private(set) var cuenta: Int
let capacidad: Int
init(capacidad: Int, valorInicial: Elemento) {
self.capacidad = capacidad
self.cuenta = capacidad
self.base = .allocate(capacity: capacidad)
base.initialize(repeating: valorInicial, count: capacidad)
}
subscript(i: Int) -> Elemento {
get { precondition(i < cuenta); return base[i] }
set { precondition(i < cuenta); base[i] = newValue }
}
var vista: UnsafeBufferPointer<Elemento> {
UnsafeBufferPointer(start: base, count: cuenta)
}
deinit {
base.deinitialize(count: cuenta)
base.deallocate()
}
}
Dos detalles de ese código merecen atención. El deinit deinicializa exactamente cuenta elementos, no capacidad: deinicializar posiciones que nunca se inicializaron es tan indefinido como leerlas. Y el subíndice recupera la comprobación de rango con precondition, que sobrevive a la optimización, porque el objetivo del tipo es precisamente devolver garantías al mundo exterior.
Cuando el tipo no se conoce de antemano o el formato lo dicta un protocolo externo, se reserva en crudo y se liga después. UnsafeMutableRawPointer.allocate(byteCount:alignment:) te obliga a decir la alineación a mano, y bindMemory(to:capacity:) establece la ligadura antes de inicializar.
let crudo = UnsafeMutableRawPointer.allocate(
byteCount: MemoryLayout<Double>.stride * 8,
alignment: MemoryLayout<Double>.alignment
)
let tipado = crudo.bindMemory(to: Double.self, capacity: 8)
tipado.initialize(repeating: 0, count: 8)
tipado.deinitialize(count: 8)
crudo.deallocate() // se libera el puntero base, con la misma alineacion
deallocate debe llamarse sobre exactamente la dirección que devolvió allocate. Un puntero desplazado con aritmética, aunque apunte dentro de la misma región, no es una dirección válida para liberar. Y si una función puede lanzar entre la reserva y la liberación, envuelve la liberación en un defer inmediatamente después de reservar: es la única forma de que el camino de error no fugue.
Lo que estas cuatro llamadas hacen visible es que todo valor de un programa tiene en realidad dos biografías superpuestas: la de su almacenamiento y la de su contenido. La memoria nace cuando alguien la reserva y muere cuando alguien la devuelve; el valor nace cuando alguien lo construye allí y muere cuando alguien lo destruye. Los lenguajes de alto nivel deciden hacer coincidir ambas biografías porque casi siempre coinciden, y a cambio ganan una simplicidad enorme; pero casi siempre no es siempre, y toda estructura de datos seria vive en la excepción. Un array con capacidad de reserva tiene almacenamiento sin valores. Un Optional que envuelve un tipo con hueco de bits reutiliza almacenamiento vivo para representar la ausencia. Una tabla hash con direccionamiento abierto necesita distinguir la celda nunca usada de la celda liberada, y esa distinción es precisamente la frontera entre los dos ciclos. Cada tradición resolvió esto a su manera: C fundió construcción con memoria y dejó la inicialización al programador sin nombrarla; C++ separó ambas cosas pero escondió la separación tras una sintaxis intimidante; Rust las unió de nuevo en el sistema de propiedad y relegó la separación a un módulo aparte. Swift eligió el camino más pedagógico de todos: puso las cuatro transiciones en cuatro métodos con nombres del idioma, de modo que el modelo mental correcto no hay que reconstruirlo leyendo el estándar, se lee en el autocompletado. Y una vez que has visto la máquina de cuatro estados no puedes dejar de verla: ARC no es magia, es un compilador insertando initialize y deinitialize en los sitios donde tu código dice implícitamente que corresponde.
- Reserva memoria para cinco
String, leepointeeantes de inicializar bajo el sanitizador de direcciones y anota qué informa. - Llama a
initializedos veces seguidas sobre la misma posición con dos cadenas largas y comprueba la fuga con Instruments o con el sanitizador de fugas. - Reescribe ese caso con
updatey verifica que la fuga desaparece; explica qué llamada de liberación añadió el compilador. - Amplía la clase
Bufercon un método que crezca al doble usandomoveInitialize, y razona por qué el búfer viejo no debe deinicializarse después. - Añade una operación que pueda lanzar entre la reserva y la inicialización; provoca el error y comprueba con
deferque no queda memoria sin liberar.