Igualdad: estructural, referencial y el contrato de equals
La diferencia entre comparar contenido y comparar identidad, el contrato que ligan equals y hashCode, qué ocurre con las propiedades declaradas fuera del constructor primario y por qué un var en el constructor puede hacer desaparecer un objeto de un conjunto.
La igualdad parece el concepto más simple de la programación hasta que intentas definirlo. Preguntar si dos cosas son iguales puede significar dos preguntas radicalmente distintas: si son el mismo objeto o si contienen lo mismo. Kotlin separa esas dos preguntas en dos operadores, y una data class responde la segunda de forma automática a partir del constructor primario. El problema es que esa respuesta automática viene con un contrato matemático que el compilador genera pero no vigila, y que se rompe con una facilidad asombrosa en cuanto añades un var, heredas de algo o escribes media implementación a mano.
- Distinguir la igualdad estructural de la referencial y saber qué compila cada operador.
- Enunciar el contrato de
equalsyhashCodey las tres formas habituales de violarlo. - Explicar qué ocurre con las propiedades que quedan fuera del constructor primario.
- Detectar el fallo silencioso de un
varmutable usado como clave en una tabla de dispersión.
Dos operadores, dos preguntas
Kotlin ofrece igualdad estructural con == e igualdad referencial con ===. El segundo pregunta si dos referencias apuntan a la misma dirección de memoria y no se puede sobrescribir. El primero delega en equals y sí se puede definir:
data class Punto(val x: Int, val y: Int)
val a = Punto(1, 2)
val b = Punto(1, 2)
println(a == b) // true: mismo contenido
println(a === b) // false: distintos objetos
El operador == no es una llamada directa a equals. El compilador lo expande a una comparación segura frente a nulos que se puede escribir así:
// a == b se traduce a
a?.equals(b) ?: (b === null)
De ahí sale una propiedad que se usa a diario sin pensarla: comparar con == dos referencias que pueden ser nulas nunca lanza una excepción, y dos nulos son iguales entre sí. Y de ahí sale también una asimetría que conviene tener presente: el operador aplica equals sobre el operando izquierdo, de modo que una implementación mal escrita puede hacer que a == b y b == a difieran, algo que el compilador no comprueba jamás.
=== sigue siendo la herramienta correcta en dos escenarios: comprobar si un estado ha cambiado de verdad antes de recalcular algo caro, que es lo que hacen internamente los sistemas de recomposición, y detectar aliasing en estructuras compartidas. Fuera de eso, comparar identidades de objetos suele ser un olor a diseño que confunde el objeto con lo que representa.
El contrato que el compilador genera pero no vigila
equals y hashCode no son dos funciones independientes: son una pareja atada por un contrato heredado de la plataforma. La implementación de equals debe ser reflexiva, simétrica, transitiva y consistente en el tiempo, y debe tratar null como distinto de cualquier valor. Y la ley que une ambas funciones dice que si dos objetos son iguales, sus códigos de dispersión deben ser iguales. La implicación inversa no se exige: dos objetos distintos pueden compartir código de dispersión, y a eso se le llama colisión, que es un asunto de rendimiento y no de corrección.
Reflexiva y simétrica
Todo objeto es igual a sí mismo, y si a es igual a b entonces b es igual a a. La simetría es la cláusula que rompen las jerarquías, y la razón de que una data class no pueda tener subclases.
Transitiva
Si a es igual a b y b es igual a c, entonces a es igual a c. Las implementaciones que comparan solo algunos campos según el tipo del argumento suelen romperla sin que nadie lo note.
Consistente en el tiempo
Repetir la comparación sin modificar los objetos debe dar el mismo resultado. Un var en el constructor primario convierte esta cláusula en una promesa que no puedes cumplir.
Ligada a hashCode
Objetos iguales deben producir el mismo código de dispersión. El recíproco no se exige: dos objetos distintos pueden colisionar, y eso solo cuesta rendimiento.
El equals y el hashCode generados por una data class cumplen el contrato por construcción, siempre que las propiedades del constructor primario lo cumplan a su vez. Pero hay tres formas cómodas de romperlo.
La primera es implementar solo la mitad. Si escribes un equals a medida en el cuerpo de la clase, el compilador deja de generar ese método concreto pero sigue generando hashCode a partir de todas las propiedades del constructor. El resultado son dos funciones que miran cosas distintas, sin ningún aviso:
data class Documento(val id: Long, val version: Int) {
// equals mira solo el id; hashCode generado mira id y version
override fun equals(other: Any?): Boolean =
other is Documento && other.id == id
}
val x = Documento(1, 1)
val y = Documento(1, 2)
println(x == y) // true
println(hashSetOf(x).contains(y)) // false: contrato roto
La segunda es la consistencia en el tiempo. Nada impide declarar un var en el constructor primario, y entonces el código de dispersión de la instancia cambia cuando cambia la propiedad. Si el objeto ya está guardado en una tabla de dispersión, queda almacenado bajo el cubo de su antiguo código y se vuelve inalcanzable:
data class Sesion(val id: String, var activa: Boolean)
val s = Sesion("abc", activa = false)
val conjunto = hashSetOf(s)
s.activa = true
println(conjunto.contains(s)) // false: el objeto sigue dentro, pero perdido
println(conjunto.first() === s) // true: ahi esta, en el cubo equivocado
flowchart TD A[Insercion en el HashSet] --> B[hashCode inicial cubo 7] B --> C[Objeto guardado en el cubo 7] D[Mutacion de la propiedad var] --> E[hashCode nuevo cubo 12] E --> F[contains busca en el cubo 12] F --> G[No encuentra nada y devuelve false] C -.objeto huerfano.-> G
La tercera es la herencia de estado. Una data class puede heredar de una clase abierta, y el equals generado solo compara las propiedades del propio constructor primario. Dos instancias que difieran únicamente en el estado heredado se declararán iguales, lo cual es casi siempre una mentira sobre el dominio.
Lo que queda fuera del constructor primario
La frontera es la misma que gobierna el resto de miembros generados, pero en el caso de la igualdad tiene un efecto especialmente contraintuitivo, porque produce objetos que son iguales y a la vez se comportan distinto:
data class Peticion(val url: String) {
var reintentos: Int = 0
val cabeceras: MutableMap<String, String> = mutableMapOf()
}
val p1 = Peticion("https://ejemplo.test").apply { reintentos = 3 }
val p2 = Peticion("https://ejemplo.test")
println(p1 == p2) // true, pese a que no son intercambiables
Ese true es una afirmación fuerte y falsa: dice que p1 y p2 son sustituibles el uno por el otro en cualquier contexto, cuando en realidad uno lleva tres reintentos acumulados y unas cabeceras que el otro no tiene. En cuanto uno de los dos entre en un conjunto, en un distinct, en una comprobación de un test o en la comparación que hace un framework de interfaz para decidir si repinta, el programa tomará decisiones basadas en una identidad que no refleja el objeto.
La regla de diseño que se deduce es tajante: si una propiedad forma parte de la identidad conceptual del tipo, debe estar en el constructor primario; y si no forma parte de ella, probablemente no debería vivir dentro de la clase. El cuerpo de una data class es un buen sitio para propiedades derivadas y calculadas a partir de las del constructor, porque esas no añaden identidad nueva; es un mal sitio para cualquier estado independiente.
Hay además una tercera relación que casi nadie mantiene alineada: la que existe entre la igualdad y el orden. Si tu tipo implementa Comparable, la convención de la plataforma pide que compareTo devuelva cero exactamente cuando equals devuelva verdadero. Cuando no ocurre, obtienes un tipo que se comporta de una forma en un HashSet y de otra distinta en un conjunto ordenado, porque las colecciones ordenadas ignoran equals y deciden la pertenencia únicamente con el comparador:
data class Version(val mayor: Int, val menor: Int, val etiqueta: String) : Comparable<Version> {
override fun compareTo(other: Version): Int =
compareValuesBy(this, other, Version::mayor, Version::menor)
}
val a = Version(1, 0, "beta")
val b = Version(1, 0, "rc")
println(a == b) // false: equals mira tambien la etiqueta
println(sortedSetOf(a, b).size) // 1: el orden dice que son la misma
Queda un par de casos límite que conviene conocer de memoria. Una propiedad de tipo Array se compara por referencia en el equals generado, de modo que dos instancias con arrays de idéntico contenido salen distintas; la solución pasa por escribir equals y hashCode a mano con contentEquals y contentHashCode, o por sustituir el array por una List. Y las propiedades de coma flotante dentro de una data class usan el orden total de Kotlin en vez de la semántica IEEE 754, con la consecuencia de que NaN resulta igual a NaN y 0.0 distinto de -0.0. Esa elección es la que permite usar la instancia como clave sin que el objeto se pierda, y es deliberada.
El equals generado por una data class resuelve la mecánica de la comparación, no la semántica, y confundir ambas cosas es el error de modelado más caro que se comete con este mecanismo. Cuando marcas una clase como data, estás declarando que la identidad de sus instancias es exactamente la tupla de valores de su constructor primario, ni más ni menos, y esa declaración se convierte en la ley que gobierna el comportamiento de tu tipo en todas las estructuras de datos del programa. Un HashMap decidirá si dos claves son la misma consultando tu hashCode y tu equals; un distinct sobre una lista descartará elementos según ese criterio; un motor de interfaz declarativa decidirá si un fragmento de pantalla necesita repintarse comparando el estado anterior con el nuevo mediante equals, y una implementación equivocada se traduce literalmente en fotogramas que no se dibujan o en dibujados que no hacían falta; una aserción de test dará por buena una respuesta que difiere en un campo que dejaste fuera. La cuestión, entonces, nunca es si el compilador genera una implementación correcta, porque la genera, sino si la definición de identidad que estás firmando corresponde con la de tu dominio. Un pedido con el mismo importe y el mismo cliente pero distinto identificador, ¿es el mismo pedido? Un usuario cuyo correo ha cambiado, ¿sigue siendo el mismo usuario? La respuesta a esas preguntas no está en el lenguaje: está en el negocio, y determina si tu tipo es un valor, donde todo el contenido constituye la identidad y una data class es la herramienta exacta, o una entidad, donde solo el identificador constituye la identidad y una data class genera un equals que miente en cada comparación. Añade a esto que el contrato tiene una cláusula temporal que casi nadie lee: los objetos deben ser consistentes mientras estén dentro de una colección basada en dispersión, y un solo var en el constructor primario basta para violarla y provocar la clase de fallo más desconcertante que existe, un objeto que está en el conjunto y responde que no está. Por eso la recomendación operativa no admite matices: en el constructor primario de una data class, todo val, siempre, y la identidad decidida a conciencia antes de teclear la palabra data.
- Escribe un
HashSetcon unadata classque tenga unvaren el constructor, muta la propiedad y comprueba quecontainsdevuelvefalsemientras el objeto sigue dentro. - Coge tus tres tipos de dominio principales y responde por escrito, para cada uno, qué propiedades constituyen su identidad; contrasta la respuesta con lo que hay en su constructor primario.
- Implementa un
equalsa medida en unadata classsin tocarhashCodey demuestra con un conjunto que el contrato está roto sin que nadie te avise. - Busca en tu código alguna comparación con
==sobre un tipo con una propiedadArrayy verifica si el resultado es el que asumías.