tutoriales.com

C#: Desentrañando los Records y Structs para una Inmutabilidad y Eficiencia Óptimas

Este tutorial profundiza en los tipos `record` y `struct` de C#, ofreciendo una guía completa sobre su uso, ventajas y escenarios de aplicación. Aprenderás a crear tipos de datos inmutables y de alto rendimiento, optimizando la gestión de memoria y la claridad del código. Compararemos sus características clave, diferencias con las clases y cómo impactan el diseño de tus aplicaciones.

Intermedio18 min de lectura6 views
Reportar error

Introducción: La Inmutabilidad y la Eficiencia en el Corazón de C# ✨

En el mundo del desarrollo de software, la gestión de datos es fundamental. Dos conceptos clave que emergen con fuerza son la inmutabilidad (una vez creado, un objeto no cambia) y la eficiencia (uso óptimo de los recursos). C# ha evolucionado para ofrecer herramientas poderosas que abordan estas necesidades de manera elegante y concisa: los records y los structs.

Tradicionalmente, las clases eran el caballo de batalla para modelar datos, pero a menudo requieren un boilerplate considerable para lograr inmutabilidad y pueden incurrir en sobrecargas de memoria cuando se usan para tipos de datos pequeños y frecuentes. Los structs, por otro lado, siempre han ofrecido eficiencia basada en el valor, pero con sus propias complejidades.

Con la introducción de records en C# 9, los desarrolladores obtuvieron una forma concisa de crear tipos inmutables orientados a datos. Al mismo tiempo, las mejoras continuas en structs han solidificado su papel como una opción de alto rendimiento para escenarios específicos. Este tutorial explorará a fondo ambos, te guiará a través de sus características, te ayudará a entender cuándo elegir uno sobre el otro y te mostrará cómo aplicarlos eficazmente en tus proyectos.


Records en C#: Simplificando la Inmutabilidad 📖

Los records son un tipo de referencia (clase) introducido en C# 9. Están diseñados para modelar datos y sobresalen en la creación de tipos inmutables con una sintaxis significativamente reducida. Su objetivo principal es facilitar la creación de clases cuyos valores se determinan en el momento de la construcción y no cambian después.

¿Por qué Records? El Problema de la Inmutabilidad Tradicional ⚠️

Considera una clase tradicional que queremos hacer inmutable:

public class PersonaClasica
{
    public string Nombre { get; }
    public string Apellido { get; }
    public int Edad { get; }

    public PersonaClasica(string nombre, string apellido, int edad)
    {
        Nombre = nombre;
        Apellido = apellido;
        Edad = edad;
    }

    // Requiere override de Equals y GetHashCode para comparación por valor
    public override bool Equals(object obj)
    {
        return obj is PersonaClasica persona &&
               Nombre == persona.Nombre &&
               Apellido == persona.Apellido &&
               Edad == persona.Edad;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Nombre, Apellido, Edad);
    }

    // Requiere override de ToString para una representación útil
    public override string ToString()
    {
        return $"PersonaClasica {{ Nombre = {Nombre}, Apellido = {Apellido}, Edad = {Edad} }}";
    }
}

Para hacer PersonaClasica inmutable y que se comporte como un tipo de valor (es decir, que dos instancias con los mismos datos sean consideradas iguales), necesitamos escribir bastante código repetitivo (Equals, GetHashCode, ToString). Aquí es donde los records brillan.

Creando Records: Sintaxis Concisa ✅

Con un record, la misma Persona se ve así:

public record PersonaRecord(string Nombre, string Apellido, int Edad);

¡Así de simple! Este record posicional automáticamente genera:

  • Propiedades inmutables de solo lectura (como string Nombre { get; init; }).
  • Un constructor primario que inicializa estas propiedades.
  • Implementaciones de Equals, GetHashCode y ToString basadas en el valor de las propiedades.
  • Un método Deconstruct para desestructuración.
  • Un método With para clonación no destructiva (más sobre esto pronto).

También puedes definir records de forma más tradicional, si necesitas más control o lógica adicional:

public record ProductoRecord
{
    public string Nombre { get; init; }
    public decimal Precio { get; init; }
    public int Stock { get; init; }

    public ProductoRecord(string nombre, decimal precio, int stock)
    {
        Nombre = nombre;
        Precio = precio;
        Stock = stock;
    }
}
💡 Consejo: Usa `init` en lugar de `set` para propiedades en `records` (y clases) cuando quieras asegurar que la propiedad solo se asigne durante la inicialización del objeto (ya sea en el constructor o en un inicializador de objeto). Esto refuerza la inmutabilidad.

Comparación por Valor en Records 🤝

Una de las características más destacadas de los records es su comparación por valor. A diferencia de las clases (que comparan por referencia por defecto), dos records con los mismos valores en todas sus propiedades públicas se consideran iguales.

var persona1 = new PersonaRecord("Juan", "Pérez", 30);
var persona2 = new PersonaRecord("Juan", "Pérez", 30);
var persona3 = new PersonaRecord("María", "García", 25);

Console.WriteLine($"Persona1 == Persona2: {persona1 == persona2}"); // True
Console.WriteLine($"Persona1 == Persona3: {persona1 == persona3}"); // False

Clonación No Destructiva con Expresiones With 🔄

Dado que los records son inmutables, no puedes cambiar sus propiedades una vez creados. Pero ¿qué pasa si necesitas una versión modificada de un record existente? Para esto, los records proporcionan las expresiones with.

var libroOriginal = new LibroRecord("El Quijote", "Miguel de Cervantes", 1605);

// Crear una nueva instancia de record con una propiedad modificada
var libroConAnoNuevo = libroOriginal with { AnoPublicacion = 1615 };

Console.WriteLine(libroOriginal);      // LibroRecord { AnoPublicacion = 1605, Autor = Miguel de Cervantes, Titulo = El Quijote }
Console.WriteLine(libroConAnoNuevo); // LibroRecord { AnoPublicacion = 1615, Autor = Miguel de Cervantes, Titulo = El Quijote }
Console.WriteLine(ReferenceEquals(libroOriginal, libroConAnoNuevo)); // False

public record LibroRecord(string Titulo, string Autor, int AnoPublicacion);

La expresión with crea una nueva instancia del record, copiando todos los valores de las propiedades del record original y aplicando las modificaciones especificadas. Esto es crucial para mantener la inmutabilidad y es un patrón muy común en programación funcional.

Desestructuración 🎯

Los records posicionales también vienen con un método Deconstruct implícito, lo que te permite extraer sus valores fácilmente:

var punto = new PuntoRecord(10, 20);
var (x, y) = punto; // Desestructuración

Console.WriteLine($"X: {x}, Y: {y}"); // X: 10, Y: 20

public record PuntoRecord(int X, int Y);

Records de Estructura (record struct) 🚀

Además de record class (que es el record por defecto), C# 10 introdujo record struct. Estos combinan la semántica de valor de los structs con las características de concisión de los records (comparación por valor, ToString, With).

public record struct CoordenadaRecordStruct(double Latitud, double Longitud);

var c1 = new CoordenadaRecordStruct(40.7128, -74.0060);
var c2 = new CoordenadaRecordStruct(40.7128, -74.0060);

Console.WriteLine($"C1 == C2: {c1 == c2}"); // True
🔥 Importante: Aunque `record struct` ofrece la concisión de `record` para tipos de valor, las mismas consideraciones de tamaño y rendimiento que se aplican a los `structs` tradicionales también se aplican aquí. No uses `record struct` para tipos grandes o que requieran semántica de referencia.

Structs en C#: La Eficiencia en Tipo de Valor ⚙️

Los structs (estructuras) son tipos de valor en C#. Esto significa que cuando asignas un struct a una nueva variable o lo pasas como argumento a un método, se crea una copia completa de los datos. A diferencia de las clases (tipos de referencia) donde se copia la referencia a la misma ubicación en memoria (heap).

¿Cuándo usar Structs? Casos de Uso Clave 🎯

Los structs son ideales para:

  1. Datos pequeños y simples: Cuando un tipo representa un solo valor o un conjunto pequeño de valores relacionados (por ejemplo, Point, Size, Color, DateTime).
  2. Rendimiento: Pueden ser más eficientes que las clases en ciertos escenarios, especialmente cuando se manejan muchas instancias pequeñas, ya que se asignan en la pila (stack) o se incluyen directamente dentro de la memoria de sus tipos contenedores, reduciendo la presión sobre el recolector de basura (GC).
  3. Inmutabilidad: Aunque no es automática como en los records, los structs deben diseñarse para ser inmutables siempre que sea posible para evitar comportamientos inesperados de copia.

Creando Structs 🛠️

public struct Punto
{
    public int X { get; }
    public int Y { get; }

    public Punto(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override string ToString()
    {
        return $"Punto (X: {X}, Y: {Y})";
    }
}

Observa que las propiedades son de solo lectura (get;), lo cual es una buena práctica para la inmutabilidad en structs.

Comparación por Valor en Structs (Implícita) 🤝

Por defecto, los structs ya implementan comparación por valor para Equals y GetHashCode (aunque es recomendable sobrescribirlas para optimizar el rendimiento, especialmente para structs grandes o que se usarán en colecciones de hash).

var p1 = new Punto(5, 10);
var p2 = new Punto(5, 10);
var p3 = new Punto(1, 2);

Console.WriteLine($"P1 == P2: {p1.Equals(p2)}"); // True (usando la implementación predeterminada de Equals)
Console.WriteLine($"P1 == P3: {p1.Equals(p3)}"); // False
Console.WriteLine($"P1 == P2: {p1 == p2}"); // True, si se sobrecarga el operador ==

Para usar el operador == directamente con structs personalizados, necesitas sobrecargarlo, lo cual es algo que los records hacen por ti automáticamente.

public struct PuntoConOperador
{
    public int X { get; }
    public int Y { get; }

    public PuntoConOperador(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override bool Equals(object obj)
    {
        return obj is PuntoConOperador other && X == other.X && Y == other.Y;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(X, Y);
    }

    public static bool operator ==(PuntoConOperador left, PuntoConOperador right)
    {
        return left.Equals(right);
    }

    public static bool operator !=(PuntoConOperador left, PuntoConOperador right)
    {
        return !(left == right);
    }
}

var pc1 = new PuntoConOperador(5, 10);
var pc2 = new PuntoConOperador(5, 10);
Console.WriteLine($"PC1 == PC2: {pc1 == pc2}"); // True

readonly struct y ref struct 🔒

  • readonly struct: Garantiza que todas las propiedades y campos de la estructura son inmutables. Esto mejora la seguridad y la predictibilidad. Si una estructura es readonly, el compilador se asegura de que no se modifique ninguno de sus miembros. Es una excelente práctica para la mayoría de los structs.
public readonly struct ColorRGB
{
public byte R { get; init; }
public byte G { get; init; }
public byte B { get; init; }

public ColorRGB(byte r, byte g, byte b)
{
R = r;
G = g;
B = b;
}
}
  • ref struct: Son tipos especiales de struct que deben vivir en la pila (stack) y no pueden ser boxed (convertidos a object) ni implementan interfaces. Se usan para escenarios de alto rendimiento donde se quiere evitar asignaciones en el heap, como Span<T> y ReadOnlySpan<T>. Son para uso muy avanzado.
⚠️ Advertencia: Los `structs` pueden ser peligrosos si no se manejan correctamente la inmutabilidad. Dado que las asignaciones y los pasos de parámetros copian la estructura completa, modificar una copia *no* modifica el original. Si tienes campos de tipo de referencia dentro de un `struct` mutable, puedes caer en situaciones confusas. ¡Prioriza `readonly struct`!

Comparativa: Records vs. Structs vs. Clases 📊

Para ayudarte a decidir, aquí tienes una tabla comparativa de las características clave:

CaracterísticaClase (Tipo de Referencia)Record (Tipo de Referencia)Struct (Tipo de Valor)Record Struct (Tipo de Valor)
---------------
SemánticaReferenciaReferenciaValorValor
Uso PrincipalObjetos con identidad, comportamiento, estado mutableModelos de datos inmutables, DTOsDatos pequeños, eficientes, inmutablesDatos pequeños, eficientes, inmutables (con concisión record)
---------------
AlmacenamientoHeapHeapStack o InlineStack o Inline
Comparación ==Por Referencia (por defecto)Por Valor (automático)Por Referencia (por defecto), Por Valor (manual)Por Valor (automático)
---------------
InmutabilidadManual, requiere readonly o initPor diseño (init properties, constructor primario)Manual, requiere readonly structPor diseño (init properties, constructor primario)
Clonación (with)No (requiere patrón Builder o constructor de copia)Sí (automático)NoSí (automático)
---------------
ToString()Por defecto: nombre de tipoFormato conciso con nombres/valores de propiedades (automático)Por defecto: nombre de tipoFormato conciso con nombres/valores de propiedades (automático)
HerenciaSí (con otras record class)No (solo de interfaces)No (solo de interfaces)
---------------
Boxing/UnboxingNo (ya es tipo de referencia)No (ya es tipo de referencia)Sí (cuando se trata como object o interfaz)Sí (cuando se trata como object o interfaz)
INICIO ¿Necesitas semántica de referencia o identidad? SI NO ¿Priorizas inmutabilidad y concisión de datos? SI Record NO Clase ¿Datos pequeños (<16 bytes) y alta frecuencia? NO Clase / Record SI ¿Necesitas concisión de record (with, ToString)? SI Record Struct NO Struct

Cuándo Elegir Cada Uno 🧭

Clase: Elige clases cuando necesites *identidad* de objeto, *herencia*, *mutabilidad controlada*, o cuando tu objeto sea grande y su paso por referencia sea más eficiente que copiarlo. Son la opción predeterminada para la mayoría de los objetos de negocio complejos.
Record Class: Ideal para *modelos de datos inmutables*, DTOs (Data Transfer Objects), valores que se comparan por sus propiedades y quieres evitar el boilerplate manual. Perfectos para APIs, configuraciones, y cualquier lugar donde los datos no deben cambiar después de la creación.
Struct: Opta por structs para *pequeños tipos de valor*, inmutables, que se usan frecuentemente y donde la asignación en la pila puede ofrecer beneficios de rendimiento al reducir la presión del recolector de basura. Piensa en tipos como `Point`, `Guid`, `DateTime`. Prioriza `readonly struct`.
Record Struct: Combina las ventajas de los structs (tipo de valor, eficiencia) con la concisión de los records (comparación por valor automática, `ToString`, expresiones `with`). Útil para pequeños tipos de valor inmutables que se benefician de las funcionalidades de `record`.

Ejemplos Prácticos y Buenas Prácticas 💡

Ejemplo 1: Datos de Configuración Inmutables con record class

Los records son excelentes para manejar configuraciones que no deberían cambiar durante la ejecución de la aplicación.

public record AppConfig(string ApiKey, string DbConnectionString, int CacheDurationMinutes);

public class ConfiguracionManager
{
    public AppConfig ObtenerConfiguracion()
    {
        // Normalmente se cargarían desde un archivo, variables de entorno, etc.
        return new AppConfig("mi_api_key_secreta", "Server=.;Database=AppDb;", 60);
    }
}

// Uso
var config = new ConfiguracionManager().ObtenerConfiguracion();
Console.WriteLine(config.ApiKey);
// config.ApiKey = "nueva"; // Error de compilación: propiedad 'ApiKey' tiene solo un inicializador de conjunto (init-only)

Ejemplo 2: Coordenadas 3D Eficientes con readonly struct

Para representar puntos en un espacio 3D, un struct es una opción natural debido a su tamaño pequeño y semántica de valor.

public readonly struct Vector3D
{
    public double X { get; init; }
    public double Y { get; init; }
    public double Z { get; init; }

    public Vector3D(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public double Magnitud => Math.Sqrt(X*X + Y*Y + Z*Z);

    public Vector3D Sumar(Vector3D otro)
    {
        // Devuelve una nueva instancia, manteniendo la inmutabilidad
        return new Vector3D(X + otro.X, Y + otro.Y, Z + otro.Z);
    }

    public override string ToString()
    {
        return $"({X}, {Y}, {Z})";
    }
}

// Uso
var v1 = new Vector3D(1.0, 2.0, 3.0);
var v2 = new Vector3D(2.0, 3.0, 4.0);
var vSuma = v1.Sumar(v2);

Console.WriteLine($"Vector V1: {v1}");
Console.WriteLine($"Vector V2: {v2}");
Console.WriteLine($"Vector Suma: {vSuma}");
Console.WriteLine($"Magnitud de V1: {v1.Magnitud:F2}");

Ejemplo 3: Modelando una Entrada de Log con record struct

Si quieres la concisión de un record pero para un tipo de valor pequeño que se manejará en grandes volúmenes, record struct es excelente.

public record struct LogEntry(DateTime Timestamp, string Message, LogLevel Level)
{
    public override string ToString()
    {
        return $"[{Timestamp:HH:mm:ss}] [{Level}] {Message}";
    }
}

public enum LogLevel { Info, Warning, Error }

// Uso
var log1 = new LogEntry(DateTime.Now, "Aplicación iniciada.", LogLevel.Info);
var log2 = log1 with { Message = "Aplicación terminada.", Timestamp = DateTime.Now.AddMinutes(5) };

Console.WriteLine(log1);
Console.WriteLine(log2);
Console.WriteLine($"Son iguales: {log1 == log2}"); // False, porque los valores son diferentes
📌 Nota: Al usar `record struct` o `readonly struct`, si necesitas cambiar un valor, siempre crea una nueva instancia con los valores modificados. Este es el paradigma de la inmutabilidad.

Patrones Avanzados y Consideraciones 🧐

Inmutabilidad y Colecciones

Cuando trabajas con records o structs que contienen colecciones (listas, arrays), es importante recordar que la colección en sí misma es un tipo de referencia. Si una propiedad de record es List<T>, el record es inmutable respecto a qué List<T> referencia, pero la lista misma sigue siendo mutable.

Para colecciones inmutables, considera usar System.Collections.Immutable:

using System.Collections.Immutable;

public record ConfiguracionUsuario(string NombreUsuario, ImmutableList<string> Preferencias);

var usuario = new ConfiguracionUsuario("alice", ImmutableList.Create("TemaOscuro", "NotificacionesEmail"));

// Para añadir una preferencia, necesitas crear un nuevo record con una nueva lista inmutable
var usuarioModificado = usuario with 
{
    Preferencias = usuario.Preferencias.Add("GuardarContrasena")
};

Console.WriteLine(usuario.Preferencias.Count);     // 2
Console.WriteLine(usuarioModificado.Preferencias.Count); // 3

Rendimiento de structs y Boxing/Unboxing

Los structs son eficientes porque evitan la asignación en el heap y la recolección de basura asociada. Sin embargo, cuando un struct se convierte implícitamente a object o a una interfaz que implementa (lo que se conoce como boxing), se asigna una copia de la estructura en el heap. Esto puede anular las ganancias de rendimiento si ocurre con frecuencia.

public struct Contador {
    public int Valor { get; set; }
}

Contador c = new Contador { Valor = 10 }; // En la pila
object obj = c; // Boxing: se copia 'c' al heap
Contador c2 = (Contador)obj; // Unboxing: se copia el valor del heap a la pila

Para evitar el boxing con interfaces, C# 8 introdujo las interfazes con miembros estáticos virtuales por defecto, y C# 11, las interfazes con miembros abstractos estáticos, que permiten a los structs implementar métodos de interfaz directamente sin boxing en algunos escenarios avanzados, como los operadores numéricos genéricos.

Herencia en Records

Los records (clase) soportan herencia, lo que permite crear jerarquías de tipos de datos inmutables. Los record struct no soportan herencia, solo pueden implementar interfaces, al igual que los structs tradicionales.

public record Persona(string Nombre, string Apellido);

public record Empleado(string Nombre, string Apellido, string Puesto) : Persona(Nombre, Apellido);

var emp = new Empleado("Ana", "López", "Desarrolladora");
Console.WriteLine(emp);
// Empleado { Puesto = Desarrolladora, Apellido = López, Nombre = Ana }

Al sobrescribir miembros en un record derivado, la comparación por valor y ToString incluirán los miembros del tipo derivado.


Conclusión: Elegir la Herramienta Adecuada 🔚

Los records y structs son adiciones valiosas al arsenal de C# para crear código más robusto, eficiente y legible. Entender sus diferencias y cuándo aplicarlos es clave para escribir aplicaciones de alto rendimiento y fácil mantenimiento.

  • Usa record class para tipos de referencia inmutables, orientados a datos, donde la concisión y la comparación por valor son prioritarias, y no te importa la asignación en el heap.
  • Usa struct (preferiblemente readonly struct) para pequeños tipos de valor inmutables donde la eficiencia de memoria (pila) y la evitación del recolector de basura son cruciales.
  • Usa record struct si quieres las ventajas de rendimiento de un struct pequeño con la concisión sintáctica de un record (comparación por valor, With, ToString automático).
  • Usa class para objetos complejos que requieren identidad, herencia o mutabilidad controlada, y donde el costo del heap es aceptable.

Dominar estos conceptos te permitirá escribir código C# más idiomático y optimizado, alineándote con las mejores prácticas de programación moderna.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!