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.
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,GetHashCodeyToStringbasadas en el valor de las propiedades. - Un método
Deconstructpara desestructuración. - Un método
Withpara 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;
}
}
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
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:
- 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). - 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).
- Inmutabilidad: Aunque no es automática como en los
records, losstructsdeben 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 esreadonly, el compilador se asegura de que no se modifique ninguno de sus miembros. Es una excelente práctica para la mayoría de losstructs.
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 destructque deben vivir en la pila (stack) y no pueden ser boxed (convertidos aobject) ni implementan interfaces. Se usan para escenarios de alto rendimiento donde se quiere evitar asignaciones en el heap, comoSpan<T>yReadOnlySpan<T>. Son para uso muy avanzado.
Comparativa: Records vs. Structs vs. Clases 📊
Para ayudarte a decidir, aquí tienes una tabla comparativa de las características clave:
| Característica | Clase (Tipo de Referencia) | Record (Tipo de Referencia) | Struct (Tipo de Valor) | Record Struct (Tipo de Valor) |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| Semántica | Referencia | Referencia | Valor | Valor |
| Uso Principal | Objetos con identidad, comportamiento, estado mutable | Modelos de datos inmutables, DTOs | Datos pequeños, eficientes, inmutables | Datos pequeños, eficientes, inmutables (con concisión record) |
| --- | --- | --- | --- | --- |
| Almacenamiento | Heap | Heap | Stack o Inline | Stack o Inline |
Comparación == | Por Referencia (por defecto) | Por Valor (automático) | Por Referencia (por defecto), Por Valor (manual) | Por Valor (automático) |
| --- | --- | --- | --- | --- |
| Inmutabilidad | Manual, requiere readonly o init | Por diseño (init properties, constructor primario) | Manual, requiere readonly struct | Por diseño (init properties, constructor primario) |
Clonación (with) | No (requiere patrón Builder o constructor de copia) | Sí (automático) | No | Sí (automático) |
| --- | --- | --- | --- | --- |
ToString() | Por defecto: nombre de tipo | Formato conciso con nombres/valores de propiedades (automático) | Por defecto: nombre de tipo | Formato conciso con nombres/valores de propiedades (automático) |
| Herencia | Sí | Sí (con otras record class) | No (solo de interfaces) | No (solo de interfaces) |
| --- | --- | --- | --- | --- |
| Boxing/Unboxing | No (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) |
Cuándo Elegir Cada Uno 🧭
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
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 classpara 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(preferiblementereadonly 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 structsi quieres las ventajas de rendimiento de unstructpequeño con la concisión sintáctica de unrecord(comparación por valor,With,ToStringautomático). - Usa
classpara 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
- C#: Construyendo APIs Resilientes con Polly y HttpClients para Manejo de Fallos Avanzadointermediate25 min
- C#: Desbloqueando el Poder de las Interfaces y la Inversión de Dependencias para Diseños Flexiblesintermediate15 min
- Delegados y Eventos en C#: Construyendo Arquitecturas Flexibles y Reactivasintermediate18 min
- C#: Explorando Inyección de Dependencias con IServiceCollection y Proveedores de Serviciointermediate25 min
- C#: Refactorizando con Patrones de Diseño - Estrategia y Observador en la Prácticaintermediate25 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!