Novedades de C# 9.0

C# 9.0 agrega las siguientes características y mejoras al lenguaje C#:

C# 9.0 es compatible con .NET 5. Para obtener más información, vea Control de versiones del lenguaje C#.

Puede descargar el SDK de .NET más reciente de la página de descargas de .NET.

Tipos de registro

C# 9.0 introduce 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:

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:

<nombre de tipo de registro> { <nombre de propiedad> = <value>, <nombre de propiedad> = <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 eliminan la complejidad innecesaria de muchas aplicaciones. Considere el programa canónico "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 de C# principiantes pueden escribir el programa canónico "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 coinciden con un objeto que coincide con un tipo determinado.
  • Los patrones con 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 disyuntivos es necesario que alguno de los 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 extensiones de la sintaxis de método parcial 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 de 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.