Um catálogo não é uma lista de chamadas fixas no código: é um dado que o programa lê, altera e consulta, e para isso existem as coleções.
Em "Métodos: Dividindo o Problema em Partes Que Conversam" a Livraria ganhou uma estrutura organizada, mas o catálogo continuava preso ao código-fonte: três chamadas a ImprimirItem com títulos e preços fixos. Para adicionar um livro, só editando e recompilando. Programas de verdade guardam muitos valores do mesmo tipo e os manipulam em tempo de execução. O C# oferece para isso uma família de coleções, e três delas resolvem a grande maioria dos casos: o array, a lista e o dicionário. Hoje vamos entender cada uma, o que ela faz bem, o que faz mal e como escolher entre elas.
Arrays: tamanho fixo, acesso direto
Um array guarda uma sequência de elementos do mesmo tipo, em posições numeradas a partir de zero:
decimal[] precos = new decimal[3]; // três posições, todas começam com 0
precos[0] = 59.90m;
precos[1] = 42.50m;
precos[2] = 38.00m;
Console.WriteLine(precos[1]); // 42,50
Console.WriteLine(precos.Length); // 3
Quando os valores são conhecidos, a expressão de coleção (C# 12) cria e preenche de uma vez:
string[] titulos = ["Dom Casmurro", "O Cortiço", "Vidas Secas"];
O tamanho de um array é definido na criação e nunca muda. Não existe "adicionar um elemento" a um array: para crescer, é preciso criar outro maior e copiar os dados. Essa rigidez tem uma contrapartida valiosa: os elementos ficam lado a lado na memória, e acessar qualquer posição pelo índice é instantâneo, não importa se o array tem dez ou dez milhões de elementos.
Se você vem do C, há uma diferença que vale ouro: todo acesso é verificado. Ler ou escrever fora dos limites não corrompe memória em silêncio. Lança uma exceção imediatamente:
Console.WriteLine(titulos[3]); // IndexOutOfRangeException: o último índice é 2
Uma classe inteira de falhas de segurança do mundo C (os buffer overflows) simplesmente não existe no código C# comum.
Índices do fim e intervalos
O C# 8 trouxe duas sintaxes que eliminam muita aritmética com Length. O operador ^ conta a partir do fim, e .. recorta um intervalo:
int[] numeros = [10, 20, 30, 40, 50];
Console.WriteLine(numeros[^1]); // 50: o último
Console.WriteLine(numeros[^2]); // 40: o penúltimo
int[] meio = numeros[1..4]; // 20, 30, 40: do índice 1 até antes do 4
int[] inicio = numeros[..2]; // 10, 20
int[] copia = numeros[..]; // todos: uma CÓPIA do array
Recortar um array com .. cria um array novo, com cópia dos elementos. Guarde esse detalhe: ele volta no exercício 2 e na aula sobre Span<T>, que permite recortar sem copiar.
Arrays são tipos de referência
Em "Cada Valor Tem um Tipo" vimos que duas variáveis podem apontar para o mesmo array. Com métodos acontece o mesmo: o método recebe uma cópia da referência e, portanto, altera o array original.
decimal[] precos = [59.90m, 42.50m, 38.00m];
AplicarReajuste(precos, 0.05m);
Console.WriteLine(precos[0]); // 62,8950: o original mudou, e o decimal guarda as quatro casas da conta
static void AplicarReajuste(decimal[] valores, decimal percentual)
{
for (int i = 0; i < valores.Length; i++)
valores[i] *= 1 + percentual;
}
Repare que usamos for, e não foreach: a variável do foreach é somente leitura, e para alterar cada posição precisamos do índice. A biblioteca padrão também oferece operações prontas sobre arrays, como Array.Sort(precos), que ordena no lugar, Array.IndexOf(titulos, "O Cortiço"), que devolve 1 (ou -1 se não achar), e Array.Reverse.
Existem ainda arrays de várias dimensões, úteis para tabuleiros e grades:
char[,] tabuleiro = new char[3, 3];
tabuleiro[1, 1] = 'X';
Console.WriteLine(tabuleiro.GetLength(0)); // 3 linhas
Eles reaparecem na fase de jogos. No dia a dia, porém, você vai usar muito mais a próxima estrutura.
List<T>: o array que cresce
A lista é a coleção mais usada do C#. Ela funciona como um array, mas aceita adicionar e remover elementos:
List<string> carrinho = []; // lista vazia
carrinho.Add("Dom Casmurro");
carrinho.Add("Vidas Secas");
carrinho.Insert(0, "O Cortiço"); // insere na posição 0
Console.WriteLine(carrinho.Count); // 3
Console.WriteLine(carrinho[0]); // O Cortiço
Console.WriteLine(carrinho.Contains("Vidas Secas"));// True
carrinho.Remove("Dom Casmurro"); // remove pelo valor
carrinho.RemoveAt(0); // remove pela posição
foreach (string titulo in carrinho)
Console.WriteLine(titulo); // Vidas Secas
O <string> entre sinais de menor e maior indica o tipo dos elementos: List<string> é "lista de strings", List<decimal> é "lista de decimais". Esse mecanismo se chama genéricos e ganha uma aula própria na Fase 3. Por enquanto, leia o <T> como "de quê". Diferente do ArrayList do C# 1.0 que vimos na aula de história, a lista genérica só aceita o tipo declarado, e o compilador garante isso.
Por dentro, uma List<T> é um array. Quando ele enche, a lista cria outro com o dobro do tamanho e copia tudo. Isso tem consequências práticas:
- adicionar no fim é quase sempre instantâneo, e a cópia ocasional se dilui entre as muitas inserções;
- inserir ou remover no início ou no meio obriga a deslocar todos os elementos seguintes, e fica lento em listas grandes;
- procurar um valor com
ContainsouIndexOfpercorre a lista inteira, elemento por elemento.
Uma armadilha clássica: não altere uma lista enquanto a percorre com foreach.
List<int> numeros = [1, 2, 3, 4, 5, 6];
foreach (int n in numeros)
{
if (n % 2 == 0)
numeros.Remove(n); // InvalidOperationException: a coleção foi modificada
}
O foreach detecta a mudança e lança uma exceção, em vez de continuar percorrendo uma estrutura que mudou debaixo dele. Duas saídas corretas: percorrer de trás para frente com for, ou usar numeros.RemoveAll(n => n % 2 == 0). Essa sintaxe com => dentro dos parênteses é uma lambda, também tema da Fase 3.
Dictionary<TKey, TValue>: procurar pela chave
Muitas vezes você não quer "o terceiro elemento". Quer "o preço de Vidas Secas". Para buscar por uma chave, o dicionário é a estrutura certa:
Dictionary<string, decimal> precos = new()
{
["Dom Casmurro"] = 59.90m,
["O Cortiço"] = 42.50m,
["Vidas Secas"] = 38.00m,
};
Console.WriteLine(precos["Vidas Secas"]); // 38,00
precos["Grande Sertão: Veredas"] = 89.00m; // adiciona (ou substitui, se já existir)
precos["O Cortiço"] = 39.90m; // substitui o preço
Console.WriteLine(precos.Count); // 4
A diferença de desempenho em relação à lista é dramática. Para achar um título numa lista, é preciso comparar com cada elemento, um por um. O dicionário calcula um número a partir da chave (o hash) e vai direto à posição onde ela deve estar. A busca leva praticamente o mesmo tempo com dez ou com dez milhões de chaves. Essa ideia de tabela de dispersão é uma das mais importantes da computação, e vamos reencontrá-la nos índices de banco de dados.
Duas operações lançam exceção e pedem cuidado. Ler uma chave que não existe com precos["Inexistente"] lança KeyNotFoundException. E precos.Add("Dom Casmurro", 10m) com uma chave já existente lança ArgumentException (diferente da atribuição com colchetes, que substitui). Quando a chave pode não existir, use o padrão Try que você já conhece:
if (precos.TryGetValue("Memórias Póstumas", out decimal preco))
Console.WriteLine(preco);
else
Console.WriteLine("Título fora do catálogo.");
Para percorrer um dicionário, cada elemento é um par chave-valor, que pode ser desmontado direto no foreach:
foreach (var (titulo, valor) in precos)
Console.WriteLine($"{titulo,-25}{valor,10:C}");
Não conte com a ordem: um dicionário não garante a ordem de inserção nem a ordem alfabética. Se precisar de ordem, ordene na hora de exibir. Para chaves de texto digitadas por pessoas, costuma valer a pena ignorar maiúsculas e minúsculas, informando um comparador na criação:
var precos = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
Com isso, precos["vidas secas"] e precos["VIDAS SECAS"] encontram a mesma entrada.
Outras coleções que vale conhecer de nome
A biblioteca tem muitas outras, e três aparecem com frequência. HashSet<T> guarda valores sem repetição e responde "contém?" tão rápido quanto um dicionário: ótima para "quais gêneros existem no catálogo". Queue<T> é uma fila (o primeiro a entrar é o primeiro a sair), boa para pedidos aguardando processamento. Stack<T> é uma pilha (o último a entrar é o primeiro a sair), boa para um histórico de "desfazer".
Como escolher? Uma regra prática:
- quantidade fixa e conhecida, com acesso por posição: array;
- sequência que cresce e encolhe, percorrida em ordem:
List<T>; - busca por uma chave:
Dictionary<TKey, TValue>; - só saber se algo existe, sem repetições:
HashSet<T>.
Juntando tudo: um catálogo de verdade
A Livraria agora guarda o catálogo em dicionários e ganha um carrinho:
using System.Globalization;
CultureInfo.CurrentCulture = new CultureInfo("pt-BR");
var precos = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["Dom Casmurro"] = 59.90m,
["O Cortiço"] = 42.50m,
["Vidas Secas"] = 38.00m,
};
var estoque = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["Dom Casmurro"] = 5,
["O Cortiço"] = 2,
["Vidas Secas"] = 0,
};
var carrinho = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int opcao;
do
{
Console.WriteLine("\n1 - Catálogo | 2 - Adicionar ao carrinho | 3 - Fechar pedido | 0 - Sair");
Console.Write("Escolha: ");
if (!int.TryParse(Console.ReadLine(), out opcao)) opcao = -1;
switch (opcao)
{
case 1: ExibirCatalogo(precos, estoque); break;
case 2: AdicionarAoCarrinho(precos, estoque, carrinho); break;
case 3: FecharPedido(precos, carrinho); break;
}
}
while (opcao != 0);
static void ExibirCatalogo(Dictionary<string, decimal> precos, Dictionary<string, int> estoque)
{
foreach (var (titulo, preco) in precos)
{
int disponiveis = estoque.GetValueOrDefault(titulo);
string situacao = disponiveis > 0 ? $"{disponiveis} em estoque" : "esgotado";
Console.WriteLine($"{titulo,-25}{preco,10:C} {situacao}");
}
}
static void AdicionarAoCarrinho(Dictionary<string, decimal> precos,
Dictionary<string, int> estoque,
Dictionary<string, int> carrinho)
{
Console.Write("Título: ");
string titulo = (Console.ReadLine() ?? "").Trim();
if (!precos.ContainsKey(titulo))
{
Console.WriteLine("Título fora do catálogo.");
return;
}
// o carrinho guarda a grafia do catálogo, e não a que o cliente digitou
foreach (string t in precos.Keys)
if (string.Equals(t, titulo, StringComparison.OrdinalIgnoreCase))
titulo = t;
int jaNoCarrinho = carrinho.GetValueOrDefault(titulo);
if (estoque.GetValueOrDefault(titulo) <= jaNoCarrinho)
{
Console.WriteLine("Sem estoque suficiente.");
return;
}
carrinho[titulo] = jaNoCarrinho + 1;
Console.WriteLine($"Adicionado. Você tem {carrinho[titulo]} de \"{titulo}\".");
}
static void FecharPedido(Dictionary<string, decimal> precos, Dictionary<string, int> carrinho)
{
if (carrinho.Count == 0)
{
Console.WriteLine("Carrinho vazio.");
return;
}
decimal total = 0;
foreach (var (titulo, quantidade) in carrinho)
{
decimal subtotal = precos[titulo] * quantidade;
Console.WriteLine($"{quantidade} x {titulo,-25}{subtotal,10:C}");
total += subtotal;
}
Console.WriteLine($"{"Total",-29}{total,10:C}");
carrinho.Clear();
}
Algumas escolhas merecem nota:
GetValueOrDefaultdevolve o valor da chave ou o padrão do tipo (zero, paraint) quando ela não existe. É perfeito para "quantos deste título já estão no carrinho".- Os métodos alteram os dicionários recebidos por parâmetro, e as mudanças valem para o programa inteiro. É a consequência direta de dicionários serem tipos de referência.
- Todos os dicionários ignoram maiúsculas e minúsculas, então o cliente pode digitar "vidas secas". O laço no início de
AdicionarAoCarrinhotroca o que foi digitado pela grafia do catálogo: sem ele, o carrinho guardaria "o cortiço", e o fechamento do pedido mostraria o título em minúsculas.
Olhe com atenção para o que construímos, porque há algo errado no desenho. As informações de um livro estão espalhadas em dois dicionários, amarrados apenas pelo título. Se quisermos guardar autor, número de páginas e ano, serão mais três dicionários paralelos. E basta um erro de digitação num título para um livro ter preço e não ter estoque. As coleções resolveram o problema de guardar muitos valores, mas não o de agrupar os valores que pertencem juntos. Esse é o trabalho das classes, que abrem a Fase 2. Antes disso, precisamos fechar a Fase 1 com uma peça que apareceu várias vezes de passagem: as exceções, que o programa lança quando algo sai do trilho.
Fontes e leituras recomendadas
- Arrays (Microsoft Learn): https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/arrays. Cobre declaração, arrays multidimensionais e arrays de arrays.
- Índices e intervalos: https://learn.microsoft.com/dotnet/csharp/tutorials/ranges-indexes. É o tutorial oficial dos operadores
^e... - Expressões de coleção: https://learn.microsoft.com/dotnet/csharp/language-reference/operators/collection-expressions. Mostra a sintaxe
[...]para arrays, listas e outras coleções. List<T>(referência da API): https://learn.microsoft.com/dotnet/api/system.collections.generic.list-1. Traz todos os métodos e as notas de desempenho de cada operação.Dictionary<TKey, TValue>(referência da API): https://learn.microsoft.com/dotnet/api/system.collections.generic.dictionary-2. Inclui a explicação do funcionamento por hash.- Selecionar uma classe de coleção: https://learn.microsoft.com/dotnet/standard/collections/selecting-a-collection-class. É o guia oficial para escolher a coleção certa para cada situação.
Exercícios
Exercício 1
Crie um array com cinco notas (double) e, usando um único laço, calcule a média, a maior e a menor nota.
Ver resposta
✓ Resposta:
double[] notas = [7.5, 9.0, 6.0, 8.5, 10.0];
double soma = 0;
double maior = notas[0];
double menor = notas[0];
foreach (double nota in notas)
{
soma += nota;
if (nota > maior) maior = nota;
if (nota < menor) menor = nota;
}
Console.WriteLine($"Média: {soma / notas.Length:F2}"); // 8,20
Console.WriteLine($"Maior: {maior}"); // 10
Console.WriteLine($"Menor: {menor}"); // 6
O detalhe importante é inicializar maior e menor com o primeiro elemento, e não com zero. Com zero, se todas as notas fossem negativas (numa escala de -10 a 10, por exemplo), a maior nota seria zero, um valor que nem está no array. Na Fase 3 este exercício inteiro vira uma linha por item: notas.Average(), notas.Max() e notas.Min(), com o LINQ. Mas é importante saber o que essas operações fazem por dentro.
Exercício 2
Preveja a saída e explique:
int[] a = [1, 2, 3];
int[] b = a;
int[] c = a[..];
b[0] = 10;
c[1] = 20;
Console.WriteLine($"{a[0]} {a[1]}");
Ver resposta
✓ Resposta: Imprime 10 2. b = a copia a referência: a e b apontam para o mesmo array, então b[0] = 10 altera o que a enxerga. Já a[..] cria um array novo com cópia dos elementos, então alterar c[1] não afeta a. É a diferença entre copiar o endereço e copiar o conteúdo, e ela explica boa parte dos bugs de "alterei aqui e mudou lá" em qualquer linguagem com tipos de referência.
Exercício 3
Dada a lista List<int> numeros = [1, 2, 3, 4, 5, 6, 7, 8];, remova todos os números ímpares de duas formas: com um laço for e com RemoveAll. Por que não dá para usar foreach?
Ver resposta
✓ Resposta:
// Forma 1: laço de trás para frente
for (int i = numeros.Count - 1; i >= 0; i--)
{
if (numeros[i] % 2 != 0)
numeros.RemoveAt(i);
}
// Forma 2: RemoveAll com uma lambda
numeros.RemoveAll(n => n % 2 != 0);
O foreach não permite modificar a coleção durante o percurso: a lista detecta a mudança e lança InvalidOperationException. O for precisa andar de trás para frente porque, ao remover o elemento da posição i, todos os seguintes deslocam uma posição para a esquerda. Andando para frente, o elemento que ocupou o lugar do removido seria pulado. De trás para frente, os deslocamentos só afetam posições já visitadas. RemoveAll faz tudo isso internamente, de forma mais eficiente, e deixa a intenção explícita.
Exercício 4
Conte quantas vezes cada palavra aparece na frase "o livro e o autor e o leitor", ignorando maiúsculas e minúsculas, e imprima o resultado.
Ver resposta
✓ Resposta:
string frase = "O livro e o autor e o leitor";
string[] palavras = frase.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var contagem = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (string palavra in palavras)
{
contagem[palavra] = contagem.GetValueOrDefault(palavra) + 1;
}
foreach (var (palavra, vezes) in contagem)
Console.WriteLine($"{palavra}: {vezes}");
A saída conta "O" com 3 ocorrências, "e" com 2 e "livro", "autor" e "leitor" com 1 cada. Split quebra a string num array usando o espaço como separador, e RemoveEmptyEntries descarta pedaços vazios causados por espaços duplos. O coração da solução é GetValueOrDefault(palavra) + 1: se a palavra ainda não existe, o valor padrão é 0 e ela entra com 1; se já existe, é incrementada. Repare que a chave guardada é a primeira grafia encontrada, "O" maiúsculo. O comparador faz as buscas ignorarem a caixa, mas não altera o texto da chave.
Exercício 5
Escreva um método que inverta um array de inteiros no próprio array, sem criar outro e sem usar Array.Reverse.
Ver resposta
✓ Resposta:
int[] valores = [1, 2, 3, 4, 5];
Inverter(valores);
Console.WriteLine(string.Join(", ", valores)); // 5, 4, 3, 2, 1
static void Inverter(int[] array)
{
int inicio = 0;
int fim = array.Length - 1;
while (inicio < fim)
{
(array[inicio], array[fim]) = (array[fim], array[inicio]); // troca os dois
inicio++;
fim--;
}
}
Dois índices caminham das pontas para o meio, trocando os elementos, até se encontrarem. São Length / 2 trocas, sem memória extra. O método não precisa devolver nada: arrays são tipos de referência, e ele altera o original. A linha da troca usa uma sintaxe de tuplas que dispensa a variável temporária do Trocar que escrevemos na aula de métodos. Por ora, leia como "troque estes dois valores". string.Join junta os elementos numa string com o separador indicado, o jeito mais prático de imprimir uma coleção inteira.