C# LINQ OrderBy e ThenBy

Um relatório só é útil se estiver em ordem: do mais barato ao mais caro, por categoria, em ordem alfabética. Com o LINQ, você diz por qual informação ordenar (a chave) e ele ordena: OrderBy(p => p.Preco). Para desempatar ("por categoria e, dentro de cada uma, por preço"), entra o ThenBy. Esta aula também mostra a armadilha mais comum (dois OrderBy seguidos) e como os textos em português são ordenados.

Você vai aprender
  • Ordenar com OrderBy (crescente) e OrderByDescending (decrescente).
  • Desempatar com ThenBy e ThenByDescending.
  • Por que dois OrderBy seguidos não fazem o que parece.
  • O que é uma ordenação estável.
  • Order() e OrderDescending() para listas de números e textos.
  • Como acentos e maiúsculas são ordenados, e o StringComparer.Ordinal.
  • A diferença entre OrderBy e o Sort da List.
  • Os erros reais: CS1061 (ThenBy sem OrderBy) e a InvalidOperationException de uma chave que não sabe se comparar.

Todos os programas rodaram com o .NET 10 (C# 14) na máquina da Ana, na pasta ~/cs, com dotnet run arquivo.cs. Eles usam a lista de produtos da lojinha apresentada em O que é LINQ (linhas 1 a 10 de cada arquivo) e o record Produto no fim; os arquivos abaixo mostram só o programa.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

// … o programa de cada exemplo começa na linha 12 …

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

A ideia: a fila da chamada

Uma professora pede: "façam uma fila por altura". Ela não diz como trocar as pessoas de lugar; diz só o critério, a altura. Se dois alunos têm a mesma altura, ela pode completar: "e, entre os da mesma altura, por ordem alfabética". O primeiro critério é o OrderBy; o desempate, o ThenBy.

No LINQ, o critério é uma lambda que, dado um produto, devolve a chave da ordenação: p => p.Preco quer dizer "ordene pelo preço".


1. OrderBy e OrderByDescending

// (a lista de produtos)

var maisBaratos = produtos.OrderBy(p => p.Preco);
var maisCaros = produtos.OrderByDescending(p => p.Preco);
var porNome = produtos.OrderBy(p => p.Nome);

Console.WriteLine(string.Join(", ", maisBaratos.Select(p => $"{p.Nome} {p.Preco}")));
Console.WriteLine(string.Join(", ", maisCaros.Select(p => $"{p.Nome} {p.Preco}")));
Console.WriteLine(string.Join(", ", porNome.Select(p => p.Nome)));
Console.WriteLine(string.Join(", ", produtos.Select(p => p.Nome)));

// (o record Produto)
$ dotnet run orderby.cs
Borracha 1,75, Lápis 2,50, Caderno 18,90, Caneca 22,50, Estojo 24,90, Garrafa 35,00, Mochila 129,90
Mochila 129,90, Garrafa 35,00, Estojo 24,90, Caneca 22,50, Caderno 18,90, Lápis 2,50, Borracha 1,75
Borracha, Caderno, Caneca, Estojo, Garrafa, Lápis, Mochila
Caderno, Lápis, Mochila, Borracha, Estojo, Garrafa, Caneca   « a lista original não mudou
OrderBy
"Ordenar por": do menor para o maior (crescente). Para números, do menor valor; para textos, em ordem alfabética.
OrderByDescending
"Ordenar por, decrescente" (descending = "descendo"): do maior para o menor.
p => p.Preco
A chave: o valor pelo qual cada produto é comparado. Pode ser qualquer coisa comparável: número, texto, data, ou uma conta (p => p.Preco * p.Estoque).

A última linha mostra que produtos continua na ordem em que foi escrita: o OrderBy produz uma sequência nova, como todo o LINQ. E, como o Where e o Select, ele é adiado: a ordenação só acontece quando o resultado é percorrido.

Missão: a Ana quer ver primeiro os produtos com mais unidades no estoque. Troque a ordenação por nome por uma ordenação decrescente pelo estoque. A primeira linha deve ser Lápis 40 e a última, Estojo 0.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var ordenados = produtos.OrderBy(p => p.Nome);

foreach (Produto p in ordenados)
{
    Console.WriteLine($"{p.Nome} {p.Estoque}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);
List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var ordenados = produtos.OrderByDescending(p => p.Estoque);

foreach (Produto p in ordenados)
{
    Console.WriteLine($"{p.Nome} {p.Estoque}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

2. Desempatar com ThenBy

O relatório da Ana agrupa visualmente por categoria e, dentro de cada categoria, mostra do mais caro ao mais barato:

// (a lista de produtos)

var relatorio = produtos
    .OrderBy(p => p.Categoria)
    .ThenByDescending(p => p.Preco);

foreach (Produto p in relatorio)
{
    Console.WriteLine($"{p.Categoria,-10} {p.Nome,-9} {p.Preco,10:C}");
}

Console.WriteLine("--- sintaxe de consulta");
var consulta =
    from p in produtos
    orderby p.Categoria, p.Preco descending
    select p.Nome;
Console.WriteLine(string.Join(", ", consulta));

// (o record Produto)
$ dotnet run thenby.cs
Bolsas     Mochila    R$ 129,90
Bolsas     Estojo      R$ 24,90
Cozinha    Garrafa     R$ 35,00
Cozinha    Caneca      R$ 22,50
Papelaria  Caderno     R$ 18,90
Papelaria  Lápis        R$ 2,50
Papelaria  Borracha     R$ 1,75
--- sintaxe de consulta
Mochila, Estojo, Garrafa, Caneca, Caderno, Lápis, Borracha
LinhaO que faz
.OrderBy(p => p.Categoria)O critério principal: categorias em ordem alfabética (Bolsas, Cozinha, Papelaria).
.ThenByDescending(p => p.Preco)"Depois por", decrescente: só desempata produtos da mesma categoria. Dentro de Papelaria: Caderno (18,90), Lápis (2,50), Borracha (1,75).
{p.Categoria,-10}Alinha o texto à esquerda numa coluna de 10 caracteres (veja Formatar Números).
orderby p.Categoria, p.Preco descendingA mesma ordenação na sintaxe de consulta: os critérios separados por vírgula, e descending depois do que é decrescente.

Dá para encadear quantos ThenBy quiser: .OrderBy(…).ThenBy(…).ThenByDescending(…). Cada um só entra em ação quando todos os anteriores empataram.

A armadilha: dois OrderBy seguidos

Parece natural escrever o desempate com outro OrderBy. Veja o que sai:

// (a lista de produtos)

var errado = produtos
    .OrderBy(p => p.Categoria)
    .OrderByDescending(p => p.Preco);

Console.WriteLine(string.Join(", ", errado.Select(p => $"{p.Categoria}/{p.Nome}")));

// (o record Produto)
$ dotnet run duasorderby.cs
Bolsas/Mochila, Cozinha/Garrafa, Bolsas/Estojo, Cozinha/Caneca, Papelaria/Caderno, Papelaria/Lápis, Papelaria/Borracha

As categorias ficaram misturadas: a lista saiu ordenada só pelo preço. O segundo OrderBy reordena tudo de novo, do zero, e a ordem por categoria se perde. Compila, roda, não dá erro nenhum, e o relatório sai errado. Regra: um OrderBy (ou OrderByDescending) no começo, e todos os desempates com ThenBy.

Ordem estável: o empate mantém a ordem original

// (a lista de produtos)

var porCategoria = produtos.OrderBy(p => p.Categoria);
Console.WriteLine(string.Join(", ", porCategoria.Select(p => $"{p.Categoria}/{p.Nome}")));

// (o record Produto)
$ dotnet run estavel.cs
Bolsas/Mochila, Bolsas/Estojo, Cozinha/Garrafa, Cozinha/Caneca, Papelaria/Caderno, Papelaria/Lápis, Papelaria/Borracha

Sem ThenBy, os produtos da mesma categoria ficaram na ordem em que estavam na lista (Caderno, Lápis, Borracha). O OrderBy do LINQ é estável (stable): empates nunca trocam de lugar. Isso é garantido, e você pode contar com isso.


3. Listas simples: Order() .NET 7

Quando os próprios elementos já são a chave (uma lista de preços, de nomes), não precisa de lambda: Order() e OrderDescending() ordenam pelos próprios valores. E aqui aparece um detalhe importante sobre textos:

decimal[] precos = [18.90m, 2.50m, 129.90m, 1.75m];
string[] nomes = ["lápis", "Caderno", "água", "Borracha", "estojo", "Álbum"];

Console.WriteLine(string.Join(" | ", precos.Order()));
Console.WriteLine(string.Join(" | ", precos.OrderDescending()));
Console.WriteLine(string.Join(" | ", nomes.Order()));
Console.WriteLine(string.Join(" | ", nomes.Order(StringComparer.Ordinal)));
$ dotnet run order.cs
1,75 | 2,50 | 18,90 | 129,90
129,90 | 18,90 | 2,50 | 1,75
água | Álbum | Borracha | Caderno | estojo | lápis
Borracha | Caderno | estojo | lápis | Álbum | água
  • Terceira linha: por padrão, textos são ordenados pelas regras do idioma do computador (a cultura pt-BR da Ana, veja Cultura), como num dicionário: água e Álbum ficam no "a", e maiúsculas e minúsculas se misturam (Caderno antes de estojo). É o que uma pessoa espera ver.
  • Quarta linha: com StringComparer.Ordinal ("pela ordem dos códigos"), a comparação é pelos números de cada caractere na tabela Unicode: primeiro todas as maiúsculas sem acento, depois as minúsculas, e as letras acentuadas por último. É rápido e igual em qualquer computador, mas não serve para mostrar a uma pessoa.

Use a ordem padrão (do idioma) para o que vai aparecer na tela, e StringComparer.Ordinal para coisas técnicas, como códigos de produto. O OrderBy também aceita o comparador: produtos.OrderBy(p => p.Nome, StringComparer.Ordinal).


4. OrderBy × List.Sort

Na aula de Lambdas, a lista foi ordenada com Sort. A diferença:

// (a lista de produtos)

var ordenada = produtos.OrderBy(p => p.Preco).ToList();
Console.WriteLine($"Original: {produtos[0].Nome}, nova: {ordenada[0].Nome}");

produtos.Sort((a, b) => a.Preco.CompareTo(b.Preco));
Console.WriteLine($"Depois do Sort: {produtos[0].Nome}");

var invertida = produtos.AsEnumerable().Reverse();
Console.WriteLine(string.Join(", ", invertida.Select(p => p.Nome)));

// (o record Produto)
$ dotnet run sort.cs
Original: Caderno, nova: Borracha
Depois do Sort: Borracha
Mochila, Garrafa, Estojo, Caneca, Caderno, Lápis, Borracha
lista.OrderBy(p => chave)lista.Sort((a, b) => …)
A lista originalfica igualmuda (é reordenada)
Devolveuma sequência nova (adiada)nada (void)
Funciona emqualquer IEnumerable<T> (arrays, resultados de outro LINQ…)só List<T> (e arrays, com Array.Sort)
Empatesmantêm a ordem (estável)podem trocar de lugar (não estável)
DesempateThenBydentro da própria lambda

O Reverse() ("inverter") vira a sequência de trás para frente, sem comparar nada. O AsEnumerable() antes dele garante que é o Reverse do LINQ, que devolve uma sequência nova; a List tem um Reverse() próprio, que inverte a lista no lugar e não devolve nada.


Quando dá errado

CS1061: ThenBy sem OrderBy antes

Este código tem um erro de propósito.

// (a lista de produtos, linhas 1 a 10)

var lista = produtos.ThenBy(p => p.Nome);

// (o record Produto)
$ dotnet run thenby-erro.cs
/home/ana/cs/thenby-erro.cs(12,22): error CS1061: ‘List<Produto>’ não contém uma definição para "ThenBy" e não foi possível encontrar nenhum método de extensão "ThenBy" que aceite um primeiro argumento do tipo ‘List<Produto>’ (você está se esquecendo de usar uma diretiva ou uma referência de assembly?)

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

O que quer dizer: o ThenBy só existe depois de um OrderBy. Ele é uma extensão de IOrderedEnumerable<T> ("enumerável já ordenado"), o tipo que o OrderBy devolve; uma lista comum não tem critério principal para desempatar.

Como consertar: comece com OrderBy: produtos.OrderBy(p => p.Categoria).ThenBy(p => p.Nome).

InvalidOperationException: a chave não sabe se comparar

Este código tem um erro de propósito.

// (a lista de produtos, linhas 1 a 10)

var ordenados = produtos.OrderBy(p => p);
foreach (Produto p in ordenados)
{
    Console.WriteLine(p.Nome);
}

// (o record Produto)
$ dotnet run comparar-erro.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.Linq.Enumerable.EnumerableSorter`2.CompareAnyKeys(Int32 index1, Int32 index2)
   at System.Collections.Generic.ArraySortHelper`1.InsertionSort(Span`1 keys, Comparison`1 comparer)
   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, Comparison`1 comparer)
   --- End of inner exception stack trace ---
   at System.Collections.Generic.ArraySortHelper`1.Sort(Span`1 keys, Comparison`1 comparer)
   at System.MemoryExtensions.Sort[T](Span`1 span, Comparison`1 comparison)
   at System.Linq.Enumerable.EnumerableSorter`2.QuickSort(Int32[] keys, Int32 lo, Int32 hi)
   at System.Linq.Enumerable.OrderedIterator`2.MoveNext()
   at Program.<Main>$(String[] args) in /home/ana/cs/comparar-erro.cs:line 13

O que quer dizer: "falha ao comparar dois elementos", causada por (--->) "pelo menos um objeto precisa implementar IComparable". A chave é o produto inteiro, e o C# não sabe se um Produto vem antes ou depois de outro: pelo nome? pelo preço? As linhas do meio são o caminho dentro do .NET; a que importa é a última, a linha 13 do seu programa (o foreach, onde a ordenação adiada finalmente rodou). Repare que o erro só aparece ao rodar: o compilador aceitou.

Como consertar: ordene por uma propriedade comparável, OrderBy(p => p.Nome), ou ensine o tipo a se comparar implementando IComparable<Produto> (veja IComparable e IComparer).

Dois OrderBy: nenhum erro, resultado errado

Visto na seção 2: o segundo OrderBy desfaz o primeiro. Como consertar: troque o segundo por ThenBy (ou ThenByDescending).


Referência

CódigoO que faz
OrderBy(p => chave)Ordena crescente pela chave (estável, adiado).
OrderByDescending(p => chave)Ordena decrescente.
.ThenBy(p => chave)Desempate crescente (só depois de um OrderBy).
.ThenByDescending(p => chave)Desempate decrescente.
Order() / OrderDescending()Ordena pelos próprios elementos .NET 7.
OrderBy(p => p.Nome, StringComparer.Ordinal)Ordena textos pelo código dos caracteres, sem regras de idioma.
Reverse()Inverte a sequência.
orderby a, b descendingSintaxe de consulta: OrderBy(a).ThenByDescending(b).

Exercícios

1. Do mais caro ao mais barato

Complete para ordenar os produtos do mais caro para o mais barato:

var caros = produtos.;

2. O desempate

Complete para ordenar por categoria e, dentro de cada uma, por nome:

produtos.OrderBy(p => p.Categoria).

3. Na sintaxe de consulta

Complete a cláusula para ordenar pelo estoque, do maior para o menor:

from p in produtos select p.Nome

4. produtos.OrderBy(p => p.Categoria).OrderBy(p => p.Nome) ordena…

5. Dois produtos têm o mesmo preço. Depois de OrderBy(p => p.Preco), em que ordem eles ficam?

6. Depois de var o = produtos.OrderBy(p => p.Preco);, como fica a lista produtos?

Missão final: a vitrine deve mostrar os produtos por categoria e, dentro de cada categoria, em ordem alfabética. O código caiu na armadilha dos dois OrderBy (rode e veja as categorias misturadas). Conserte. A saída deve começar com Bolsas/Estojo e Bolsas/Mochila e terminar com Papelaria/Lápis.

List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var vitrine = produtos
    .OrderBy(p => p.Categoria)
    .OrderBy(p => p.Nome);

foreach (Produto p in vitrine)
{
    Console.WriteLine($"{p.Categoria}/{p.Nome}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);
List<Produto> produtos =
[
    new("Caderno", "Papelaria", 18.90m, 12),
    new("Lápis", "Papelaria", 2.50m, 40),
    new("Mochila", "Bolsas", 129.90m, 3),
    new("Borracha", "Papelaria", 1.75m, 25),
    new("Estojo", "Bolsas", 24.90m, 0),
    new("Garrafa", "Cozinha", 35.00m, 8),
    new("Caneca", "Cozinha", 22.50m, 5),
];

var vitrine = produtos
    .OrderBy(p => p.Categoria)
    .ThenBy(p => p.Nome);

foreach (Produto p in vitrine)
{
    Console.WriteLine($"{p.Categoria}/{p.Nome}");
}

record Produto(string Nome, string Categoria, decimal Preco, int Estoque);

Resumo

  • OrderBy(p => chave) ordena do menor para o maior; OrderByDescending, do maior para o menor. A lista original não muda.
  • Desempates vêm com ThenBy / ThenByDescending, que só existem depois de um OrderBy (CS1061).
  • Dois OrderBy seguidos: o segundo refaz tudo, sem erro nenhum. Use um OrderBy e depois ThenBy.
  • A ordenação é estável: empates mantêm a ordem original.
  • Order() ordena listas simples. Textos seguem as regras do idioma (acentos e maiúsculas juntos); StringComparer.Ordinal compara pelos códigos.
  • OrderBy cria uma sequência nova e adiada; List.Sort reordena a própria lista.
  • A chave precisa ser comparável; senão, InvalidOperationException ao rodar.

Próxima aula: Agrupar (GroupBy).