Clases donde tocaba struct: identidad accidental y estado compartido
Elegir `class` por costumbre reparte por el programa dos regalos que nadie pidió: identidad y aliasing. Cómo detectar el bug del objeto compartido por sorpresa, cómo migrar a valores sin perder las capacidades que sí necesitabas, y qué criterio distingue de verdad una entidad de un dato.
En la mayoría de los proyectos con historia, la proporción de class frente a struct no refleja ninguna decisión: refleja de qué lenguaje venía el equipo. Quien llegó de Java o de Objective-C escribe class porque es la palabra con la que se define un tipo, y solo después descubre que Swift le ha dado dos propiedades adicionales que no había solicitado. La primera es la identidad: dos objetos con exactamente el mismo contenido son distintos, y === lo confirma. La segunda es el aliasing: pasar el objeto a otra función no entrega una copia, entrega una llave de la misma habitación. Ninguna de las dos es mala; ambas son necesarias para modelar entidades. Lo que produce bugs es recibirlas sin querer, en un tipo que solo pretendía transportar tres campos.
- Aplicar el criterio operativo que separa una entidad con identidad de un valor, sin recurrir a reglas de pulgar sobre el tamaño.
- Reconocer en depuración la firma del aliasing accidental y del
Equatableincoherente que lo acompaña. - Ejecutar la migración de
classastructresolviendo mutación, herencia y referencia a estado común. - Justificar los casos en que la clase es la respuesta correcta y no una comodidad heredada.
Dos regalos que no pediste
El primer síntoma no aparece en el tipo, aparece a distancia. Una función recibe un objeto, lo modifica para su propio cálculo y el llamante descubre que su copia también cambió, porque nunca hubo copia.
final class Filtro {
var texto = ""
var soloActivos = false
}
func aplicarPresetSemanal(_ f: Filtro) -> [Registro] {
f.soloActivos = true // "temporal", para este calculo
return consultar(con: f)
}
let miFiltro = Filtro()
let semana = aplicarPresetSemanal(miFiltro)
// miFiltro.soloActivos ahora vale true. Nadie lo escribio a proposito.
El bug no está en aplicarPresetSemanal; su autor razonó sobre un parámetro como si fuese suyo, que es exactamente lo que un parámetro parece. Está en que Filtro es una clase sin ningún motivo. No tiene ciclo de vida, no representa a nadie, no se comparte deliberadamente: es una bolsa de dos campos que se convirtió en un canal de comunicación oculto entre funciones que creían no hablarse.
El segundo síntoma es la igualdad. Cuando alguien necesita comparar dos filtros y el tipo es una clase, == compara por identidad salvo que se implemente a mano, y quien lo implementa a mano suele olvidar mantenerlo al añadir el tercer campo. A partir de ahí conviven dos nociones de igualdad —contenido e identidad— y el código elige una u otra según qué operador se teclee, lo cual es una fuente inagotable de comportamientos que solo se reproducen con datos reales.
Mutación a distancia
Una función modifica su parámetro y el efecto se ve en el llamante. Con struct esto es imposible salvo que se escriba inout, que además lo hace visible en la llamada.
Igualdad ambigua
== y === conviven y no coinciden. Si nadie sabe cuál es la correcta en cada sitio, el tipo no debería tener identidad.
Carrera silenciosa
Dos tareas comparten la referencia sin saberlo. Con struct cada una tiene su copia y el compilador de concurrencia estricta deja de protestar.
El criterio que sí funciona
La regla que circula —«usa struct para tipos pequeños»— es la peor posible, porque el tamaño es irrelevante y la copia perezosa de los tipos con almacenamiento indirecto la vacía de contenido. El criterio real se formula como una prueba de sustitución: si reemplazo esta instancia por otra con exactamente los mismos campos, ¿cambia algo? Si nada cambia, el tipo es un valor y debe ser struct. Si algo cambia —hay observadores registrados, hay una conexión abierta, hay una fila en la base de datos con esa identidad— el tipo es una entidad y necesita class.
flowchart TD
A[Tipo nuevo] --> B{Dos instancias iguales en contenido son intercambiables}
B -- Si --> C[Es un valor: usa struct]
B -- No --> D{Que aporta la identidad}
D -- Ciclo de vida o recurso --> E[Entidad: usa class o actor]
D -- Solo evitar copias --> F[Sigue siendo valor: mide antes de decidir]
C --> G[Igualdad por contenido y sin aliasing]
E --> H[Igualdad por identidad y ciclo de vida explicito]La prueba tiene una virtud: es objetiva y se responde sin conocer el rendimiento del programa. Un Filtro, dos Filtro con los mismos campos son intercambiables, luego es un valor. Una ConexionSQLite no lo es: cerrar una no cierra la otra, aunque los campos coincidan. Un UsuarioSesion que mantiene un token renovable y notifica cambios tampoco lo es. Un Usuario que solo transporta identificador, nombre y correo sí lo es, aunque hable de una persona real: la persona tiene identidad, el registro que la describe no.
Migrar sin perder nada
La resistencia habitual a la migración se apoya en tres capacidades que parecen exclusivas de la clase. Ninguna lo es.
La mutación se resuelve con mutating. Un método que cambia el estado de un valor declara que lo hace, y el compilador exige que la variable sea var. Lo que se pierde no es capacidad: es la posibilidad de mutar algo que el llamante creía constante.
struct Filtro: Equatable, Hashable, Sendable {
var texto = ""
var soloActivos = false
func conSoloActivos(_ v: Bool) -> Filtro { // estilo funcional
var copia = self; copia.soloActivos = v; return copia
}
}
func aplicarPresetSemanal(_ f: Filtro) -> [Registro] {
consultar(con: f.conSoloActivos(true)) // no toca al llamante
}
La herencia se resuelve con protocolos y composición, y casi siempre mejora el resultado. Una jerarquía de tres niveles con propiedades heredadas se convierte en un protocolo con requisitos y un struct por caso, o en una enumeración si los casos son cerrados. Se pierde la reutilización por copia de implementación y se gana exhaustividad y ausencia de la clase base con métodos que nadie llama.
El estado verdaderamente compartido se resuelve nombrándolo. Si tres pantallas necesitan ver el mismo filtro y reaccionar a sus cambios, lo que se comparte no es el filtro: es el almacén que lo contiene. Extraer ese almacén como una clase pequeña, con el valor dentro, separa las dos responsabilidades que estaban fundidas.
@Observable
final class FiltroStore { // la identidad vive aqui, y solo aqui
var actual = Filtro() // el dato sigue siendo un valor
}
Este reparto tiene una consecuencia que suele sorprender: el número de tipos con identidad en una aplicación bien diseñada es pequeño y se puede enumerar en una pizarra. Todo lo demás son valores que atraviesan el sistema sin dejar rastro. Cuando esa lista no cabe en la pizarra, no es que el dominio sea complejo; es que la identidad se repartió por omisión.
Queda un cuarto obstáculo del que se habla poco: la recursión. Un struct no puede contener directamente una propiedad de su propio tipo, porque su tamaño sería infinito. Es un límite real, pero no obliga a volver a la clase: indirect en enumeraciones y el almacenamiento indirecto de las colecciones lo resuelven manteniendo la semántica de valor.
indirect enum Expresion { // recursivo y sigue siendo valor
case literal(Double)
case suma(Expresion, Expresion)
case negacion(Expresion)
}
struct Nodo { // el array ya es indirecto
var etiqueta: String
var hijos: [Nodo]
}
La discusión sobre struct frente a class se plantea casi siempre en términos de rendimiento, y ese es el terreno menos interesante, porque en él las dos opciones empatan más de lo que la intuición sugiere: un struct grande copiado en un bucle caliente puede ser más lento que una referencia, y un tipo con CoW interno copia un puntero y un contador. El terreno decisivo es otro y no tiene instrumentación: el aliasing es una propiedad no local del programa. Cuando una función recibe un valor, todo lo que necesitas para razonar sobre ella está en su cuerpo y en su firma; el razonamiento es composicional, y por eso se puede leer una función sola y saber qué hace. Cuando recibe una referencia mutable, la firma deja de ser suficiente: para saber qué hace hay que conocer también quién más tiene esa referencia, y esa información no está en ningún sitio del código, está distribuida en el grafo de llamadas completo. El coste de una clase mal elegida no se paga en ciclos, se paga en que el conjunto de hechos que hay que tener en la cabeza para modificar una función crece con el tamaño del programa en lugar de mantenerse constante. Es la misma razón por la que la concurrencia estricta de Swift trata a los valores como ciudadanos de primera y exige ceremonia para las referencias: Sendable no es una etiqueta arbitraria, es el reconocimiento formal de que un tipo sin aliasing puede cruzar fronteras de aislamiento porque nadie puede observar su mutación desde fuera. Elegir struct por omisión no es una preferencia estilística; es mantener el razonamiento local, y el razonamiento local es lo único que hace tratable un sistema grande.
Cuándo la clase gana
Hay tres situaciones en que la clase no es una comodidad heredada sino la herramienta correcta, y conviene tenerlas claras para no caer en el fanatismo inverso.
La primera es el recurso con ciclo de vida: un fichero abierto, un socket, un observador registrado. Aquí deinit es el mecanismo, y deinit solo existe en tipos de referencia. La segunda es la entidad compartida por diseño, donde varios puntos del programa deben ver la misma mutación: un almacén de estado, un coordinador, un caché. La tercera es la interoperabilidad, cuando un marco de trabajo exige subclasificar o cuando se cruza a Objective-C.
Hay además un caso intermedio que conviene nombrar porque desactiva la falsa dicotomía: la clase que envuelve un valor para darle almacenamiento compartido y copia perezosa. Es el patrón que usa la propia biblioteca estándar, y permite tener semántica de valor con el coste de copia de una referencia.
struct Buffer {
private final class Caja { var bytes: [UInt8]; init(_ b: [UInt8]) { bytes = b } }
private var caja: Caja
var bytes: [UInt8] {
get { caja.bytes }
set {
if !isKnownUniquelyReferenced(&caja) { caja = Caja(caja.bytes) }
caja.bytes = newValue
}
}
}
Aquí la clase existe, pero es privada y no se filtra: nadie fuera del tipo puede observar identidad ni provocar aliasing. Es la diferencia decisiva entre usar una referencia como detalle de implementación y usarla como contrato público.
«Es muy grande para copiarlo» rara vez sobrevive a una medición. Los tipos con almacenamiento indirecto —Array, String, Dictionary— ya comparten el búfer y solo copian al mutar. Un struct de veinte campos escalares se copia con una instrucción de bloque. Mide antes de convertir un valor en entidad por miedo al coste.
- Lista todas las
classde un módulo tuyo y aplícales la prueba de sustitución. Anota cuántas la superan, es decir, cuántas deberían serstruct. - Elige la clase más usada que sea en realidad un valor y conviértela. Anota cada error de compilación que aparezca y clasifícalo: mutación que necesitaba
mutating, aliasing que era intencionado, o herencia que se disuelve en protocolo. - Si aparece algún aliasing intencionado, no lo restaures: extrae un almacén explícito que contenga el valor y comprueba cuántos puntos del código lo necesitaban de verdad.
- Implementa
Equatablea mano en la clase original y compáralo con el sintetizado delstruct. Cuenta las líneas y los campos que habías olvidado. - Escribe una prueba que pase el tipo a dos tareas concurrentes. Observa qué dice el compilador con concurrencia estricta antes y después de la migración, y explica por qué el
structno necesita anotación alguna.