C# IComparable e IComparer

Ordenar números e nomes é fácil: lista.Sort() e pronto. Mas a professora Ana tem uma lista de alunos, cada um com nome e nota, e quer o ranking da turma. Maior nota primeiro? Ordem alfabética? O C# não tem como adivinhar. Nesta aula você vai ensinar o C# a comparar dois objetos seus, de dois jeitos: com IComparable<T> (a ordem "natural" da classe) e com IComparer<T> (quantas ordens diferentes você quiser).

Você vai aprender
  • O que o CompareTo responde: negativo, zero ou positivo.
  • O erro real de ordenar objetos que não sabem se comparar.
  • Implementar IComparable<T>: a ordem natural de uma classe.
  • Criar comparadores com IComparer<T>, com lambda e com Comparer<T>.Create.
  • Ordem decrescente e desempate (por nota e, empatou, por nome).
  • Textos: a ordem da cultura pt-BR × StringComparer.Ordinal.
  • A armadilha do SortedSet: "empate" vira "repetido".

Antes, é bom conhecer Interfaces, List e Lambdas. Todos os programas desta aula rodaram com o .NET 10 na máquina da Ana (usuária ana, computador notebook), na pasta ~/cs, com dotnet run arquivo.cs.


A ideia: só uma pergunta

Para pôr uma fila de pessoas em ordem de altura, você não precisa saber a altura exata de ninguém. Basta, repetidas vezes, colocar duas pessoas lado a lado e perguntar: "quem vem antes?". Os métodos de ordenação fazem isso: comparam pares de itens, muitas vezes, até tudo estar no lugar.

No C#, a resposta a "quem vem antes?" é um número inteiro:

a.CompareTo(b) devolve…Quer dizer
um número negativo (como -1)a vem antes de b
zeroempate: tanto faz a ordem
um número positivo (como 1)a vem depois de b

Um truque para lembrar: pense em a - b. Se a é menor, a conta dá negativo; se são iguais, zero; se a é maior, positivo. Só o sinal importa, não o valor: -1 e -57 querem dizer a mesma coisa.


1. Os tipos que já sabem se comparar

List<int> notas = [7, 10, 5, 8];
notas.Sort();
Console.WriteLine(string.Join(", ", notas));

List<string> nomes = ["Carla", "ana", "Bruno", "Ágata", "Zé"];
nomes.Sort();
Console.WriteLine(string.Join(", ", nomes));

Console.WriteLine(5.CompareTo(8));
Console.WriteLine(8.CompareTo(8));
Console.WriteLine(8.CompareTo(5));
Console.WriteLine("Ana".CompareTo("Bruno"));
$ dotnet run prontos.cs
5, 7, 8, 10
Ágata, ana, Bruno, Carla, Zé       « ordem de dicionário do português
-1                                 « 5 vem antes de 8
0                                  « empate
1                                  « 8 vem depois de 5
-1                                 « "Ana" vem antes de "Bruno"

int, double, decimal, string, DateTime e muitos outros já implementam a interface IComparable<T>, que tem um único método, o CompareTo ("comparar com"). É ele que o Sort chama por baixo. Repare que os textos saíram na ordem de um dicionário de papel: o "Ágata" com acento ficou junto do "ana", e o "ana" minúsculo não foi para o fim. Isso é a cultura pt-BR trabalhando (mais na seção 4).


2. Objetos seus: o erro e o IComparable<T>

Com uma classe sua, o Sort não sabe o que fazer:

List<Aluno> turma =
[
    new Aluno("Carla", 8.5),
    new Aluno("Ana", 9.0),
    new Aluno("Bruno", 7.0),
];
turma.Sort();

record Aluno(string Nome, double Nota);
$ dotnet run falha.cs
Unhandled exception. System.InvalidOperationException: Failed to compare two elements in the array.
 ---> System.ArgumentException: At least one object must implement IComparable.
   at System.Collections.Comparer.Compare(Object a, Object b)
   at System.Collections.Generic.ArraySortHelper`1.SwapIfGreater(Span`1 keys, Comparison`1 comparer, Int32 i, Int32 j)
   at System.Collections.Generic.ArraySortHelper`1.IntroSort(Span`1 keys, Int32 depthLimit, Comparison`1 comparer)
   at System.Collections.Generic.ArraySortHelper`1.Sort(Span`1 keys, IComparer`1 comparer)
   --- End of inner exception stack trace ---
   at System.Collections.Generic.ArraySortHelper`1.Sort(Span`1 keys, IComparer`1 comparer)
   at System.Collections.Generic.List`1.Sort(Int32 index, Int32 count, IComparer`1 comparer)
   at Program.<Main>$(String[] args) in /home/ana/cs/falha.cs:line 7
$ echo $?
134
InvalidOperationException: Failed to compare two elements in the array.
"Falha ao comparar dois elementos do array." O Sort tentou perguntar "quem vem antes?" e não conseguiu.
---> System.ArgumentException: At least one object must implement IComparable.
A causa (a "exceção interna", marcada com --->): "pelo menos um dos objetos tem de implementar IComparable". Nenhum aluno sabe se comparar.
falha.cs:line 7
A linha do turma.Sort();. O programa compilou sem reclamar: o problema só aparece quando ele roda.
record Aluno(…)
Um record sabe dizer se dois alunos são iguais (Equals), mas não qual vem antes. Com uma class o erro seria o mesmo.

A primeira solução é dar à classe uma ordem natural, implementando IComparable<Aluno>. Para a professora, a ordem natural de alunos é pela nota:

List<Aluno> turma =
[
    new Aluno("Carla", 8.5),
    new Aluno("Ana", 9.0),
    new Aluno("Bruno", 7.0),
];

turma.Sort();
foreach (Aluno a in turma)
    Console.WriteLine(a);

Console.WriteLine($"Maior nota: {turma.Max()}");
Console.WriteLine(turma[0].CompareTo(turma[1]));

class Aluno : IComparable<Aluno>
{
    public string Nome { get; }
    public double Nota { get; }

    public Aluno(string nome, double nota)
    {
        Nome = nome;
        Nota = nota;
    }

    public int CompareTo(Aluno? outro)
    {
        if (outro is null) return 1;
        return Nota.CompareTo(outro.Nota);
    }

    public override string ToString() => $"{Nome} ({Nota})";
}
$ dotnet run icomparable.cs
Bruno (7)
Carla (8,5)
Ana (9)
Maior nota: Ana (9)              « o Max do LINQ também usa o CompareTo
-1                               « Bruno (7) vem antes de Carla (8,5)
class Aluno : IComparable<Aluno>
"Um aluno sabe se comparar com outro aluno." A interface exige um método: int CompareTo(Aluno? outro).
Aluno? outro
O ? diz que outro pode ser null: é assim que a interface está escrita no .NET com as referências anuláveis ligadas.
if (outro is null) return 1;
A convenção do .NET: qualquer objeto vem depois de null (o null fica no começo).
return Nota.CompareTo(outro.Nota);
O coração do método. Em vez de fazer contas, repasse a pergunta para um tipo que já sabe responder: o double compara as notas e devolve o sinal certo.
public override string ToString()
Só para o Console.WriteLine(a) mostrar algo legível (veja A Classe object).

Com o IComparable, o aluno passou a funcionar em tudo que ordena ou procura o maior: Sort, Max, Min, Order(), SortedSet, SortedDictionary…

Missão: rode e veja a InvalidOperationException. Faça a classe Produto implementar IComparable<Produto>, comparando pelo Preco, para que o Sort ponha os produtos do mais barato para o mais caro.

Pão: R$ 0,75
Leite: R$ 5,49
Café: R$ 18,90
List<Produto> produtos =
[
    new Produto("Café", 18.90m),
    new Produto("Pão", 0.75m),
    new Produto("Leite", 5.49m),
];

produtos.Sort();
foreach (Produto p in produtos)
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");

class Produto
{
    public string Nome { get; }
    public decimal Preco { get; }

    public Produto(string nome, decimal preco)
    {
        Nome = nome;
        Preco = preco;
    }
}
List<Produto> produtos =
[
    new Produto("Café", 18.90m),
    new Produto("Pão", 0.75m),
    new Produto("Leite", 5.49m),
];

produtos.Sort();
foreach (Produto p in produtos)
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");

class Produto : IComparable<Produto>
{
    public string Nome { get; }
    public decimal Preco { get; }

    public Produto(string nome, decimal preco)
    {
        Nome = nome;
        Preco = preco;
    }

    public int CompareTo(Produto? outro)
    {
        if (outro is null) return 1;
        return Preco.CompareTo(outro.Preco);
    }
}

3. Outras ordens: IComparer<T>

Uma classe só tem uma ordem natural. Mas a professora quer a lista de chamada por nome e o ranking pela maior nota. Para cada ordem extra, você cria um comparador: um objeto separado, que sabe comparar dois alunos de um jeito. Ele implementa IComparer<T> ("comparador de T"), que tem o método Compare(x, y), com o mesmo significado do sinal.

List<Aluno> turma =
[
    new Aluno("Carla", 8.5),
    new Aluno("Ana", 9.0),
    new Aluno("Bruno", 7.0),
    new Aluno("Davi", 9.0),
];

turma.Sort(new PorNome());
Console.WriteLine(string.Join(", ", turma));

turma.Sort((a, b) => b.Nota.CompareTo(a.Nota));
Console.WriteLine(string.Join(", ", turma));

var porNotaDepoisNome = Comparer<Aluno>.Create((a, b) =>
{
    int r = b.Nota.CompareTo(a.Nota);
    if (r != 0) return r;
    return a.Nome.CompareTo(b.Nome);
});
turma.Sort(porNotaDepoisNome);
Console.WriteLine(string.Join(", ", turma));

class PorNome : IComparer<Aluno>
{
    public int Compare(Aluno? x, Aluno? y) => string.Compare(x?.Nome, y?.Nome);
}

record Aluno(string Nome, double Nota)
{
    public override string ToString() => $"{Nome} {Nota}";
}
$ dotnet run icomparer.cs
Ana 9, Bruno 7, Carla 8,5, Davi 9        « PorNome: A → Z
Ana 9, Davi 9, Carla 8,5, Bruno 7        « maior nota primeiro
Ana 9, Davi 9, Carla 8,5, Bruno 7        « maior nota; empate, por nome
class PorNome : IComparer<Aluno>
Um comparador: uma classe pequena, só com o método Compare. O Aluno nem precisa saber que ele existe.
string.Compare(x?.Nome, y?.Nome)
Repassa a pergunta para a comparação de textos (na cultura atual, pt-BR). O ?. evita erro se algum aluno for null (veja Operadores ??, ?. e ??=).
turma.Sort(new PorNome())
Ordena usando o comparador, em vez da ordem natural.
turma.Sort((a, b) => b.Nota.CompareTo(a.Nota))
O atalho: uma lambda que recebe dois alunos e devolve o sinal. Repare na troca: b antes de a. Isso inverte a resposta e dá a ordem decrescente (maior nota primeiro).
Comparer<Aluno>.Create(…)
Transforma uma lambda num IComparer<Aluno> de verdade, que você pode guardar numa variável e passar para um SortedSet, por exemplo.
if (r != 0) return r; return a.Nome.CompareTo(b.Nome);
O desempate: compare pela nota; se deu zero (empate), compare pelo nome.
Sem desempate, a ordem dos empatados não é garantida

Na segunda linha, a Ana saiu antes do Davi (os dois com 9), mas o Sort da List não promete manter a ordem dos empatados: ele pode trocá-los, dependendo dos dados. Se a ordem dos empates importa, escreva o desempate, como na terceira ordenação. (O OrderBy do LINQ é "estável": ele mantém os empatados na ordem em que estavam.)

IComparable<T>IComparer<T>
Quem implementaA própria classe (class Aluno : IComparable<Aluno>)Uma classe separada, ou uma lambda com Comparer<T>.Create
Métodoa.CompareTo(b)comparador.Compare(a, b)
Quantas ordensUma: a naturalQuantas você quiser
Usolista.Sort()lista.Sort(comparador) ou lista.Sort((a, b) => …)
Classe que não é suaNão dá para mudarFunciona: o comparador fica de fora

4. Comparar textos: cultura × Ordinal

Comparar textos tem uma pegadinha. A comparação padrão segue as regras da língua (a cultura pt-BR na máquina da Ana): acentos e maiúsculas pesam pouco, como num dicionário. Já o StringComparer.Ordinal compara o código de cada letra, um por um:

List<string> nomes = ["Carla", "ana", "Bruno", "Ágata", "Zé"];
nomes.Sort(StringComparer.Ordinal);
Console.WriteLine(string.Join(", ", nomes));
$ dotnet run ordinal.cs
Bruno, Carla, Zé, ana, Ágata

No código das letras, todas as maiúsculas sem acento (A–Z) vêm antes das minúsculas (a–z), e as letras acentuadas vêm depois de todas elas. Por isso "ana" e "Ágata" foram para o fim. Para mostrar uma lista a uma pessoa, use a ordem da cultura (o padrão do Sort()). O Ordinal é mais rápido e não muda de máquina para máquina, por isso serve para comparações "técnicas": nomes de arquivos, códigos, chaves.


5. A armadilha: empate num SortedSet

Coleções ordenadas como SortedSet<T> e SortedDictionary<TKey, TValue> usam o comparador também para saber se dois itens são o mesmo. Para elas, CompareTo igual a zero quer dizer "repetido":

var porNota = Comparer<Aluno>.Create((a, b) => a.Nota.CompareTo(b.Nota));
var ranking = new SortedSet<Aluno>(porNota);
Console.WriteLine(ranking.Add(new Aluno("Ana", 9.0)));
Console.WriteLine(ranking.Add(new Aluno("Bruno", 7.0)));
Console.WriteLine(ranking.Add(new Aluno("Davi", 9.0)));
Console.WriteLine(string.Join(", ", ranking));

record Aluno(string Nome, double Nota);
$ dotnet run sortedset.cs
True
True
False                                                         « o Davi foi recusado!
Aluno { Nome = Bruno, Nota = 7 }, Aluno { Nome = Ana, Nota = 9 }

O Davi tem nota 9, como a Ana. O comparador disse "empate" (zero), e o SortedSet entendeu "já tenho esse aluno". O Add devolveu false e o Davi sumiu, sem erro nenhum. O conserto é o mesmo desempate da seção 3: se a nota empatar, compare pelo nome. Assim, só dá zero quando o aluno é o mesmo de verdade.


Quando dá errado

InvalidOperationException: Failed to compare two elements in the array. → At least one object must implement IComparable.

O que significa: você chamou Sort(), Max(), Order() ou criou um SortedSet de objetos que não sabem se comparar (seção 2).

Como consertar: implemente IComparable<T> na classe, ou passe um comparador: lista.Sort((a, b) => a.Nota.CompareTo(b.Nota)).

error CS0535: "Aluno" não implementa membro de interface "IComparable<Aluno>.CompareTo(Aluno?)"

O que significa: a classe diz : IComparable<Aluno>, mas não tem o método CompareTo (ou ele tem outro nome ou outro tipo de parâmetro).

$ dotnet run semmetodo.cs
/home/ana/cs/semmetodo.cs(1,15): error CS0535: "Aluno" não implementa membro de interface "IComparable<Aluno>.CompareTo(Aluno?)"

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

Como consertar: escreva public int CompareTo(Aluno? outro). A mensagem mostra a assinatura exata que falta.

warning CS8767: A nulidade de tipos de referência no tipo de parâmetro 'outro' … não corresponde ao membro implementado implicitamente 'int IComparable<Aluno>.CompareTo(Aluno? other)'

O que significa: você escreveu CompareTo(Aluno outro), sem o ?. A interface diz que o parâmetro pode ser null; o seu método diz que não pode. Compila, mas com aviso.

Como consertar: use Aluno? outro e trate o null na primeira linha: if (outro is null) return 1;.

A ordem ficou ao contrário

O que significa: o sinal está invertido. a.X.CompareTo(b.X) dá a ordem crescente; b.X.CompareTo(a.X), a decrescente.

Como consertar: troque a e b de lugar (ou chame lista.Reverse() depois do Sort).

Um item sumiu do SortedSet (ou do SortedDictionary)

O que significa: o comparador deu zero para dois itens diferentes, e a coleção os tratou como repetidos (seção 5).

Como consertar: acrescente um desempate que só dá zero para o mesmo item.


Referência rápida

EscritaO que faz
a.CompareTo(b)Negativo: a antes; zero: empate; positivo: a depois
class T : IComparable<T> + public int CompareTo(T? outro)Ordem natural da classe
return Campo.CompareTo(outro.Campo);Repassa a comparação para um tipo pronto
class C : IComparer<T> + public int Compare(T? x, T? y)Comparador separado
Comparer<T>.Create((a, b) => …)Comparador a partir de uma lambda
lista.Sort() / lista.Sort(comparador) / lista.Sort((a, b) => …)Ordena: natural / com comparador / com lambda
(a, b) => b.X.CompareTo(a.X)Ordem decrescente
int r = …; if (r != 0) return r; return …;Desempate por um segundo critério
StringComparer.Ordinal / OrdinalIgnoreCaseCompara textos pelo código das letras
new SortedSet<T>(comparador)Conjunto ordenado; Compare zero = repetido

Exercícios

1. A interface da ordem natural

Complete para os produtos terem uma ordem natural:

class Produto :

2. Repassar a pergunta

Complete o CompareTo que compara pelo preço:

return Preco.(outro.Preco);

3. a.CompareTo(b) devolveu -12. O que isso quer dizer?

4. Qual lambda ordena os alunos da maior nota para a menor?

5. Você precisa ordenar objetos de uma classe de uma biblioteca, que você não pode mudar. O que usa?

Missão final: o ranking da turma

Missão: o ranking está em ordem de nome. Troque o Sort por um com lambda (ou com Comparer<Aluno>.Create) que ponha a maior nota primeiro e, quando a nota empatar, a ordem alfabética do nome. Repare que o Davi vem antes da Ana na lista: sem o desempate, ele fica na frente dela.

1º Ana 9
2º Davi 9
3º Carla 8,5
4º Bruno 7
List<Aluno> turma =
[
    new Aluno("Davi", 9.0),
    new Aluno("Bruno", 7.0),
    new Aluno("Ana", 9.0),
    new Aluno("Carla", 8.5),
];

turma.Sort((a, b) => a.Nome.CompareTo(b.Nome));

for (int i = 0; i < turma.Count; i++)
    Console.WriteLine($"{i + 1}º {turma[i].Nome} {turma[i].Nota}");

record Aluno(string Nome, double Nota);
List<Aluno> turma =
[
    new Aluno("Davi", 9.0),
    new Aluno("Bruno", 7.0),
    new Aluno("Ana", 9.0),
    new Aluno("Carla", 8.5),
];

turma.Sort((a, b) =>
{
    int r = b.Nota.CompareTo(a.Nota);
    if (r != 0) return r;
    return a.Nome.CompareTo(b.Nome);
});

for (int i = 0; i < turma.Count; i++)
    Console.WriteLine($"{i + 1}º {turma[i].Nome} {turma[i].Nota}");

record Aluno(string Nome, double Nota);

Resumo

  • Ordenar é responder muitas vezes "quem vem antes?". A resposta é um int: negativo (antes), zero (empate), positivo (depois).
  • Ordenar objetos que não sabem se comparar dá InvalidOperationException: Failed to compare two elements in the array.
  • IComparable<T> dá à classe uma ordem natural (CompareTo); repasse a pergunta para um campo: Nota.CompareTo(outro.Nota).
  • IComparer<T>, Comparer<T>.Create ou uma lambda no Sort dão outras ordens, sem mexer na classe. Trocar a e b inverte a ordem.
  • Escreva o desempate: o Sort não garante a ordem dos empatados, e um SortedSet descarta itens com comparação zero.
  • Textos: a comparação padrão segue a cultura (pt-BR); StringComparer.Ordinal segue o código das letras.