Saltar al contenido
comparar-y-ordenar.java · devschool

Comparable y Comparator en Java

Lección 31 de 39 · 13 min de lectura · Actualizado el

En esta lección
  1. Cómo compara Java dos objetos
  2. Comparable: el orden natural de una clase
  3. Comparator: cualquier otro orden
  4. Construir comparadores con métodos de fábrica
  5. Valores null: nullsFirst y nullsLast
  6. Ordenar en español: mayúsculas y acentos
  7. Dónde se usan los comparadores
  8. TreeSet y TreeMap con comparador
  9. El error de comparar restando
  10. Errores frecuentes
  11. Resumen

Ordenar una lista de números o de textos en Java es inmediato. Pero ¿cómo ordena Java una lista de libros, de rutas de senderismo o de alumnos? ¿Por título, por año, por nota? Java no puede adivinarlo: se lo tienes que decir tú. Para eso existen dos interfaces, Comparable y Comparator. En esta lección aprenderás a definir el orden natural de tus clases, a crear comparadores para cualquier criterio, a combinarlos, a manejar valores null y acentos, y a usarlos con TreeSet y TreeMap.

Cómo compara Java dos objetos

Para ordenar, cualquier algoritmo necesita responder una y otra vez a la misma pregunta: “de estos dos, ¿cuál va antes?”. En Java esa respuesta es un número entero:

ResultadoSignifica
NegativoEl primero va antes que el segundo
CeroSon equivalentes en orden
PositivoEl primero va después que el segundo

Solo importa el signo, no el valor exacto. Lo puedes ver con clases que ya traen esta comparación, como String o Integer:

System.out.println("ana".compareTo("luis"));        // -11
System.out.println("luis".compareTo("ana"));        // 11
System.out.println("ana".compareTo("ana"));         // 0
System.out.println(Integer.compare(5, 9));          // -1
System.out.println(Integer.valueOf(9).compareTo(5)); // 1

El -11 sale de restar los códigos de la a y la l, pero no te fíes de ese número: otro día podría ser -1. Pregunta siempre si es < 0, == 0 o > 0.

Comparable: el orden natural de una clase

En colecciones viste que Collections.sort(lista) ordena textos alfabéticamente y números de menor a mayor. Eso funciona porque String e Integer implementan la interfaz Comparable, que define su orden natural: el orden “por defecto” de ese tipo.

Si intentas ordenar objetos de una clase tuya sin más, no compila (o, con algunos métodos, lanza ClassCastException), porque Java no sabe qué orden usar. La solución es implementar Comparable<T> y su único método, compareTo:

class Libro implements Comparable<Libro> {
    private final String titulo;
    private final int anio;
    private final int paginas;

    Libro(String titulo, int anio, int paginas) {
        this.titulo = titulo;
        this.anio = anio;
        this.paginas = paginas;
    }

    String getTitulo() { return titulo; }
    int getAnio() { return anio; }
    int getPaginas() { return paginas; }

    @Override
    public int compareTo(Libro otro) {
        return Integer.compare(this.anio, otro.anio); // orden natural: por año
    }

    @Override
    public String toString() {
        return titulo + " (" + anio + ")";
    }
}

compareTo compara este objeto (this) con otro. Para comparar números usa siempre Integer.compare, Double.compare o Long.compare, que devuelven el signo correcto. Ahora sí:

List<Libro> estanteria = new ArrayList<>(List.of(
        new Libro("La colmena", 1951, 352),
        new Libro("Nada", 1945, 320),
        new Libro("Fortunata y Jacinta", 1887, 1100),
        new Libro("Tiempo de silencio", 1962, 240)));

Collections.sort(estanteria);
System.out.println(estanteria);
// [Fortunata y Jacinta (1887), Nada (1945), La colmena (1951), Tiempo de silencio (1962)]

Desempatar dentro de compareTo

Si el primer criterio empata, se pasa al segundo. Un ejemplo con números de versión, donde 2.10 es más nuevo que 2.3:

record Version(int mayor, int menor) implements Comparable<Version> {
    @Override
    public int compareTo(Version otra) {
        int c = Integer.compare(mayor, otra.mayor);
        if (c != 0) {
            return c;                            // ya hay ganador
        }
        return Integer.compare(menor, otra.menor); // empate: decide el segundo número
    }
}

List<Version> versiones = new ArrayList<>(List.of(
        new Version(2, 10), new Version(2, 3), new Version(1, 15)));
Collections.sort(versiones);
System.out.println(versiones);
// [Version[mayor=1, menor=15], Version[mayor=2, menor=3], Version[mayor=2, menor=10]]

Los records también pueden implementar interfaces, como ves aquí.

Cuándo implementar Comparable

Solo cuando la clase tenga un orden obvio y único que casi todo el mundo esperaría: las fechas por cronología, las versiones por número, el dinero por cantidad. Para un Libro es discutible (¿año?, ¿título?): en esos casos es mejor no darle orden natural y usar comparadores según lo que necesites en cada momento.

Consejo: el orden natural debería ser coherente con equals: si compareTo devuelve 0, lo ideal es que equals devuelva true. Verás por qué en la sección de TreeSet.

Comparator: cualquier otro orden

Un Comparator<T> es un objeto aparte que sabe comparar dos T. Su método es compare(a, b) y sigue la misma regla de signos. Ventajas sobre Comparable:

  • Puedes tener tantos como quieras para la misma clase: por páginas, por título, por año al revés…
  • Sirve para clases que no son tuyas y no puedes modificar.

Es una interfaz funcional, así que se puede escribir con una lambda:

Comparator<Libro> porPaginas = (x, y) -> Integer.compare(x.getPaginas(), y.getPaginas());
estanteria.sort(porPaginas);
System.out.println(estanteria);
// [Tiempo de silencio (1962), Nada (1945), La colmena (1951), Fortunata y Jacinta (1887)]

En código anterior a Java 8 verás lo mismo escrito con una clase anónima, que hace exactamente lo mismo con más líneas:

Comparator<Libro> porPaginas = new Comparator<Libro>() {
    @Override
    public int compare(Libro x, Libro y) {
        return Integer.compare(x.getPaginas(), y.getPaginas());
    }
};
Comparable<T>Comparator<T>
Paquetejava.langjava.util
MétodocompareTo(T otro)compare(T a, T b)
Dónde se escribeDentro de la propia claseFuera, como objeto aparte
CuántosUno (el orden natural)Todos los que quieras
UsoCollections.sort(lista)lista.sort(comparador)

Construir comparadores con métodos de fábrica

Escribir la lambda a mano es propenso a errores. Comparator trae métodos que los construyen por ti a partir de un atributo. Ya los usaste en colecciones; aquí los verás todos. Trabajaremos con rutas de senderismo:

record Ruta(String nombre, double km, int desnivel, String zona) {}

List<Ruta> rutas = new ArrayList<>(List.of(
        new Ruta("Ruta del Cares", 12.0, 300, "Asturias"),
        new Ruta("Pico Urriellu", 14.5, 1200, "Asturias"),
        new Ruta("Faro de Cabo Mayor", 6.0, 80, "Cantabria"),
        new Ruta("Valle del Pas", 12.0, 450, "Cantabria"),
        new Ruta("Monte Buciero", 9.5, 300, "Cantabria")));

comparing, comparingInt y comparingDouble

comparing recibe una función que extrae la clave por la que ordenar. Las versiones comparingInt y comparingDouble hacen lo mismo con números primitivos, sin convertirlos en objetos:

rutas.sort(Comparator.comparingDouble(Ruta::km));
rutas.forEach(r -> System.out.println(r.km() + " " + r.nombre()));
// 6.0 Faro de Cabo Mayor
// 9.5 Monte Buciero
// 12.0 Ruta del Cares
// 12.0 Valle del Pas
// 14.5 Pico Urriellu

Las dos rutas de 12 km mantienen el orden que tenían antes: el ordenamiento de Java es estable, es decir, nunca cambia de sitio los elementos que empatan.

thenComparing: criterios de desempate

rutas.sort(Comparator.comparingDouble(Ruta::km)
        .thenComparingInt(Ruta::desnivel));
rutas.forEach(r -> System.out.println(r.km() + " " + r.desnivel() + " " + r.nombre()));
// 6.0 80 Faro de Cabo Mayor
// 9.5 300 Monte Buciero
// 12.0 300 Ruta del Cares
// 12.0 450 Valle del Pas
// 14.5 1200 Pico Urriellu

Puedes encadenar tantos thenComparing como necesites.

reversed() y su trampa

reversed() invierte el comparador completo sobre el que se llama. Queremos las rutas por zona (A-Z) y, dentro de cada zona, de la más larga a la más corta:

rutas.sort(Comparator.comparing(Ruta::zona)
        .thenComparing(Comparator.comparingDouble(Ruta::km).reversed()));
rutas.forEach(r -> System.out.println(r.zona() + " " + r.km() + " " + r.nombre()));
// Asturias 14.5 Pico Urriellu
// Asturias 12.0 Ruta del Cares
// Cantabria 12.0 Valle del Pas
// Cantabria 9.5 Monte Buciero
// Cantabria 6.0 Faro de Cabo Mayor

El reversed() va dentro del paréntesis, aplicado solo al comparador de kilómetros. Si lo pones al final, invierte toda la cadena, incluida la zona:

rutas.sort(Comparator.comparing(Ruta::zona)
        .thenComparingDouble(Ruta::km)
        .reversed());                  // invierte zona Y km
rutas.forEach(r -> System.out.println(r.zona() + " " + r.km() + " " + r.nombre()));
// Cantabria 12.0 Valle del Pas
// Cantabria 9.5 Monte Buciero
// Cantabria 6.0 Faro de Cabo Mayor
// Asturias 14.5 Pico Urriellu
// Asturias 12.0 Ruta del Cares

naturalOrder y reverseOrder

Para tipos que ya son Comparable:

List<Integer> notas = new ArrayList<>(List.of(7, 3, 9, 5));
notas.sort(Comparator.reverseOrder());
System.out.println(notas); // [9, 7, 5, 3]
notas.sort(Comparator.naturalOrder());
System.out.println(notas); // [3, 5, 7, 9]

lista.sort(null) equivale a naturalOrder().

Valores null: nullsFirst y nullsLast

Si una lista tiene null, el orden natural falla, porque no se puede llamar a compareTo sobre null:

List<String> apodos = new ArrayList<>(Arrays.asList("Rayo", null, "Duende", null, "Ardilla"));
apodos.sort(Comparator.naturalOrder()); // NullPointerException

nullsFirst y nullsLast envuelven otro comparador y colocan los null al principio o al final:

apodos.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(apodos); // [null, null, Ardilla, Duende, Rayo]

apodos.sort(Comparator.nullsLast(Comparator.reverseOrder()));
System.out.println(apodos); // [Rayo, Duende, Ardilla, null, null]

Lo más frecuente es que el null esté en un atributo, no en el objeto. Para eso, comparing acepta un segundo argumento: el comparador que se usa con la clave.

record Socio(String nombre, String telefono) {}

List<Socio> socios = new ArrayList<>(List.of(
        new Socio("Elena", "600111222"),
        new Socio("Pablo", null),            // no dejó teléfono
        new Socio("Ainhoa", "611000999")));

socios.sort(Comparator.comparing(Socio::telefono,
        Comparator.nullsLast(Comparator.naturalOrder())));
socios.forEach(s -> System.out.println(s.nombre() + " " + s.telefono()));
// Elena 600111222
// Ainhoa 611000999
// Pablo null

Ordenar en español: mayúsculas y acentos

El orden natural de String compara códigos de carácter, no el alfabeto español. Las mayúsculas van antes que las minúsculas y las letras con tilde, al final de todo:

List<String> nombres = new ArrayList<>(List.of("Óscar", "zapato", "Álvaro", "bea", "Zoe", "ana"));

Collections.sort(nombres);
System.out.println(nombres); // [Zoe, ana, bea, zapato, Álvaro, Óscar]

nombres.sort(String.CASE_INSENSITIVE_ORDER);
System.out.println(nombres); // [ana, bea, zapato, Zoe, Álvaro, Óscar]

CASE_INSENSITIVE_ORDER arregla las mayúsculas, pero no las tildes. Para un orden alfabético de verdad usa un Collator (paquete java.text), que es un Comparator que conoce las reglas de cada idioma:

Collator espanol = Collator.getInstance(Locale.of("es", "ES"));
nombres.sort(espanol);
System.out.println(nombres); // [Álvaro, ana, bea, Óscar, zapato, Zoe]

Nota: Locale.of existe desde Java 19. En versiones anteriores se escribe new Locale("es", "ES").

Dónde se usan los comparadores

Un mismo comparador sirve en muchos sitios:

DóndeCómo
Ordenar una listalista.sort(comp) o Collections.sort(lista, comp)
Ordenar un arrayArrays.sort(array, comp)
Ordenar un streamstream.sorted(comp)
Máximo y mínimoCollections.max(lista, comp), stream.max(comp)
Cola de prioridadnew PriorityQueue<>(comp) (ver pilas y colas)
Conjunto o mapa ordenadonew TreeSet<>(comp), new TreeMap<>(comp)
String[] ciudades = {"Santander", "Bilbao", "Oviedo", "León"};
Arrays.sort(ciudades, Comparator.comparing(String::length)
        .thenComparing(Comparator.reverseOrder()));
System.out.println(Arrays.toString(ciudades)); // [León, Oviedo, Bilbao, Santander]

Ruta masDura = Collections.max(rutas, Comparator.comparingInt(Ruta::desnivel));
System.out.println(masDura.nombre()); // Pico Urriellu

List<String> suaves = rutas.stream()
        .sorted(Comparator.comparingInt(Ruta::desnivel))
        .limit(2)
        .map(Ruta::nombre)
        .toList();
System.out.println(suaves); // [Faro de Cabo Mayor, Monte Buciero]

Collections.sort(lista) y lista.sort(null) son equivalentes; hoy se prefiere lista.sort(...), que es un método de la propia lista.

TreeSet y TreeMap con comparador

En HashMap y HashSet viste que TreeSet y TreeMap guardan sus elementos ordenados. Por defecto usan el orden natural, pero puedes pasarles un comparador al crearlos:

Map<String, Integer> stock = new TreeMap<>(Comparator.reverseOrder());
stock.put("botas", 4);
stock.put("anorak", 7);
stock.put("mochila", 2);
System.out.println(stock); // {mochila=2, botas=4, anorak=7}

TreeSet<Libro> libros = new TreeSet<>(estanteria);   // usa el compareTo de Libro
System.out.println(libros.first() + " | " + libros.last());
// Fortunata y Jacinta (1887) | Tiempo de silencio (1962)

Y aquí viene algo importante: un TreeSet no usa equals para detectar duplicados, usa el comparador. Si el comparador dice 0, para el TreeSet son el mismo elemento y el segundo no entra:

Set<Ruta> porKm = new TreeSet<>(Comparator.comparingDouble(Ruta::km));
porKm.add(new Ruta("Ruta del Cares", 12.0, 300, "Asturias"));
System.out.println(porKm.add(new Ruta("Valle del Pas", 12.0, 450, "Cantabria"))); // false
System.out.println(porKm.size()); // 1

Las dos rutas son distintas, pero miden lo mismo, así que “Valle del Pas” se ha perdido sin ningún error. La solución es añadir un desempate que distinga elementos distintos:

Set<Ruta> porKmBien = new TreeSet<>(Comparator.comparingDouble(Ruta::km)
        .thenComparing(Ruta::nombre));

Lo mismo pasa con las claves de un TreeMap: con String.CASE_INSENSITIVE_ORDER, "Botas" y "botas" son la misma clave.

Map<String, Integer> sinMayusculas = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
sinMayusculas.put("Botas", 4);
sinMayusculas.put("botas", 9);
System.out.println(sinMayusculas); // {Botas=9}

El error de comparar restando

En muchos apuntes verás comparadores escritos con una resta: (a, b) -> a - b. Parece lógico: si a es menor, la resta es negativa. Pero falla con números grandes, porque el resultado no cabe en un int y se desborda (da la vuelta y cambia de signo):

Comparator<Integer> conResta = (x, y) -> x - y;
System.out.println(conResta.compare(-2_000_000_000, 1_000_000_000));    // 1294967296 (¡positivo!)
System.out.println(Integer.compare(-2_000_000_000, 1_000_000_000));    // -1

List<Integer> nums = new ArrayList<>(List.of(1_000_000_000, -2_000_000_000, 0));
nums.sort(conResta);
System.out.println(nums); // [1000000000, -2000000000, 0]   (mal ordenada)
nums.sort(Integer::compare);
System.out.println(nums); // [-2000000000, 0, 1000000000]

Con decimales es aún peor: (int) (a.precio() - b.precio()) convierte 0.5 en 0 y da por iguales precios distintos. Usa siempre Integer.compare, Double.compare o, mejor, Comparator.comparingInt y comparingDouble.

Errores frecuentes

  • Ordenar objetos sin Comparable ni Comparator. Collections.sort no compila; TreeSet lanza ClassCastException al añadir.
  • Comparar con una resta. Se desborda con números grandes y redondea mal con decimales.
  • Poner reversed() al final de una cadena cuando solo querías invertir el último criterio.
  • Olvidar el desempate en un TreeSet o TreeMap. Los elementos que el comparador da por iguales desaparecen sin avisar.
  • Fiarte del valor exacto de compareTo. Solo importa el signo.
  • Ordenar textos en español con el orden natural. Las tildes y las mayúsculas quedan mal colocadas; usa Collator.
  • Ordenar listas con null sin nullsFirst o nullsLast.
  • Intentar ordenar una lista de List.of. Es inmutable: copia antes con new ArrayList<>(...).

Resumen

ConceptoCódigo
Orden naturalclass X implements Comparable<X> + compareTo
Comparar númerosInteger.compare(a, b), Double.compare(a, b)
Comparador por atributoComparator.comparing(X::campo), comparingInt, comparingDouble
Desempate.thenComparing(X::otroCampo)
Invertir.reversed(), Comparator.reverseOrder()
NulosComparator.nullsFirst(...), nullsLast(...)
Alfabeto españolCollator.getInstance(Locale.of("es", "ES"))
Ordenarlista.sort(c), Arrays.sort(a, c), stream.sorted(c)
Colecciones ordenadasnew TreeSet<>(c), new TreeMap<>(c)

En la siguiente lección usarás comparadores, lambdas y referencias a métodos para procesar colecciones enteras de un tirón: lambdas y streams.

Pon a prueba lo que has aprendido

[Java] Con estas rutas (nombre, km, zona): A 5 Norte, B 9 Norte, C 7 Sur, D 2 Sur, ¿en qué orden quedan los nombres tras ordenar así?
rutas.sort(Comparator.comparing(Ruta::zona)
        .thenComparingDouble(Ruta::km)
        .reversed());

[Java] ¿Qué imprimen las dos últimas líneas?
Set<Ruta> s = new TreeSet<>(Comparator.comparingDouble(Ruta::km));
System.out.println(s.add(new Ruta("A", 5, "Norte")));
System.out.println(s.add(new Ruta("Z", 5, "Sur")));
System.out.println(s.size());

[Java] ¿Por qué el comparador (a, b) -> a - b es un error con enteros grandes?

[Java] ¿Qué imprime este código?
List<String> l = new ArrayList<>(List.of("b", "A", "a", "B"));
Collections.sort(l);
System.out.println(l);

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.