wandres.dev
VALOR Y REFERENCIA · la semántica que define Swift

Cuándo class y cuándo struct: identidad frente a valor

El criterio no es el rendimiento sino la ontología: decidir si el tipo modela algo que tiene identidad propia o algo que solo tiene contenido, y qué se paga realmente en cada caso.

⏱ 17 min

La pregunta se plantea casi siempre mal. “¿Uso struct o class?” suele traducirse mentalmente por “¿cuál es más rápido?”, y esa versión no tiene respuesta útil. La pregunta correcta es anterior al código y pertenece al dominio que estás modelando: ¿esta cosa tiene identidad propia, o es solo su contenido? Un billete de veinte euros no es distinguible de otro billete de veinte euros; una cuenta bancaria con veinte euros sí lo es de otra cuenta con veinte euros. Responde eso y la palabra clave se elige sola.

🎯 Al terminar esta lección sabrás
  • Formular la prueba de identidad y decidir el tipo a partir de ella.
  • Aplicar el criterio práctico y reconocer las señales que obligan a usar class.
  • Detectar los síntomas de una elección equivocada en cada dirección.
  • Evaluar el coste real: copias, montículo, ARC y despacho dinámico.

La prueba de identidad

Toma dos instancias con exactamente el mismo contenido y pregunta: ¿son la misma cosa o son dos cosas? Si el dominio no distingue entre ellas, tienes un valor. Si el dominio necesita distinguirlas —porque una tiene historia, ubicación, ciclo de vida o dueño propio—, tienes una entidad con identidad.

Swift materializa esa diferencia en dos operadores distintos. == compara contenido y lo definen los tipos de valor; === compara identidad y solo existe para las referencias.

struct Dinero: Equatable { var centimos: Int }
final class Cuenta { var saldo = Dinero(centimos: 2000) }

let a = Dinero(centimos: 2000)
let b = Dinero(centimos: 2000)
print(a == b)          // true: no hay nada más que preguntar

let c1 = Cuenta()
let c2 = Cuenta()
print(c1 === c2)       // false: mismo saldo, cuentas distintas

La prueba tiene un corolario práctico: si para usar tu struct acabas inventando un campo id y un diccionario que lo indexa, el dominio te estaba pidiendo identidad y la estabas simulando a mano. Y al revés: si tu class no tiene más que propiedades let, ningún deinit y ningún estado observable, la identidad que le diste no la usa nadie.

El criterio práctico

La guía oficial invierte la carga de la prueba: empieza por struct y cambia a class solo cuando aparezca una razón concreta. Estas son las razones que cuentan.

🍊

Struct o enum · por defecto

Datos, medidas, coordenadas, fechas, dinero, respuestas de red, configuraciones, estados de interfaz, resultados de un cálculo. Todo lo que se define por su contenido.

🔗

Class o actor · con motivo

Necesitas identidad compartida, ciclo de vida con deinit, herencia, interoperar con Objective-C o KVO, o un estado único que muchos observan y mutan.

Las señales que obligan a la referencia son verificables, no cuestión de gusto. Si necesitas un deinit para cerrar un descriptor de fichero o cancelar una suscripción, hay ciclo de vida y por tanto identidad. Si el protocolo que conformas está restringido con AnyObject, o el framework espera un NSObject, la decisión ya está tomada fuera. Si varias partes del programa deben ver el mismo cambio sin recibir notificación explícita, estás describiendo un objeto compartido.

Un matiz que suele olvidarse: cuando la razón para usar clase es estado compartido y mutable, la respuesta moderna no es class a secas, sino actor. La identidad se conserva y el acceso concurrente queda serializado.

actor Sesion {                       // identidad + aislamiento
    private var solicitudes = 0
    func registrar() { solicitudes += 1 }
}

struct Peticion: Sendable {          // valor: se copia, viaja libre
    let url: URL
    let cabeceras: [String: String]
}

De todas las señales, la más limpia es deinit. Si tu tipo necesita ejecutar algo cuando deja de existir —cerrar un descriptor, cancelar una tarea, liberar un recurso del sistema—, entonces existe un instante único en el que deja de existir, y eso es identidad en estado puro.

final class Fichero {
    private let descriptor: Int32
    init(_ d: Int32) { descriptor = d }
    deinit { /* cerrar el descriptor exactamente una vez */ }
}

Fíjate en que las estructuras no admiten deinit, y no por una limitación del compilador: es que la pregunta no tiene respuesta. Un valor puede copiarse arbitrariamente y cada copia muere cuando le toca, así que no hay ningún momento privilegiado al que asociar la liberación. La ausencia de deinit en los tipos de valor no es una carencia; es la consecuencia lógica de no tener identidad.

Los síntomas de haber elegido mal

Un tipo de referencia usado para algo sin identidad se delata por el bug de “quién lo cambió”: una función lejana muta un objeto que tú creías tuyo, el orden de ejecución empieza a importar y aparecen métodos copia escritos a mano para defenderte. También por la necesidad de escribir == comparando campo a campo, señal de que lo que te importaba era el contenido desde el principio.

Un tipo de valor usado para algo con identidad se delata por el ruido inverso: identificadores artificiales, tablas de búsqueda para “encontrar el original”, devoluciones constantes de copias actualizadas que hay que volver a insertar donde estaban, y la sensación de estar sincronizando dos versiones de la misma cosa.

Hay un caso intermedio que conviene no confundir con un error. Un struct que conforma Identifiable no está simulando identidad: está declarando que sus valores representan a una entidad que vive en otro sitio —una fila de la base de datos, un registro del servidor— y que el identificador es la referencia a esa entidad. El valor es una fotografía; la identidad la tiene el original, no la foto. Ese patrón es correcto y es la base de casi toda la arquitectura de SwiftUI.

// Síntoma de valor mal modelado como referencia
final class Coordenada {          // dos coordenadas iguales son la misma coordenada
    var lat: Double
    var lon: Double
    init(lat: Double, lon: Double) { self.lat = lat; self.lon = lon }
}

// Síntoma de entidad mal modelada como valor
struct Conexion {                 // ¿quién la cierra? ¿cuál de las copias es la real?
    var id: UUID
    var abierta: Bool
}
flowchart TB
p[Dos instancias con el mismo contenido] --> mismo[Son la misma cosa para el dominio]
p --> dos[Son dos cosas distinguibles]
mismo --> st[Struct o enum: comparar con doble igual]
dos --> cl[Class o actor: comparar con triple igual]
st --> e1[Dinero fecha coordenada respuesta estado]
cl --> e2[Conexion fichero cache ventana sesion]

El coste: lo que de verdad se paga

Solo cuando la semántica está decidida tiene sentido hablar de rendimiento, y entonces conviene saber qué se paga en cada lado. Un struct pequeño vive en registros o en la pila y no cuesta nada; uno grande cuesta proporcionalmente a sus propiedades almacenadas, pero solo cuando se copia de verdad, y los parámetros de función no lo hacen. Una class cuesta una asignación en el montículo por instancia y tráfico atómico de ARC en cada copia de referencia, más una indirección al acceder y despacho dinámico si no es final.

El matiz que decide las mediciones reales es que ambos costes se suman en los tipos mixtos. Copiar una estructura cuyas propiedades son todas escalares es mover unos bytes; copiar una cuyas propiedades son referencias implica retener cada una de ellas, con una operación atómica por campo.

struct Escalares { var x, y, z: Double }      // copiar: mover 24 bytes

struct Referencias {                          // copiar: tres retain atomicos
    var capa: CALayer
    var fuente: NSObject
    var delegado: AnyObject
}

Por eso una estructura “ligera” llena de objetos puede resultar más cara de copiar que una estructura “pesada” de números, y por eso la pregunta del rendimiento nunca se responde mirando el tamaño: se responde mirando de qué está hecha.

💡
Marca final por defecto en tus clases

Una clase no final obliga al compilador a despachar sus métodos por tabla de testigos, porque cualquiera podría heredar y sobrescribir. Marcar final —o declarar la clase internal en un módulo con optimización de módulo entero— permite desvirtualizar la llamada y a menudo integrarla en el sitio. Si no has diseñado la clase para ser heredada, final no es una restricción: es información.

La elección es una afirmación ontológica, no una optimización

Cuando escribes struct estás afirmando algo fuerte sobre el mundo: que dos ejemplares con el mismo contenido son indiscernibles y, por tanto, intercambiables. Es el principio de Leibniz aplicado a tu dominio, y de él se deducen sin esfuerzo la igualdad estructural, la libertad para copiar sin coordinarse, la seguridad frente a la mutación remota y —no por casualidad— la conformidad casi automática a Sendable. Cuando escribes class afirmas lo contrario: que existe una cosa concreta, con historia y con lugar, de la que puede haber muchas vistas pero un solo ejemplar; y de ahí se deducen igual de directamente la comparación por identidad, la necesidad de coordinar a los observadores, el ciclo de vida gestionado y la aparición de las referencias débiles para romper ciclos. Ninguna de esas consecuencias es una preferencia técnica: todas son teoremas de la afirmación inicial. Por eso equivocarse cuesta tan caro y se nota tan tarde. Si le das identidad a lo que no la tiene, inventas una entidad que el dominio nunca pidió, y a partir de ahí cada bug de mutación a distancia es un síntoma legítimo de esa mentira. Si se la quitas a lo que sí la tiene, tendrás que reconstruirla con identificadores, tablas y sincronización manual, es decir, reimplementando peor lo que la palabra class te daba gratis. La pregunta correcta nunca fue cuál corre más rápido, sino qué clase de cosa es esto.

📝
Lo esencial del criterio

Empieza siempre por struct. Cambia a class cuando exista una razón demostrable: identidad compartida, ciclo de vida con deinit, herencia, interoperabilidad o estado observado por varios —y en ese último caso valora un actor—. Usa == para contenido y === para identidad, y trata la aparición de identificadores artificiales o de métodos de copia manual como el aviso de que elegiste al revés.

⚔️ Decide con la prueba de identidad
  1. Clasifica diez tipos de un proyecto tuyo aplicando la pregunta de las dos instancias iguales y justifica cada respuesta en una línea.
  2. Busca una clase sin deinit, sin herencia y con todas sus propiedades let: conviértela en struct y observa qué se rompe.
  3. Busca una estructura con un campo id consultado en un diccionario y razona si el dominio pedía una entidad.
  4. Convierte una clase con estado mutable compartido en actor y anota qué llamadas necesitaron await.
  5. Marca final todas las clases de un módulo que no estén diseñadas para heredarse y comprueba que sigue compilando.