Ordem e Critério — Comparable, Comparator e Coleções Ordenadas

Ordem e Critério — Comparable, Comparator e Coleções Ordenadas

Ordenação em Java: Comparable e a ordem natural, o perigo de comparar subtraindo, Comparator com classes aninhadas, internas e anônimas, thenComparing e reversed, Collator para ordenar texto em português, TreeMap com floorEntry, comparadores inconsistentes com equals e PriorityQueue.
Java

• • 16 min de leitura

Como definir a ordem dos seus objetos, ter vários critérios de ordenação para a mesma classe, ordenar texto como uma pessoa espera e aproveitar as coleções que se mantêm ordenadas.

"Listas, Conjuntos e Mapas" terminou perguntando como um TreeSet sabe em que ordem colocar os elementos. Com números e textos, a resposta é a ordem natural deles. Mas uma turma de alunos pode ser ordenada por nome, por nota ou por idade, conforme a necessidade, e a ordem natural dos textos põe "Banana" antes de "abacaxi" e "árvore" depois de "uva", o que nenhum leitor espera. Esta aula mostra as duas ferramentas do Java para definir ordens, Comparable e Comparator. No caminho, apresenta as classes aninhadas e anônimas, e depois explora as coleções ordenadas e a fila de prioridade.

Comparable: a ordem natural de um tipo

Uma classe declara a sua ordem natural implementando Comparable<T>, que você já usou como limite em "Escreva uma Vez para Todo Tipo". O único método, compareTo, devolve um número negativo se este objeto vem antes do outro, zero se são equivalentes na ordem, e positivo se vem depois:

public record Aluno(String nome, int idade, double nota) implements Comparable<Aluno> {
    @Override
    public int compareTo(Aluno outro) {
        return nome.compareTo(outro.nome);
    }
}

A ordem natural de um Aluno é por nome, e a implementação simplesmente delega ao compareTo de String. Com isso, uma lista de alunos pode ser ordenada sem mais nada:

List<Aluno> turma = new ArrayList<>(List.of(
    new Aluno("Davi", 19, 8.5),
    new Aluno("ana", 22, 9.0),
    new Aluno("Bia", 20, 7.0),
    new Aluno("Caio", 19, 9.0)
));

Collections.sort(turma);
Ordem natural: Bia(7.0,20) Caio(9.0,19) Davi(8.5,19) ana(9.0,22)

A "ana" com minúscula ficou por último. O compareTo de String compara os códigos Unicode dos caracteres, e todas as maiúsculas vêm antes das minúsculas. Isso será resolvido mais adiante.

Muitas classes da biblioteca são Comparable: String, os números embrulhados, os enums (na ordem de declaração) e as datas do pacote java.time. Por isso TreeSet<String> e Arrays.sort com números funcionavam sem nenhum código extra.

Compare sem subtrair

Uma forma tentadora de comparar inteiros é subtrair um do outro, porque o resultado tem o sinal certo:

public int compare(Integer a, Integer b) {
    return a - b;      // ERRADO
}

Ordenando [-2000000000, 2000000000, 0] com isso, o resultado é [0, 2000000000, -2000000000], fora de ordem. A subtração 2000000000 - (-2000000000) estoura o int e dá a volta para um número negativo, o overflow de "Cada Valor em sua Caixa" de novo. Use sempre Integer.compare(a, b), Long.compare ou Double.compare, que comparam sem fazer conta.

Comparator: outros critérios

Uma classe tem uma só ordem natural, mas os programas precisam de várias ordens. Um Comparator<T> é um objeto que sabe comparar dois T, de fora da classe:

public interface Comparator<T> {
    int compare(T a, T b);
    // ... e vários métodos default
}

Para ordenar alunos por nota, do maior para o menor, criamos uma classe que implementa Comparator<Aluno>. Como ela só interessa ao programa que ordena, pode ser declarada dentro dele.

Classes aninhadas estáticas

public class Ordenar {

    static class PorNotaDecrescente implements Comparator<Aluno> {
        @Override
        public int compare(Aluno a, Aluno b) {
            return Double.compare(b.nota(), a.nota());
        }
    }

    public static void main(String[] args) {
        // ... turma criada como antes
        turma.sort(new PorNotaDecrescente());
    }
}
Por nota:      Caio(9.0,19) ana(9.0,22) Davi(8.5,19) Bia(7.0,20)

Uma classe aninhada estática é uma classe comum declarada dentro de outra, só para organização: ela fica agrupada com quem a usa, e pode ser private para ficar invisível fora dali. De fora, seu nome completo é Ordenar.PorNotaDecrescente. O compilador gera um arquivo Ordenar$PorNotaDecrescente.class, o mesmo $ que apareceu em "Moldes e Instâncias". As classes Veiculo, Carro e Taxi de "Do Geral ao Específico" eram desse tipo. Para inverter a ordem, a comparação foi feita de b para a.

Classes internas

Sem o static, a classe aninhada vira uma classe interna: cada instância dela fica ligada a uma instância da classe de fora e pode acessar os campos dela, inclusive os privados:

public class Loja {
    private final String prefixo;

    Loja(String prefixo) { this.prefixo = prefixo; }

    class Etiqueta {
        private final String texto;
        Etiqueta(String texto) { this.texto = texto; }
        String rotulo() { return prefixo + "-" + texto; }   // usa o campo da Loja
    }
}

Criar uma Etiqueta exige uma Loja: loja.new Etiqueta("001"), com uma sintaxe estranha que já denuncia o vínculo. rotulo() devolve "LJ-001". Classes internas são úteis quando o objeto de dentro realmente pertence ao de fora, como os iteradores das coleções, que precisam acessar os dados internos da lista que percorrem. Mas a ligação escondida tem custo: cada objeto interno guarda uma referência ao externo, e isso mantém o externo vivo na memória. Prefira aninhadas estáticas, a menos que precise do acesso à instância de fora.

Classes anônimas

Quando o comparador é usado num único lugar, dar nome a ele é cerimônia. Uma classe anônima declara e instancia a classe na mesma expressão:

Comparator<Aluno> porIdade = new Comparator<Aluno>() {
    @Override
    public int compare(Aluno a, Aluno b) {
        return Integer.compare(a.idade(), b.idade());
    }
};
turma.sort(porIdade);
Por idade:     Caio(9.0,19) Davi(8.5,19) Bia(7.0,20) ana(9.0,22)

Lê-se: "crie um objeto de uma classe sem nome que implementa Comparator<Aluno>, com este corpo". O compilador gera um arquivo Ordenar$1.class, com um número no lugar do nome. As constantes da Operacao de "Dados sem Cerimônia", com corpos próprios, eram classes anônimas.

Classes anônimas e internas podem usar variáveis locais do método em que foram declaradas, desde que essas variáveis sejam final ou efetivamente finais, isto é, nunca reatribuídas depois da inicialização. Reatribuir dá erro:

Efetivo.java:5: error: local variables referenced from an inner class must be final or effectively final

O motivo é que a classe anônima guarda uma cópia do valor. Se a variável pudesse mudar depois, a cópia e a original divergiriam em silêncio. A regra impede essa confusão, e vai reaparecer com as lambdas.

Combinando comparadores

Os comparadores podem ser combinados por métodos default da própria interface, sem nenhuma classe nova:

turma.sort(new PorNotaDecrescente().thenComparing(porIdade));
IO.println("Nota, idade:   " + nomes(turma));

turma.sort(porIdade.reversed());
IO.println("Idade desc:    " + nomes(turma));

turma.sort(Comparator.naturalOrder());
turma.sort(Comparator.reverseOrder());
Nota, idade:   Caio(9.0,19) ana(9.0,22) Davi(8.5,19) Bia(7.0,20)
Idade desc:    ana(9.0,22) Bia(7.0,20) Caio(9.0,19) Davi(8.5,19)
  • thenComparing(outro) desempata: se o primeiro critério considera dois alunos equivalentes, como Caio e ana, com 9,0, o segundo decide.
  • reversed() inverte qualquer comparador.
  • Comparator.naturalOrder() e reverseOrder() usam o compareTo da classe, na ordem normal ou invertida.

A ordenação do Java é estável: elementos equivalentes pelo critério mantêm a ordem relativa que tinham antes. Isso permite ordenar em etapas, mas thenComparing deixa a intenção muito mais clara.

As classes anônimas funcionam, mas são verbosas: seis linhas para dizer "compare pela idade". A partir de "Comportamento como Valor", a mesma ordenação da tabela acima vai caber numa linha legível:

turma.sort(Comparator.comparingDouble(Aluno::nota).reversed().thenComparingInt(Aluno::idade));

Por enquanto, guarde a forma. Os :: e o que eles significam são o assunto daquela aula.

Ordenando texto para pessoas

A ordem natural de String é a ordem dos códigos Unicode, adequada para computadores e inadequada para pessoas:

List<String> palavras = new ArrayList<>(List.of(
    "ônibus", "abacaxi", "Banana", "árvore", "uva", "Égua", "caju"));

Collections.sort(palavras);
// [Banana, abacaxi, caju, uva, Égua, árvore, ônibus]

palavras.sort(String.CASE_INSENSITIVE_ORDER);
// [abacaxi, Banana, caju, uva, árvore, Égua, ônibus]

palavras.sort(java.text.Collator.getInstance(Locale.of("pt", "BR")));
// [abacaxi, árvore, Banana, caju, Égua, ônibus, uva]

O CASE_INSENSITIVE_ORDER resolve as maiúsculas, mas as letras acentuadas continuam no fim, porque seus códigos são maiores que os de todas as letras sem acento. Um Collator é um comparador que conhece as regras de ordenação de um idioma: trata "á" como uma variação de "a" e ignora a diferença entre maiúsculas e minúsculas no primeiro nível de comparação. É a resposta à pendência de "Muitos Valores sob um Nome". Para qualquer lista exibida a um usuário, como nomes de clientes ou cidades, use um Collator com o idioma certo.

Coleções que se mantêm ordenadas

O TreeSet e o TreeMap mantêm os elementos sempre ordenados, pela ordem natural ou por um Comparator passado no construtor. Por dentro, são árvores rubro-negras, árvores de busca binária que se reequilibram a cada inserção. Por isso, buscar, inserir e remover custam O(log n), como a tabela da aula anterior mostrou.

A vantagem que só elas têm é a navegação por ordem: encontrar o elemento imediatamente abaixo ou acima de um valor, ou uma faixa inteira. Um exemplo, com uma tabela de frete por peso (valores fictícios):

TreeMap<Integer, Double> fretePorPeso = new TreeMap<>();
fretePorPeso.put(0, 12.90);     // de 0 kg em diante
fretePorPeso.put(1, 18.50);     // de 1 kg em diante
fretePorPeso.put(5, 29.90);
fretePorPeso.put(10, 44.00);
fretePorPeso.put(30, 89.00);

for (int gramas : new int[] {300, 1000, 7500, 45000}) {
    int kg = gramas / 1000;
    IO.println(gramas + " g -> R$ " + fretePorPeso.floorEntry(kg).getValue());
}
300 g -> R$ 12.9
1000 g -> R$ 18.5
7500 g -> R$ 29.9
45000 g -> R$ 89.0

floorEntry(k) devolve a entrada com a maior chave menor ou igual a k, exatamente a faixa em que o peso se encaixa. Sem ela, seria uma cadeia de if que precisa mudar a cada alteração da tabela. A família completa de métodos de navegação:

Método Devolve
floorKey(k) a maior chave ≤ k
ceilingKey(k) a menor chave ≥ k
lowerKey(k) / higherKey(k) a maior chave < k / a menor chave > k
firstKey() / lastKey() a menor / a maior chave
headMap(k) / tailMap(k) as entradas com chave < k / ≥ k

Os métodos com Entry no nome, como floorEntry, devolvem o par completo. O TreeSet tem os equivalentes para elementos: floor, ceiling, first, last, headSet, tailSet e descendingSet().

A armadilha da consistência com equals

As coleções ordenadas usam o compareTo ou o comparador no lugar do equals para decidir se dois elementos são o mesmo. Se o critério de ordem não bate com a igualdade, o resultado surpreende:

record Aluno(String nome, double nota) { }

Set<Aluno> ranking = new TreeSet<>(porNota);    // porNota compara só a nota
ranking.add(new Aluno("Ana", 9.0));
ranking.add(new Aluno("Caio", 9.0));
ranking.add(new Aluno("Bia", 7.0));
IO.println(ranking.size() + " " + ranking);
2 [Aluno[nome=Bia, nota=7.0], Aluno[nome=Ana, nota=9.0]]

O Caio sumiu. Para o comparador, um aluno com nota 9,0 é equivalente à Ana, e um conjunto não aceita equivalentes. Pior: ranking.contains(new Aluno("Zeca", 9.0)) devolve true, para um aluno que nunca foi inserido. A documentação chama isso de comparador inconsistente com equals. A solução é desempatar até que só elementos realmente iguais sejam equivalentes, com porNota.thenComparing(...) pelo nome. Guarde essa armadilha: ela aparece de novo com o BigDecimal em "Tempo, Dinheiro e Padrões", em que 2.0 e 2.00 são iguais pelo compareTo e diferentes pelo equals.

PriorityQueue: o mais importante primeiro

Uma fila de prioridade entrega sempre o elemento de maior prioridade, segundo um comparador, independentemente da ordem de chegada. Uma triagem de pronto-socorro:

record Paciente(String nome, int gravidade) { }

Queue<Paciente> triagem = new PriorityQueue<>(new Comparator<Paciente>() {
    @Override
    public int compare(Paciente a, Paciente b) {
        return Integer.compare(b.gravidade(), a.gravidade());   // mais grave primeiro
    }
});
triagem.offer(new Paciente("Ana", 2));
triagem.offer(new Paciente("Bruno", 5));
triagem.offer(new Paciente("Carla", 3));
triagem.offer(new Paciente("Davi", 5));

while (!triagem.isEmpty()) {
    IO.print(triagem.poll().nome() + " ");
}
// Bruno Davi Carla Ana

Bruno saiu antes de Davi por acaso: entre elementos de mesma prioridade, a fila não garante ordem nenhuma, e quem precisar da ordem de chegada deve desempatar no comparador. Por dentro, a PriorityQueue é um heap binário, uma estrutura que mantém só o suficiente de ordem para achar o mais prioritário rapidamente: inserir e retirar custam O(log n). O mais prioritário está sempre no topo, mas o resto não está ordenado, e imprimir a fila mostra isso: new PriorityQueue<>(List.of(5, 1, 4, 2, 3)) imprime [1, 2, 4, 5, 3]. Só as retiradas sucessivas com poll saem em ordem. Filas de prioridade aparecem em agendadores de tarefas, simulações e algoritmos de caminho mínimo, como o de rotas em mapas.

Todos os exemplos até aqui supuseram que as coisas dão certo. O Comparator recebe dois alunos válidos, o mapa tem a chave procurada, o texto digitado é um número. Mas em várias aulas você viu programas pararem com mensagens como NumberFormatException, IllegalArgumentException e ConcurrentModificationException, e em "O que Fica Dentro, o que Fica Fora" você mesmo lançou uma. Até agora, toda exceção encerrava o programa.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um record Versao(int maior, int menor, int correcao) que implemente Comparable<Versao>, de modo que 1.10.0 venha depois de 1.9.5, e 2.0.0 depois de ambas. Teste ordenando uma lista.

Ver resposta

✓ Resposta:

record Versao(int maior, int menor, int correcao) implements Comparable<Versao> {
    @Override
    public int compareTo(Versao outra) {
        int c = Integer.compare(maior, outra.maior);
        if (c != 0) {
            return c;
        }
        c = Integer.compare(menor, outra.menor);
        if (c != 0) {
            return c;
        }
        return Integer.compare(correcao, outra.correcao);
    }
}

A comparação é lexicográfica: o primeiro campo decide, e os seguintes só desempatam. Ordenando [Versao(2,0,0), Versao(1,10,0), Versao(1,9,5)], a ordem é 1.9.5, 1.10.0, 2.0.0. É exatamente o erro que a comparação de textos cometeria, já que "1.10.0" vem antes de "1.9.5" alfabeticamente, porque o caractere "1" vem antes de "9". Usar Integer.compare, e não subtração, evita o overflow. Como o record gera um equals baseado nos mesmos três campos, a ordem natural é consistente com equals.

Exercício 2

Um record Produto(String nome, double preco) precisa ser exibido em ordem crescente de preço e, em caso de empate, por nome em ordem alfabética. Escreva os comparadores como classes anônimas e combine-os.

Ver resposta

✓ Resposta:

Comparator<Produto> porPreco = new Comparator<>() {
    @Override
    public int compare(Produto a, Produto b) {
        return Double.compare(a.preco(), b.preco());
    }
};

Comparator<Produto> porNome = new Comparator<>() {
    @Override
    public int compare(Produto a, Produto b) {
        return a.nome().compareTo(b.nome());
    }
};

produtos.sort(porPreco.thenComparing(porNome));

O diamante new Comparator<>() funciona em classes anônimas desde o Java 9. thenComparing aplica o segundo critério só quando o primeiro devolve zero. Com produtos de mesmo preço, o nome decide. Se os nomes fossem exibidos a usuários, porNome deveria usar um Collator em vez de compareTo, para lidar com maiúsculas e acentos, e o Collator é ele próprio um comparador de textos que pode ser usado dentro do compare.

Exercício 3

Explique por que o TreeSet do exemplo do ranking perdeu o Caio, e corrija o comparador para que alunos diferentes com a mesma nota convivam no conjunto, ordenados por nota e depois por nome.

Ver resposta

✓ Resposta: O TreeSet não usa equals para detectar duplicatas: usa o comparador. Para porNota, dois alunos com nota 9,0 são equivalentes (o compare devolve zero), e o conjunto rejeita o segundo, como faria com uma duplicata de verdade. O comparador é inconsistente com o equals do record, que considera Ana e Caio diferentes. A correção é desempatar até a equivalência coincidir com a igualdade:

Comparator<Aluno> porNome = new Comparator<>() {
    @Override
    public int compare(Aluno a, Aluno b) {
        return a.nome().compareTo(b.nome());
    }
};
Set<Aluno> ranking = new TreeSet<>(porNota.thenComparing(porNome));

Agora Ana e Caio convivem, na ordem Bia, Ana, Caio. Só dois alunos com mesmo nome e mesma nota seriam equivalentes, e esses são iguais pelo equals do record. A regra: um comparador usado em coleções ordenadas deve comparar todos os campos que o equals compara.

Exercício 4

Um colega escreveu um comparador de idades como return a.idade() - b.idade(); e diz que "sempre funcionou". Em que condição ele falha, e por que é melhor corrigir mesmo que idades nunca cheguem a esse ponto?

Ver resposta

✓ Resposta: Ele falha quando a diferença entre os dois valores não cabe num int, como com valores perto de ±2 bilhões: a subtração estoura e troca de sinal, e o comparador passa a dizer que o maior é menor. Com idades reais, isso nunca acontece. Mesmo assim, vale corrigir para Integer.compare(a.idade(), b.idade()), por três motivos. O código é copiado: o mesmo padrão reaparece depois para comparar saldos em centavos, códigos ou identificadores, e aí o defeito aparece. Integer.compare expressa a intenção de forma mais clara do que um truque aritmético. E um comparador errado não dá erro: produz ordenações incorretas em silêncio, ou até faz o algoritmo de ordenação lançar "Comparison method violates its general contract", uma das mensagens mais confusas da biblioteca. O custo da correção é zero.

Exercício 5

IO.println(new PriorityQueue<>(List.of(5, 1, 4, 2, 3))) mostra [1, 2, 4, 5, 3], que não está ordenado. Explique por quê, e mostre como obter os elementos em ordem a partir da fila.

Ver resposta

✓ Resposta: A PriorityQueue é um heap binário. A única garantia dele é que cada elemento é menor ou igual aos seus "filhos" na árvore, o que põe o menor no topo, mas não ordena o resto. O toString percorre o array interno na ordem em que os elementos estão guardados, que é a ordem do heap, e não a ordem de valor. Manter tudo ordenado a cada inserção custaria mais, e a fila de prioridade só promete entregar o menor elemento. Para obter todos em ordem, retira-se um por um:

PriorityQueue<Integer> pq = new PriorityQueue<>(List.of(5, 1, 4, 2, 3));
while (!pq.isEmpty()) {
    IO.print(pq.poll() + " ");   // 1 2 3 4 5
}

Cada poll remove o topo e reorganiza o heap em O(log n), e o conjunto das retiradas sai ordenado. Esse é, aliás, um algoritmo de ordenação completo, o heapsort. Se a intenção for só ter uma lista ordenada, é mais simples copiar para um ArrayList e usar sort.

Comentários

Mais em Java

Extensão — Java no Servidor: Servlets, Jakarta EE, Spring e Quarkus
Extensão — Java no Servidor: Servlets, Jakarta EE, Spring e Quarkus

A história de como o Java se tornou a linguagem dos servidores, dos Servlets…

A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive
A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive

Nesta aula o Farol passa a atender cada conexão numa thread virtual própria…

Iguais ou o Mesmo? — equals, hashCode e Identidade
Iguais ou o Mesmo? — equals, hashCode e Identidade

Identidade e igualdade em Java: o contrato do equals, Double.compare, hashCode…