¿Comparar cosas?
Si sabes programar, entonces con seguridad sabes cómo funcionan los operadores de comparación relacionales, como el > o el <. Podemos aplicarle dos operandos numéricos a este operador para determinar si un valor es mayor, menor o igual a otro. Por ejemplo, el siguiente código ejecuta la rama del if cuando la llamada al método getAge() devuelva 18 o algo mayor a 18:
if (user.getAge() >= 18) {
System.out.println("Esta persona tiene más de 18 años");
}
Ahora bien, ¿qué pasa cuando queremos ordenar valores que no sean numéricos? Java nos proporciona en estos casos una forma orientada a objetos de crear este tipo de ordenación, que son los comparadores. Un método comparador es un método que decide si un objeto de tipo T es mayor, menor o igual a otro. No debes confundir esto con el método equals(), aunque es verdad que en parte solapa y que en algunas ocasiones podría hasta comportarse igual.
La ventaja de este sistema es que es flexible, porque podemos usarlo para comparar cualquier tipo de datos. Cualquiera es cualquiera. Imagina tomar el siguiente par de records,
public record Estudiante(String nombre, String apellido) { }
public record Examen(Estudiante estudiante, float nota) { }
y fabricar un comparador que los ordene, por ejemplo, por orden alfabético del apellido del estudiante que ha hecho ese examen; o un comparador que los ordene de la mejor nota a la peor nota. En esos casos, podríamos decir que E1 < E2 si E1 es un examen calificado con un 6.5 y E2 es un examen calificado con un 8.2.
Hay dos formas de crear comparadores en Java. Una es mediante la interfaz funcional Comparator, y otra es mediante la interfaz funcional Comparable. Hoy voy a hablar de Comparator y otro día hablo de la otra.
Comparator
Comparator es una interfaz que expone un método llamado compare, que acepta dos parámetros. Estos dos parámetros son las dos cosas que queremos comparar. Esta interfaz de forma simplificada tiene esta forma:
interface Comparator<T> {
int compare(T o1, T o2);
}
Como puedes observar, lleva un genérico. Eso significa que según lo que pongamos en el genérico, comparará dos cosas de un tipo u otro. Si queremos hacer un comparador de estudiantes, implementaremos Comparator<Estudiante>. Si queremos hacer un comparador de exámenes, haremos un Comparator<Examen>.
En cuanto al método que se implementa, se llama compare. Recibe los dos elementos a comparar como dos parámetros. El orden es importante. En el Javadoc oficial los denominan o1 y o2. Esta función tiene que devolver un valor numérico que especifique si o1 es menor, mayor o igual a o2.
- Si
o1 < o2, es decir, si el primer parámetro es menor que el segundo, la función debe devolver un número negativo. - Si
o1 == o2, es decir, si el primer parámetro es igual al segundo, la función debe devolver cero. - Si
o1 > o2, es decir, si el primer parámetro es mayor al segundo, la función debe devolver un número positivo.
Típicamente, devolveremos -1, 0 o 1. Sin embargo, cualquier negativo y cualquier positivo vale.
¿Pero cómo $#@&?() voy a saber si algo es menor o mayor?
La primera vez que te enfrentes a este tipo de situaciones te va a costar. Sobre todo, si no tienes muy clara la noción de orden ascendente y descendente. El ejemplo de la tabla de datos que puse antes resulta útil porque muestra que lo que estamos haciendo es clasificar una colección de datos para que algunas veces unos elementos aparezcan al principio de la lista (en la parte superior de la tabla), y otros al final de la lista (en la parte inferior de la tabla).
Cuando decimos que un elemento o1 es menor que un elemento o2, lo que estamos diciendo es que en caso de representarlos por orden ascendente, o1 aparecería antes que o2. Así que si o1 es menor que o2, cuando pongamos la información en orden ascendente aparecería antes. Y si es mayor, aparecerá después. Si es igual, dependerá de si nuestro criterio de ordenación es estable o no, pero no nos conviene sacar ese tema en este momento porque sólo complicará aún más las cosas.
En este otro ejemplo, dado un par de records como el siguiente, donde represento una película e información sobre su director o directora,
public record Director(String nombre, int anioNacimiento) { }
public record Pelicula(String nombre, Director director, int presupuesto) { }
me gustaría fabricar una clasificación que las ponga en orden ascendente por edad de la persona que la dirigió. Es decir, las películas aparecerían arriba de la tabla o al principio de la lista cuanto más joven sea quien la dirigió. Un ejemplo de comparador que haga esto sería el siguiente:
public class PeliculasPorDirectoresMasJovenes implements Comparator<Pelicula>
{
@Override
public int compare(Pelicula p1, Pelicula p2) {
int edad1 = p1.director().anioNacimiento();
int edad2 = p2.director().anioNacimiento();
if (edad1 < edad2)
// p1 fue dirigida por alguien más joven que p2
return -1;
else if (edad1 > edad2)
// p1 fue dirigida por alguien más mayor que p2
return 1;
else
// se entiende que edad1 == edad2
return 0;
}
}
Pero claro, recuerda que si quieres que salgan primero las películas cuyo valor numérico sea mayor, tendrás que hacer la comparación al revés. Es decir, será menor quien tenga mayor valor. Lo cual al principio resulta muy extraño de entender, pero si lo ves como que quieres que en orden ascendente salga primero quien tiene mayor valor, será más claro de ver. O simplemente, si las quieres en orden descendente en vez de ascendente. Este ejemplo muestra un comparador de ese tipo, donde están ordenadas por presupuesto de forma descendente (más valor, menor orden):
public class PeliculasMasCaras implements Comparator<Pelicula>
{
@Override
public int compare(Pelicula p1, Pelicula p2) {
if (p1.presupuesto() > p2.presupuesto())
// La película p1 fue más cara que la p2.
return -1;
else if (p1.presupuesto() < p2.presupuesto())
// La película p1 fue más barata que la p1.
return 1;
else
// se entiende que p1 y p2 costaron lo mismo.
return 0;
}
}
Ahora las primeras posiciones de la lista o de la tabla las tendrán películas con presupuesto millonario, y las menores producciones aparecerán al final de la lista.
Funciones comparadoras ya implementadas
Muchos tipos primitivos de Java ya traen un método estático llamado compare que devuelve positivo, negativo o cero. Para esos casos, puedes utilizar directamente esos métodos a la hora de hacer una comparación. Por ejemplo, Integer.compare es un método estático de la clase Integer que actúa así. Si tomas dos números a y b y llamas a Integer.compare(a, b), devolverá -1 si a < b, 0 si a == b y 1 si a > b. Otros tipos primitivos numéricos tienen su equivalente, como Short.compare(a, b) o Long.compare().
Si quieres comparar cadenas de caracteres, tienes el método CharSequence.compare(). No busques el método estático String.compare(); no existe. (Aunque tienes el método no estático compareTo, que actúa parecido). CharSequence.compare() devuelve -1 si la primera CharSequence es lexicográficamente menor que la segunda, 1 si es lexicográficamente mayor, y 0 si son iguales. Dos cadenas lexicográficamente se ordenan buscando el primer caracter i de las mismas que sea diferente, y comparando el orden alfabético de ese primer caracter. Así que si comparo hexagonal con hexadecimal, la primera letra que no tienen en común es la quinta (retiramos el prefijo hexa), y puesto que la D aparece antes que la G en el alfabeto, entonces "hexagonal" > "hexadecimal".
Mi favorito por caos es Boolean.compare(). Te traduzco lo que dice literalmente su Javadoc:
Devuelve 0 si
x == y, un valor negativo si!x && y, y un valor positivo six && !y.
O sea:
- Si ambos son verdaderos o falsos, devuelve 0.
- Si el primer valor es falso y el segundo verdadero, devuelve negativo.
- Si el primer valor es verdadero y el segundo falso, devuelve positivo.
Por lo tanto, al ordenar una colección de cosas booleanas, los elementos falsos irán al principio que los elementos verdaderos. Probablemente un array de booleans sea la cosa menos interesante del mundo, pero esto tiene sentido cuando estemos comparando propiedades. Te pongo un ejemplo, este comparador dejará en las primeras posiciones de la lista los exámenes que han aprobado.
class ComparadorAprobados implements Comparator<Estudiante> {
@Override
public void compare(Examen e1, Examen e2) {
boolean suspenso1 = e1.nota() < 5;
boolean suspenso2 = e2.nota() < 5;
return Boolean.compare(suspenso1, suspenso2);
}
}
El predicado suspenso será falso para un examen que tenga nota mayor o igual a 5, por lo que al aplicar este criterio de ordenación, los exámenes aprobados, que serán todos falsos, irán primero.
Comparator es una interfaz funcional
Como Comparator es una interfaz que únicamente expone un método obligatorio de implementar, entonces esta interfaz es funcional, así que vas a poder escribirla como un método lambda. Esto simplificará muchísimo escribir comparadores anónimos en el momento que nos haga falta.
De este modo, cualquier método que acepte como parámetro un Comparator, permitirá recibir una función lambda que tenga la forma (o1, o2) → xxx, para representar cómo ordenarlo.
Un ejemplo muy importante de este caso sería el método Arrays.sort(). Este es un método complejo lleno de sobrecargas. Podemos pasarle como parámetro un int[], y se ordenará usando números. Sin embargo, podemos pasarle como parámetro un T[] también, y si le proporcionamos una implementación de Comparator<T> nos permitirá especificar cómo ordenar ese tipo de datos.
Otro ejemplo es en las colecciones que tienen noción de orden, como las listas. El método sort() también nos permitirá ordenar los elementos de la lista, y acepta como parámetro el comparador a utilizar.
void ordenarPorNota(List<Examen> examenes) {
// Estarán en orden ascendente de nota, lo que significa que primero
// tendremos los exámenes con peor calificación, y luego los mejores.
examenes.sort((e1, e2) -> Integer.compare(e1.getNota(), e2.getNota()));
}
En cualquier caso, Comparator también trae métodos auxiliares como comparingInt o comparingLong, que directamente dan formas más creativas de fabricar comparadores de forma declarativa. Por ejemplo, si quiero fabricar un comparador que compare objetos de tipo T a partir de una propiedad numérica que tenga ese tipo T, puedo hacer uso de la función comparingInt:
void ordenarPorNota(List<Examen> examenes) {
examenes.sort(Comparator.comparingInt((examen) -> examen.getNota()))
}
O simplemente, como seguramente tu editor de textos te sugerirá refactorizarlo a:
void ordenaPorNota(List<Examen> examenes) {
examenes.sort(Comparator.comparingInt(Examen::getNota))
}
Vamos a verlo de dentro a fuera:
- Por un lado, tanto
(examen) -> examen.getNota()comoExamen::getNotarepresenta una función que permite transformar un examen en la nota que ha sacado ese examen. - Por otro, cuando llamo a
Comparator.comparingInt, lo que hago es pedirle a Java que fabrique un comparador donde en vez de especificar mediante lambda cómo compararo1yo2, directamente llame por mi aInteger.comparede, noo1yo2, sino el resultado de llamar esa función transformadora parao1yo2. - Por lo tanto,
Comparator.comparingInt(f)es equivalente a escribir(o1, o2) -> Integer.compareTo(f(o1), f(o2)).
Siguiendo esta filosofía, otro de mis métodos favoritos de la interfaz Comparator es reversed, que devuelve otro comparador que funciona de forma exactamente opuesta al comparador sobre el que se ha llamado a este método. Dicho de otro modo, transforma un comparador ascendente en uno descendente. Por lo tanto, si en este ejemplo te interesa un comparador descendente que te entregue primero las mejores notas, podrías invertirlo:
void ordenaPorNota(List<Examen> examenes) {
examenes.sort(Comparator.comparingInt(Examen::getNota).reversed())
}
Nota: ¿Puedo restar para comparar cosas?
Un truco que puedes implementar en algunos de tus programas puede ser utilizar la resta para comparar dos valores. Ten en cuenta que esto no te vale para todos los casos. En particular, este método va a fallar cuando los valores numéricos corran el riesgo de desbordarse (es decir, de tener un valor numérico tan alto o tan bajo que no pueda ser representado por el ordenador y por lo tanto pase de ser muy negativo a muy positivo). De hecho, tampoco te vale cuando se mezclan positivos con negativos.
En cualquier caso, si tienes valores positivos que rondan cifras similares y que entran de sobra en los límites de lo representable con el tipo de datos numérico de tu máquina, podrías implementar un comparador de o1 y o2 directamente restando o1 - o2. Si la resta da un valor negativo, entonces o1 es menor que o2. Si la resta da un valor positivo, entonces o1 es mayor que o2, y si la resta da 0, entonces o1 es igual a o2.
Para ordenar un rango de precios de productos, por ejemplo:
productos.sort((p1, p2) -> p1.precio() - p2.precio());
De todos modos, como digo, ten cuidado con asumir que esto se va a cumplir siempre, porque matemáticamente no vale si juntas positivos con negativos, y desde luego, no va a valer si las variables corriesen el riesgo de desbordarse. ¡Menos mal que Java no tiene tipos numéricos sin signo y podemos asumir de forma segura que 0 - 1 da -1 y no 4294967295!
Nota: ¿Qué diferencia a equals() de compare()?
El método equals se usa para comprobar si dos cosas son iguales. El método compare se usa para comparar dos cosas.
El mismo Javadoc del método compare admite que en algunas ocasiones ambas propiedades se van a cumplir a la vez. Es decir, que para dos objetos t1 y t2, si t1.equals(t2) es verdad, entonces compare(t1, t2) == 0 también lo va a ser. Si comparamos value objects que tienen noción de orden, esto sí se va a cumplir.
Imagina que tienes un enumerado que representa el estado de una compra, como los de Amazon:
public enum EstadoPedido {
Creado,
Enviado,
EnReparto,
Entregado,
Cancelado
}
Si implementas un Comparator<EstadoPedido>, por ejemplo para formalizar el hecho de que Creado < Enviado, entonces sí se va a cumplir esa propiedad, porque se entiende que compare(Creado, Creado) va a devolver 0 ya que es el mismo estado y por lo tanto por orden van a ir juntas. Y además, Creado.equals(Creado) también va a devolver verdadero.
Sin embargo, sí puede ocurrir que equals devuelva true y compare devuelva false, o viceversa. Si implementas un Comparator<Pedido> que los ordene precisamente por ese estado del pedido, dos pedidos que estén en el mismo estado van a ser relacionalmente iguales (compare(p1, p2) va a devolver 0), a pesar de que se debería entender de que esos dos pedidos no serán el mismo, sino diferentes pedidos.
Así que no asumas esto directamente. El Javadoc recomienda, de todos modos, que un comparador que desvincule el orden de la igualdad lo especifique de forma clara para evitar este tipo de confusiones.