Menu

Record en C#: igualdad por valor, expresiones with y record struct

Qué son los records de C# (C# 9 y posteriores): la sintaxis posicional, los miembros que genera el compilador, la igualdad por valor, las copias no destructivas con with, el ToString incorporado, record struct de C# 10, la herencia entre records y la clase equivalente escrita a mano.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

Un record es un tipo cuyo trabajo principal es contener datos, y cuya igualdad la definen esos datos. Los records llegaron en C# 9. Escribes una línea, y el compilador genera los miembros que necesita una clase de datos: propiedades, un constructor, Equals y == basados en el valor, GetHashCode, un ToString legible, Deconstruct y el soporte para copias con with.

Los records necesitan C# 9 o posterior (.NET 5+), así que el código con records de esta página se muestra como C# normal, con su salida en comentarios. La última sección escribe los mismos miembros a mano en C# 7, y eso sí puedes ejecutarlo.

Records posicionales

La forma más corta enumera las propiedades entre paréntesis después del nombre. Cada parámetro se convierte en una propiedad pública init-only con el mismo nombre:

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 de esa única línea, el compilador genera:

  • un constructor que recibe (string Sku, string Name, decimal Price);
  • tres propiedades public ... { get; init; };
  • Equals(object), Equals(Product), GetHashCode() y los operadores == y !=, que comparan las tres propiedades;
  • ToString(), que imprime el nombre del tipo y todas las propiedades públicas;
  • Deconstruct(out string Sku, out string Name, out decimal Price);
  • un constructor de copia (protected, o private en un record sealed) y un método de clonación oculto que usa with.

Un record también puede escribirse con un cuerpo normal, lo que resulta práctico cuando las propiedades necesitan valores por defecto o validación:

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" };

Y las dos formas pueden combinarse: parámetros posicionales más miembros extra entre llaves.

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

Igualdad por valor

En una clase normal, == pregunta "¿son el mismo objeto?". En un record, pregunta "¿tienen los mismos 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 se genera de forma coherente, así que los records funcionan bien como claves de diccionario y en un HashSet<T>: un segundo record con los mismos valores encuentra la entrada del primero.

La igualdad compara cada campo (en los records posicionales, el campo que hay detrás de cada propiedad) con EqualityComparer<T>.Default, que llama al propio Equals del tipo. En una propiedad que es una colección, eso es igualdad de referencias, lo que sorprende a la gente:

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

Si un record contiene una colección y debe compararse por su contenido, sobrescribe Equals(Basket other) y GetHashCode(), o usa una colección inmutable con una semántica de valor propia.

Expresiones with: cambios no destructivos

Los records suelen ser inmutables, así que "cambias" uno creando una copia modificada. with copia todas las propiedades y después aplica las asignaciones que van entre llaves:

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

La copia es superficial. Una propiedad de tipo de referencia se copia como referencia, así que los dos records comparten el 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

O bien mantén las propiedades del record inmutables hasta el fondo (IReadOnlyList<T> rellenada una vez, o ImmutableList<T>), o bien crea una lista nueva en el with: c1 with { Tags = new List<string>(c1.Tags) }.

ToString

El ToString generado imprime el nombre del tipo y todas las propiedades públicas, lo que hace que los records sean cómodos en los logs y en el depurador:

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

Las colecciones se imprimen como el nombre de su tipo (System.Collections.Generic.List`1[System.String]), y los records anidados se imprimen de forma recursiva. Puedes sustituir toda la salida sobrescribiendo ToString:

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

record struct (C# 10)

record a secas significa record class: un tipo de referencia. C# 10 añadió record struct, un tipo de valor con los mismos miembros generados:

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 }

Conviene recordar la diferencia en los valores por defecto: un record struct posicional tiene propiedades mutables ({ get; set; }), en línea con el comportamiento habitual de los structs, mientras que readonly record struct y record class las tienen init-only. Elige entre ellos como elegirías entre un struct y una clase: los valores pequeños que se copian sin problema encajan con readonly record struct; todo lo demás, con record.

Herencia

Un record puede heredar de otro record (no de una clase, y una clase no puede heredar de un record). Los parámetros posicionales se pasan a la base como argumentos del constructor:

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 }

La igualdad incluye el tipo en tiempo de ejecución, mediante una propiedad generada EqualityContract. Por eso un Circle nunca es igual a un Square con el mismo Color, aunque los dos se comparen a través de Shape, y por eso ToString y with funcionan sobre el tipo derivado aunque la variable sea del tipo base.

Lo mismo en C# 7: una clase con igualdad por valor

Los records generan código que puedes escribir tú, y verlo explica su comportamiento. Aquí tienes una clase de C# 7 equivalente a public record Point(int X, int Y);: propiedades de solo lectura, un constructor, Deconstruct, igualdad por valor, un código hash coherente, ==, ToString y un método With que hace el papel de la expresión with.

Salida:

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

Unas 30 líneas para dos propiedades, y cada propiedad nueva obliga a volver a tocar el constructor, Deconstruct, Equals, GetHashCode y ToString. Olvidar uno de ellos es un bug clásico (dos puntos que son == pero tienen hashes distintos, así que un HashSet les pierde la pista). Ese mantenimiento es lo que eliminan los records.

La clase es sealed a propósito: la igualdad por valor combinada con la herencia necesita la comprobación de tipo extra que los records generan mediante EqualityContract, y sellarla esquiva el problema.

Cuándo usar un record

Los records encajan con datos que se definen por sus valores y no cambian después de crearse:

  • modelos de petición y respuesta de las API web;
  • mensajes, comandos y eventos que se pasan entre partes de un sistema;
  • objetos de configuración y de opciones;
  • claves compuestas de diccionario (record CacheKey(string Region, int Year));
  • resultados de un cálculo (record PriceQuote(decimal Net, decimal Tax)).

Encajan mal donde la identidad importa más que los valores: una entidad de Entity Framework es "el cliente 42" aunque cambie su nombre, y el seguimiento de cambios de EF Core depende de la identidad de referencia. Usa una clase ahí.

Errores comunes

  • Esperar igualdad profunda en las colecciones. Una propiedad List<T> se compara por referencia. Dos records con listas que parecen iguales no son iguales.
  • Esperar que with haga una copia profunda. Los objetos anidados y las colecciones se comparten entre el original y la copia.
  • Un record struct posicional mutable sin querer. Añade readonly salvo que quieras propiedades asignables.
  • Usar records como entidades de EF Core. La igualdad por valor y las copias chocan con el seguimiento de cambios.
  • Añadir un record a un proyecto de C# 8. Los records necesitan C# 9 (el valor por defecto en .NET 5 y posteriores). En destinos más antiguos, escribe la clase a mano como se muestra arriba.

Preguntas frecuentes

¿Qué es un record en C#?

Un record (C# 9) es una clase, o con record struct (C# 10) un struct, para la que el compilador genera la igualdad por valor, un ToString() legible, un método Deconstruct y el soporte para copias con with. public record Product(string Sku, decimal Price); es un tipo completo con dos propiedades init-only. Dos records con los mismos valores en sus propiedades son iguales, aunque sean objetos distintos.

¿Qué diferencia hay entre un record y una clase en C#?

Un record es por debajo una clase, así que es un tipo de referencia y puede heredar de otros records. Las diferencias son los miembros generados: los records comparan por valor (== y Equals comprueban cada campo), imprimen sus propiedades desde ToString() y admiten with. Una clase normal compara por referencia e imprime el nombre de su tipo salvo que escribas tú esos miembros.

¿Qué hace la expresión with en C#?

var sale = product with { Price = 6.00m }; crea un record nuevo que copia todas las propiedades de product y después asigna las indicadas. El original no cambia. La copia es superficial: una propiedad List<T> la comparten los dos records, así que añadirle algo a través de uno se ve a través del otro.

¿Qué es un record struct en C#?

record struct (C# 10) es un tipo de valor con los mismos miembros generados que un record de clase: igualdad por valor, ToString, Deconstruct y with. A diferencia de un record de clase, sus propiedades posicionales son mutables por defecto; decláralo readonly record struct para hacerlas init-only. Úsalo para valores pequeños como coordenadas o cantidades de dinero.

¿Cuándo debo usar un record en C#?

Para datos cuya identidad son sus valores: DTO, modelos de petición y respuesta de API, mensajes y eventos, configuración y claves de diccionarios. Evita los records para entidades que cambian con el tiempo y se identifican por un id, como las entidades de Entity Framework, porque la igualdad por valor y las copias con with van en contra del seguimiento de cambios.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR