1. ¿Por qué hicieron falta los record?
En C# (y en .NET en general) durante mucho tiempo los bloques principales eran las clases (class) y las estructuras (struct). Pero cada uno de ellos es incompleto para ciertas tareas. Las clases son de referencia, mutables y se comparan por referencia (salvo raras excepciones). Las estructuras son tipos por valor (se copian al pasar), por defecto se comparan byte a byte y casi siempre son mutables (hasta que aparecieron los readonly struct).
Pero si querías una forma simple y natural de guardar datos, que fuera fácil de comparar por valor, clonar rápido y sin preocuparte de que alguien cambie tu objeto en algún sitio — tocaba inventar mil soluciones caseras o tirar de librerías como ValueTuple o incluso System.Tuple. Pero ninguna de ellas es tan bonita y expresiva como uno quisiera.
Por eso en C# 9 apareció record — un tipo de dato que combina declaración corta, seguridad de inmutabilidad y comportamiento orientado a comparación por valor.
2. Los cuatro tipos en una sola página
| class | struct | record | record struct | |
|---|---|---|---|---|
| Categoría | Tipo de referencia | Tipo por valor | Tipo de referencia | Tipo por valor |
| Mutabilidad | Por defecto — sí | Por defecto — sí | Por defecto — no (init) | Por defecto — no (init) |
| Comparación | Por referencia (==) | Por valor (campos) | Por valor (fields/properties) | Por valor (fields) |
| Clonación | Sólo manualmente | Sólo manualmente | Soporte integrado (with) | Soporte integrado (with) |
| Herencia | Sí | No | Sí | No |
| Inmutabilidad | Hay que implementarla | Hay que implementarla | Muy fácil de implementar | Muy fácil de implementar |
| Sintaxis | La más larga | Corta | La más corta (posicional) | Bastante corta |
| Uso en colecciones | Por referencia | Copias | Por referencia | Copias |
Esquema visual
+----------------+ +----------------+ +--------------------+
| class | | struct | | record |
+----------------+ +----------------+ +--------------------+
| Reference Type | | Value Type | | Reference Type |
| Mutable | | Mutable | | Immutable (init) |
| == : Reference | | == : By Fields | | == : By Value |
+----------------+ +----------------+ +--------------------+
3. Lo esencial entre record, class y struct
Comportamiento en memoria: ¿referencia o valor?
- class y record — son tipos de referencia. Cuando los pasas a una función, se copia la referencia al objeto.
- struct y record struct — son tipos por valor. Siempre se copian byte a byte (a menos que los pases explícitamente por referencia).
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; }
record PointRecord(int X, int Y);
record struct PointRecordStruct(int X, int Y);
void Demo()
{
var pc = new PointClass { X = 1, Y = 2 };
var ps = new PointStruct { X = 1, Y = 2 };
var pr = new PointRecord(1, 2);
var prs = new PointRecordStruct(1, 2);
ChangeY(pc); // ¡pc.Y cambiará!
ChangeY(ps); // ps.Y no cambiará — ¡es una copia!
ChangeY(pr); // ¡pr.Y cambiará!
ChangeY(prs); // prs.Y no cambiará — ¡es una copia!
}
void ChangeY(dynamic p) { p.Y = 99; }
Si te parece que dynamic aquí es magia — sí, es sólo para el ejemplo, para que veas que al trabajar con struct el valor no cambia, pero con class o record (de referencia) — sí cambia.
Comparación: ¿cómo saber si dos objetos son iguales?
- class: se comparan por referencia (== — true sólo si es el mismo objeto en memoria), salvo que sobreescribas Equals.
- struct: se comparan por valor de todos los campos (por defecto).
- record: se comparan por valor de todos los campos/propiedades definidos en el constructor primario.
class Foo { public int A; public int B; }
record Bar(int A, int B);
var foo1 = new Foo { A = 42, B = 1 };
var foo2 = new Foo { A = 42, B = 1 };
var bar1 = new Bar(42, 1);
var bar2 = new Bar(42, 1);
Console.WriteLine(foo1 == foo2); // ¡False! Objetos diferentes
Console.WriteLine(bar1 == bar2); // ¡True! Los valores coinciden
Dato curioso:
Con record struct es aún más guay: la comparación es "por valor", como en los struct normales, pero con la sintaxis y ventajas de record.
4. Inmutabilidad: ¿quién garantiza qué?
Comparamos la seguridad de los objetos:
- class: Por defecto es fácilmente mutable, salvo que pongas todos los campos como readonly.
- struct: Igual, pero puedes declararlo como readonly struct, entonces ni campos ni propiedades se pueden cambiar.
- record: Normalmente los campos se declaran con el modificador init, o sea, sólo puedes asignarlos en el constructor o en el inicializador del objeto (with). Es cómodo para pasar datos de forma segura.
- record struct: Igual, puedes hacer readonly record struct y tener inmutabilidad en tipos por valor con los extras de record.
record Person(string Name, int Age);
var p1 = new Person("Aleksei", 23);
// p1.Age = 24; // ¡Error! Sólo init
var p2 = p1 with { Age = 24 }; // ¡Funciona! Crea una copia con nuevos datos
Cuando tienes un proyecto grande y decenas de entidades, record te ayuda a evitar un montón de bugs del tipo "alguien cambió un campo en algún sitio — y todo se rompió".
5. Sintaxis: cómo se declaran y cómo no liarse
// class
public class Product
{
public int Id { get; init; }
public string Name { get; init; }
}
// struct
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
// record
public record Product(int Id, string Name);
// record struct
public record struct Point(int X, int Y);
Como ves, la declaración más corta es la del tipo record. Obtienes constructor, destructor, igualdad, ToString() y mucho más "de serie".
| Sintaxis | Lo que obtienes gratis | ¿Se puede heredar? |
|---|---|---|
| class | nada (casi) | Sí |
| struct | nada (casi) | No |
| record | ToString, Equals, Deconstruct, with | Sí |
| record struct | ToString, Equals, Deconstruct, with | No |
6. Clonación y el operador with
Sólo record y record struct tienen el operador especial with, que te permite copiar un objeto cambiando sólo algunas propiedades, sin dolor.
record User(string Name, int Age);
var user1 = new User("Irina", 28);
var user2 = user1 with { Age = 29 }; // user2.Name == "Irina", user2.Age == 29
En el caso de una clase (class) tendrás que implementar el método de copia a mano, y si te olvidas de copiar algún campo — hola, bugs.
7. Herencia: quién puede heredar de quién
- class: Soporta herencia estándar (jerarquías de clases, métodos virtuales, abstracciones, etc.).
- struct: No soporta (sólo puede implementar interfaces).
- record: Soporta herencia, pero con algunas limitaciones (por ejemplo, sólo entre tipos record, no con class).
- record struct: No soporta, igual que los struct normales.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // ¡Ok!
class Vehicle { }
class Car : Vehicle { } // ¡Ok!
// struct no puede heredar de struct
Más sobre herencia en el siguiente nivel :P
8. ¿Dónde se usan estos tipos en la práctica?
- class: Objetos grandes con mucho comportamiento, ciclo de vida largo, estado mutable, jerarquía (por ejemplo, lógica de negocio, componentes de UI).
- struct: Objetos pequeños por valor, donde importa la copia rápida, sin GC y con mínimo overhead (por ejemplo, coordenadas, colores, sumas — cosas que se clonan fácil y rápido).
- record: DTO (Data Transfer Object), objetos parámetro, parámetros de configuración, estados inmutables, resultados de cálculos que es cómodo comparar por contenido.
- record struct: Tipos por valor donde quieres inmutabilidad y comportamiento de records, pero sin asignaciones extra en el heap.
9. Errores típicos y trampas
A veces la gente piensa que record es simplemente "un reemplazo de class". ¡No es así! Si escribes un objeto que debe cambiar su estado durante su vida — usa class.
Si quieres comparar objetos por referencia (por ejemplo, en el patrón singleton o cuando el ciclo de vida del objeto es crítico) — usa class.
Si haces un objeto por valor que debe comportarse como un número o un punto en una cuadrícula — struct o record struct.
Si trabajas con objetos inmutables, fáciles de comparar, que se pasan mucho entre capas, se guardan en colecciones, se loguean y serializan — tu opción es record.
Recuerda también: si declaras un record-clase con propiedades sólo de lectura, pero olvidas los objetos anidados — los campos anidados pueden seguir cambiando si ellos mismos son mutables.
record Student(string Name, List<int> Grades);
var s1 = new Student("Anton", new List<int>() {5,5,5});
var s2 = s1 with { };
s2.Grades.Add(2); // ¡Ambos objetos "malditos" con un dos! s1.Grades == s2.Grades
GO TO FULL VERSION