Swift de Cero a Experto #8: Structs vs Classes — la decisión que define tu app
Value semantics vs reference semantics, static vs dynamic dispatch, y por qué Apple recomienda structs por defecto. El artículo que cambia cómo piensas en Swift.
En el artículo anterior vimos que los enums son value types con una representación en memoria increíblemente eficiente. Hoy abordamos la decisión más fundamental en Swift: struct o class. No es una preferencia estética — es una elección que afecta directamente dónde vive tu dato (stack vs heap), cómo se copia, cómo se despacha cada método (es decir, cómo Swift decide qué código de función ejecutar realmente — explicado en detalle más adelante en este artículo), y cuánta presión ejerces sobre la memoria — a diferencia de Java o C# (que usan un garbage collector, un proceso en segundo plano que limpia la memoria sin uso), Swift usa ARC en su lugar. Swift usa ARC — Automatic Reference Counting (una referencia es una variable que apunta a un objeto en lugar de contener su valor). ARC lleva una cuenta por objeto: cada nueva variable que lo apunta suma 1, cada una que deja de apuntarlo resta 1, y cuando la cuenta llega a 0 el objeto se libera — un deep-dive completo vendrá en un artículo posterior.
Este artículo te va a cambiar la forma en que piensas sobre tus tipos.
Struct o class no es una cuestión de preferencia — es una decisión de arquitectura. Elige mal y pagarás en rendimiento, bugs de estado compartido, o ambos.

Lo que comparten
Antes de ver las diferencias, vale la pena notar que structs y classes tienen mucho en común:
// Ambos pueden tener:// ✓ Stored and computed properties// (computed: calculan su valor al vuelo en vez de almacenarlo — ver artículo #9)// ✓ Methods// ✓ Subscripts// (subscripts permiten acceder a un tipo con [] como un array — lo vemos en el próximo artículo)// ✓ Initializers// ✓ Extensions (extensions = agregar nuevos métodos o propiedades a un tipo existente sin editar su definición original)// ✓ Protocol conformance (un protocol es un contrato de métodos/propiedades que un tipo promete proveer — deep-dive completo en #13)Si solo miras la lista de features, parecen casi iguales. Las diferencias están bajo el capó.
Lo que las separa
- Herencia — una class puede construirse sobre otra, obteniendo automáticamente sus propiedades y métodos mientras agrega o reemplaza algunos (detalle completo en #10)
- Type casting — preguntar en runtime si un objeto es en realidad una subclass más específica (usando
as?ois) - Deinitializers — código que corre antes de ser destruida (
deinit), útil para limpieza como cerrar un archivo o liberar un recurso — cubierto en un artículo posterior - Reference counting — múltiples referencias al mismo objeto (ARC)
- Identity — el operador
===para verificar si dos variables apuntan al mismo objeto
Value semantics vs Reference semantics
Veamos esto en acción:
// VALUE SEMANTICS — structstruct Size { var width: Double var height: Double}
var a = Size(width: 100, height: 200)var b = a // b es una COPIA independienteb.width = 999
print(a.width) // 100 — intactoprint(b.width) // 999 — solo b cambió// REFERENCE SEMANTICS — classclass SizeClass { var width: Double var height: Double init(width: Double, height: Double) { self.width = width self.height = height }}
var x = SizeClass(width: 100, height: 200)var y = x // y apunta al MISMO objetoy.width = 999
print(x.width) // 999 — ¡también cambió!print(y.width) // 999Esta es la distinción más importante entre ambos. El diagrama de abajo los muestra lado a lado: un value type hace una copia independiente, mientras que un reference type entrega un segundo puntero al mismo objeto:

Dónde viven: Stack vs Heap
Structs: el stack
Los structs viven directamente en el stack frame (el bloque de memoria del stack reservado para una llamada a función) de la función que los crea:
func process() { var point = Size(width: 10, height: 20) // point vive aquí, en el stack de process() // Cuando process() termina, point desaparece // No hay malloc, no hay free, no hay refcount}El stack es LIFO (Last In, First Out) — como una pila de platos: el último plato agregado es el primero en retirarse. Por esto, alocar o liberar espacio en el stack es solo mover un único puntero una cantidad fija. Es la operación de memoria más rápida que existe.
Classes: el heap
Las classes siempre se alocan en el heap. Dos términos aparecen en los comentarios de abajo, así que definámoslos primero: un puntero es simplemente una dirección de memoria (dirección = una ranura numerada en la RAM) — 8 bytes en los dispositivos Apple de 64 bits — que indica dónde vive el objeto real en el heap. Y malloc y free son las operaciones de bajo nivel que reservan y liberan un bloque de memoria del heap — Swift las llama por ti automáticamente bajo el capó (vía ARC).
func process() { var point = SizeClass(width: 10, height: 20) // En el stack: 8 bytes (puntero al heap) // En el heap: metadata + refcount + width + height // malloc() al crear, free() cuando refcount llega a 0}Cada instancia de class implica:
malloc— buscar espacio libre en el heap- metadata — información de control que Swift guarda sobre el tipo del objeto, incluyendo un puntero a su vtable (una tabla de búsqueda que Swift usa para encontrar el código de los métodos — definida en la sección de dispatch más abajo)
- refcount — el contador de referencias presentado en la introducción (ARC)
- El dato real (propiedades)
free— cuando el último reference desaparece

Memberwise initializers
Los structs obtienen un initializer automático con todos sus stored properties. Memberwise = un parámetro por cada stored property, en orden de declaración — Swift lo genera automáticamente para cada struct.
struct Color { var red: Double var green: Double var blue: Double}
// El compilador genera esto gratis:let white = Color(red: 1.0, green: 1.0, blue: 1.0)Las classes no — tienes que escribir tu propio init:
class ColorClass { var red: Double var green: Double var blue: Double
init(red: Double, green: Double, blue: Double) { self.red = red self.green = green self.blue = blue }}Identity: === vs ==
Las classes tienen algo que los structs no: identidad. Puedes preguntar si dos variables apuntan al mismo objeto (no solo si tienen los mismos valores):
let point1 = SizeClass(width: 10, height: 20)let point2 = point1 // misma instancialet point3 = SizeClass(width: 10, height: 20) // otra instancia, mismos valores
point1 === point2 // true — MISMO objetopoint1 === point3 // false — objetos DIFERENTESLos structs no tienen === porque no tiene sentido — cada variable es su propia copia. Solo puedes comparar valores con == (si el tipo conforma Equatable — Equatable es un protocolo de Swift integrado — un contrato — que un tipo adopta para declarar que == funciona sobre él; detalle completo en #13).
mutating: la keyword que protege tus structs
Como los structs son value types, Swift prohíbe mutar sus propiedades desde métodos por defecto — vuelves a habilitarlo marcando el método como mutating, una keyword que le promete al compilador “este método va a cambiar self” (la instancia actual):
struct Counter { var count = 0
mutating func increment() { count += 1 // Solo posible con 'mutating' }}
var counter = Counter()counter.increment() // OK — counter es var
let fixed = Counter()// fixed.increment() // ERROR — no puedes mutar un letmutating es un contrato: le dice al compilador “este método va a modificar self” (self = la instancia actual). En las classes no existe porque las propiedades siempre son mutables a través de cualquier referencia (incluso let).
let classCounter = SomeCounterClass()classCounter.count += 1 // OK — let solo protege la REFERENCIA, no el objetoStatic dispatch vs Dynamic dispatch

Esta es la diferencia de rendimiento que menos se discute — y la que más impacta.
// STRUCT — static dispatchstruct Calculator { func add(_ a: Int, _ b: Int) -> Int { a + b }}let calc = Calculator()calc.add(2, 3)// El compilador inserta directamente: a + b (puede hasta hacer inline)// (inlining = el compilador pega el cuerpo de la función directo en el call site, saltándose la llamada por completo)
// CLASS — dynamic dispatch (por defecto)class CalculatorClass { func add(_ a: Int, _ b: Int) -> Int { a + b }}let calcClass = CalculatorClass()calcClass.add(2, 3)// Runtime: busca add() en la vtable → salta al código → ejecuta
final: la optimización que debes conocer
Marcar una class o método como final le dice al compilador “nadie va a subclasear/overridear esto” — y habilita static dispatch:
final class FastCalculator { func add(_ a: Int, _ b: Int) -> Int { a + b } // static dispatch — igual de rápido que un struct}- Struct methods → siempre static dispatch (el compilador resuelve en compilación)
- Class methods → dynamic dispatch por defecto (vtable lookup en runtime)
- final class methods → static dispatch (el compilador sabe que no hay override)
- Protocol methods → witness table (similar a vtable, lo veremos en artículo #13)
- Whole Module Optimization → el compilador puede inferir
finalsi ve que nadie subclasea
Copy-on-Write (CoW): lo mejor de ambos mundos
Si los structs se copian cada vez que los asignas… ¿no es terriblemente lento para un Array de 10,000 elementos? No — gracias a Copy-on-Write.
El buffer que se menciona abajo es el bloque de memoria donde un Array realmente guarda sus elementos:
var original = [1, 2, 3, 4, 5]var copy = original // NO copia los datos — comparten el buffer interno
// Ambos apuntan al mismo buffer (refcount = 2)
copy.append(6) // AHORA se copia — copy necesita su propio buffer// original sigue con [1, 2, 3, 4, 5]// copy tiene [1, 2, 3, 4, 5, 6] en su propio buffer
CoW te da lo mejor de ambos mundos:
- Seguridad de value semantics (cada variable es independiente)
- Rendimiento de sharing (no copias si no modificas)
¿Cuándo los structs terminan en el heap?
Los structs normalmente viven en el stack, pero el compilador los mueve al heap cuando:
- Escape de scope — capturados por un escaping closure (artículo #6)
- Existential containers — cuando se almacenan como protocolo (
any Drawable) - Demasiado grandes — structs muy grandes pueden ser más eficientes en el heap
- Dentro de una class — si un struct es propiedad de una class, vive en el heap allocation de esa class
protocol Drawable { func draw()}
struct Circle: Drawable { var radius: Double func draw() { print("Drawing circle") }}
// Existential container — Circle puede terminar en el heaplet shape: any Drawable = Circle(radius: 5)¿Cuándo usar struct? ¿Cuándo usar class?
- Los datos son auto-contenidos y no necesitan identidad
- Quieres value semantics (copias independientes)
- No necesitas herencia
- Quieres el rendimiento del stack y static dispatch
- Es la opción por defecto recomendada por Apple
- Necesitas identidad (verificar si dos variables son el mismo objeto con
===) - Necesitas herencia (jerarquías de tipos)
- Necesitas reference semantics (múltiples partes del código comparten el mismo estado)
- Interoperas con Objective-C (que solo tiene classes)
- El ciclo de vida del objeto necesita deinit para cleanup
La recomendación de Apple es simple: usa structs por defecto, y recurre a classes solo cuando necesites sus capacidades específicas.
La memoria: el resumen definitivo
┌─────────────────────────────────────────────────┐│ STRUCT ││ • Stack allocation (rápido) ││ • Value semantics (copia independiente) ││ • Static dispatch (resuelto en compilación) ││ • No refcount, no malloc, no free ││ • CoW para colecciones (Array, Dict, Set) ││ • Memberwise init gratuito ││ • Sin herencia, sin deinit, sin identity │└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐│ CLASS ││ • Heap allocation (malloc + free) ││ • Reference semantics (estado compartido) ││ • Dynamic dispatch (vtable en runtime) ││ • Refcount management (ARC) ││ • Metadata/vtable pointer en cada instancia ││ • Herencia, deinit, identity (===) ││ • final → static dispatch (optimización) │└─────────────────────────────────────────────────┘No es que las classes sean “malas” — es que los structs son la opción correcta para la mayoría de los casos. Un struct es más rápido, más seguro, y más predecible. Reserva las classes para cuando realmente necesites herencia, identidad, o estado compartido.
Recapitulación
- Value semantics (struct) — cada variable tiene su propia copia; mutar una no afecta a otras
- Reference semantics (class) — múltiples variables comparten el mismo objeto
- Stack vs Heap — structs en el stack (rápido), classes en el heap (malloc + refcount + free)
- Memberwise init — gratis para structs, manual para classes
- Identity — solo classes tienen
===; structs solo tienen== - mutating — necesario en structs para modificar
self; innecesario en classes - Static dispatch (struct) — el compilador resuelve en compilación
- Dynamic dispatch (class) — vtable lookup en runtime;
finallo convierte en static - Copy-on-Write — Array/Dict/Set copian solo al mutar
- Structs en el heap — existential containers, escaping closures, dentro de classes
Lo que viene
En el próximo artículo exploramos Propiedades, Métodos y Subscripts — el tejido conectivo de tus tipos. Veremos la diferencia entre stored y computed properties (y por qué computed = zero storage), property observers, lazy properties, y cómo las propiedades definen el layout en memoria de tus structs y classes.
Nos vemos la próxima semana.
La decisión entre struct y class no es de sintaxis — es de semántica. Value semantics te da seguridad y rendimiento por defecto. Reference semantics te da poder y flexibilidad cuando lo necesitas. Elige con intención.
Referencias
Relacionados
-
- swift
- swift-cero-experto
- swift-fundamentals
Swift de Cero a Experto #7: Enumeraciones — más que una lista de casos
Raw values, associated values, enums recursivos con indirect, y cómo el compilador elige la representación mínima en memoria.
-
- swift
- swift-cero-experto
- swift-fundamentals
Swift de Cero a Experto #6: Closures — capturas, memoria y poder funcional
Closure expressions, captura de valores, capture lists, @escaping vs non-escaping y por qué los closures son reference types que viven en el heap.
-
- swift
- ios
- performance
Dominando Instruments (Parte 4): Flame Graphs, Swift Concurrency bajo el microscopio y Processor Trace en acción
Aprende a leer Flame Graphs, auditar tareas asíncronas con Swift Tasks y exprimir el Processor Trace con un proyecto CLI real que usa Swift Concurrency de forma intensiva.