Start Debugging

record vs class vs struct en C#: una matriz de decisión

C# 14 te da cuatro formas de tipo de datos -- class, record class, struct y record struct. Esta es la matriz de decisión: cuándo cada uno es correcto, qué cuesta cada uno, y las reglas que deciden por ti.

Si estás eligiendo entre class, record y struct para un nuevo tipo en C# 14 / .NET 10, el valor por defecto es class. Recurre a record class (el record estándar) cuando el tipo es datos inmutables y la igualdad por valor es el contrato. Recurre a readonly record struct cuando el tipo es pequeño (16 bytes o menos), inmutable, y se copia a través de rutas calientes donde una asignación en el heap por instancia dolería. Usa un struct simple solo para interoperabilidad no administrada o cuando genuinamente necesitas mutar un tipo de valor de tamaño fijo en el sitio. Usa un record simple (que es record class) cuando quieres inmutabilidad e igualdad por valor sin pelear con el GC.

Esta publicación es la versión larga. Todos los ejemplos apuntan a <TargetFramework>net10.0</TargetFramework> con <LangVersion>14.0</LangVersion>.

Las cuatro formas que realmente tienes

C# tiene dos tipos de almacenamiento (tipo de referencia, tipo de valor) y un modificador record ortogonal que añade igualdad por valor, un constructor primario, soporte para expresiones with y un ToString generado por el compilador. Eso da cuatro formas:

readonly record struct es la forma de struct más común que realmente escribirás. Marca cada campo como readonly y hace que toda la instancia sea inmutable, que es lo que quieres el 90 por ciento de las veces que recurres a un struct.

Matriz de características

Característicaclassrecord classstructrecord struct
Almacenamientoheapheapinline / pilainline / pila
Igualdad por defectoreferenciavalor (gen. por compilador)valor (reflexión)valor (gen. por compilador)
Expresión withnono
ToString generado por el compiladornono
Herenciasí (solo entre records)nono
Mutabilidad por defectomutableinit-only (inmutable)mutablemutable; readonly record struct es inmutable
Hace boxing al convertir a object / interfaznono
Costo de copiacopia de punterocopia de punterocopia bit a bit completacopia bit a bit completa
null permitido (NRT desactivado)no (usa T?)no (usa T?)
Asigna en el heapcada instanciacada instanciasolo cuando hay boxingsolo cuando hay boxing
Buena como clave de diccionariosolo si implementas Equals/GetHashCodesí, de serieno — la igualdad por reflexión es lentasí, de serie
Buena como entidad de EF Coresí (con cuidado)nono

La tabla es la publicación. Todo lo siguiente es el porqué.

Por qué class es el valor por defecto

Una class se asigna en el heap administrado, se accede por referencia, y es igual a otra instancia solo cuando ambas referencias apuntan al mismo objeto. La semántica de referencia es el ajuste natural para cosas que tienen una identidad: un User, un Customer, un HttpClient. Dos objetos User con el mismo nombre y correo no son el mismo usuario; son dos registros que casualmente comparten datos. La igualdad por referencia coincide con ese modelo mental.

class también es la única forma que admite herencia con tipos derivados arbitrarios. record también admite herencia, pero solo entre otros records. struct y record struct no admiten ninguna.

Elige class cuando:

// .NET 10, C# 14
public class Customer
{
    public Guid Id { get; init; }
    public string Email { get; set; } = "";
    public DateTimeOffset CreatedAt { get; init; }
}

Este es el asiento que posee una fila en la base de datos y se le permite cambiar con el tiempo.

Cuándo recurrir a record class

Un record (que es record class — la palabra clave class está implícita) es la respuesta correcta para portadores de datos inmutables donde dos instancias con los mismos valores de campo deberían tratarse como iguales. El compilador genera un Equals, GetHashCode, ToString basados en valor, y un método virtual EqualityContract que hace que la herencia funcione. La sintaxis posicional public record Address(string City, string Zip); añade un constructor primario y una propiedad init-only por parámetro.

Elige record class cuando:

Una record class se sigue asignando en el heap y se accede por referencia, así que toda la intuición de “esto es barato de pasar” sobre class sigue aplicando. Pagas una asignación por instancia, pero no pagas una copia bit a bit cada vez que la pasas a un método.

// .NET 10, C# 14
public sealed record OrderPlaced(Guid OrderId, decimal Total, DateTimeOffset At);

var evt = new OrderPlaced(orderId, 42.50m, DateTimeOffset.UtcNow);
var corrected = evt with { Total = 42.95m };

// evt != corrected
// Console.WriteLine(evt) prints OrderPlaced { OrderId = ..., Total = 42.50, At = ... }

Dos advertencias. Primera, declara los records como sealed a menos que realmente necesites una jerarquía de records. El compilador emite una indirección EqualityContract en cada record para que los records derivados puedan participar en la igualdad por valor, y sealed permite al JIT desvirtualizar las llamadas. Segunda, no pongas propiedades de colección mutables en un record. La igualdad por valor de record compara referencias para esas propiedades, no contenidos, lo que lleva a sorpresas del estilo “por qué estos dos records no son iguales”. Usa ImmutableArray<T> o IReadOnlyList<T> inicializado una vez.

Cuándo recurrir a struct (y especialmente readonly record struct)

Un struct es un tipo de valor. Sus campos viven inline en lo que sea que lo contenga: en la pila para variables locales, dentro del objeto contenedor en el heap para campos, empaquetados de extremo a extremo en arreglos. Cada asignación es una copia bit a bit del struct completo. La igualdad, cuando la suministras, puede ser una sola comparación de CPU en lugar de una llamada virtual.

Esto es fantástico cuando los datos son pequeños y tienes muchos. Un struct de dos campos int puede mantenerse en un par de registros, compararse con una rama, y almacenarse en un arreglo como 8 bytes por elemento sin encabezado por elemento. La misma carga útil como class sería un encabezado de objeto de 24 bytes más una referencia de 8 bytes por slot, lo que destruye la localidad de caché una vez que el arreglo es mayor que la línea de L1.

La guía de Microsoft choose between class and struct lista cuatro condiciones para un struct: representa lógicamente un solo valor, tiene un tamaño de instancia por debajo de 16 bytes, es inmutable, y no sufre boxing con frecuencia. Las cuatro juntas, no tres de cuatro.

Elige readonly record struct (o readonly struct si no necesitas igualdad por valor) cuando:

// .NET 10, C# 14
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);
    public Money Plus(Money other) =>
        other.Currency == Currency
            ? new(Amount + other.Amount, Currency)
            : throw new InvalidOperationException("currency mismatch");
}

Esto compila a un tipo de valor con igualdad por valor integrada, un deconstructor, una sobrescritura de ToString, y semántica inmutable. Es el reemplazo moderno de “escribiré un struct y recordaré no hacerlo mutable”.

La regla de 16 bytes es una heurística, no un tope rígido. El JIT pasará felizmente un struct de 24 bytes en registros en AMD64 si encaja en la convención de llamada. La razón para mantener los structs pequeños son las copias bit a bit. Cada asignación, cada paso de parámetro sin in, cada paso de LINQ copia todo. Un struct de 64 bytes pasado por valor a través de cinco marcos de método son 320 bytes de copia.

Cuándo record struct (mutable) es la elección correcta

Un record struct simple (sin readonly) es raro pero legítimo. Te da igualdad por valor, un constructor primario y un ToString, mientras sigue permitiendo que los campos sean reasignados. Dos escenarios tienen sentido:

Para todo lo demás, prefiere readonly record struct. Un struct mutable es un famoso disparate: asignarlo a una propiedad crea una copia, mutando la copia, y silenciosamente no hace nada al original.

La matriz de decisión que puedes pegar en una pared

Tres preguntas, en orden. Detente en la primera que apunte a algún lugar.

  1. ¿Tiene este tipo identidad, o posee estado cambiante a lo largo del tiempo? Sí -> class. Ejemplos: User, Order, HttpClient, entidades de EF Core, cualquier cosa en un contenedor de servicios con un tiempo de vida.

  2. ¿Es este tipo datos inmutables que deberían ser iguales por valor y pequeños (16 bytes o menos, sin referencias a objetos grandes)? Sí -> readonly record struct. Ejemplos: Money, Point, IDs con tipo fuerte como UserId(Guid Value), celdas (int Row, int Column). El umbral de 16 bytes importa más cuando los mantienes en arreglos, span, o los pasas a través de bucles calientes.

  3. De lo contrario: ¿es el tipo datos inmutables con igualdad por valor? Sí -> record (record class). Ejemplos: DTOs, modelos de solicitud/respuesta, eventos de dominio, instantáneas de configuración, tipos de mensaje en una cola. Este es el valor por defecto para “clases de datos” en C# moderno.

Si nada de lo anterior apunta a algún lugar, casi seguro quieres class. El caso restante es “necesito un tipo de valor pero tiene más de 16 bytes”, lo que generalmente significa reestructurar el tipo, no inclinarse más hacia struct.

El benchmark: cuándo las copias de struct realmente duelen

Una afirmación común es “los structs son más rápidos”. A veces lo son, a veces el costo de copia domina. Aquí hay una medición rápida para una carga útil de 24 bytes pasada a través de cinco marcos de método.

// .NET 10, C# 14, BenchmarkDotNet 0.14.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<CopyCost>();

public readonly record struct PayloadStruct(long A, long B, long C); // 24 bytes
public sealed record PayloadClass(long A, long B, long C);           // pointer + 24 bytes on heap

[MemoryDiagnoser]
public class CopyCost
{
    private readonly PayloadStruct _s = new(1, 2, 3);
    private readonly PayloadClass _c = new(1, 2, 3);

    [Benchmark(Baseline = true)]
    public long Struct_ByVal()  => Sum1(_s);
    [Benchmark]
    public long Struct_ByIn()   => Sum2(in _s);
    [Benchmark]
    public long Class_ByRef()   => Sum3(_c);

    static long Sum1(PayloadStruct p)     => p.A + p.B + p.C;
    static long Sum2(in PayloadStruct p)  => p.A + p.B + p.C;
    static long Sum3(PayloadClass p)      => p.A + p.B + p.C;
}

Metodología: BenchmarkDotNet 0.14.0, .NET 10.0.0 RTM, Windows 11 24H2, AMD Ryzen 9 7900X. Números de una sola ejecución; vuelve a ejecutar en tu propio hardware antes de apostar por ellos.

MétodoMediaAsignado
Struct_ByVal0.31 ns0 B
Struct_ByIn0.28 ns0 B
Class_ByRef0.34 ns0 B

El struct pasado por valor es ligeramente más rápido que la clase accedida por referencia, e in ahorra un pelo más. Pero la brecha es de subnanosegundos. El struct gana decisivamente solo cuando asignas la clase millones de veces — el costo de asignación es lo que difiere, no el costo de acceso. Elige struct por presión de asignación, no por “acceso más rápido”.

Cuando el struct crece, el patrón se invierte. Un struct mutable de 64 bytes pasado por valor a través de tres marcos es una regresión medible frente a una referencia de class. La regla de 16 bytes existe porque ahí es aproximadamente donde la copia bit a bit deja de ser gratis en AMD64.

Las trampas que deciden por ti

Algunas cosas fuerzan la decisión sin importar la preferencia.

La recomendación opinada, reformulada

Por defecto, class. Elige record (record class) para datos inmutables con igualdad por valor. Elige readonly record struct para valores pequeños inmutables que mantienes en masa o pasas a través de bucles calientes. Elige un struct simple solo cuando la interoperabilidad o la mutación en el sitio en una sola local hagan que valga la pena el disparate, y elige un class no-record para entidades y tipos que llevan identidad.

Dos corolarios que vale la pena comprometer en memoria muscular:

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver