← C#: linguagem e decisões de aplicação
05 / 10 · 50 MIN

Coleções, generics e igualdade

Escolhe coleções e comparadores, conserva identidade e distingue uma vista de uma sequência independente.

Escolher a estrutura pelo contrato

List<T> representa uma sequência com posições. Permite duplicados e é útil quando a ordem faz parte do resultado. Dictionary<TKey, TValue> associa uma chave a um valor segundo uma regra de igualdade. HashSet<T> representa pertença e unicidade segundo o seu comparador. Escolher uma coleção apenas pelo nome ou pela velocidade esperada pode perder requisitos importantes.

Um importador pode precisar de duas estruturas: um conjunto para reconhecer identificadores já vistos e uma lista para conservar a ordem de chegada. O conjunto decide se um elemento é novo; a lista representa a sequência que será apresentada. O facto de uma estrutura eliminar duplicados não significa que seja a especificação adequada para uma ordem pública estável.

Define também o que é um duplicado. Para identificadores ASCII de protocolo, a regra pode ser OrdinalIgnoreCase; para outros dados, pode ser identidade exata. Esta decisão deve ser aplicada tanto na entrada como na pesquisa. Limitar o exemplo a uma única thread evita confundir a semântica da coleção com garantias de concorrência que estas estruturas comuns não fornecem.

Capacidade não é conteúdo

new List<int>(100) reserva capacidade inicial para elementos, mas começa com Count igual a zero. Add acrescenta elementos lógicos e aumenta Count. A capacidade pode evitar realocações durante esse crescimento; não representa cem posições já preenchidas que possam ser lidas como dados.

Ao dimensionar uma lista, usa uma estimativa razoável e distingue memória reservada de quantidade efetivamente processada. Um painel que mostre Capacity como número de registos pode anunciar trabalho que nunca ocorreu. Se o número de elementos crescer além da capacidade, a implementação pode expandir o armazenamento; a aplicação não deve depender de um fator de crescimento específico não exigido pelo contrato.

A leitura por índice também depende de Count. Espaço disponível no armazenamento não torna válidos índices fora dos elementos presentes. Em código de revisão, escreve explicitamente a contagem esperada após cada inserção e só depois raciocina sobre alocação. A capacidade é uma decisão de recursos, não uma evidência de completude do lote.

Presença, substituição e duplicados

TryGetValue devolve uma indicação de presença e um valor de saída. Num dicionário de inteiros, zero pode ser um valor guardado ou o default de uma pesquisa sem resultado. Só o booleano distingue esses estados. Ler o indexador de uma chave ausente lança uma exceção; não devolve automaticamente o default.

A política de escrita também deve ser explícita. Add rejeita uma chave existente. Atribuir pelo indexador insere ou substitui. TryAdd devolve false e conserva o valor anterior quando a chave já existe. Trocar estas operações para eliminar uma exceção pode transformar uma regra de rejeição numa perda silenciosa de dados.

Durante uma importação, decide se um duplicado deve interromper, conservar a primeira entrada ou substituir pela mais recente. Depois escolhe a operação que exprime essa política e regista os casos rejeitados quando necessário. Estes exemplos são sequenciais: uma verificação seguida de escrita não recebe uma garantia geral de atomicidade entre threads apenas porque usa um dicionário.

A identidade deve permanecer estável

Dicionários usam igualdade e hash para organizar pesquisas. O comparador faz parte da definição de chave. Construir o dicionário com StringComparer.OrdinalIgnoreCase permite tratar PORT e port como a mesma chave, segundo uma regra independente da cultura. Uma migração de um comparador sensível à capitalização pode revelar colisões que precisam de uma política.

Uma chave de classe cujo hash depende de uma propriedade mutável introduz outro risco. Se essa propriedade muda depois da inserção, a posição organizada a partir do hash anterior pode deixar de corresponder à pesquisa atual. O dicionário não acompanha automaticamente cada setter da aplicação para reorganizar entradas.

Mantém estável o estado que participa na igualdade e no hash enquanto a chave estiver em uso. Uma string ou um valor de identidade imutável pode ser mais adequado do que um objeto de negócio inteiro e mutável. Não prometas que toda a mutação terá exatamente o mesmo sintoma: o problema é a violação da regra de estabilidade, mesmo que um exemplo ocasional ainda pareça funcionar.

Vistas, snapshots e enumeração

IReadOnlyList<T> limita as operações disponíveis através dessa interface. Se a origem é uma List<T> e outra referência a altera, a vista continua a observar a mesma lista. A conversão não cria um snapshot e não torna imutáveis os objetos contidos. Uma promessa de histórico estável exige uma decisão de cópia e propriedade dos dados.

Um enumerador de List<T> também não é um snapshot. Depois de obter o enumerador e avançar uma vez, adicionar um elemento invalida-o. A próxima chamada MoveNext lança InvalidOperationException. Isto pode acontecer numa única thread; concorrência não é um requisito para a falha.

Se precisas de decidir remoções enquanto percorres dados, separa a fase de observação da alteração ou usa uma operação concebida para a transformação desejada. Se precisas de uma cópia, define se basta copiar a sequência ou se os elementos mutáveis também devem ser separados. Mudar a interface pública ou capturar a exceção não estabelece, por si só, consistência do resultado.

Generics e limites da covariância

Uma List<string> não pode ser atribuída diretamente a List<object>. Se isso fosse permitido, um consumidor poderia acrescentar outro tipo de objeto à mesma lista, violando o contrato original de strings. List<T> é invariante: a relação entre os tipos dos elementos não cria essa conversão entre as listas.

IEnumerable<T> permite uma conversão covariante para tipos de referência relacionados. Uma sequência de strings pode ser observada como IEnumerable<object> sem construir outra sequência de elementos. Esta capacidade não transforma a interface numa lista mutável nem copia os dados.

A regra não aplica automaticamente boxing a uma sequência de inteiros. IEnumerable<int> não se converte diretamente em IEnumerable<object> por covariância. Para representar cada número como object é necessária uma operação por elemento, cujo custo e momento de execução são decisões próprias. A aula seguinte aprofundará operações de sequência e execução diferida; aqui, o objetivo é reconhecer quais conversões de tipo existem.

Exemplo guiado e resumo: primeira ocorrência

O importador abaixo recebe uma sequência finita de identificadores ASCII já validados como não nulos e não vazios. Não há concorrência. O resultado deve conservar a primeira grafia e a ordem de chegada, ignorando diferenças de capitalização para reconhecer duplicados. O conjunto decide pertença; a lista guarda a saída.

Para NodeB, nodea, NODEB e NodeC, a saída é NodeB, nodea, NodeC. A segunda ocorrência de NodeB não substitui a grafia nem muda a posição anterior. O código não depende da ordem de enumeração do HashSet porque nunca o usa para produzir a sequência final. Esta função também não valida a origem nem garante unicidade entre execuções diferentes.

Resumo: escolhe a coleção pelo contrato; distingue Count de Capacity e presença de default; explicita substituição e duplicação; conserva a estabilidade de hash; separa vista de snapshot; respeita as regras de variância. No trabalho real, estas escolhas surgem em caches, importadores, relatórios e fronteiras entre componentes.

using System;
using System.Collections.Generic;

public static class Importing
{
    public static List<string> FirstOccurrences(IEnumerable<string> source)
    {
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var result = new List<string>();
        foreach (string id in source)
        {
            if (seen.Add(id)) result.Add(id);
        }
        return result;
    }
}
NA PRÁTICA

Assume source finito e não nulo, itens ASCII não vazios e execução sequencial. NodeB, nodea, NODEB, NodeC produz NodeB, nodea, NodeC; a lista devolvida é mutável.

Armadilhas comuns

Usar Capacity como contagem; confundir zero com ausência; substituir duplicados sem decisão; mudar chaves; tomar IReadOnlyList por snapshot; alterar List durante enumeração.

Tópicos relacionados: Objetos, interfaces e records · Referências, nullability e texto · LINQ, trabalho diferido e materialização

Leva esta ideia contigo

Uma coleção tem contratos de identidade, ordem e alteração; torna-os explícitos antes de escolher a operação.

Criar conta

Referência: Dictionary<TKey,TValue> · C# 14 / .NET 10; no preview features