Tipos de registro
C# 9.0 presenta los tipos de registro. Se usa la palabra clave record para definir un tipo de
referencia que proporciona funcionalidad integrada para encapsular los datos. Puede crear
tipos de registros con propiedades inmutables mediante parámetros posicionales o sintaxis de
propiedades estándar:
public record Person(string FirstName, string LastName);
public record Person
{
public string FirstName { get; init; } = default!;
public string LastName { get; init; } = default!;
};
También puede crear tipos de registros con propiedades y campos mutables:
public record Person
{
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
};
Aunque los registros pueden ser mutables, están destinados principalmente a admitir modelos
de datos inmutables. El tipo de registro ofrece las siguientes características:
Sintaxis concisa para crear un tipo de referencia con propiedades inmutables
Comportamiento útil para un tipo de referencia centrado en datos:
o Igualdad de valores
o Sintaxis concisa para la mutación no destructiva
o Formato integrado para la presentación
Compatibilidad con las jerarquías de herencia
Puede utilizar tipos de estructura para diseñar tipos centrados en datos que proporcionen
igualdad de valores y un comportamiento escaso o inexistente. Pero, en el caso de los modelos
de datos relativamente grandes, los tipos de estructura tienen algunas desventajas:
No admiten la herencia.
Son menos eficaces a la hora de determinar la igualdad de valores. En el caso de
los tipos de valor, el método ValueType.Equals usa la reflexión para buscar todos
los campos. En el caso de los registros, el compilador genera el método Equals. En
la práctica, la implementación de la igualdad de valores en los registros es
bastante más rápida.
Usan más memoria en algunos escenarios, ya que cada instancia tiene una copia
completa de todos los datos. Los tipos de registro son tipos de referencia, por lo
que una instancia de registro solo contiene una referencia a los datos.
Sintaxis posicional para la definición de propiedad
Puede usar parámetros posicionales para declarar propiedades de un registro e inicializar los
valores de propiedad al crear una instancia:
public record Person(string FirstName, string LastName);
public static void Main()
{
Person person = new("Nancy", "Davolio");
Console.WriteLine(person);
// output: Person { FirstName = Nancy, LastName = Davolio }
}
Cuando se usa la sintaxis posicional para la definición de propiedad, el compilador crea lo
siguiente:
Una propiedad pública implementada automáticamente de solo inicialización para
cada parámetro posicional proporcionado en la declaración de registro. Una
propiedad de solo inicialización solo se puede establecer en el constructor o
mediante un inicializador de propiedad.
Un constructor primario cuyos parámetros coinciden con los parámetros
posicionales en la declaración del registro.
Un método Deconstruct con un parámetro out para cada parámetro posicional
proporcionado en la declaración de registro.
Para obtener más información, vea Sintaxis posicional en el artículo de referencia del lenguaje
C# acerca de los registros.
Inmutabilidad
Un tipo de registro no es necesariamente inmutable. Puede declarar propiedades con
descriptores de acceso set y campos que no sean readonly. Sin embargo, aunque los registros
pueden ser mutables, facilitan la creación de modelos de datos inmutables. Las propiedades
que se crean mediante la sintaxis posicional son inmutables.
La inmutabilidad puede resultar útil si quiere que un tipo centrado en datos sea seguro para
subprocesos o un código hash quede igual en una tabla hash. Puede impedir que se produzcan
errores cuando se pasa un argumento por referencia a un método y el método cambia
inesperadamente el valor del argumento.
Las características exclusivas de los tipos de registro se implementan mediante métodos
sintetizados por el compilador, y ninguno de estos métodos pone en peligro la inmutabilidad
mediante la modificación del estado del objeto.
Igualdad de valores
La igualdad de valores significa que dos variables de un tipo de registro son iguales si los tipos
coinciden y todos los valores de propiedad y campo coinciden. Para otros tipos de referencia, la
igualdad significa identidad. Es decir, dos variables de un tipo de referencia son iguales si hacen
referencia al mismo objeto.
En el ejemplo siguiente se muestra la igualdad de valores de tipos de registro:
public record Person(string FirstName, string LastName, string[] PhoneNumbers);
public static void Main()
{
var phoneNumbers = new string[2];
Person person1 = new("Nancy", "Davolio", phoneNumbers);
Person person2 = new("Nancy", "Davolio", phoneNumbers);
Console.WriteLine(person1 == person2); // output: True
person1.PhoneNumbers[0] = "555-1234";
Console.WriteLine(person1 == person2); // output: True
Console.WriteLine(ReferenceEquals(person1, person2)); // output: False
}
En los tipos class, podría invalidar manualmente los métodos y los operadores de igualdad para
lograr la igualdad de valores, pero el desarrollo y las pruebas de ese código serían lentos y
propensos a errores. Al tener esta funcionalidad integrada, se evitan los errores que resultarían
de olvidarse de actualizar el código de invalidación personalizado cuando se agreguen o
cambien propiedades o campos.
Para obtener más información, vea Igualdad de valores en el artículo de referencia del lenguaje
C# acerca de los registros.
Mutación no destructiva
Si necesita mutar propiedades inmutables de una instancia de registro, puede usar una
expresión with para lograr una mutación no destructiva. Una expresión with crea una instancia
de registro que es una copia de una instancia de registro existente, con las propiedades y los
campos especificados modificados. Use la sintaxis del inicializador de objeto para especificar los
valores que se van a cambiar, como se muestra en el ejemplo siguiente:
public record Person(string FirstName, string LastName)
{
public string[] PhoneNumbers { get; init; }
}
public static void Main()
{
Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new string[1] };
Console.WriteLine(person1);
// output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[]
}
Person person2 = person1 with { FirstName = "John" };
Console.WriteLine(person2);
// output: Person { FirstName = John, LastName = Davolio, PhoneNumbers = System.String[] }
Console.WriteLine(person1 == person2); // output: False
person2 = person1 with { PhoneNumbers = new string[1] };
Console.WriteLine(person2);
// output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[]
}
Console.WriteLine(person1 == person2); // output: False
person2 = person1 with { };
Console.WriteLine(person1 == person2); // output: True
}
Para obtener más información, vea Mutación no destructiva en el artículo de referencia del
lenguaje C# acerca de los registros.
Formato integrado para la presentación
Los tipos de registros tienen un método ToString generado por el compilador que muestra los
nombres y los valores de las propiedades y los campos públicos. El método ToString devuelve
una cadena con el formato siguiente:
<record type name> { <property name> = <value>, <property name> = <value>, ...}
En el caso de los tipos de referencia, se muestra el nombre del tipo del objeto al que hace
referencia la propiedad en lugar del valor de propiedad. En el ejemplo siguiente, la matriz es un
tipo de referencia, por lo que se muestra System.String[] en lugar de los valores de los
elementos de matriz reales:
Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }
Para obtener más información, vea Formato integrado en el artículo de referencia del lenguaje
C# acerca de los registros.
Herencia
Un registro puede heredar de otro registro. Sin embargo, un registro no puede heredar de una
clase, y una clase no puede heredar de un registro.
En el ejemplo siguiente se muestra la herencia con la sintaxis de la propiedad posicional:
public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
public static void Main()
{
Person teacher = new Teacher("Nancy", "Davolio", 3);
Console.WriteLine(teacher);
// output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}
Para que dos variables de registro sean iguales, el tipo en tiempo de ejecución debe ser el
mismo. Los tipos de las variables contenedoras podrían ser diferentes. Esto se muestra en el
siguiente código de ejemplo:
public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
public static void Main()
{
Person teacher = new Teacher("Nancy", "Davolio", 3);
Person student = new Student("Nancy", "Davolio", 3);
Console.WriteLine(teacher == student); // output: False
Student student2 = new Student("Nancy", "Davolio", 3);
Console.WriteLine(student2 == student); // output: True
}
En el ejemplo, todas las instancias tienen las mismas propiedades y los mismos valores de
propiedad. Pero student == teacher devuelve False aunque ambas sean variables de
tipo Person. Y student == student2 devuelve True aunque una sea una variable Person y otra
sea una variable Student.
Todas las propiedades y los campos públicos de los tipos derivados y base se incluyen en la
salida ToString, como se muestra en el ejemplo siguiente:
public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
public static void Main()
{
Person teacher = new Teacher("Nancy", "Davolio", 3);
Console.WriteLine(teacher);
// output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}
Para obtener más información, vea Herencia en el artículo de referencia del lenguaje C# acerca
de los registros.
Establecedores de solo inicialización
Los establecedores de solo inicialización proporcionan una sintaxis coherente para inicializar
los miembros de un objeto. Los inicializadores de propiedades indican con claridad qué valor
establece cada propiedad. El inconveniente es que esas propiedades se deben establecer. A
partir de C# 9.0, puede crear descriptores de acceso init en lugar de descriptores de
acceso set para propiedades e indizadores. Los autores de la llamada pueden usar la sintaxis de
inicializador de propiedad para establecer estos valores en expresiones de creación, pero esas
propiedades son de solo lectura una vez que se ha completado la construcción. Los
establecedores de solo inicialización proporcionan una ventana para cambiar el estado, que se
cierra cuando finaliza la fase de construcción. La fase de construcción finaliza de forma eficaz
después de que se complete toda la inicialización, incluidos los inicializadores de propiedades y
las expresiones with.
Puede declarar los establecedores de solo init en cualquier tipo que escriba. Por ejemplo, en la
estructura siguiente se define una estructura de observación meteorológica:
public struct WeatherObservation
{
public DateTime RecordedAt { get; init; }
public decimal TemperatureInCelsius { get; init; }
public decimal PressureInMillibars { get; init; }
public override string ToString() =>
$"At {RecordedAt:h:mm tt} on {RecordedAt:M/d/yyyy}: " +
$"Temp = {TemperatureInCelsius}, with {PressureInMillibars} pressure";
}
Los autores de la llamada pueden usar la sintaxis de inicializador de propiedades para
establecer los valores, a la vez que conservan la inmutabilidad:
var now = new WeatherObservation
{
RecordedAt = DateTime.Now,
TemperatureInCelsius = 20,
PressureInMillibars = 998.0m
};
Un intento de cambiar una observación después de la inicialización genera un error del
compilador:
// Error! CS8852.
now.TemperatureInCelsius = 18;
Los establecedores de solo inicialización pueden ser útiles para establecer las propiedades de
clase base de las clases derivadas. También pueden establecer propiedades derivadas mediante
asistentes en una clase base. Los registros posicionales declaran propiedades mediante
establecedores de solo inicialización. Esos establecedores se usan en expresiones with. Puede
declarar establecedores de solo inicialización para cualquier objeto class, struct o record que
defina.
Para obtener más información, vea init (referencia de C#).
Instrucciones de nivel superior
Las instrucciones de nivel superior eliminación operaciones innecesarias de muchas
aplicaciones. Considere el famoso programa "Hola mundo":
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
Solo hay una línea de código que haga algo. Con las instrucciones de nivel superior, puede
reemplazar todo lo que sea reutilizable por la directiva using y la línea única que realiza el
trabajo:
using System;
Console.WriteLine("Hello World!");
Si quisiera un programa de una línea, podría quitar la directiva using y usar el nombre de tipo
completo:
System.Console.WriteLine("Hello World!");
Solo un archivo de la aplicación puede usar instrucciones de nivel superior. Si el compilador
encuentra instrucciones de nivel superior en varios archivos de código fuente, se trata de un
error. También es un error si combina instrucciones de nivel superior con un método de punto
de entrada de programa declarado, normalmente Main. En cierto sentido, puede pensar que un
archivo contiene las instrucciones que normalmente se encontrarían en el método Main de una
clase Program.
Uno de los usos más comunes de esta característica es la creación de materiales educativos. Los
desarrolladores principiantes de C# pueden escribir el programa "Hola mundo" en una o dos
líneas de código. No se necesitan pasos adicionales. Pero los desarrolladores veteranos también
encontrarán muchas aplicaciones a esta característica. Las instrucciones de nivel superior
permiten una experiencia de experimentación de tipo script similar a la que proporcionan los
cuadernos de Jupyter Notebook. Las instrucciones de nivel superior son excelentes para
programas y utilidades de consola pequeños. Azure Functions es un caso de uso ideal para las
instrucciones de nivel superior.
Y sobre todo, las instrucciones de nivel superior no limitan el ámbito ni la complejidad de la
aplicación. Estas instrucciones pueden acceder a cualquier clase de .NET o usarla. Tampoco
limitan el uso de argumentos de línea de comandos ni de valores devueltos. Las instrucciones
de nivel superior pueden acceder a una matriz de cadenas denominada args. Si las instrucciones
de nivel superior devuelven un valor entero, ese valor se convierte en el código devuelto entero
de un método Main sintetizado. Las instrucciones de nivel superior pueden contener
expresiones asincrónicas. En ese caso, el punto de entrada sintetizado devuelve un objeto Task,
o Task<int>.
Para obtener más información, vea Instrucciones de nivel superior en la Guía de programación
de C#.
Mejoras de coincidencia de patrones
C# 9 incluye nuevas mejoras de coincidencia de patrones:
Los patrones de tipo comparan si una variable es un tipo
Los patrones entre paréntesis aplican o resaltan la prioridad de las combinaciones
de patrones
En los patrones and conjuntivos es necesario que los dos patrones coincidan
En los patrones or disyuntivo es necesario que uno de los dos patrones coincida
En los patrones not negados es necesario que un patrón no coincida
Los patrones relacionales requieren que la entrada sea menor que, mayor que,
menor o igual que, o mayor o igual que una constante determinada.
Estos patrones enriquecen la sintaxis de los patrones. Tenga en cuenta estos ejemplos:
public static bool IsLetter(this char c) =>
c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';
Con paréntesis opcionales para que quede claro que and tiene mayor prioridad que or:
public static bool IsLetterOrSeparator(this char c) =>
c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',';
Uno de los usos más comunes es una nueva sintaxis para una comprobación NULL:
if (e is not null)
{
// ...
}
Cualquiera de estos patrones se puede usar en cualquier contexto en el que se permitan
patrones: expresiones de patrón is, expresiones switch, patrones anidados y el patrón de la
etiqueta case de una instrucción switch.
Para obtener más información, consulte Patrones (referencia de C#).
Para obtener más información, vea las secciones Patrones relacionales y Patrones lógicos del
artículo Patrones.
Rendimiento e interoperabilidad
Tres nuevas características mejoran la compatibilidad con la interoperabilidad nativa y las
bibliotecas de bajo nivel que requieren alto rendimiento: enteros de tamaño nativo, punteros
de función y la omisión de la marca localsinit.
Los enteros de tamaño nativo, nint y nuint, son tipos enteros. Se expresan mediante los tipos
subyacentes System.IntPtr y System.UIntPtr. El compilador muestra las conversiones y
operaciones adicionales para estos tipos como enteros nativos. Los enteros con tamaño nativo
definen propiedades para MaxValue o MinValue. Estos valores no se pueden expresar como
constantes en tiempo de compilación porque dependen del tamaño nativo de un entero en el
equipo de destino. Estos valores son de solo lectura en tiempo de ejecución. Puede usar valores
constantes para nint en el intervalo [int.MinValue .. int.MaxValue]. Puede usar valores
constantes para nuint en el intervalo [uint.MinValue .. uint.MaxValue]. El compilador realiza un
plegamiento constante para todos los operadores unarios y binarios que usan los
tipos System.Int32 y System.UInt32. Si el resultado no cabe en 32 bits, la operación se ejecuta
en tiempo de ejecución y no se considera una constante. Los enteros con tamaño nativo
pueden aumentar el rendimiento en escenarios en los que se usa la aritmética de enteros y es
necesario tener el rendimiento más rápido posible. Para obtener más información, consulte
los tipos nint y nuint
Los punteros de función proporcionan una sintaxis sencilla para acceder a los códigos de
operación de lenguaje intermedio ldftn y calli. Puede declarar punteros de función con la nueva
sintaxis de delegate*. Un tipo delegate* es un tipo de puntero. Al invocar el tipo delegate* se
usa calli, a diferencia de un delegado que usa callvirt en el método Invoke(). Sintácticamente,
las invocaciones son idénticas. La invocación del puntero de función usa la convención de
llamada managed. Agregue la palabra clave unmanaged después de la sintaxis
de delegate* para declarar que quiere la convención de llamada unmanaged. Se pueden
especificar otras convenciones de llamada mediante atributos en la declaración de delegate*.
Para obtener más información, vea Código no seguro y tipos de puntero.
Por último, puede agregar System.Runtime.CompilerServices.SkipLocalsInitAttribute para
indicar al compilador que no emita la marca localsinit. Esta marca indica al CLR que inicialice en
cero todas las variables locales. La marca localsinit ha sido el comportamiento predeterminado
en C# desde la versión 1.0. Pero la inicialización en cero adicional puede afectar al rendimiento
en algunos escenarios. En concreto, cuando se usa stackalloc. En esos casos, puede
agregar SkipLocalsInitAttribute. Puede agregarlo a un único método o propiedad, a un
objeto class, struct, interface, o incluso a un módulo. Este atributo no afecta a los
métodos abstract; afecta al código generado para la implementación. Para obtener más
información, vea Atributo SkipLocalsInit.
Estas características pueden aumentar significativamente el rendimiento en algunos escenarios.
Solo se deben usar después de realizar pruebas comparativas minuciosamente antes y después
de la adopción. El código que implica enteros con tamaño nativo se debe probar en varias
plataformas de destino con distintos tamaños de enteros. Las demás características requieren
código no seguro.
Características de ajuste y finalización
Muchas de las características restantes ayudan a escribir código de forma más eficaz. En C# 9.0,
puede omitir el tipo de una expresión new cuando ya se conoce el tipo del objeto creado. El uso
más común es en las declaraciones de campo:
private List<WeatherObservation> _observations = new();
El tipo de destino new también se puede usar cuando es necesario crear un objeto para pasarlo
como argumento a un método. Considere un método ForecastFor() con la signatura siguiente:
public WeatherForecast ForecastFor(DateTime forecastDate, WeatherForecastOptions options)
Podría llamarlo de esta forma:
var forecast = station.ForecastFor(DateTime.Now.AddDays(2), new());
Otra aplicación muy útil de esta característica es para combinarla con propiedades de solo
inicialización para inicializar un objeto nuevo:
WeatherStation station = new() { Location = "Seattle, WA" };
Puede devolver una instancia creada por el constructor predeterminado mediante una
declaración return new();.
Una característica similar mejora la resolución de tipos de destino de las expresiones
condicionales. Con este cambio, las dos expresiones no necesitan tener una conversión
implícita de una a otra, pero pueden tener conversiones implícitas a un tipo de destino. Lo más
probable es que no note este cambio. Lo que observará es que ahora funcionan algunas
expresiones condicionales para las que anteriormente se necesitaban conversiones o que no se
compilaban.
A partir de C# 9.0, puede agregar el modificador static a expresiones lambda o métodos
anónimos. Las expresiones lambda estáticas son análogas a las funciones static locales: un
método anónimo o una expresión lambda estáticos no puede capturar variables locales ni el
estado de la instancia. El modificador static impide la captura accidental de otras variables.
Los tipos de valor devuelto covariantes proporcionan flexibilidad a los tipos de valor devuelto
de los métodos override. Un método override puede devolver un tipo derivado del tipo de valor
devuelto del método base invalidado. Esto puede ser útil para los registros y para otros tipos
que admiten métodos de generador o clonación virtuales.
Además, el bucle foreach reconocerá y usará un método de extensión GetEnumerator que, de
otro modo, satisface el patrón foreach. Este cambio significa que foreach es coherente con
otras construcciones basadas en patrones, como el patrón asincrónico y la desconstrucción
basada en patrones. En la práctica, esto quiere decir que puede agregar compatibilidad
con foreach a cualquier tipo. Debe limitar su uso a cuando la enumeración de un objeto tiene
sentido en el diseño.
Después, puede usar descartes como parámetros para las expresiones lambda. De esta forma
no tiene que asignar un nombre al argumento y el compilador puede evitar usarlo. Use _ para
cualquier argumento. Para más información, consulte sección sobre parámetros de entrada de
una expresión lambda en el artículo sobre expresiones lambda.
Por último, ahora puede aplicar atributos a las funciones locales. Por ejemplo, puede
aplicar anotaciones de atributo que admiten un valor NULL a las funciones locales.
Compatibilidad con generadores de código
Dos últimas características admiten generadores de código de C#. Los generadores de código de
C# son un componente que se puede escribir y que es similar a una corrección de código o un
analizador Roslyn. La diferencia es que los generadores de código analizan el código y escriben
nuevos archivos de código fuente como parte del proceso de compilación. Un generador de
código típico busca atributos y otras convenciones en el código.
Un generador de código lee atributos u otros elementos de código mediante las API de análisis
de Roslyn. A partir de esa información, agrega código nuevo a la compilación. Los generadores
de código fuente solo pueden agregar código; no se les permite modificar ningún código
existente en la compilación.
Las dos características agregadas a los generadores de código son las extensiones de la sintaxis
de métodos parciales _ y los _inicializadores de módulos**. En primer lugar, los cambios en los
métodos parciales. Antes de C# 9.0, los métodos parciales eran private, pero no podían
especificar un modificador de acceso, tener un valor devuelto void ni parámetros out. Estas
restricciones implican que si no se proporciona ninguna implementación de método, el
compilador quita todas las llamadas al método parcial. En C# 9.0 se quitan estas restricciones,
pero es necesario que las declaraciones de métodos parciales tengan una implementación. Los
generadores de código pueden proporcionar esa implementación. Para evitar la introducción
de un cambio importante, el compilador tiene en cuenta cualquier método parcial sin un
modificador de acceso para seguir las reglas anteriores. Si el método parcial incluye el
modificador de acceso private, las nuevas reglas rigen ese método parcial. Para obtener más
información, vea partial (Método) (referencia de C#).
La segunda característica nueva para los generadores de código son los inicializadores de
módulos. Los inicializadores de módulos son métodos que tienen asociado el
atributo ModuleInitializerAttribute. El entorno de ejecución llamará a estos métodos antes de
cualquier otro acceso de campo o invocación de método en todo el módulo. Un método de
inicializador de módulo:
Debe ser estático
No debe tener parámetros
Debe devolver void
No debe ser un método genérico
No debe estar incluido en una clase genérica
Debe ser accesible desde el módulo contenedor
Ese último punto significa en realidad que el método y su clase contenedora deben ser internos
o públicos. El método no puede ser una función local. Para obtener más información,
vea Atributo ModuleInitializer.