Menu

Record em C#: igualdade por valor, expressões with e record struct

O que são os records em C# (C# 9 em diante): sintaxe posicional, os membros que o compilador gera, igualdade baseada em valor, cópias não destrutivas com with, o ToString embutido, record struct do C# 10, herança entre records e a classe equivalente escrita à mão.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

Um record é um tipo cuja função principal é guardar dados, e cuja igualdade é definida por esses dados. Os records chegaram no C# 9. Você escreve uma linha, e o compilador gera os membros de que uma classe de dados precisa: propriedades, um construtor, Equals e == baseados em valor, GetHashCode, um ToString legível, Deconstruct e suporte a cópias com with.

Records exigem C# 9 ou posterior (.NET 5+), então o código com records nesta página aparece como C# simples, com a saída em comentários. A última seção escreve os mesmos membros à mão em C# 7, e esse código você pode executar.

Records posicionais

A forma mais curta lista as propriedades entre parênteses depois do nome. Cada parâmetro vira uma propriedade pública init-only com o mesmo nome:

public record Product(string Sku, string Name, decimal Price);

var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name);        // Mug
Console.WriteLine(mug);             // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m;                  // error CS8852: init-only property

var (sku, name, price) = mug;       // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50

A partir dessa única linha, o compilador gera:

  • um construtor que recebe (string Sku, string Name, decimal Price);
  • três propriedades public ... { get; init; };
  • Equals(object), Equals(Product), GetHashCode() e os operadores == e !=, todos comparando as três propriedades;
  • um ToString() que imprime o nome do tipo e todas as propriedades públicas;
  • Deconstruct(out string Sku, out string Name, out decimal Price);
  • um construtor de cópia (protected, ou private em um record sealed) e um método de clonagem oculto usado pelo with.

Um record também pode ser escrito com um corpo normal, o que é útil quando as propriedades precisam de valores padrão ou de validação:

public record Customer
{
    public required string Email { get; init; }   // required: C# 11
    public string Name { get; init; } = "";
}

var c = new Customer { Email = "ana@example.com" };

E as duas formas podem ser combinadas: parâmetros posicionais mais membros extras entre chaves.

public record Order(string Id, decimal Subtotal)
{
    public decimal Tax => Subtotal * 0.23m;
    public decimal Total => Subtotal + Tax;
}

Igualdade por valor

Para uma classe comum, == pergunta "estes são o mesmo objeto?". Para um record, pergunta "estes têm os mesmos valores?":

var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);

Console.WriteLine(a == b);                  // True
Console.WriteLine(a.Equals(b));             // True
Console.WriteLine(ReferenceEquals(a, b));   // False: still two objects

GetHashCode é gerado de forma compatível, então records funcionam corretamente como chaves de dicionário e em um HashSet<T>: um segundo record com os mesmos valores encontra a entrada do primeiro.

A igualdade compara cada campo (nos records posicionais, o campo por trás de cada propriedade) com EqualityComparer<T>.Default, que chama o Equals do próprio tipo. Para uma propriedade de coleção, isso é igualdade de referência, o que surpreende:

public record Basket(string Owner, List<string> Items);

var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y);   // False: two different List objects

Se um record guarda uma coleção e deve comparar pelo conteúdo dela, sobrescreva Equals(Basket other) e GetHashCode(), ou use uma coleção imutável com semântica de valor feita por você.

Expressões with: mudanças não destrutivas

Records normalmente são imutáveis, então você "muda" um criando uma cópia modificada. with copia todas as propriedades e depois aplica as atribuições entre chaves:

var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };

Console.WriteLine(sale);        // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price);   // 8.50: the original is untouched

A cópia é rasa. Uma propriedade de tipo de referência é copiada como referência, então os dois records compartilham o objeto:

public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }

var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");

Console.WriteLine(string.Join(",", c1.Tags));   // vip,newsletter

Mantenha as propriedades do record imutáveis até o fim (IReadOnlyList<T> preenchida uma vez, ou ImmutableList<T>), ou crie uma lista nova no with: c1 with { Tags = new List<string>(c1.Tags) }.

ToString

O ToString gerado imprime o nome do tipo e todas as propriedades públicas, o que deixa os records agradáveis em logs e no depurador:

Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }

Coleções são impressas como o nome do tipo (System.Collections.Generic.List`1[System.String]), e records aninhados são impressos de forma recursiva. Você pode substituir a saída inteira sobrescrevendo ToString:

public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount:F2} {Currency}";
}

record struct (C# 10)

record sozinho significa record class: um tipo de referência. O C# 10 adicionou record struct, um tipo de valor com os mesmos membros gerados:

public readonly record struct Point(int X, int Y);

var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4));   // True
Console.WriteLine(q);                      // Point { X = 3, Y = 10 }

Vale lembrar a diferença nos padrões: uma record struct posicional tem propriedades mutáveis ({ get; set; }), de acordo com o comportamento usual das structs, enquanto readonly record struct e record class têm propriedades init-only. Escolha entre elas como escolheria entre uma struct e uma class: valores pequenos copiados à vontade combinam com readonly record struct; todo o resto, com record.

Herança

Um record pode herdar de outro record (não de uma classe, e uma classe não pode herdar de um record). Os parâmetros posicionais são passados à base como argumentos de construtor:

public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);

Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);

Console.WriteLine(a == b);   // True
Console.WriteLine(a == c);   // False: different runtime types are never equal
Console.WriteLine(a);        // Circle { Color = red, Radius = 2 }

A igualdade inclui o tipo em tempo de execução, por meio de uma propriedade EqualityContract gerada. É por isso que um Circle nunca é igual a um Square com a mesma Color, mesmo que os dois sejam comparados por meio de Shape, e por isso ToString e with funcionam no tipo derivado mesmo quando a variável é do tipo base.

A mesma coisa em C# 7: uma classe com igualdade por valor

Os records geram código que você mesmo pode escrever, e vê-lo explica o comportamento deles. Aqui está uma classe em C# 7 equivalente a public record Point(int X, int Y);: propriedades só com get, um construtor, Deconstruct, igualdade por valor, um hash code compatível, ==, ToString e um método With no lugar da expressão with.

Saída:

True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False

Cerca de 30 linhas para duas propriedades, e cada propriedade nova exige mexer de novo no construtor, no Deconstruct, no Equals, no GetHashCode e no ToString. Esquecer um deles é um bug clássico (dois pontos que são == mas têm hashes diferentes, então um HashSet os perde de vista). É essa manutenção que os records eliminam.

A classe é sealed de propósito: igualdade por valor combinada com herança precisa da verificação extra de tipo que os records geram por meio do EqualityContract, e selar a classe contorna o problema.

Quando usar um record

Records combinam com dados definidos pelos seus valores e que não mudam depois da criação:

  • modelos de requisição e resposta de APIs web;
  • mensagens, comandos e eventos trocados entre partes de um sistema;
  • objetos de configuração e de opções;
  • chaves compostas de dicionário (record CacheKey(string Region, int Year));
  • resultados de um cálculo (record PriceQuote(decimal Net, decimal Tax)).

Eles combinam mal onde a identidade importa mais que os valores: uma entidade do Entity Framework é "o cliente 42" mesmo depois de o nome mudar, e o rastreamento de mudanças do EF Core depende da identidade de referência. Use uma classe nesse caso.

Erros comuns

  • Esperar igualdade profunda em coleções. Uma propriedade List<T> compara por referência. Dois records com listas aparentemente iguais não são iguais.
  • Esperar que o with faça uma cópia profunda. Objetos aninhados e coleções são compartilhados entre o original e a cópia.
  • record struct posicional mutável sem querer. Acrescente readonly, a menos que você queira propriedades que podem ser alteradas.
  • Usar records como entidades do EF Core. A igualdade por valor e as cópias entram em conflito com o rastreamento de mudanças.
  • Adicionar um record a um projeto C# 8. Records exigem C# 9 (o padrão para .NET 5 em diante). Em plataformas mais antigas, escreva a classe à mão como mostrado acima.

Perguntas frequentes

O que é um record em C#?

Um record (C# 9) é uma classe, ou, com record struct (C# 10), uma struct, para a qual o compilador gera igualdade baseada em valor, um ToString() legível, um método Deconstruct e suporte a cópias com with. public record Product(string Sku, decimal Price); é um tipo completo com duas propriedades init-only. Dois records com valores de propriedades iguais são iguais, mesmo sendo objetos diferentes.

Qual a diferença entre um record e uma class em C#?

Um record é uma classe por baixo, então é um tipo de referência e pode herdar de outros records. As diferenças são os membros gerados: records comparam por valor (== e Equals verificam cada campo), imprimem as propriedades no ToString() e suportam with. Uma classe comum compara por referência e imprime o nome do tipo, a menos que você mesmo escreva esses membros.

O que a expressão with faz em C#?

var sale = product with { Price = 6.00m }; cria um novo record que copia todas as propriedades de product e depois define as que foram listadas. O original não muda. A cópia é rasa: uma propriedade List<T> é compartilhada pelos dois records, então adicionar a ela por meio de um fica visível pelo outro.

O que é uma record struct em C#?

record struct (C# 10) é um tipo de valor com os mesmos membros gerados de uma record class: igualdade por valor, ToString, Deconstruct e with. Ao contrário de uma record class, as propriedades posicionais dela são mutáveis por padrão; declare-a como readonly record struct para torná-las init-only. Use-a para valores pequenos, como coordenadas ou valores em dinheiro.

Quando usar um record em C#?

Para dados cuja identidade são os próprios valores: DTOs, modelos de requisição e resposta de APIs, mensagens e eventos, configuração e chaves de dicionários. Evite records para entidades que mudam ao longo do tempo e são identificadas por um id, como entidades do Entity Framework, porque a igualdade por valor e as cópias com with atrapalham o rastreamento de mudanças.

Coddy programming languages illustration

Aprenda a programar com o Coddy

COMEÇAR