Comparator: ordenar por múltiplos critérios com comparing e thenComparing
1. Visão geral
O problema de ordenação no mundo real raramente tem apenas um critério. Por exemplo: ordenar pedidos por prioridade, pedidos com a mesma prioridade com o valor total mais alto vêm primeiro, se ainda assim forem iguais, o pedido mais antigo vem primeiro.
Este artigo orienta você a construir comparators como esses usando Comparator.comparing e thenComparing. Você também aprenderá como lidar com null, como usar reversed() corretamente e erros comuns com comparators.
2. Preparação dos dados
Usaremos um record simples para pedidos:
import java.time.LocalDate;
public record Order(String customer, int priority, double total, LocalDate createdAt) {
}
E uma lista de exemplo. A ordem inicial é 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))
));
}
}
Envolvemos em new ArrayList<>(...) porque List.of retorna uma lista imutável. Chamar sort em uma lista assim lançará UnsupportedOperationException.
3. Comparator com um critério usando comparing
3.1. comparing com referência de método
Comparator.comparing recebe uma função extratora de chave (key extractor). Esta função retorna um valor Comparable, e o comparator compara essas chaves:
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 retorna String. String já implementa Comparable, então o comparator usa a ordem natural da string.
3.2. comparingInt, comparingLong, comparingDouble
Para chaves de tipo primitivo, use as variantes especializadas. Elas não precisam fazer boxing para Integer ou Double em cada comparação:
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 e Em têm o mesmo total de 150. Eles mantêm sua ordem original porque List.sort usa TimSort, um algoritmo de ordenação estável (stable). Elementos iguais segundo o comparator não trocam de posição.
3.3. comparing com um comparator para a chave
A segunda sobrecarga de comparing recebe um comparator adicional para comparar a chave. Assim você pode ordenar decrescente para apenas um critério:
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
Este comando coloca os pedidos mais recentes primeiro. Essa forma de escrever é muito útil ao combinar múltiplos critérios, como você verá na próxima seção.
4. Múltiplos critérios com thenComparing
thenComparing é usado apenas quando o comparator anterior retorna 0, ou seja, quando dois elementos estão empatados nos critérios anteriores. Voltando ao requisito original:
prioritycrescente (1 tem a maior prioridade).totaldecrescente.createdAtcrescente (pedidos mais antigos primeiro).
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]
O processo acontece assim:
- O grupo
priority = 1contém Binh e Dung. Ambos têmtotal = 300, então o critério de data decide. Dung (01/10) vem antes de Binh (03/10). - O grupo
priority = 2contém An, Chi, Em. Chi tem 300, então vem primeiro no grupo. An e Em têm 150, então Em (30/09) vem antes de An (01/10).
Preste atenção na segunda linha: reversed() é chamado em um comparator separado para total. Ele não se aplica a toda a cadeia. Este é o lugar onde muitos cometem erros.
5. A armadilha de reversed()
reversed() inverte todo o comparator em que é chamado. Veja o código abaixo:
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]
O autor queria “priority crescente, total decrescente”. O resultado é que ambos estão decrescentes: o grupo priority = 2 é empurrado para o início. A razão é que reversed() é aplicado ao comparator já montado a partir de dois critérios.
Existem duas maneiras corretas de escrever. Escolha a que for mais legível para você:
// Forma 1: comparator separado para a chave, então inverte
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed());
// Forma 2: passar o comparator da chave para thenComparing
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
.thenComparing(Order::total, Comparator.reverseOrder());
A forma 2 é mais curta, mas precisa fazer boxing de double para Double. Com alguns milhares de elementos, essa diferença é insignificante. Com dados grandes ordenados continuamente, prefira a forma 1.
Nota: Chamar
reversed()no final da cadeia é correto apenas quando você realmente quer inverter todos os critérios. Se quiser inverter apenas um critério, inverta naquele critério.
6. Lidando com valores null
6.1. Chave pode ser null
Comparator.comparing(Customer::nickname) lançará NullPointerException se encontrar nickname nulo. Você envolve o comparator da chave com nullsFirst ou 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 exemplo usa String.CASE_INSENSITIVE_ORDER, então maiúsculas e minúsculas são comparadas igualmente. Se você usar Comparator.naturalOrder(), o resultado será [An, Cuong, binh, lan, null]. Isso porque a ordem natural de String compara por código Unicode, e maiúsculas vêm antes de minúsculas.
6.2. O elemento em si pode ser null
Se o elemento na lista em si pode ser null, envolva todo o comparator do lado de fora:
Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);
Esses dois casos são diferentes. Envolver a chave não protege você de elementos null, e envolver o elemento também não protege você de chaves null.
7. Erro de inferência de tipo com lambda
O código abaixo não compila:
// Erro: cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
.thenComparing(Order::createdAt);
O compilador infere o tipo da esquerda para a direita na cadeia de chamadas de método. Na chamada de comparing(...), ele ainda não sabe que o destino final é Comparator<Order>, então o parâmetro o é inferido como Object.
Existem três maneiras de corrigir:
Comparator.comparing(Order::customer) // referência de método
Comparator.comparing((Order o) -> o.customer()) // declaração de tipo de parâmetro
Comparator.<Order, String>comparing(o -> o.customer()) // type witness
A referência de método é a mais concisa. Use sempre que possível.
8. Escrevendo testes para comparators
Comparators contêm regras de negócio, então merecem testes separados. Adicione as dependências:
<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>
Coloque o comparator em um lugar fixo para reutilização fácil:
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() {
}
}
Depois escreva os testes:
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 a ordem exata do resultado. isSortedAccordingTo verifica que a lista foi ordenada corretamente por esse comparator. O segundo teste documenta o comportamento de reversed() no final da cadeia, para que você não repita esse erro. getFirst() é um novo método de List em Java 21.
9. Erros comuns e quando não usar
9.1. Comparação usando subtração
// Não faça assim
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();
A subtração pode sofrer overflow. Por exemplo, Integer.MIN_VALUE - 1 resulta em um número positivo grande, então a comparação fica errada. Use Comparator.comparingInt(...) ou Integer.compare(a, b).
9.2. Comparator inconsistente com equals em TreeSet/TreeMap
TreeSet considera dois elementos como iguais quando o comparator retorna 0. Ele não usa equals para verificar igualdade:
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
Cinco pedidos restam apenas dois, um para cada priority. Se você usar um comparator com TreeSet ou TreeMap, adicione um critério final que seja único, como um código de pedido. Assim o comparator retorna 0 apenas para elementos verdadeiramente iguais.
9.3. Quando não usar a cadeia comparing
- Critérios dependem um do outro. Por exemplo: “pedidos VIP são comparados por valor total, pedidos normais por data”. Um comparator com lambda e
ifserá mais claro do que uma cadeia dethenComparingforçada. - Chave é cara de calcular. O key extractor é chamado em cada comparação, cerca de
n log nvezes. Se a chave precisar fazer parsing de string ou chamar um serviço, calcule a chave uma vez antes de ordenar. - Ordenação pode ser feita no banco de dados. Se os dados vêm de uma consulta SQL,
ORDER BYgeralmente é mais rápido e evita carregar dados extras na memória.
10. Conclusão
Neste artigo, você aprendeu como:
- Usar
comparing,comparingIntecomparingDoublepara criar comparators a partir de uma chave. - Combinar múltiplos critérios com
thenComparing, e inverter corretamente um critério comreversed()ouComparator.reverseOrder(). - Lidar com
nullusandonullsFirstenullsLast, tanto no nível de chave quanto no nível de elemento. - Evitar erros de inferência de tipo com lambda, overflow ao subtrair e perda de elementos em
TreeSet.
Coloque comparators de negócio como constantes bem nomeadas e sempre inclua um teste que verifique a ordem esperada. Quando as regras de ordenação mudarem, o teste o alertará imediatamente.