Qué genera exactamente una data class
Las cinco familias de miembros que el compilador escribe al ver la palabra data: equals, hashCode, toString, copy y las funciones componentN. Y la lista, igual de importante, de todo lo que no genera nunca.
Escribir data delante de class es probablemente el gesto con mejor relación entre pulsaciones y código generado de todo Kotlin: cinco caracteres a cambio de cinco familias de miembros que tendrías que escribir, revisar y mantener a mano. Pero esa economía tiene letra pequeña. El compilador no genera lo que tú querías decir: genera exactamente lo que dicta una regla mecánica sobre el constructor primario, y todo lo que quede fuera de esa regla queda fuera del contrato para siempre. Entender la regla, y no el eslogan, es la diferencia entre usar una data class y confiar en ella.
- Enumerar con precisión los miembros que genera una
data classy su forma exacta. - Fijar la frontera entre el constructor primario y el cuerpo de la clase.
- Reconocer las restricciones estructurales que impone la palabra
data. - Saber qué no genera nunca y por qué esa segunda lista importa tanto como la primera.
Los cinco miembros generados
Partamos de la declaración más sosa posible y hagamos el ejercicio de escribir a mano lo que el compilador escribe por nosotros. Toda la magia de una data class cabe en una expansión mecánica que puedes reproducir de memoria:
data class Usuario(val id: Long, val nombre: String, val email: String)
El compilador, al ver data, sintetiza esto sobre las propiedades del constructor primario:
class Usuario(val id: Long, val nombre: String, val email: String) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Usuario) return false
return id == other.id && nombre == other.nombre && email == other.email
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + nombre.hashCode()
result = 31 * result + email.hashCode()
return result
}
override fun toString(): String =
"Usuario(id=$id, nombre=$nombre, email=$email)"
fun copy(
id: Long = this.id,
nombre: String = this.nombre,
email: String = this.email,
): Usuario = Usuario(id, nombre, email)
operator fun component1(): Long = id
operator fun component2(): String = nombre
operator fun component3(): String = email
}
Cinco detalles de esa expansión merecen atención. Primero, equals empieza por una comprobación de identidad this === other que actúa como atajo barato antes de comparar campo a campo. Segundo, el descarte usa other !is Usuario, no una comparación de clases exacta; como las data class son finales, ambas cosas coinciden. Tercero, hashCode acumula con el factor 31, el mismo primo impar que usa la convención de la JVM porque el compilador puede convertir esa multiplicación en un desplazamiento y una resta. Cuarto, copy no es un método especial del lenguaje sino una función normal con valores por defecto: en el backend de la JVM eso implica un método sintético adicional, habitualmente llamado copy$default, que recibe una máscara de bits indicando qué argumentos fueron omitidos. Y quinto, las funciones componentN llevan el modificador operator, que es precisamente lo que las conecta con el desestructurado.
Desde Kotlin 1.9 puedes escribir data object, que no tiene constructor primario y por tanto no genera copy ni componentN. Lo único que aporta frente a un object normal es un toString estable con el nombre del singleton, en lugar de la representación con la dirección de memoria. Es el remate natural de las jerarquías sealed donde algunas ramas no llevan datos.
El constructor primario es la frontera
La regla que gobierna todo lo anterior se enuncia en una línea: solo participan las propiedades declaradas en el constructor primario. Nada de lo que declares en el cuerpo de la clase entra en equals, ni en hashCode, ni en toString, ni en copy, ni recibe una función componentN.
flowchart TD A[data class Punto] --> B[Constructor primario val x val y] A --> C[Cuerpo de la clase var etiqueta] B --> D[equals y hashCode] B --> E[toString] B --> F[copy con valores por defecto] B --> G[component1 y component2] C -.-> H[No participa en ningun miembro generado]
El efecto práctico es más agresivo de lo que suele esperarse:
data class Punto(val x: Int, val y: Int) {
var etiqueta: String = "sin etiqueta"
}
val a = Punto(1, 2).apply { etiqueta = "origen" }
val b = Punto(1, 2)
println(a == b) // true: etiqueta no participa en equals
println(a.toString()) // Punto(x=1, y=2): etiqueta no aparece
println(a.copy().etiqueta) // sin etiqueta: copy pierde el estado del cuerpo
Esa última línea es la que muerde en producción. copy invoca el constructor primario, de modo que los bloques init sí se ejecutan y las validaciones sí se aplican, pero cualquier propiedad del cuerpo vuelve a su valor de inicialización. Una data class con estado en el cuerpo no tiene un copy roto: tiene un copy que hace exactamente lo prometido, y lo prometido nunca incluyó ese estado.
Las restricciones estructurales completan el cuadro. El constructor primario debe existir y tener al menos un parámetro; todos sus parámetros deben ir marcados como val o var; y la clase no puede ser abstract, open, sealed ni inner. Sí puede implementar interfaces y sí puede heredar de otra clase, con la consecuencia incómoda de que el estado heredado queda fuera del equals generado.
Lo que una data class no genera nunca
No genera copias profundas
copy es superficial. Si una propiedad es una lista, la nueva instancia comparte exactamente la misma lista que la original, con todo lo que eso implica si es mutable.
No genera orden
No implementa Comparable. Que dos valores sean comparables por igualdad no dice nada sobre si uno es menor que otro, y el compilador no se inventa esa semántica.
No genera arrays sensatos
Una propiedad de tipo Array se compara por referencia en el equals generado. Si necesitas comparación por contenido tendrás que escribir equals y hashCode a mano con contentEquals.
No genera discreción
El toString generado imprime todas las propiedades del constructor, incluidas contraseñas y tokens. Cualquier registro de diagnóstico que interpole la instancia los filtra.
Hay una asimetría más que casi nadie conoce y que conviene tener presente desde el primer día: si tú proporcionas una implementación explícita de equals, hashCode o toString en el cuerpo, el compilador deja de generar esa función concreta, pero sigue generando las otras dos. Escribir un equals a medida y olvidar hashCode no produce ningún error: produce una clase donde el hashCode generado sigue mirando todas las propiedades del constructor mientras tu equals mira otra cosa. El contrato queda roto en silencio. Con copy y componentN ocurre lo contrario: no puedes darles una implementación propia con la misma firma, el compilador lo rechaza.
Queda un último punto sobre las propiedades de coma flotante. En el equals y el hashCode generados, un Double o un Float se comparan con el orden total de Kotlin y no con la semántica IEEE 754 que aplica el operador == sobre tipos estáticamente conocidos. La consecuencia es que dentro de una data class, NaN es igual a NaN, y 0.0 no es igual a -0.0. Es la elección correcta para poder usar la instancia como clave de un mapa, pero contradice la intuición aritmética.
Comprobarlo en vez de creerlo
Nada de lo anterior hay que aceptarlo por fe: el código generado es inspeccionable y conviene mirarlo al menos una vez en la vida para que deje de ser una abstracción. En el backend de la JVM, la herramienta más directa es pedir el bytecode y descompilarlo a Java, cosa que el IDE ofrece en su menú de herramientas de Kotlin, o hacerlo desde la línea de órdenes:
kotlinc Usuario.kt -d salida
javap -p -c salida/UsuarioKt.class
javap -p salida/Usuario.class
Lo que aparece confirma las cinco familias y añade tres detalles que solo se ven ahí. Primero, equals no compara los objetos con una llamada directa a equals, sino a través de una función auxiliar de la biblioteca de intrínsecos que ya contempla los nulos, porque el == de Kotlin es seguro frente a referencias nulas. Segundo, junto a copy aparece un método sintético adicional, con el sufijo que denota los valores por defecto, que recibe una máscara de bits y decide para cada parámetro si usar el valor recibido o el actual: por eso el número de propiedades tiene un techo práctico y por eso copy no siempre se integra en el punto de llamada. Y tercero, las funciones componentN figuran como métodos públicos normales, lo cual deja claro que el orden de tu constructor es superficie pública real y no una convención interna.
Declara una data class con dos propiedades, otra idéntica pero con una propiedad extra en el cuerpo, y compara los dos bytecodes. La diferencia entre ambos es, literalmente, la definición operativa de la frontera del constructor primario: verás que la propiedad del cuerpo genera su acceso y su mutador, y nada más.
La lectura ingenua de data es que ahorra teclas, y por eso mucha gente la usa como si fuera un modificador cosmético que se pone por defecto a todo lo que transporta datos. La lectura correcta es que data es una declaración semántica: estás afirmando que las instancias de esa clase son valores, es decir, que dos instancias con el mismo contenido son intercambiables en cualquier contexto de tu programa y que la identidad de referencia no significa nada. Esa afirmación tiene consecuencias que el compilador materializa de inmediato y que tú no puedes retirar después. Al generar equals y hashCode sobre el constructor primario estás decidiendo qué constituye la identidad de tu tipo, y esa decisión se propaga a todos los HashSet, todos los HashMap, todas las comparaciones de tests, todos los operadores distinct y todas las comparaciones de estado que hagan los frameworks reactivos para decidir si repintan la pantalla. Al generar copy estás publicando un constructor alternativo con tantos parámetros opcionales como propiedades tengas, y por tanto estás congelando la forma de tu constructor primario en tu superficie de API: añadir una propiedad más adelante cambia la firma de copy y de la mitad de tus componentN, lo que es fuente y binariamente incompatible para cualquiera que ya te consuma como librería. Al generar toString estás autorizando a cualquier línea de registro del sistema a volcar el contenido completo de la instancia. Y al generar las componentN estás exponiendo el orden de tu constructor como parte del contrato público, un orden que durante quince años se pudo cambiar sin que nada cantase y que rompía el desestructurado de tus usuarios en silencio, hasta que Kotlin 2.3.20 introdujo el desestructurado basado en nombres precisamente para desactivar esa trampa. La conclusión no es que las data class sean peligrosas, sino que son una afirmación fuerte sobre la naturaleza de un tipo. Si el tipo es un valor, la afirmación es cierta y el compilador te regala una cantidad extraordinaria de trabajo correcto. Si el tipo tiene identidad, o ciclo de vida, o secretos, o ambición de crecer en una API pública, la afirmación es falsa y estás pidiendo que el compilador genere un contrato que tu dominio no puede cumplir.
- Coge una
data classde tu código con estado en el cuerpo y escribe a mano la expansión completa de sus cinco miembros generados; comprueba después qué pierde sucopy. - Busca una
data classque tenga alguna propiedad de tipoArrayoDoubley razona qué devuelve suequalsen los casos límite antes de ejecutarlo. - Localiza la
data classmás ancha de tu proyecto y cuenta cuántos parámetros tiene sucopygenerado: eso es exactamente lo que has publicado como API. - Busca cualquier
data classque interpoles en un registro de diagnóstico y decide si sutoStringgenerado está imprimiendo algo que no debería salir del proceso.