Stack vs Heap en Swift: más allá de la regla que todos repiten
Arturo Rivas Arias
🧩 Hay una regla que circula sin parar en entrevistas técnicas de iOS: los structs van al stack y las clases van al heap. Es fácil de memorizar, suena convincente y, en la mayoría de los casos sencillos, parece funcionar. El problema es que no describe cómo Swift gestiona la memoria en la práctica. En cuanto hablamos de código real, la regla se rompe. Entender el modelo de verdad requiere empezar por otro sitio: la semántica, no el almacenamiento.
⚖️ Swift divide sus tipos en dos categorías según cómo se comportan al ser pasados como parámero y copiados. Los tipos de valor —struct, enum, tuplas— producen copias independientes en cada asignación. Los tipos de referencia —class, actor, closures— comparten una única instancia a través de referencias. Esta distinción es la que realmente importa al razonar sobre el comportamiento de tu código. La ubicación en memoria es una consecuencia de la semántica, no al revés.
📦 Para ilustrarlo, imagina un sistema de notificaciones push donde cada configuración de dispositivo es un tipo de valor:
struct DeviceConfig {
var token: String
var environment: String
}
var configA = DeviceConfig(token: "abc123", environment: "production")
var configB = configA
configB.token = "xyz789"
print(configA.token) // abc123
print(configB.token) // xyz789
Cada variable tiene su propia copia del dato. Modificar configB no afecta a configA. Ese comportamiento es la garantía que ofrece la semántica de valor, independientemente de dónde resida físicamente en memoria.
🗂️ El stack y el heap son dos estrategias diferentes para gestionar la vida útil de los datos. El stack es una región de memoria contigua que funciona como una pila LIFO: cada llamada a función reserva un bloque (stack frame) que se libera automáticamente al salir del scope (ámbito). La asignación es prácticamente gratuita, equivalente a mover un puntero. El heap, en cambio, gestiona bloques de tamaño arbitrario y/o con ciclos de vida variables. La asignación implica coordinarse con el gestor de memoria del sistema, lo que tiene un coste más alto. En Swift, las instancias de clase viven en el heap y su ciclo de vida lo gestiona ARC.
🚧 Aquí es donde la regla simplificada empieza a fallar. Un struct declarado dentro de una class no vive en el stack: vive en el heap, dentro de la propia instancia de la clase. El almacenamiento del struct es parte del objeto, y ese objeto está en el heap. La semántica sigue siendo de valor, pero la ubicación física no. Considera un modelo de preferencias de usuario dentro en un manager de sesión:
struct Preferences {
var theme: String
var fontSize: Int
}
final class SessionManager {
var preferences: Preferences
var userID: String
init(userID: String) {
self.userID = userID
self.preferences = Preferences(theme: "dark", fontSize: 14)
}
}
Preferences es un struct, pero al vivir dentro de SessionManager —que es una class— su almacenamiento está en el heap. La copia de preferences se comporta con semántica de valor; su dirección de memoria no está en el stack de ninguna función.
🪤 Los tipos de valor también pueden usar el heap internamente de formas que no resultan obvias. Los tipos de la librería estándar como Array, Dictionary o String tienen semántica de valor pero utilizan un buffer interno almacenado en el heap. Esto es posible gracias a una técnica llamada Copy-on-Write (COW): el buffer se comparte entre copias hasta que alguna de ellas muta, momento en que se crea una copia exclusiva. Es un compromiso entre eficiencia y semántica que el compilador y el runtime coordinan de forma transparente.
var logsA = ["login", "purchase", "logout"]
var logsB = logsA // Comparte el buffer interno, sin copiar todavía
logsB.append("error") // Aquí se crea el buffer independiente para logsB
print(logsA.count) // 3
print(logsB.count) // 4
Desde fuera, logsA y logsB se comportan como copias independientes desde el momento de la asignación. Por dentro, el compilador ha diferido la copia real hasta que fue estrictamente necesaria, evitando trabajo innecesario.
🔬 El compilador de Swift tiene un mecanismo adicional que puede eliminar asignaciones en el heap por completo: el análisis de escape. Si el compilador puede demostrar que un objeto de tipo referencia no escapa del scope donde se crea —es decir, que ninguna referencia a él sobrevive más allá de la función— puede crear la asignación al stack. Esto ocurre con más frecuencia de lo que parece en código optimizado, especialmente cuando se usa final en clases concretas, eliminando el dispatch dinámico y facilitando el análisis estático.
⚡️ El coste real de la gestión de memoria en Swift no es cara o cruz entre stack y heap. Hay que considerar tres factores: el coste de asignación (stack es trivial, heap tiene overhead), el coste de conteo de referencias (ARC inserta retain/release que son operaciones atómicas, y las operaciones atómicas tienen coste en sistemas multinúcleo) y la presión sobre la caché de CPU (datos contiguos en el stack son más fácilmente cacheables que objetos dispersos en el heap). En la mayoría del código de aplicación esto no es el cuello de botella, pero entender el modelo ayuda a tomar mejores decisiones si llegan los problemas.
🛠️ Cuando el rendimiento importa, las herramientas de Xcode dan visibilidad directa sobre la memoria. Instruments con la plantilla Allocations permite ver en tiempo real cuántas asignaciones heap se están produciendo por tipo, su tamaño y su frecuencia. El Memory Graph Debugger muestra el grafo de objetos vivos y sus referencias. Si en un bucle crítico ves miles de pequeñas asignaciones de un mismo tipo, generalmente es señal de que vale la pena revisar si ese tipo puede ser un struct o si Copy-on-Write está generando más copias de lo esperado.
🎯 El modelo mental correcto no empieza por “¿dónde va esto en memoria?” sino por “¿cuál es la semántica que necesito?”. Si necesitas copias independientes, un tipo de valor es la herramienta adecuada y Swift lo optimizará para que viva en el stack cuando sea posible. Si necesitas identidad compartida y ciclo de vida dinámico, una clase con alojada en el heap es lo correcto y ARC se encarga de liberar la memoria en el momento que toque. El compilador toma las decisiones de ubicación física en memoria; tu trabajo es expresar la semántica de forma clara.