Comparator: ordenar múltiples criterios con comparing y thenComparing

1. Descripción general

Los problemas de ordenamiento en la práctica rara vez tienen solo un criterio. Por ejemplo: ordenar pedidos por prioridad, con la misma prioridad el monto total más alto va primero, y si aún son iguales el pedido más antiguo va primero.

Este artículo te guía en la construcción de comparators así usando Comparator.comparing y thenComparing. También aprenderás cómo manejar null, cómo usar correctamente reversed() y los errores más comunes con comparator.

2. Preparación de datos

Usamos un simple record para un pedido:

import java.time.LocalDate;

public record Order(String customer, int priority, double total, LocalDate createdAt) {
}

Y una lista de ejemplo. El orden inicial es An, Binh, Chi, Dung, Em:

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class SampleData {

    public static List<Order> orders() {
        return new ArrayList<>(List.of(
            new Order("An",   2, 150.0, LocalDate.of(2026, 10, 1)),
            new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
            new Order("Chi",  2, 300.0, LocalDate.of(2026, 10, 2)),
            new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
            new Order("Em",   2, 150.0, LocalDate.of(2026, 9, 30))
        ));
    }
}

Lo envolvemos con new ArrayList<>(...) porque List.of devuelve una lista inmutable. Llamar a sort en esa lista lanzará UnsupportedOperationException.

3. Comparator con un criterio usando comparing

3.1. comparing con method reference

Comparator.comparing recibe una función extractora de clave (key extractor). Esta función devuelve un valor Comparable, y luego el comparator compara esas claves:

List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));

System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]

Order::customer devuelve String. String ya implementa Comparable, así que el comparator usa el orden natural de las cadenas.

3.2. comparingInt, comparingLong, comparingDouble

Para claves de tipo primitivo, usa las variantes especializadas. No requieren boxing a Integer o Double en cada comparación:

List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));

System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]

An y Em tienen el mismo total de 150. Mantienen su orden original porque List.sort usa TimSort, un algoritmo de ordenamiento estable (stable). Los elementos iguales según el comparator no cambian de posición.

3.3. comparing con comparator para la clave

La segunda sobrecarga de comparing recibe un comparator adicional para comparar la clave. Esto te permite ordenar de forma descendente solo para un criterio:

orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));

Este comando ordena el pedido más reciente al principio. Esta forma de escribir es muy útil al combinar múltiples criterios, como verás en la siguiente sección.

4. Múltiples criterios con thenComparing

thenComparing se usa solo cuando el comparator anterior devuelve 0, es decir, cuando dos elementos están empatados en los criterios anteriores. Volviendo al requisito inicial:

  1. priority ascendente (1 es la prioridad más alta).
  2. total descendente.
  3. createdAt ascendente (el pedido más antiguo va primero).
Comparator<Order> byBusinessRule = Comparator
    .comparingInt(Order::priority)
    .thenComparing(Comparator.comparingDouble(Order::total).reversed())
    .thenComparing(Order::createdAt);

List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);

System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]

El proceso ocurre paso a paso:

  • El grupo priority = 1 incluye Binh y Dung. Ambos tienen total = 300, así que el criterio de fecha decide. Dung (01/10) va antes que Binh (03/10).
  • El grupo priority = 2 incluye An, Chi, Em. Chi tiene 300 así que encabeza el grupo. An y Em comparten 150, así que Em (30/09) va antes que An (01/10).

Fíjate en la segunda línea: reversed() se llama en un comparator separado para total. Esa orden no se aplica a toda la cadena. Este es exactamente el lugar donde muchos cometen errores.

5. La trampa de reversed()

reversed() invierte todo el comparator en el que se llama. Mira el siguiente código:

Comparator<Order> wrong = Comparator
    .comparingInt(Order::priority)
    .thenComparingDouble(Order::total)
    .reversed();

List<Order> orders = SampleData.orders();
orders.sort(wrong);

System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]

El autor quiso “priority ascendente, total descendente”. Pero el resultado es que ambos están descendentes: el grupo priority = 2 se empuja al principio. La razón es que reversed() se aplica al comparator que ya combina dos criterios.

Hay dos formas correctas de escribirlo, elige la que te parezca más legible:

// Forma 1: comparator separado para la clave, luego invertir
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
    .thenComparing(Comparator.comparingDouble(Order::total).reversed());

// Forma 2: pasar el comparator de la clave a thenComparing
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
    .thenComparing(Order::total, Comparator.reverseOrder());

La forma 2 es más corta pero requiere boxing de double a Double. Con algunos miles de elementos, esta diferencia es insignificante. Con datos grandes y ordenamientos continuos, prefiere la forma 1.

Nota: Llamar a reversed() al final de la cadena solo es correcto si realmente quieres invertir todos los criterios. Si solo quieres invertir un criterio, invierte justo en ese criterio.

6. Manejo de valores null

6.1. Clave puede ser null

Comparator.comparing(Customer::nickname) lanzará NullPointerException si encuentra un nickname igual a null. Envuelves el comparator de la clave con nullsFirst o nullsLast:

public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
    new Customer("lan"),
    new Customer(null),
    new Customer("An"),
    new Customer("binh"),
    new Customer("Cuong")
));

customers.sort(Comparator.comparing(
    Customer::nickname,
    Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));

System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]

Este ejemplo usa String.CASE_INSENSITIVE_ORDER, así que mayúsculas y minúsculas se comparan de manera equivalente. Si usas Comparator.naturalOrder(), el resultado sería [An, Cuong, binh, lan, null]. La razón es que el orden natural de String compara según el código Unicode, y las mayúsculas van antes que las minúsculas.

6.2. El elemento mismo es null

Si el elemento mismo en la lista puede ser null, envuelve todo el comparator en la parte más externa:

Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);

Estos dos casos son diferentes. Envolver la clave no te protege de elementos null, y envolver el elemento tampoco te protege de claves null.

7. Error de inferencia de tipos con lambda

El siguiente código no compila:

// Error: cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
    .thenComparing(Order::createdAt);

El compilador infiere el tipo de izquierda a derecha en la cadena de llamadas de métodos. En la llamada a comparing(...), aún no conoce que el destino final es Comparator<Order>, así que el parámetro o se infiere como Object.

Hay tres formas de arreglarlo:

Comparator.comparing(Order::customer)                    // method reference
Comparator.comparing((Order o) -> o.customer())          // declara el tipo del parámetro
Comparator.<Order, String>comparing(o -> o.customer())   // type witness

Method reference es la forma más concisa. Úsalo siempre que sea posible.

8. Escribir tests para comparators

Los comparators contienen reglas de negocio, así que merecen ser testeados por separado. Añade estas dependencias:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Colocas el comparator en un lugar fijo para reutilizarlo fácilmente:

import java.util.Comparator;

public final class OrderComparators {

    public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
        .comparingInt(Order::priority)
        .thenComparing(Comparator.comparingDouble(Order::total).reversed())
        .thenComparing(Order::createdAt);

    private OrderComparators() {
    }
}

Luego escribe los tests:

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class OrderComparatorsTest {

    @Test
    void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
        List<Order> orders = SampleData.orders();

        orders.sort(OrderComparators.BY_BUSINESS_RULE);

        assertThat(orders)
            .extracting(Order::customer)
            .containsExactly("Dung", "Binh", "Chi", "Em", "An");
        assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
    }

    @Test
    void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
        List<Order> orders = SampleData.orders();

        orders.sort(java.util.Comparator.comparingInt(Order::priority)
            .thenComparingDouble(Order::total)
            .reversed());

        assertThat(orders.getFirst().priority()).isEqualTo(2);
    }
}

containsExactly verifica el orden exacto del resultado. isSortedAccordingTo verifica que la lista esté ordenada según ese comparator exactamente. El segundo test documenta el comportamiento de reversed() al final de la cadena, para que otros no repitan este error. getFirst() es un método nuevo de List en Java 21.

9. Errores comunes y cuándo no usar comparators encadenados

9.1. Comparar usando resta

// No hagas esto
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();

La resta puede causar desbordamiento. Por ejemplo, Integer.MIN_VALUE - 1 produce un número positivo grande, así que el resultado de la comparación es incorrecto. Usa Comparator.comparingInt(...) o Integer.compare(a, b).

9.2. Comparator inconsistente con equals en TreeSet/TreeMap

TreeSet considera dos elementos iguales cuando el comparator devuelve 0. No usa equals para la igualdad:

Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());

System.out.println(set.size());
2

De los cinco pedidos solo quedan dos, uno para cada priority. Si usas un comparator para TreeSet o TreeMap, añade un último criterio único, como un código de pedido. Entonces el comparator solo devuelve 0 con elementos realmente idénticos.

9.3. Cuándo no usar cadenas de comparing

  • Los criterios dependen unos de otros. Por ejemplo: “los pedidos VIP se comparan por monto total, los normales por fecha”. Escribir un comparator en forma de lambda con if será más claro que una cadena thenComparing forzada.
  • La clave es cara de calcular. El key extractor se llama en cada comparación, aproximadamente n log n veces. Si la clave requiere parsear una cadena o llamar a un servicio, calcula la clave una sola vez antes de ordenar.
  • El ordenamiento puede hacerse en la base de datos. Si los datos provienen de una consulta SQL, ORDER BY generalmente es más rápido y ayuda a evitar cargar datos innecesarios en memoria.

10. Conclusión

En este artículo, has aprendido cómo:

  • Usar comparing, comparingInt y comparingDouble para crear un comparator a partir de una clave.
  • Combinar múltiples criterios con thenComparing, e invertir la dirección de solo un criterio con reversed() o Comparator.reverseOrder().
  • Manejar null con nullsFirst y nullsLast, tanto a nivel de clave como a nivel de elemento.
  • Evitar errores de inferencia de tipos con lambda, desbordamiento por resta y pérdida de elementos en TreeSet.

Coloca los comparators de negocio en constantes con nombres claros, y siempre acompaña de un test que verifique el orden esperado. Cuando las reglas de ordenamiento cambien, el test te lo dirá inmediatamente.

javacomparatorsortingstreamcollections