Swifty Journey

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.

El capibara y el ave Swift comparando dos mundos: stack y heap

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

Solo las classes tienen
  • 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? o is)
  • 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 — struct
struct Size {
var width: Double
var height: Double
}
var a = Size(width: 100, height: 200)
var b = a // b es una COPIA independiente
b.width = 999
print(a.width) // 100 — intacto
print(b.width) // 999 — solo b cambió
// REFERENCE SEMANTICS — class
class 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 objeto
y.width = 999
print(x.width) // 999 — ¡también cambió!
print(y.width) // 999

Esta 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:

Value semantics vs reference semantics: con value semantics, las variables a y b tienen cada una su propia copia independiente de un struct, así que cambiar b deja a intacto; con reference semantics, las variables x e y apuntan con flechas al mismo objeto class, así que cambiar una cambia ambas

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:

  1. malloc — buscar espacio libre en el heap
  2. 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)
  3. refcount — el contador de referencias presentado en la introducción (ARC)
  4. El dato real (propiedades)
  5. free — cuando el último reference desaparece

Layout en memoria stack vs heap: a la izquierda un struct vive completo dentro del stack frame de la función con sus campos width y height inline; a la derecha una class guarda solo un puntero de 8 bytes en el stack que apunta con una flecha a un bloque del heap que contiene metadata, refcount y el dato width/height alocado con malloc

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 instancia
let point3 = SizeClass(width: 10, height: 20) // otra instancia, mismos valores
point1 === point2 // true — MISMO objeto
point1 === point3 // false — objetos DIFERENTES

Los 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 let

mutating 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 objeto

Static dispatch vs Dynamic dispatch

El ave Swift mostrando dos caminos: uno directo y otro con indirección

Esta es la diferencia de rendimiento que menos se discute — y la que más impacta.

// STRUCT — static dispatch
struct 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

Static dispatch vs dynamic dispatch: a la izquierda el static dispatch va directo del call site al código de la función en un solo salto; a la derecha el dynamic dispatch enruta el call site a través de un lookup en la vtable antes de saltar al código, y marcar la class como final lo colapsa de vuelta al camino directo

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
}
Dispatch en resumen
  • 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 final si 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

Copy-on-Write en dos estados: primero, original y copy apuntan ambos a un único buffer compartido con refcount = 2 y no se copia ningún dato; segundo, llamar a copy.append(6) fuerza una copia, así que original conserva el buffer A y copy obtiene un buffer B nuevo con el elemento añadido, cada uno ahora con refcount = 1

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:

  1. Escape de scope — capturados por un escaping closure (artículo #6)
  2. Existential containers — cuando se almacenan como protocolo (any Drawable)
  3. Demasiado grandes — structs muy grandes pueden ser más eficientes en el heap
  4. 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 heap
let shape: any Drawable = Circle(radius: 5)

¿Cuándo usar struct? ¿Cuándo usar class?

Usa struct cuando...
  • 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
Usa class cuando...
  • 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; final lo 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