Guardando Muitos Valores: Arrays, Listas e Dicionários

Guardando Muitos Valores: Arrays, Listas e Dicionários

Coleções em C#: arrays de tamanho fixo e com acesso verificado, índices do fim e intervalos, a lista genérica e o custo de inserir no meio, por que não alterar a lista dentro do foreach, o dicionário com busca por hash e TryGetValue, HashSet, Queue, Stack e o carrinho da Livraria.
Linguagem C#

• • 15 min de leitura

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 Contains ou IndexOf percorre 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:

  • GetValueOrDefault devolve o valor da chave ou o padrão do tipo (zero, para int) 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 AdicionarAoCarrinho troca 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

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.

Comentários

Mais em Linguagem C#

Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão
Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão

Genéricos em C#: métodos e classes com parâmetro de tipo, restrições com…

MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam
MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam

A Livraria no MySQL com MySqlConnector e EF Core, e as diferenças que fazem um…

LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa
LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa

LINQ avançado em C#: GroupBy e tipos anônimos, CountBy e ToLookup, Join e a…