C# Records

Na aula de A Classe object, a Ana escreveu umas 30 linhas para um Produto com nome e preço: propriedades, construtor, ToString, Equals e GetHashCode. Tudo isso para uma coisa que é só "um punhado de dados". O record C# 9 faz tudo isso em uma linha: record Produto(string Nome, decimal Preco);. Nesta aula você vai ver o que essa linha gera, como "mudar" um record que não muda (o with) e a diferença entre record class e record struct.

Você vai aprender
  • Como declarar um record em uma linha, e o que o compilador gera para você.
  • O ToString() pronto e a igualdade por valor (== compara o conteúdo).
  • Por que um record não muda (o erro CS8852) e como criar cópias alteradas com with.
  • Desconstrução: var (nome, preco) = produto;.
  • Records com métodos e propriedades a mais.
  • record class, record struct C# 10 e readonly record struct.
  • Quando usar record e quando usar class.

Todos os programas desta aula rodaram com dotnet run arquivo.cs no .NET 10 da máquina da Ana, na pasta ~/cs.


A ideia: uma ficha preenchida

Pense numa ficha de cadastro de papel, já preenchida: "Nome: Café. Preço: R$ 18,90." Duas fichas com os mesmos dados dizem a mesma coisa, mesmo que sejam dois papéis diferentes. E, para corrigir uma ficha, o certo não é rasurar: é preencher uma ficha nova, copiando o que não mudou.

Um record é essa ficha:

  • serve para guardar dados (não para ter "identidade", como uma conta bancária);
  • dois records com os mesmos dados são iguais;
  • depois de preenchido, não muda; para "mudar", você cria uma cópia alterada.

Um record em uma linha

Produto cafe = new Produto("Café", 18.90m);
Console.WriteLine(cafe);
Console.WriteLine(cafe.Nome);
Console.WriteLine(cafe.Preco);

record Produto(string Nome, decimal Preco);
record Produto(…);
Declara um record chamado Produto. Repare que termina com ; e não tem chaves: não precisa de corpo.
(string Nome, decimal Preco)
Os parâmetros posicionais. Para cada um, o compilador cria uma propriedade pública com o mesmo nome (Nome, Preco) e um construtor que recebe os valores nessa ordem. Por isso os nomes ficam em PascalCase, como propriedades.
new Produto("Café", 18.90m)
Usa o construtor gerado: o primeiro argumento vai para Nome, o segundo para Preco.
Console.WriteLine(cafe)
Usa o ToString() gerado, que mostra o nome do tipo e todas as propriedades.
$ dotnet run produto.cs
Produto { Nome = Café, Preco = 18,90 }
Café
18,90

Compare com a classe da aula A Classe object, que mostrava só Produto. O ToString() de um record é ótimo para depurar: você vê o conteúdo inteiro de uma vez.

O que a linha gera

Aquela linha única equivale, mais ou menos, a esta classe (você não precisa escrever isto; é só para entender):

class Produto
{
    public string Nome { get; init; }
    public decimal Preco { get; init; }

    public Produto(string Nome, decimal Preco) { this.Nome = Nome; this.Preco = Preco; }

    public override string ToString() { … }          // "Produto { Nome = …, Preco = … }"
    public override bool Equals(object? obj) { … }   // compara Nome e Preco
    public override int GetHashCode() { … }          // combina Nome e Preco
    public static bool operator ==(Produto? a, Produto? b) { … }
    public static bool operator !=(Produto? a, Produto? b) { … }
    public void Deconstruct(out string Nome, out decimal Preco) { … }
    // … e o que o "with" precisa para copiar
}

Um record é uma classe (um tipo de referência; veja Valor × Referência), só que com tudo isso pronto. Por isso ele também se chama record class; as duas formas, record Produto(…) e record class Produto(…), são a mesma coisa.

Missão: rode e veja Produto e False. Apague a classe Produto inteira e coloque no lugar a linha record Produto(string Nome, decimal Preco);. Sem mudar mais nada, a saída deve virar Produto { Nome = Café, Preco = 18,90 } e True.

Produto a = new Produto("Café", 18.90m);
Produto b = new Produto("Café", 18.90m);
Console.WriteLine(a);
Console.WriteLine(a == b);

class Produto
{
    public string Nome { get; }
    public decimal Preco { get; }

    public Produto(string nome, decimal preco)
    {
        Nome = nome;
        Preco = preco;
    }
}
Produto a = new Produto("Café", 18.90m);
Produto b = new Produto("Café", 18.90m);
Console.WriteLine(a);
Console.WriteLine(a == b);

record Produto(string Nome, decimal Preco);

Igualdade por valor

Produto a = new Produto("Café", 18.90m);
Produto b = new Produto("Café", 18.90m);
Produto c = new Produto("Chá", 7.50m);

Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(a == c);
Console.WriteLine(ReferenceEquals(a, b));

HashSet<Produto> itens = [a, b, c];
Console.WriteLine(itens.Count);

record Produto(string Nome, decimal Preco);
$ dotnet run igual.cs
True       « a == b: mesmo conteúdo
True       « Equals também
False      « café × chá
False      « mas continuam sendo dois objetos
2          « o HashSet sabe que a e b são iguais
a == b
Num record, o == compara todas as propriedades. Numa classe comum, compararia os endereços e daria False.
ReferenceEquals(a, b)
Pergunta "é o mesmo objeto?" (veja A Classe object). Não é: são dois new. O record só muda o significado de "igual".
itens.Count
O GetHashCode gerado combina as propriedades, então o HashSet e o Dictionary funcionam sem você escrever nada.

Um record não muda

As propriedades geradas são { get; init; }: o init (veja readonly e init) deixa definir o valor só na criação. Depois, nada muda. A Ana tentou subir o preço do café:

Este código tem um erro de propósito
Produto cafe = new Produto("Café", 18.90m);
cafe.Preco = 20.00m;

record Produto(string Nome, decimal Preco);
$ dotnet run imutavel.cs
/home/ana/cs/imutavel.cs(2,1): error CS8852: A propriedade somente de inicialização ou o indexador 'Produto.Preco' só pode ser atribuído em um inicializador de objeto, em 'this' ou 'base' em um construtor de instância ou em um acessador 'init'.

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

"Propriedade somente de inicialização": só pode receber valor quando o objeto é criado. Por que isso é bom? Porque um record é usado como "valor" (num HashSet, numa chave de Dictionary, compartilhado entre várias partes do programa). Se ele pudesse mudar, um produto guardado num HashSet poderia mudar de "estante" sem avisar, e uma parte do programa veria a mudança feita por outra. Imutável, ninguém se preocupa.


with: uma cópia com mudanças

Para "mudar" um record, você cria um novo, copiando o antigo e trocando só o que precisa. É o que faz a expressão with ("com"):

Produto cafe = new Produto("Café", 18.90m);
Produto promocao = cafe with { Preco = 15.90m };
Produto descafeinado = cafe with { Nome = "Café descafeinado", Preco = 21.50m };

Console.WriteLine(cafe);
Console.WriteLine(promocao);
Console.WriteLine(descafeinado);

record Produto(string Nome, decimal Preco);
cafe with { Preco = 15.90m }
"O cafe, com o preço 15,90." Cria um novo Produto, copia todas as propriedades do cafe e troca as que estão entre as chaves.
{ Nome = "…", Preco = 21.50m }
Dá para trocar várias propriedades de uma vez, separadas por vírgula, como num inicializador.
Console.WriteLine(cafe)
O original continua igual: o with nunca mexe nele.
$ dotnet run with.cs
Produto { Nome = Café, Preco = 18,90 }                « o original, intacto
Produto { Nome = Café, Preco = 15,90 }
Produto { Nome = Café descafeinado, Preco = 21,50 }

O with só funciona com records, structs e tipos anônimos. Com uma classe comum, mesmo com propriedades init:

$ dotnet run classe.cs
/home/ana/cs/classe.cs(2,23): error CS8858: O tipo de receptor 'ProdutoClasse' não é um tipo de registro válido e não é um tipo struct.

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

Nas mensagens em português, record é traduzido como "registro". O conserto é transformar a classe em record.

Cuidado: a cópia é rasa

O with copia as propriedades, mas, se uma delas é uma referência (uma lista, por exemplo), ele copia o endereço, não a lista:

Pedido p1 = new Pedido("Ana", ["Café"]);
Pedido p2 = p1 with { Cliente = "Bia" };
p2.Itens.Add("Chá");

Console.WriteLine($"{p1.Cliente}: {string.Join(", ", p1.Itens)}");
Console.WriteLine($"{p2.Cliente}: {string.Join(", ", p2.Itens)}");
Console.WriteLine(p1);

record Pedido(string Cliente, List<string> Itens);
$ dotnet run raso.cs
Ana: Café, Chá      « o chá da Bia foi parar no pedido da Ana
Bia: Café, Chá
Pedido { Cliente = Ana, Itens = System.Collections.Generic.List`1[System.String] }

Duas lições: o record só é tão imutável quanto as coisas que ele guarda (uma List dentro dele continua mudando), e o ToString() gerado mostra a lista pelo nome do tipo, não pelo conteúdo. Para records de verdade imutáveis, use dados imutáveis dentro deles (string, números, outros records) ou crie uma lista nova no with: p1 with { Cliente = "Bia", Itens = [.. p1.Itens] }.


Desconstrução

Um record posicional pode ser "aberto" em variáveis, na ordem dos parâmetros:

Produto cafe = new Produto("Café", 18.90m);
var (nome, preco) = cafe;
Console.WriteLine($"{nome} custa {preco:C}");

record Produto(string Nome, decimal Preco);
$ dotnet run descon.cs
Café custa R$ 18,90

O var (nome, preco) cria duas variáveis e as preenche com Nome e Preco. É o mesmo recurso das tuplas, a próxima aula.


Records com corpo

Um record pode ter chaves, com métodos e propriedades a mais, como uma classe:

Produto cafe = new Produto("Café", 18.90m);
Console.WriteLine(cafe.ComDesconto(10));
Console.WriteLine(cafe.PrecoFormatado);

record Produto(string Nome, decimal Preco)
{
    public string PrecoFormatado => Preco.ToString("C");

    public Produto ComDesconto(int porcento) => this with { Preco = Preco - Preco * porcento / 100 };
}
public string PrecoFormatado => …
Uma propriedade calculada. Ela entra no ToString() gerado, porque é pública.
this with { … }
Um método que devolve um produto novo, com desconto. É o estilo dos records: em vez de mudar, devolver uma cópia.
$ dotnet run corpo.cs
Produto { Nome = Café, Preco = 17,01, PrecoFormatado = R$ 17,01 }
R$ 18,90      « o cafe original não mudou

Sem parâmetros posicionais

Um record também pode ser escrito com propriedades normais, sem os parênteses. É útil quando há muitas propriedades ou algumas opcionais:

Cliente ana = new Cliente { Nome = "Ana", Email = "ana@exemplo.com" };
Console.WriteLine(ana);

record Cliente
{
    public required string Nome { get; init; }
    public string? Email { get; init; }
}
$ dotnet run nominal.cs
Cliente { Nome = Ana, Email = ana@exemplo.com }

O required obriga quem cria o objeto a preencher o Nome (veja Inicializadores e required). O ToString, o == e o with continuam prontos.


record struct C# 10

O record comum é uma classe. Com record struct, você ganha as mesmas facilidades num struct, um tipo de valor:

Ponto p = new Ponto(2, 3);
Ponto q = p with { X = 10 };
Console.WriteLine(p);
Console.WriteLine(q);
Console.WriteLine(p == new Ponto(2, 3));

p.X = 5;
Console.WriteLine(p);

record struct Ponto(int X, int Y);
$ dotnet run rstruct.cs
Ponto { X = 2, Y = 3 }
Ponto { X = 10, Y = 3 }
True
Ponto { X = 5, Y = 3 }      « um record struct pode mudar!

Repare na última linha: diferente do record (classe), as propriedades de um record struct são { get; set; }, e p.X = 5 funciona. Para um struct imutável, que é o recomendado (veja Structs), escreva readonly record struct:

Ponto p = new Ponto(2, 3);
p.X = 5;

readonly record struct Ponto(int X, int Y);
$ dotnet run rostruct.cs
/home/ana/cs/rostruct.cs(2,1): error CS8852: A propriedade somente de inicialização ou o indexador 'Ponto.X' só pode ser atribuído em um inicializador de objeto, em 'this' ou 'base' em um construtor de instância ou em um acessador 'init'.

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.
record = record classrecord structreadonly record struct
FamíliaReferênciaValorValor
Propriedades posicionaisget; init; (imutável)get; set; (mutável)get; init; (imutável)
b = aMesmo objetoCópiaCópia
Pode ser nullSimNãoNão
HerançaSim, entre recordsNãoNão
ToString, ==, Equals, withProntos nos três

Herança entre records

Um record (classe) pode herdar de outro record, passando os parâmetros para a mãe como num : base(…):

Animal rex = new Cachorro("Rex", "vira-lata");
Console.WriteLine(rex);
Console.WriteLine(rex == new Cachorro("Rex", "vira-lata"));

record Animal(string Nome);
record Cachorro(string Nome, string Raca) : Animal(Nome);
$ dotnet run heranca.cs
Cachorro { Nome = Rex, Raca = vira-lata }
True

Mesmo numa variável do tipo Animal, o ToString() mostra o tipo e as propriedades do Cachorro. Só que records e classes não se misturam na herança: uma class Cachorro : Animal (com Animal record) dá CS8865: Somente os registros podem ser herdados de registros.


Record ou classe?

SituaçãoUse
Dados que só são lidos: um produto do catálogo, uma resposta de uma API, uma linha de relatório, um endereçorecord
Dois objetos com o mesmo conteúdo devem ser "iguais"record
Valores pequenos (coordenada, cor, medida)readonly record struct
Algo com identidade e estado que muda: conta bancária, carrinho, jogador, conexãoclass
Muito comportamento (métodos) e poucos dados expostosclass

Uma pergunta resolve a maioria dos casos: "duas coisas com os mesmos dados são a mesma coisa?" Dois produtos "Café, R$ 18,90": sim, record. Duas contas com saldo de R$ 100: não, são contas diferentes, então classe.


Quando dá errado

CS8852: "A propriedade somente de inicialização … só pode ser atribuído em um inicializador de objeto …"
$ dotnet run imutavel.cs
/home/ana/cs/imutavel.cs(2,1): error CS8852: A propriedade somente de inicialização ou o indexador 'Produto.Preco' só pode ser atribuído em um inicializador de objeto, …

O que aconteceu: você tentou mudar uma propriedade de um record (ou de um readonly record struct) depois de criado.

Como resolver: crie uma cópia com with: cafe = cafe with { Preco = 20.00m };. Se o objeto precisa mesmo mudar o tempo todo, talvez ele seja uma class.

CS8858: "O tipo de receptor … não é um tipo de registro válido e não é um tipo struct"

O que aconteceu: with numa classe comum.

Como resolver: transforme a classe em record ou crie o objeto novo com new.

"Mudei a lista da cópia e o original mudou"

O que aconteceu: o with faz uma cópia rasa: listas e outros objetos dentro do record são compartilhados.

Como resolver: no with, crie uma lista nova (Itens = [.. p1.Itens]), ou guarde dentro do record dados que não mudam.


Referência

EscritaSignificado
record Produto(string Nome, decimal Preco);Record posicional (classe) C# 9
record class Produto(…);O mesmo, escrito por extenso C# 10
record struct Ponto(int X, int Y);Record de valor, mutável C# 10
readonly record struct Ponto(int X, int Y);Record de valor, imutável
a == b, a.Equals(b)Compara todas as propriedades
x with { Preco = 15.90m }Cópia com mudanças (rasa)
var (nome, preco) = x;Desconstrução
record Cachorro(string Nome, string Raca) : Animal(Nome);Herança entre records
record Cliente { public required string Nome { get; init; } }Record sem parâmetros posicionais

Exercícios

1. Declarar

Complete para declarar um record Aluno com Nome (texto) e Nota (double):

2. Uma cópia com mudança

Complete para criar um aluno igual à bia, mas com nota 10:

Aluno corrigida = bia ;

3. O ToString

O que mostra Console.WriteLine(new Aluno("Bia", 9));, com o record do exercício 1?

4. record Produto(string Nome, decimal Preco); Quanto dá new Produto("Café", 18.90m) == new Produto("Café", 18.90m)?

5. Com o mesmo record, o que acontece em cafe.Preco = 20m;?

6. Qual tipo de record pode ter as propriedades mudadas depois de criado?

Missão final: é dia de promoção na lojinha: 20% de desconto em tudo. Dentro do primeiro foreach, use with para criar uma cópia de cada produto com Preco = p.Preco * 0.8m e adicione a cópia na lista promocao. O catálogo original não pode mudar. A saída deve mostrar a promoção (Café: R$ 15,12, Chá: R$ 6,00) e, depois, o catálogo com os preços de sempre (Café: R$ 18,90).

List<Produto> catalogo = [new Produto("Café", 18.90m), new Produto("Chá", 7.50m)];
List<Produto> promocao = [];

foreach (Produto p in catalogo)
{
    // crie aqui a cópia com desconto e adicione em promocao
}

Console.WriteLine("Promoção:");
foreach (Produto p in promocao)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

Console.WriteLine("Catálogo:");
foreach (Produto p in catalogo)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

record Produto(string Nome, decimal Preco);
List<Produto> catalogo = [new Produto("Café", 18.90m), new Produto("Chá", 7.50m)];
List<Produto> promocao = [];

foreach (Produto p in catalogo)
{
    promocao.Add(p with { Preco = p.Preco * 0.8m });
}

Console.WriteLine("Promoção:");
foreach (Produto p in promocao)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

Console.WriteLine("Catálogo:");
foreach (Produto p in catalogo)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

record Produto(string Nome, decimal Preco);
$ dotnet run promocao.cs      « a solução
Promoção:
Café: R$ 15,12
Chá: R$ 6,00
Catálogo:
Café: R$ 18,90
Chá: R$ 7,50

Resumo

  • record Produto(string Nome, decimal Preco); gera propriedades, construtor, ToString, Equals, GetHashCode, == e desconstrução. É uma classe com tudo pronto.
  • Records se comparam pelo conteúdo: == dá True para dados iguais, e HashSet/Dictionary funcionam.
  • As propriedades posicionais são init: o record não muda (CS8852). Para "mudar", with { … } cria uma cópia alterada, rasa.
  • record struct é a versão de valor (mutável); readonly record struct, a de valor imutável. Records podem ter corpo e herdar de outros records.
  • Record para dados; classe para coisas com identidade e estado que muda. Próxima aula: Tuplas.