Classe, Struct ou Record: Identidade, Valor e Igualdade

Classe, Struct ou Record: Identidade, Valor e Igualdade

Classe, struct ou record em C#: identidade contra valor, structs e a semântica de cópia, as armadilhas das structs mutáveis e do valor padrão, records com igualdade por conteúdo, a expressão with que não passa pelo construtor, um ISBN validado e um roteiro de escolha.
Linguagem C#

• • 16 min de leitura

Dois objetos com os mesmos dados são a mesma coisa? A resposta depende do que eles representam, e o C# oferece um tipo diferente para cada resposta.

Em "Interfaces: Contratos Que Qualquer Tipo Pode Assinar" ficou em aberto uma pergunta que vem desde "Classes e Objetos": quando dois objetos são iguais? Para um cliente, a resposta é "quando são a mesma pessoa". Dois clientes chamados Ana Souza podem ser pessoas diferentes, e o mesmo cliente continua sendo ele mesmo depois de mudar de e-mail. Para um endereço, uma coordenada ou um item de pedido, a resposta é outra: dois endereços com a mesma rua, o mesmo número e a mesma cidade são o mesmo endereço. Não há identidade além dos dados. O C# expressa essa diferença com três tipos de declaração: class, struct e record. Escolher bem entre eles é uma das decisões de modelagem mais frequentes do seu dia a dia.

Identidade contra valor

A distinção vem da modelagem de domínio e tem nomes consagrados. Uma entidade tem identidade própria, que persiste enquanto seus dados mudam: um pedido continua sendo o pedido nº 1042 depois de pago, enviado e entregue. Um objeto de valor é definido inteiramente pelos seus dados: R$ 59,90 é R$ 59,90, e não existe "outro R$ 59,90" diferente deste.

As classes, como vimos, têm semântica de identidade: == compara referências, ou seja, pergunta se é o mesmo objeto. É o comportamento certo para entidades. Para objetos de valor, precisamos de outra coisa.

Structs: tipos de valor definidos por você

Em "Cada Valor Tem um Tipo" vimos que int, double e decimal são tipos de valor: a variável contém o próprio dado, e atribuir copia o dado. Uma struct é um tipo de valor que você mesmo define:

struct Ponto
{
    public int X;
    public int Y;
}

Ponto a = new Ponto { X = 1, Y = 2 };
Ponto b = a;       // copia o VALOR inteiro: b é independente de a
b.X = 10;
Console.WriteLine(a.X);   // 1

Compare com as classes: se Ponto fosse uma class, a.X também valeria 10, porque a e b apontariam para o mesmo objeto.

Onde a struct fica na memória? Uma crença muito repetida diz que "structs ficam na pilha". A verdade é mais precisa: uma struct fica onde foi declarada. Como variável local, fica na pilha do método. Como campo de uma classe, fica dentro do objeto, no heap. Num array, os valores ficam lado a lado dentro do próprio array. O que a struct garante é que não existe um objeto separado apontado por uma referência. Os dados estão ali, no lugar. Isso economiza alocações e pressão sobre o coletor de lixo, um tema central na Fase 4 e decisivo em jogos.

As armadilhas das structs mutáveis

A semântica de cópia, tão útil, vira armadilha quando a struct pode ser alterada:

var pontos = new List<Ponto> { new() { X = 1, Y = 1 } };

pontos[0].X = 10;      // erro CS1612: não é possível modificar o valor de retorno

Ponto p = pontos[0];   // p é uma CÓPIA
p.X = 10;              // altera a cópia; a lista continua com X = 1

O indexador da lista devolve uma cópia da struct, e alterar a cópia não teria efeito nenhum. O compilador percebe e proíbe a primeira forma, mas a segunda compila e falha em silêncio. Curiosamente, com um array, array[0].X = 10 funciona, porque o elemento de um array é acessado diretamente, e não por uma cópia devolvida por método. Essa diferença sutil já confundiu muita gente.

Por isso a recomendação moderna é clara: structs devem ser imutáveis. O modificador readonly torna isso uma regra verificada pelo compilador:

readonly struct Coordenada
{
    public Coordenada(double latitude, double longitude)
    {
        Latitude = latitude;
        Longitude = longitude;
    }

    public double Latitude { get; }
    public double Longitude { get; }
}

As diretrizes do .NET acrescentam outros critérios: a struct deve representar um único valor lógico (como um número, uma data ou uma coordenada), ser pequena (a referência costuma ser algo em torno de 16 bytes) e não ser convertida com frequência para object ou para interfaces. Essa conversão, chamada boxing, copia a struct para um objeto no heap e anula o ganho. Se o tipo não cumpre esses critérios, provavelmente deveria ser uma classe ou um record.

Há ainda uma armadilha que toda struct carrega: o valor padrão. default(Coordenada), um array novo de coordenadas ou um campo não inicializado produzem uma struct com todos os campos zerados, sem passar pelo construtor. Se o construtor valida algo, essa validação é contornada. Tenha isso em mente ao desenhar structs que exigem invariantes.

Igualdade: o problema que ninguém resolvia sozinho

Como comparar dois valores? Para structs, == simplesmente não existe por padrão (a == b é o erro CS0019), e o Equals herdado compara campo a campo de forma genérica e lenta. Para classes, == compara referências. Tornar uma classe comparável por conteúdo exige sobrescrever Equals, GetHashCode, == e !=, de forma coerente entre si. São dezenas de linhas, fáceis de errar e fáceis de esquecer de atualizar quando se acrescenta uma propriedade. É exatamente esse trabalho que os records fazem por você.

Records: dados com igualdade por conteúdo

Um record (C# 9) é um tipo cuja razão de existir são os seus dados. A forma mais curta é a posicional:

record Endereco(string Rua, int Numero, string Cidade);

Essa única linha gera: um construtor com os três parâmetros, três propriedades init, igualdade por conteúdo (Equals, GetHashCode, == e != coerentes), um ToString legível e um método de desconstrução. Veja:

var a = new Endereco("Rua das Flores", 100, "Curitiba");
var b = new Endereco("Rua das Flores", 100, "Curitiba");

Console.WriteLine(a == b);            // True: mesmo conteúdo
Console.WriteLine(ReferenceEquals(a, b));   // False: ainda são dois objetos
Console.WriteLine(a);                 // Endereco { Rua = Rua das Flores, Numero = 100, Cidade = Curitiba }

var (rua, numero, cidade) = a;        // desconstrução

Por padrão, um record é um tipo de referência, uma classe com superpoderes de igualdade. Ele vive no heap como qualquer classe, mas se compara como um valor.

Records são imutáveis por padrão (as propriedades posicionais são init). Para "alterar" um record, você cria uma cópia modificada com a expressão with:

var mudanca = a with { Numero = 250 };
Console.WriteLine(mudanca);   // Endereco { Rua = Rua das Flores, Numero = 250, Cidade = Curitiba }
Console.WriteLine(a.Numero);  // 100: o original não mudou

Esse estilo, em que nada é modificado e toda mudança produz um novo valor, elimina uma categoria inteira de bugs: ninguém pode alterar um endereço que você guardou, porque endereços não se alteram.

Existem ainda o record struct (C# 10), que é um tipo de valor com os mesmos recursos, e o readonly record struct, que é a combinação ideal para valores pequenos e imutáveis:

readonly record struct Coordenada(double Latitude, double Longitude);

Compare com a readonly struct Coordenada declarada acima: uma linha no lugar de dez, e ainda com ==, ToString e desconstrução de brinde.

Records com validação, e a porta que o with abre

Records posicionais não validam nada. Para impor regras, declare as propriedades explicitamente. E aqui mora uma armadilha importante: a expressão with não passa pelo construtor. Ela copia o objeto e atribui as propriedades pelos acessadores init. Validações que existem só no construtor são contornadas:

record ItemRuim
{
    public ItemRuim(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Quantidade = quantidade;
    }
    public int Quantidade { get; init; }
}

var item = new ItemRuim(2);
var invalido = item with { Quantidade = -5 };   // passa! Nenhuma exceção.

A correção é colocar a validação no init, usando a palavra-chave field que conhecemos em "Propriedades e Encapsulamento". O construtor passa a atribuir pelas propriedades, e as regras valem para qualquer caminho de criação:

sealed record ItemPedido
{
    public ItemPedido(Produto produto, int quantidade)
    {
        Produto = produto;
        Quantidade = quantidade;
    }

    public Produto Produto
    {
        get;
        init
        {
            ArgumentNullException.ThrowIfNull(value);
            field = value;
        }
    }

    public int Quantidade
    {
        get;
        init
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            field = value;
        }
    }

    public decimal Subtotal => Produto.Preco * Quantidade;
}

Agora item with { Quantidade = -5 } lança a exceção como deveria. Repare também que dois itens são iguais quando têm o mesmo produto (a mesma referência, já que Produto é uma classe, uma entidade) e a mesma quantidade, que é exatamente a semântica que queremos.

Um objeto de valor de verdade: o ISBN

Todo livro publicado tem um ISBN, um código de 13 dígitos cujo último dígito é verificador: ele é calculado a partir dos outros doze e permite detectar erros de digitação. É o exemplo perfeito de objeto de valor: pequeno, imutável, definido só pelos seus dígitos, com uma regra de validade própria.

readonly record struct Isbn
{
    public Isbn(string valor)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(valor);
        string digitos = valor.Replace("-", "").Replace(" ", "");

        if (digitos.Length != 13)
            throw new ArgumentException("O ISBN deve ter 13 dígitos.", nameof(valor));

        foreach (char c in digitos)
        {
            if (!char.IsAsciiDigit(c))
                throw new ArgumentException("O ISBN só pode conter dígitos.", nameof(valor));
        }

        if (!DigitoVerificadorValido(digitos))
            throw new ArgumentException("Dígito verificador inválido.", nameof(valor));

        Valor = digitos;
    }

    public string Valor { get; }

    public override string ToString() => Valor ?? "(vazio)";

    private static bool DigitoVerificadorValido(string d)
    {
        int soma = 0;
        for (int i = 0; i < 12; i++)
        {
            int n = d[i] - '0';               // converte o caractere no número correspondente
            soma += i % 2 == 0 ? n : n * 3;   // pesos alternados 1, 3, 1, 3...
        }
        int esperado = (10 - soma % 10) % 10;
        return d[12] - '0' == esperado;
    }
}
var isbn = new Isbn("978-0-306-40615-7");
Console.WriteLine(isbn);                                  // 9780306406157
Console.WriteLine(isbn == new Isbn("9780306406157"));     // True
var errado = new Isbn("978-0-306-40615-8");               // ArgumentException: dígito inválido

Dois detalhes técnicos. d[i] - '0' aproveita que os caracteres de '0' a '9' são números consecutivos: subtrair o código de '0' dá o valor do dígito. E o ?? "(vazio)" no ToString existe por causa da armadilha do valor padrão: default(Isbn) tem Valor nulo, porque não passou pelo construtor. Uma struct nunca consegue impedir totalmente sua versão "zerada". Um desenho robusto trata esse caso explicitamente, e o sistema de nulidade da Fase 3 ajuda a tornar o problema visível.

O ganho de modelagem é enorme. Um método que recebe Isbn, e não string, sabe que o código é válido: a validação aconteceu uma vez, na criação, e o tipo carrega essa garantia para onde for. Esse padrão de embrulhar valores primitivos em tipos com significado tem até nome na comunidade: combater a obsessão por primitivos.

Como escolher

Um roteiro prático para cada novo tipo:

  1. Tem identidade que persiste enquanto os dados mudam? Pedido, cliente, produto, conta. Use class.
  2. É definido apenas pelos dados, e dois com os mesmos dados são intercambiáveis? Endereço, item de pedido, resultado de um cálculo, uma mensagem trocada entre partes do sistema. Use record.
  3. Além disso, é pequeno, representa um único valor e é criado em grande quantidade? Coordenada, ISBN, cor, dinheiro, vetor 2D num jogo. Use readonly record struct, ou readonly struct se não quiser os membros gerados.

Na dúvida entre as duas últimas, comece com record: é mais difícil errar com um tipo de referência. Troque para record struct quando uma medição mostrar que as alocações importam.

Juntando tudo: o desconto que não guarda estado

No último exercício de "Interfaces", a política MelhorDesconto guardava em um campo qual opção tinha sido escolhida. Isso criava estado mutável e compartilhado: a mesma instância, usada em dois pedidos, misturaria as escolhas. Com records, a política pode devolver um resultado completo em vez de guardar estado:

sealed record ResultadoDesconto(string Descricao, decimal Valor);

interface IPoliticaDesconto
{
    ResultadoDesconto Calcular(decimal subtotal, int quantidadeTotal);
}

sealed class DescontoPorVolume : IPoliticaDesconto
{
    public ResultadoDesconto Calcular(decimal subtotal, int quantidadeTotal)
    {
        decimal percentual = quantidadeTotal switch { >= 10 => 0.15m, >= 3 => 0.10m, _ => 0m };
        return new ResultadoDesconto($"volume ({percentual:P0})", subtotal * percentual);
    }
}

sealed class CupomPercentual(string codigo, decimal percentual) : IPoliticaDesconto
{
    public ResultadoDesconto Calcular(decimal subtotal, int quantidadeTotal) =>
        new($"cupom {codigo.ToUpperInvariant()} ({percentual:P0})", subtotal * percentual);
}

sealed class MelhorDesconto(params IPoliticaDesconto[] opcoes) : IPoliticaDesconto
{
    public ResultadoDesconto Calcular(decimal subtotal, int quantidadeTotal)
    {
        ResultadoDesconto melhor = new("sem desconto", 0m);
        foreach (var opcao in opcoes)
        {
            var resultado = opcao.Calcular(subtotal, quantidadeTotal);
            if (resultado.Valor > melhor.Valor)
                melhor = resultado;
        }
        return melhor;
    }
}

Para simplificar o exemplo, o CupomPercentual usa um construtor primário sem as validações da aula anterior. Numa versão de produção elas continuam lá. MelhorDesconto agora não tem nenhum campo mutável: pode ser usada por mil pedidos ao mesmo tempo sem risco. Cada chamada devolve um ResultadoDesconto imutável, com a descrição e o valor sempre coerentes entre si. Esse é o padrão que vamos seguir daqui para a frente: classes para as entidades que mudam ao longo do tempo, records para os dados que viajam entre elas.

Um último detalhe dessa versão: o Pedido ainda não tem como saber em que etapa está. Aberto, pago, enviado? Hoje isso seria uma string ou um número solto, uma receita para erros de digitação e estados impossíveis. O C# tem tipos específicos para nomear um conjunto fechado de possibilidades, e o pattern matching, que já usamos de relance em várias aulas, fica muito mais poderoso quando trabalha sobre eles e sobre os records.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Preveja a saída e explique:

struct ContadorS { public int Valor; }
class ContadorC { public int Valor; }

var s1 = new ContadorS { Valor = 1 };
var s2 = s1;
s2.Valor = 99;

var c1 = new ContadorC { Valor = 1 };
var c2 = c1;
c2.Valor = 99;

Console.WriteLine($"{s1.Valor} {c1.Valor}");
Ver resposta

✓ Resposta: Imprime 1 99. ContadorS é uma struct: s2 = s1 copia o valor inteiro, e s2 é independente, então alterá-la não afeta s1. ContadorC é uma classe: c2 = c1 copia a referência, e as duas variáveis apontam para o mesmo objeto. As declarações são idênticas a menos da palavra struct ou class, e o comportamento é oposto. É por isso que a escolha precisa ser consciente, e por isso structs mutáveis como ContadorS são desaconselhadas: o leitor precisa lembrar do tipo para prever o resultado de uma simples atribuição.

Exercício 2

Declare um record posicional Endereco(string Rua, int Numero, string Cidade, string Cep). Crie dois endereços iguais e compare-os, crie um terceiro a partir do primeiro com outro número usando with e imprima os três.

Ver resposta

✓ Resposta:

var casa = new Endereco("Rua das Flores", 100, "Curitiba", "80000-000");
var mesmaCasa = new Endereco("Rua das Flores", 100, "Curitiba", "80000-000");
var vizinho = casa with { Numero = 102 };

Console.WriteLine(casa == mesmaCasa);   // True
Console.WriteLine(casa == vizinho);     // False
Console.WriteLine(casa);
Console.WriteLine(vizinho);

record Endereco(string Rua, int Numero, string Cidade, string Cep);

A declaração do record vem depois das instruções de nível superior, como exige a regra que vimos em "Classes e Objetos". A saída de casa é Endereco { Rua = Rua das Flores, Numero = 100, Cidade = Curitiba, Cep = 80000-000 }, gerada automaticamente. O with copiou todas as propriedades e trocou apenas o número. casa continua intacta, porque records posicionais são imutáveis. Com uma classe comum, casa == mesmaCasa seria False, e o ToString mostraria só Endereco.

Exercício 3

Explique por que pontos[0].X = 10; não compila quando pontos é uma List<Ponto> (sendo Ponto uma struct mutável), mas compila quando pontos é um Ponto[].

Ver resposta

✓ Resposta: O indexador de List<T> é um método (o get de uma propriedade indexada) que devolve uma cópia da struct. Alterar X dessa cópia temporária não teria nenhum efeito, porque a cópia seria descartada logo em seguida, e o compilador recusa com o erro CS1612 para evitar um bug silencioso. Já o acesso a um elemento de array é uma operação da própria linguagem, que dá acesso direto à posição de memória do elemento, sem cópia. A alteração acontece no próprio array. A regra geral é simples: com structs, tudo que passa por um método ou propriedade chega como cópia. Mais um motivo para preferir structs imutáveis, que tornam essa distinção irrelevante.

Exercício 4

O record abaixo valida a quantidade só no construtor. Mostre como criar uma instância inválida sem lançar exceção e corrija o record.

record Reserva
{
    public Reserva(string cliente, int pessoas)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pessoas);
        Cliente = cliente;
        Pessoas = pessoas;
    }
    public string Cliente { get; init; }
    public int Pessoas { get; init; }
}
Ver resposta

✓ Resposta: Basta partir de uma reserva válida e usar with: var invalida = new Reserva("Ana", 2) with { Pessoas = 0 };. A expressão with copia o objeto e atribui Pessoas pelo init, sem passar pelo construtor, e a validação é contornada. A correção é mover a regra para o init:

record Reserva
{
    public Reserva(string cliente, int pessoas)
    {
        Cliente = cliente;
        Pessoas = pessoas;
    }

    public string Cliente { get; init; }

    public int Pessoas
    {
        get;
        init
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            field = value;
        }
    }
}

Agora o construtor atribui pela propriedade, e a validação vale para qualquer caminho: construtor, inicializador de objeto e with. É o mesmo princípio de "Propriedades e Encapsulamento" (a regra mora no acessador) aplicado a records.

Exercício 5

Para cada tipo abaixo, escolha entre class, record e readonly record struct e justifique: (a) Cliente; (b) CorRgb (três bytes); (c) Dinheiro (valor e código da moeda); (d) Pedido; (e) ConfiguracaoEmail (servidor, porta, usuário), lida uma vez na inicialização.

Ver resposta

✓ Resposta: (a) class: o cliente é uma entidade, continua sendo a mesma pessoa quando muda de e-mail, e dois clientes com o mesmo nome podem ser pessoas diferentes. (b) readonly record struct: é um valor pequeno (3 bytes), imutável e intercambiável (dois vermelhos puros são o mesmo vermelho), e pode ser criado aos milhões num processamento de imagem. (c) readonly record struct: é um objeto de valor clássico, e R$ 10 é igual a R$ 10. Pequeno e imutável, com a vantagem de impedir somar reais com dólares se as operações checarem a moeda. Um record também seria aceitável. (d) class: o pedido é uma entidade com identidade (o número) e um ciclo de vida em que o estado muda (aberto, pago, enviado). Igualdade por conteúdo não faz sentido: dois pedidos com os mesmos itens são pedidos diferentes. (e) record: são dados sem identidade própria, imutáveis depois de lidos, e um ToString automático ajuda no diagnóstico. Não há ganho em ser struct, porque é criado uma única vez. Um cuidado: um ToString automático imprimiria também uma senha, se houvesse uma, e registros de log não devem conter segredos. Nesse caso, sobrescreva o ToString ou mantenha a senha fora do record.

Comentários

Mais em Linguagem C#

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…

Operadores, Texto e Conversa com o Usuário
Operadores, Texto e Conversa com o Usuário

Operadores e texto em C#: a divisão inteira que perde a vírgula, o resto com…

Formulários e validação: o site recebe dados
Formulários e validação: o site recebe dados

A vitrine da Livraria passa a receber dados: busca por GET com URL…