C# 9.0 – Specification – Records
Índice general – C# 9.0 – Specification
Introducción
Dentro de C# como lenguaje, aparte del propio object, tenemos (por lo general) la posibilidad de trabajar con clases (class) y estructuras (struct).
Cada una de ellas, y según el contexto en el que trabajemos, nos ofrece una serie de ventajas con respecto a otra, pero debemos saber cuando nos conviene utilizar una u otra.
Un objeto struct es un objeto de tipo valor.
Y sobre struct, podemos decir que desde la especificación de C# 7.2, podemos utilizar el modificador readonly para declarar una estructura de tipo inmutable.
Un ejemplo rápido de código de ese uso sería:
public readonly struct Person { public Person(string name) => Name = name; public string Name { get; } }
Inicializaremos la estructura tanto sin indicar valor de name al constructor, como indicando un valor concreto de inicialización para ese parámetro, pero tendremos una estructura de sólo lectura y por lo tanto, inmutable una vez creado el objeto.
Un objeto de tipo class por su parte, es un tipo por referencia. No indico ningún ejemplo, porque es el tipo más utilizado en nuestro trabajo como desarrolladores Software.
¿Y qué es record?
Microsoft ha añadido una palabra reservada al lenguaje C# dentro de la especificación C# 9: record
Gracias a esta palabra reservada, podemos declarar ahora un objeto como class, struct y record.
Ahora bien, ¿qué nos ofrece record?.
Vamos a hacer un repaso de algunas singularidades de class, struct y record al mismo tiempo, para ir entrando en detalle poco a poco y de forma paulatina en lo que cubre y nos ofrece record.
Repasando class, struct y record
Un objeto declarado como record funciona de forma muy similar a como lo hace struct con alguna interesante diferencia, pero también tiene por otro lado, alguna similitud con class.
En mi opinión, está con un pie en un sitio y al mismo tiempo, con otro pie en el otro sitio.
Pero vayamos por partes y unamos un poco el camino de struct y record.
Supongamos el siguiente código para mi estructura (struct):
public struct Person { public Person(string name) => Name = name; public string Name; }
Y dentro de nuestra aplicación de consola el siguiente código utilizando la estructura anterior:
var person = new Person("John"); var otherPerson = person; otherPerson.Name = "Peter";
En este código, lo que hacemos es crear person y copiar person en otherPerson, creando en otherPerson un objeto completamente independiente con los mismos valores que tenía person, pero un objeto nuevo en memoria, sin compartir referencia (tipo por valor).
Eso no ocurriría en el caso de que Person no fuera un struct y fuera una class, en cuyo caso sí compartiría referencia y en el fondo sería el mismo objeto, ya que ambos, person y otherPerson, compartirían en ese caso la misma dirección de memoria.
Esto seguro que lo conoces perfectamente, pero lo comento porque si después del código que he indicado añadimos este otro código:
Console.WriteLine("Equals: " + person.Equals(otherPerson)); Console.WriteLine("Reference: " + (person == otherPerson));
En el caso de que estemos utilizando una class, el resultado será true para ambas comparaciones. La primera de ellas indica que es el mismo objeto, y la segunda que la referencia es la misma.
En el caso de que estemos utilizando una struct, sólo podremos utilizar la primera comparación (Equals), ya que la segunda de ellas no está permitida, y en ese caso, el resultado de la primera comparación (la permitida) será false.
Esto es así, porque en el caso de una struct la comparación es sobre el valor de las propiedades, y la propiedad Name de otherPerson ha sido modificada.
Sin embargo, record, aunque se comporta como una struct, sí permite la segunda comparación ya que es un tipo por referencia.
De hecho, el código anterior con record quedaría de la siguiente forma:
public record Person
{
public Person(string name) => Name = name;
public string Name;
}
Y la parte de código que consume el objeto record:
var person = new Person("John"); var otherPerson = person; Console.WriteLine("Equals: " + person.Equals(otherPerson)); Console.WriteLine("Reference: " + (person == otherPerson));
Se comporta exactamente igual que una clase, y el resultado de las comparaciones es en ambos casos true.
Trabajando con with en record
Sin embargo, a la hora de trabajar con record, podemos trabajar con with para hacer una copia del objeto y evitar trabajar con una referencia al mismo objeto.
Es decir, el código anterior que tendríamos en este caso sería el siguiente:
var person = new Person("John"); var otherPerson = person with { Name = "John" }; Console.WriteLine("Equals: " + person.Equals(otherPerson)); Console.WriteLine("Reference: " + (person == otherPerson));
El resultado aquí para ambas comparaciones será true y false.
Profundizando más en record
Ahora bien, una vez entendido esto y para evitar sorpresas, debo comentar una de las ventajas de record, y es la posibilidad de trabajar con constructores y deconstructores.
El trabajo con constructores lo hemos visto ya anteriormente, quizás casi sin darnos cuenta.
public record Person { public Person(string name) => Name = name; public string Name; }
Y en el caso de disponer de más de una propiedad o variable, podríamos tener algo parecido a esto:
public record Person { public Person(string name, int age) => (Name, Age) = (name, age); public string Name { get; set; } public int Age { get; set; } }
Y el trabajo con deconstructores sería muy similar como por ejemplo:
public record Person { public Person(string name, int age) => (Name, Age) = (name, age); public string Name { get; set; } public int Age { get; set; } public void Deconstructor(out string name, out int age) => (name, age) = (Name, Age); }
Ahora bien, utilizar record para declarar un objeto nos da mucha flexibilidad a la hora de trabajar con objetos inmutables.
Si quiero declarar un objeto Person que contenga dos propiedades (Name y Age) y quiero que ambas se comporten como sólo lectura, bastará con declarar nuestro objeto de la forma:
public record Person(string name, int age);
De esta forma, habremos declarado un objeto Person con estas dos propiedades que deberán ser inicializadas, funcionando como Init-only (ver esta característica en la especificación de C# 9).
Y aquí hago un pequeño alto en el camino para hacer algunas puntualizaciones.
- Person aquí, en este ejemplo, obliga a indicar los valores para las propiedades indicadas en su constructor, por lo que un constructor vacío aquí no es válido.
- Tal y como declaremos las variables, será como accedamos a ellas, es decir, aquí en este ejemplo en camelCase. Si quisiéramos utilizar las propiedades como PascalCase, deberemos indicárselo en el constructor como por ejemplo public record Person(string Name, int Age);
- El deconstructor se puede seguir usando aunque no sea visible a simple vista, pero el orden de los parámetros del constructor, es el mismo en el caso del deconstructor.
Es decir, teniendo en cuenta nuestro objeto record:
public record Person(string Name, int Age);
Un uso de este código podría quedar de la siguiente forma:
// Constructor (creando el objeto) var person = new Person("John", 20); // Deconstructor (obteniendo sus datos) var (name, age) = person; Console.Write($"{name} is {age} years old!");
Es decir, record nos ofrece no sólo la posibilidad de hacer una copia de un objeto con with, sino también la posibilidad de crear un constructor directo con propiedades inmutables, al mismo tiempo que nos permite utilizar un deconstructor directo del mismo modo y con los parámetros en ese mismo orden, simplificando mucho ciertas tareas programáticas.
Pero por si esto es poco, record tiene como ventaja adicional sobre struct la pequeña cantidad de memoria que necesita.
Y obviamente, podemos crear objetos complejos de struct y record, de class y record, o con herencia. Lo que no está permitido es que una class herede de un record, ni un record de una class. Un record podrá heredar de otro record o de un object.
Happy Coding!