wandres.dev
OPERADORES Y CONVENCIONES · sobrecarga por nombre

Comparación e igualdad: equals, compareTo y sus contratos

El símbolo de igualdad no compara referencias sino que llama a equals con una comprobación de nulos incorporada, y los cuatro operadores relacionales se traducen todos a una sola llamada a compareTo cuyo signo se interpreta. Esta lección detalla ambas traducciones, el contrato de equivalencia y el de orden total que el compilador jamás verifica, la relación entre igualdad y dispersión, y las consecuencias observables de que un tipo defina un orden incoherente con su igualdad.

⏱ 20 min

De todas las convenciones del lenguaje, las de igualdad y orden son las únicas cuyo mal uso no produce un error de compilación ni una excepción, sino una corrupción silenciosa que puede tardar meses en manifestarse. Un elemento que se pierde dentro de un conjunto donde fue insertado, un mapa que contiene dos claves que se leen exactamente igual, una ordenación que arroja resultados distintos según el algoritmo empleado, una búsqueda binaria que no encuentra lo que sin duda está: todo eso son síntomas de una implementación que compilaba perfectamente y que violaba un contrato que nadie comprueba. La razón es estructural y ya la conocemos, porque el compilador solo verifica la forma de la firma. Lo que las colecciones dan por supuesto no es una firma, sino unas leyes matemáticas, y esta lección trata de esas leyes con la misma seriedad con la que trata la sintaxis.

🎯 Al terminar esta lección sabrás
  • Expandir la traducción del símbolo de igualdad y explicar por qué incorpora una comprobación de nulos y cuándo no llama a equals.
  • Enunciar y aplicar el contrato de equivalencia junto con su relación obligatoria con la dispersión.
  • Implementar compareTo respetando el contrato de orden total y conocer la diferencia entre la convención y la interfaz Comparable.
  • Diagnosticar las consecuencias observables de un orden incoherente con la igualdad y decidir qué relación debe ceder.

Qué llama exactamente el símbolo de igualdad

La igualdad estructural se traduce a una expresión que incorpora la seguridad frente a nulos, y esa es la razón por la que en Kotlin nunca hace falta comprobar el operando izquierdo antes de compararlo. La igualdad referencial es un operador primitivo del lenguaje y no admite implementación propia.

// a == b  se compila como
a?.equals(b) ?: (b === null)

class Punto(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean {      // ya era operator en Any
        if (this === other) return true
        if (other !is Punto) return false
        return x == other.x && y == other.y
    }
    override fun hashCode(): Int = 31 * x + y
}

El modificador no aparece en el ejemplo porque la propiedad de ser operador se hereda del miembro sobrescrito, y el parámetro debe ser de tipo nulable universal por la misma razón. La comparación de desigualdad no tiene convención propia: se traduce a la misma llamada, negada.

Hay una excepción importante y poco conocida. Cuando el compilador conoce estáticamente que ambos operandos son de coma flotante, no genera la llamada a equals sino la comparación aritmética de la norma de punto flotante, con sus dos rarezas conocidas: el valor no numérico no es igual a sí mismo y los dos ceros con signo distinto sí lo son. En cuanto uno de los operandos se ve a través de un tipo más general, se vuelve a la llamada convencional, que aplica el orden total y da exactamente los resultados contrarios en esos dos casos.

⚠️
Dos igualdades para los mismos bits

Una comparación directa entre dos valores de coma flotante y la misma comparación tras guardarlos en una lista pueden discrepar. Es correcto en ambos casos y es un incordio real: el símbolo obedece a la aritmética, y las colecciones obedecen a equals, que necesita ser una relación de equivalencia para funcionar. Si tu dominio depende de esa distinción, no uses coma flotante como clave ni como componente de igualdad.

El contrato de equivalencia y la dispersión

Una implementación de igualdad debe definir una relación de equivalencia sobre valores no nulos: reflexiva, simétrica y transitiva, además de consistente entre llamadas sucesivas mientras no cambie nada y falsa frente al nulo. A eso se añade la obligación que enlaza con las tablas de dispersión: dos objetos iguales deben producir el mismo código de dispersión, aunque lo contrario no se exija.

Las dos violaciones más frecuentes tienen nombre. La primera es la asimetría por herencia: si una subclase añade un componente de valor y compara con is, la instancia de la superclase se declara igual a la de la subclase y la subclase no le devuelve el favor. No hay forma general de arreglarlo manteniendo la sustituibilidad, y por eso el consejo clásico es preferir la composición o cerrar las clases con identidad estructural. La segunda es la mutabilidad de los componentes: modificar una propiedad que participa en la dispersión mientras el objeto está dentro de un conjunto lo deja en un cubo equivocado, invisible para toda búsqueda posterior aunque siga contenido.

data class Etiqueta(val nombre: String)          // igualdad y dispersión generadas

class Movil(var etiqueta: Etiqueta) {
    override fun equals(other: Any?) = other is Movil && other.etiqueta == etiqueta
    override fun hashCode() = etiqueta.hashCode()
}

val conjunto = hashSetOf(Movil(Etiqueta("a")))
conjunto.first().etiqueta = Etiqueta("b")        // el elemento queda inalcanzable

La clase de datos genera una implementación correcta por construcción a partir de las propiedades del constructor primario, y esa es la respuesta por defecto para todo tipo cuya identidad sea su contenido. Escribir la igualdad a mano solo se justifica cuando hay que excluir componentes derivados o normalizar antes de comparar, y en ese caso conviene documentar la relación exacta que se está definiendo.

Conviene precisar el alcance de esa generación, porque tiene un borde que sorprende. Solo participan las propiedades declaradas en el constructor primario; las declaradas en el cuerpo quedan fuera de la igualdad, de la dispersión y de la representación textual, aunque formen parte del estado observable. Eso es una decisión deliberada y casi siempre acertada, ya que las propiedades del cuerpo suelen ser derivadas o de servicio, pero deja de serlo en cuanto alguien mueve una propiedad de sitio para simplificar una firma.

data class Sesion(val id: String) {
    var ultimoAcceso: Long = 0L     // fuera de equals y de hashCode
}

Sesion("a").also { it.ultimoAcceso = 1 } == Sesion("a")   // true

Nótese que el resultado es el correcto si se entiende que la identidad de una sesión es su identificador, y es un fallo grave si alguien esperaba comparar el estado completo. La generación no elimina la decisión de diseño, solo la traslada a la elección de qué va en el constructor.

compareTo y los cuatro relacionales

Los cuatro operadores relacionales se traducen a una única llamada cuyo resultado se compara con cero, lo que obliga a que el tipo de retorno sea Int y explica por qué no hay convenciones separadas para cada símbolo.

flowchart LR
A[a menor que b] --> Z[a.compareTo b menor que cero]
B[a mayor que b] --> Y[a.compareTo b mayor que cero]
C[a menor o igual b] --> X[a.compareTo b no mayor que cero]
D[a mayor o igual b] --> W[a.compareTo b no menor que cero]

El contrato de orden exige que el signo se invierta al intercambiar los operandos, que la relación sea transitiva y que dos elementos que se comparan como equivalentes se comporten igual frente a cualquier tercero. La implementación debe evitar la resta directa, que desborda con valores extremos, y apoyarse en las funciones auxiliares de la librería estándar cuando hay varias claves.

class Version(val mayor: Int, val menor: Int) : Comparable<Version> {
    override fun compareTo(other: Version): Int =
        compareValuesBy(this, other, Version::mayor, Version::menor)
}

Aquí aparece una distinción decisiva que se pasa por alto con frecuencia. Declarar compareTo como extensión con el modificador correspondiente basta para que los cuatro símbolos compilen, porque la convención es puramente estructural. Pero no basta para nada más: la ordenación de colecciones, los conjuntos ordenados, la búsqueda binaria y las funciones de máximo y mínimo exigen la interfaz Comparable o un comparador explícito, porque son código genérico que necesita un contrato nominal. La sintaxis se resuelve por nombre, los algoritmos se resuelven por tipo.

Cuando el orden y la igualdad discrepan

El caso canónico está en la librería del sistema: dos decimales de precisión distinta que representan la misma cantidad se comparan como equivalentes por orden y como distintos por igualdad. Nada de eso es un error, y sin embargo produce comportamientos observables que desconciertan.

val a = java.math.BigDecimal("2.0")
val b = java.math.BigDecimal("2.00")
a == b            // false: la igualdad mira también la escala
a.compareTo(b)    // 0: el orden mira solo el valor

La consecuencia es que un conjunto basado en dispersión guarda los dos y uno basado en orden guarda uno solo, y que dos operaciones que el programador considera intercambiables dejan de serlo. Lo mismo ocurre con la pertenencia: comprobar si un elemento está en una lista usa la igualdad, y comprobar si está en un rango usa el orden, de modo que la misma palabra in responde a dos preguntas distintas sobre el mismo valor.

🧮

Igualdad de valor

Relación de equivalencia usada por conjuntos y mapas de dispersión, por la eliminación de duplicados y por la pertenencia en colecciones.

📏

Orden total

Relación antisimétrica y transitiva usada por la ordenación, los conjuntos ordenados, la búsqueda binaria y la pertenencia en rangos.

🔗

Coherencia recomendada

Que la comparación devuelva cero exactamente cuando la igualdad devuelva verdadero. No es obligatoria, y romperla debe ser una decisión documentada.

🚫

Identidad referencial

El único operador de este grupo que no admite implementación propia. Compara direcciones y nunca miente, que es justo su función.

Ante esa tensión hay una salida que casi siempre es la correcta y que consiste en no declarar ningún orden en el tipo. Un comparador externo describe un criterio concreto, tiene nombre propio, admite varios simultáneos y no compromete al tipo con la afirmación de que existe una única forma natural de ordenarlo.

val porApellido = compareBy<Persona>({ it.apellido }, { it.nombre })
val porEdadDesc = compareByDescending<Persona> { it.edad }

personas.sortedWith(porApellido.thenComparing(porEdadDesc))

La regla de decisión es sencilla de enunciar: implementa Comparable únicamente cuando el orden sea intrínseco al concepto y nadie pueda discutirlo, como ocurre con una fecha, una versión o una cantidad. Para todo lo demás, un comparador con nombre comunica mejor y no obliga a elegir un ganador entre criterios legítimos.

Los contratos son leyes matemáticas que ningún tipo puede declarar

Conviene detenerse en la naturaleza exacta de lo que aquí se llama contrato, porque no es una convención de estilo ni una recomendación de la documentación: es un requisito de corrección del que dependen algoritmos que ya están escritos y compilados, y que fueron diseñados dando por supuestas unas propiedades algebraicas concretas. Una tabla de dispersión no busca comparando con todos los elementos, sino calculando una posición; toda su ventaja asintótica descansa en que objetos iguales caigan siempre en la misma posición, y esa es una propiedad que la tabla no puede verificar sin destruir la razón por la que existe. Un algoritmo de ordenación no compara todos los pares, sino los suficientes para deducir el resto por transitividad; si la transitividad falla, el resultado no es simplemente un orden malo, sino un comportamiento indefinido que en algunas implementaciones se manifiesta como una excepción y en otras como un desorden aleatorio. Una búsqueda binaria descarta la mitad del espacio en cada paso confiando en la antisimetría del signo. Es decir: las leyes no están ahí para satisfacer a un matemático, están ahí porque son exactamente las premisas de las que se dedujo la corrección de un código que no puedes modificar y que va a ejecutarse sobre tus objetos. Y aquí llega el punto que hace difícil este nivel. En un lenguaje con clases de tipos, esas premisas se declaran, se nombran y a veces se prueban; el tipo que quiere participar demuestra que pertenece a una estructura algebraica y el compilador registra esa afirmación. Kotlin, al elegir convenciones nominales resueltas por nombre y por firma, obtiene una flexibilidad enorme y renuncia por completo a esa capa: no hay ningún lugar del lenguaje donde puedas escribir que tu orden es total, ni ninguna herramienta que lo compruebe, ni ningún mecanismo que impida a otro programador combinar tu tipo con un algoritmo que lo exige. Lo único que queda entre tu implementación y la corrupción silenciosa es que tú conozcas las leyes y decidas respetarlas. Esa es la razón por la que la clase de datos es una de las mejores decisiones de diseño del lenguaje, porque sustituye una obligación humana por una generación mecánica que no puede equivocarse; y es también la razón por la que cada vez que escribes una igualdad a mano estás asumiendo, personalmente y sin red, una responsabilidad que ningún compilador va a compartir contigo.

⚔️ Rompe los contratos a propósito
  1. Escribe una igualdad asimétrica entre una clase y su subclase, insértalas en un conjunto y documenta qué elemento se pierde según el orden de inserción.
  2. Implementa equals sin implementar hashCode, guarda instancias iguales en un mapa de dispersión y explica el resultado apelando al cálculo de posición.
  3. Implementa compareTo mediante una resta de enteros y encuentra el par de valores extremos que invierte el orden.
  4. Declara compareTo como extensión sobre un tipo ajeno, comprueba que los cuatro símbolos compilan y que la ordenación de una lista sigue sin compilar. Explica la diferencia.
  5. Modela una cantidad monetaria con escala y decide si tu igualdad debe distinguir las escalas. Justifica la decisión con un caso de uso concreto de conjuntos y de ordenación.