Comparable y Comparator en Java
En esta lección
- Cómo compara Java dos objetos
- Comparable: el orden natural de una clase
- Comparator: cualquier otro orden
- Construir comparadores con métodos de fábrica
- Valores null: nullsFirst y nullsLast
- Ordenar en español: mayúsculas y acentos
- Dónde se usan los comparadores
- TreeSet y TreeMap con comparador
- El error de comparar restando
- Errores frecuentes
- 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:
| Resultado | Significa |
|---|---|
| Negativo | El primero va antes que el segundo |
| Cero | Son equivalentes en orden |
| Positivo | El 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: sicompareTodevuelve 0, lo ideal es queequalsdevuelvatrue. Verás por qué en la sección deTreeSet.
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> | |
|---|---|---|
| Paquete | java.lang | java.util |
| Método | compareTo(T otro) | compare(T a, T b) |
| Dónde se escribe | Dentro de la propia clase | Fuera, como objeto aparte |
| Cuántos | Uno (el orden natural) | Todos los que quieras |
| Uso | Collections.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.ofexiste desde Java 19. En versiones anteriores se escribenew Locale("es", "ES").
Dónde se usan los comparadores
Un mismo comparador sirve en muchos sitios:
| Dónde | Cómo |
|---|---|
| Ordenar una lista | lista.sort(comp) o Collections.sort(lista, comp) |
| Ordenar un array | Arrays.sort(array, comp) |
| Ordenar un stream | stream.sorted(comp) |
| Máximo y mínimo | Collections.max(lista, comp), stream.max(comp) |
| Cola de prioridad | new PriorityQueue<>(comp) (ver pilas y colas) |
| Conjunto o mapa ordenado | new 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
ComparableniComparator.Collections.sortno compila;TreeSetlanzaClassCastExceptional 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
TreeSetoTreeMap. 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
nullsinnullsFirstonullsLast. - Intentar ordenar una lista de
List.of. Es inmutable: copia antes connew ArrayList<>(...).
Resumen
| Concepto | Código |
|---|---|
| Orden natural | class X implements Comparable<X> + compareTo |
| Comparar números | Integer.compare(a, b), Double.compare(a, b) |
| Comparador por atributo | Comparator.comparing(X::campo), comparingInt, comparingDouble |
| Desempate | .thenComparing(X::otroCampo) |
| Invertir | .reversed(), Comparator.reverseOrder() |
| Nulos | Comparator.nullsFirst(...), nullsLast(...) |
| Alfabeto español | Collator.getInstance(Locale.of("es", "ES")) |
| Ordenar | lista.sort(c), Arrays.sort(a, c), stream.sorted(c) |
| Colecciones ordenadas | new 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.