Hemos utilizado .NET desde sus inicios. Tenemos soluciones escritas en todas las versiones del marco, desde la primera hasta la última .NET Core 3.1.
La historia de .NET, que hemos estado siguiendo de cerca todo este tiempo, está sucediendo ante nuestros ojos: la versión de .NET 5, cuyo lanzamiento está previsto para noviembre, acaba de ser lanzada en forma de Release Candidate 2. Hace tiempo que se nos advierte que la quinta versión marcará una época: terminará con ella. Esquizofrenia .NET, cuando había dos ramas del marco: clásica y Core. Ahora se fusionarán en éxtasis y habrá un .NET continuo. RC2
lanzadoya puede comenzar a usarlo por completo: no se esperan nuevos cambios antes del lanzamiento, solo habrá una corrección de los errores encontrados. Además: RC2 ya tiene un sitio web oficial dedicado a .NET.
Y le presentamos una descripción general de las innovaciones en .NET 5 y C # 9. Toda la información con ejemplos de código se toma del blog oficial de los desarrolladores de la plataforma .NET (así como de muchas otras fuentes) y se verifica personalmente.
Nuevos tipos nativos y solo nuevos
C # y .NET agregaron tipos nativos simultáneamente:
- nint y nuint para C #
- sus correspondientes System.IntPtr y System.UIntPtr en BCL
El punto para agregar estos tipos son las operaciones con API de bajo nivel. Y el truco es que el tamaño real de estos tipos ya se determina en tiempo de ejecución y depende del bitness del sistema: para los de 32 bits, su tamaño será de 4 bytes y para los de 64 bits, respectivamente, de 8 bytes.
Lo más probable es que no encuentre estos tipos en el trabajo real. Como, sin embargo, con otro tipo nuevo: Half. Este tipo existe solo en BCL, todavía no existe un análogo en C #. Es un tipo de 16 bits para valores de coma flotante. Puede ser útil para aquellos casos en los que no se requiere una precisión infernal y puede ganar algo de memoria para almacenar valores, porque los tipos float y double ocupan 4 y 8 bytes. Lo más interesante es que para este tipo en general hasta ahoralas operaciones aritméticas no están definidas y ni siquiera puede agregar dos variables de tipo Half sin convertirlas explícitamente en flotante o doble. Es decir, el propósito de este tipo ahora es puramente utilitario: ahorrar espacio. Sin embargo, planean agregarle aritmética en la próxima versión de .NET y C #. En un año.
Atributos para funciones locales
Anteriormente estaban prohibidos y esto creaba algunos inconvenientes. En particular, fue imposible colgar atributos de los parámetros de las funciones locales. Ahora puede establecer atributos para ellos, tanto para la función en sí como para sus parámetros. Por ejemplo, así:
#nullable enable
private static void Process(string?[] lines, string mark)
{
foreach (var line in lines)
{
if (IsValid(line))
{
// Processing logic...
}
}
bool IsValid([NotNullWhen(true)] string? line)
{
return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
}
}
Expresiones lambda estáticas
El objetivo de la función es asegurarse de que las expresiones lambda no puedan capturar ningún contexto y variables locales que existan fuera de la expresión en sí. En general, el hecho de que puedan captar el contexto local suele ser útil para el desarrollo. Pero a veces esto puede ser la causa de errores difíciles de detectar.
Para evitar tales errores, las expresiones lambda ahora se pueden marcar con la palabra clave estática. Y en este caso, pierden el acceso a cualquier contexto local: desde las variables locales hasta este y base.
Aquí hay un ejemplo de uso bastante completo:
static void SomeFunc(Func<int, int> f)
{
Console.WriteLine(f(5));
}
static void Main(string[] args)
{
int y1 = 10;
const int y2 = 10;
SomeFunc(i => i + y1); // 15
SomeFunc(static i => i + y1); // : y1
SomeFunc(static i => i + y2); // 15
}
Tenga en cuenta que las constantes capturan lambdas estáticas muy bien.
GetEnumerator como método de extensión
Ahora, el método GetEnumerator puede ser un método de extensión, lo que le permitirá iterar a través del foreach incluso si no se pudo enumerar antes. Por ejemplo, tuplas.
Aquí hay un ejemplo de cuando es posible iterar sobre ValueTuple a través de foreach usando el método de extensión escrito para él:
static class Program
{
public static IEnumerator<T> GetEnumerator<T>(this ValueTuple<T, T, T, T, T> source)
{
yield return source.Item1;
yield return source.Item2;
yield return source.Item3;
yield return source.Item4;
yield return source.Item5;
}
static void Main(string[] args)
{
foreach(var item in (1,2,3,4,5))
{
System.Console.WriteLine(item);
}
}
}
Este código imprime números del 1 al 5 en la consola.
Descartar patrón en parámetros de expresiones lambda y funciones anónimas
Micro-mejora. En caso de que no necesite parámetros en una expresión lambda o en una función anónima, puede reemplazarlos con un guión bajo, ignorando así:
Func<int, int, int> someFunc1 = (_, _) => {return 5;};
Func<int, int, int> someFunc2 = (int _, int _) => {return 5;};
Func<int, int, int> someFunc3 = delegate (int _, int _) {return 5;};
Declaraciones de nivel superior en C #
Esta es una estructura de código C # simplificada. Ahora, escribir el código más simple parece realmente simple:
using System;
Console.WriteLine("Hello World!");
Y todo se compilará bien. Es decir, ahora no es necesario crear un método en el que deba colocarse la declaración de salida de la consola, no es necesario que describa ninguna clase en la que deba colocarse el método y no es necesario definir un espacio de nombres en el que se debe crear la clase.
Por cierto, en el futuro, los desarrolladores de C # están pensando en desarrollar un tema con una sintaxis simplificada y tratando de deshacerse de System; en casos obvios. Mientras tanto, puede deshacerse de él simplemente escribiendo así:
System.Console.WriteLine("Hello World!");
Y realmente será un programa de trabajo de una sola línea.
Se pueden utilizar opciones más complejas:
using System;
using System.Runtime.InteropServices;
Console.WriteLine("Hello World!");
FromWhom();
Show.Excitement("Top-level programs can be brief, and can grow as slowly or quickly in complexity as you'd like", 8);
void FromWhom()
{
Console.WriteLine($"From {RuntimeInformation.FrameworkDescription}");
}
internal class Show
{
internal static void Excitement(string message, int levelOf)
{
Console.Write(message);
for (int i = 0; i < levelOf; i++)
{
Console.Write("!");
}
Console.WriteLine();
}
}
En realidad, el propio compilador incluirá todo este código en los espacios de nombres y las clases necesarios, simplemente no lo sabrá.
Por supuesto, esta función tiene limitaciones. El principal es que esto solo se puede hacer en un archivo de proyecto. Como regla, tiene sentido hacer esto en el archivo donde creó previamente el punto de entrada al programa en la forma de la función Main (string [] args). Al mismo tiempo, la función principal en sí no se puede definir allí; esta es la segunda limitación. De hecho, dicho archivo en sí mismo con una sintaxis simplificada es la función Main, e incluso contiene la variable args implícitamente, que es una matriz con parámetros. Es decir, este código también compilará y mostrará la longitud de la matriz:
System.Console.WriteLine(args.Length);
En general, la función no es la más importante, pero para fines de demostración y capacitación es bastante adecuada por sí misma. Detalles aquí .
Coincidencia de patrones en una declaración if
Imagine que necesita verificar una variable de objeto que no pertenece a un tipo determinado. Hasta ahora era necesario escribir así:
if (!(vehicle is Car)) { ... }
Pero con C # 9.0, puede escribir humanamente:
if (vehicle is not Car) { ... }
También fue posible registrar de forma compacta algunos controles:
if (context is {IsReachable: true, Length: > 1 })
{
Console.WriteLine(context.Name);
}
Esta nueva notación es equivalente a la buena anterior como esta:
if (context is object && context.IsReachable && context.Length > 1 )
{
Console.WriteLine(context.Name);
}
O también puede escribir lo mismo de una manera relativamente nueva (pero esto ya es ayer):
if (context?.IsReachable && context?.Length > 1 )
{
Console.WriteLine(context.Name);
}
En la nueva sintaxis, también puede usar los operadores booleanos y, o no, más paréntesis para priorizar:
if (context is {Length: > 0 and (< 10 or 25) })
{
Console.WriteLine(context.Name);
}
Y estas son solo mejoras en la coincidencia de patrones en un if normal. Lo que agregamos a la coincidencia de patrones para la expresión del interruptor: siga leyendo.
Coincidencia de patrones mejorada en la expresión del interruptor
La expresión de cambio (que no debe confundirse con la declaración de cambio) tiene grandes mejoras en la coincidencia de patrones. Veamos ejemplos de la documentación oficial . Se dedican ejemplos al cálculo de la tarifa de un transporte determinado en un momento determinado. Este es el primer ejemplo:
public decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car c => 2.00m,
Taxi t => 3.50m,
Bus b => 5.00m,
DeliveryTruck t => 10.00m,
{ } => throw new ArgumentException("Unknown vehicle type", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
};
Las dos últimas líneas de la declaración de cambio son nuevas. Las llaves representan cualquier objeto que no sea nulo. Y ahora puede usar la palabra clave coincidente para hacer coincidir con nulo.
Esto no es todo. Tenga en cuenta que para cada asignación a un objeto, debe crear una variable: c para Coche, t para Taxi, etc. Pero estas variables no se utilizan. En tales casos, ya puede usar el patrón de descarte en C # 8.0 ahora:
public decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car _ => 2.00m,
Taxi _ => 3.50m,
Bus _ => 5.00m,
DeliveryTruck _ => 10.00m,
// ...
};
Pero a partir de la novena versión de C #, no puede escribir nada en estos casos:
public decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car => 2.00m,
Taxi => 3.50m,
Bus => 5.00m,
DeliveryTruck => 10.00m,
// ...
};
Las mejoras en la expresión del interruptor no terminan ahí. Ahora es más fácil escribir expresiones más complejas. Por ejemplo, a menudo el resultado devuelto debe depender de los valores de propiedad del objeto pasado. Ahora, esto se puede escribir más corto y más conveniente que una combinación de if:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car { Passengers: 0 } => 2.00m + 0.50m,
Car { Passengers: 1 } => 2.0m,
Car { Passengers: 2 } => 2.0m - 0.50m,
Car => 2.00m - 1.0m,
// ...
};
Preste atención a las tres primeras líneas del conmutador: de hecho, se comprueba el valor de la propiedad Passengers y, en caso de igualdad, se devuelve el resultado correspondiente. Si no hay coincidencia, se devolverá el valor de la variante general (la cuarta línea dentro del conmutador). Por cierto, los valores de propiedad se verifican solo si el objeto de vehículo pasado no es nulo y es una instancia de la clase Car. Es decir, no debe tener miedo de la excepción de referencia nula al verificar.
Pero eso no es todo. Ahora, en la expresión de cambio, incluso puede escribir expresiones para una coincidencia más conveniente:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
Bus => 5.00m,
DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
DeliveryTruck => 8.00m,
// ...
};
Y eso no es todo. La sintaxis de la expresión de cambio se ha extendido a expresiones de cambio anidadas para que sea aún más fácil para nosotros describir condiciones complejas:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car c => c.Passengers switch
{
0 => 2.00m + 0.5m,
1 => 2.0m,
2 => 2.0m - 0.5m,
_ => 2.00m - 1.0m
},
// ...
};
Como resultado, si pega completamente todos los ejemplos de código ya dados, obtendrá esta imagen con todas las innovaciones descritas a la vez:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car c => c.Passengers switch
{
0 => 2.00m + 0.5m,
1 => 2.0m,
2 => 2.0m - 0.5m,
_ => 2.00m - 1.0m
},
Taxi t => t.Fares switch
{
0 => 3.50m + 1.00m,
1 => 3.50m,
2 => 3.50m - 0.50m,
_ => 3.50m - 1.00m
},
Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
Bus => 5.00m,
DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
DeliveryTruck => 8.00m,
null => throw new ArgumentNullException(nameof(vehicle)),
_ => throw new ArgumentException(nameof(vehicle))
};
Pero eso tampoco es todo. Aquí hay otro ejemplo: una función ordinaria que usa el mecanismo de expresión de cambio para determinar la carga en función del tiempo transcurrido: hora punta de la mañana / tarde, períodos de día y noche:
private enum TimeBand
{
MorningRush,
Daytime,
EveningRush,
Overnight
}
private static TimeBand GetTimeBand(DateTime timeOfToll) =>
timeOfToll.Hour switch
{
< 6 or > 19 => TimeBand.Overnight,
< 10 => TimeBand.MorningRush,
< 16 => TimeBand.Daytime,
_ => TimeBand.EveningRush,
};
Como puede ver, en C # 9.0 también es posible usar los operadores de comparación <,>, <=,> =, así como los operadores lógicos y, o y no, al hacer coincidir.
Pero esto, maldita sea, no es el final. Ahora puede usar ... tuplas en la expresión de cambio. Aquí hay un ejemplo completo de código que calcula un cierto coeficiente de la tarifa, dependiendo del día de la semana, la hora del día y la dirección de viaje (hacia / desde la ciudad):
private enum TimeBand
{
MorningRush,
Daytime,
EveningRush,
Overnight
}
private static bool IsWeekDay(DateTime timeOfToll) =>
timeOfToll.DayOfWeek switch
{
DayOfWeek.Saturday => false,
DayOfWeek.Sunday => false,
_ => true
};
private static TimeBand GetTimeBand(DateTime timeOfToll) =>
timeOfToll.Hour switch
{
< 6 or > 19 => TimeBand.Overnight,
< 10 => TimeBand.MorningRush,
< 16 => TimeBand.Daytime,
_ => TimeBand.EveningRush,
};
public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.MorningRush, true) => 2.00m,
(true, TimeBand.MorningRush, false) => 1.00m,
(true, TimeBand.Daytime, true) => 1.50m,
(true, TimeBand.Daytime, false) => 1.50m,
(true, TimeBand.EveningRush, true) => 1.00m,
(true, TimeBand.EveningRush, false) => 2.00m,
(true, TimeBand.Overnight, true) => 0.75m,
(true, TimeBand.Overnight, false) => 0.75m,
(false, TimeBand.MorningRush, true) => 1.00m,
(false, TimeBand.MorningRush, false) => 1.00m,
(false, TimeBand.Daytime, true) => 1.00m,
(false, TimeBand.Daytime, false) => 1.00m,
(false, TimeBand.EveningRush, true) => 1.00m,
(false, TimeBand.EveningRush, false) => 1.00m,
(false, TimeBand.Overnight, true) => 1.00m,
(false, TimeBand.Overnight, false) => 1.00m,
};
El método PeakTimePremiumFull usa tuplas para hacer coincidir, y esto se hizo posible en la nueva versión de C # 9.0. Por cierto, si observa detenidamente el código, se sugieren dos optimizaciones:
- las últimas ocho líneas devuelven el mismo valor;
- el tráfico diurno y nocturno tienen el mismo coeficiente.
Como resultado, el código del método se puede reducir en gran medida utilizando el patrón de descarte:
public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.MorningRush, true) => 2.00m,
(true, TimeBand.MorningRush, false) => 1.00m,
(true, TimeBand.Daytime, _) => 1.50m,
(true, TimeBand.EveningRush, true) => 1.00m,
(true, TimeBand.EveningRush, false) => 2.00m,
(true, TimeBand.Overnight, _) => 0.75m,
(false, _, _) => 1.00m,
};
Bueno, si miras aún más de cerca, entonces puedes reducir esta opción, sacando el coeficiente 1.0 en el caso general:
public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.Overnight, _) => 0.75m,
(true, TimeBand.Daytime, _) => 1.5m,
(true, TimeBand.MorningRush, true) => 2.0m,
(true, TimeBand.EveningRush, false) => 2.0m,
_ => 1.0m,
};
Por si acaso, permítanme aclarar: las comparaciones se realizan en el orden en que se enumeran. En la primera coincidencia, se devuelve el valor correspondiente y no se realizan más comparaciones.
Las tuplas de actualización en la expresión de cambio también se pueden usar en C # 8.0. El desarrollador inútil que escribió este artículo se volvió un poco más inteligente.
Y finalmente, aquí hay otro ejemplo loco que demuestra la nueva sintaxis para hacer coincidir tanto las tuplas como las propiedades del objeto:
public static bool IsAccessOkOfficial(Person user, Content content, int season) =>
(user, content, season) switch
{
({Type: Child}, {Type: ChildsPlay}, _) => true,
({Type: Child}, _, _) => false,
(_ , {Type: Public}, _) => true,
({Type: Monarch}, {Type: ForHerEyesOnly}, _) => true,
(OpenCaseFile f, {Type: ChildsPlay}, 4) when f.Name == "Sherlock Holmes" => true,
{Item1: OpenCaseFile {Type: var type}, Item2: {Name: var name}}
when type == PoorlyDefined && name.Contains("Sherrinford") && season >= 3 => true,
(OpenCaseFile, var c, 4) when c.Name.Contains("Sherrinford") => true,
(OpenCaseFile {RiskLevel: >50 and <100 }, {Type: StateSecret}, 3) => true,
_ => false,
};
Todo esto parece bastante inusual. Para una comprensión completa, le recomiendo que mire la fuente , hay un ejemplo completo del código.
Nueva escritura de destino nueva y básicamente mejorada
Hace mucho tiempo, en C # se hizo posible escribir var en lugar de un nombre de tipo, porque el tipo en sí se podía determinar a partir del contexto (de hecho, esto se llama escritura de destino). Es decir, en lugar de la siguiente entrada:
SomeLongNamedType variable = new SomeLongNamedType();
se hizo posible escribir de forma más compacta:
var variable = new SomeLongNamedType()
Y el compilador adivinará el tipo de variable en sí. A lo largo de los años, se implementó la sintaxis inversa:
SomeLongNamedType variable = new ();
Un agradecimiento especial por el hecho de que esta sintaxis funciona no solo al declarar una variable, sino también en muchos otros casos en los que el compilador puede adivinar inmediatamente el tipo. Por ejemplo, al pasar parámetros a un método y devolver un valor del método:
var result = SomeMethod(new (2020,10,01));
//...
public Car SomeMethod(DateTime p)
{
//...
return new() { Passengers = 2 };
}
En este ejemplo, al llamar a SomeMethod, el parámetro del tipo DateTime se crea utilizando la sintaxis abreviada. El valor devuelto por el método se crea de la misma manera.
Donde realmente habrá un beneficio para esta sintaxis es al definir colecciones:
List<DateTime> datesList = new()
{
new(2020, 10, 01),
new(2020, 10, 02),
new(2020, 10, 03),
new(2020, 10, 04),
new(2020, 10, 05)
};
Car[] cars =
{
new() {Passengers = 2},
new() {Passengers = 3},
new() {Passengers = 4}
};
La ausencia de la necesidad de escribir el nombre completo del tipo al enumerar los elementos de la colección hace que el código sea un poco más limpio.
Operadores con tipo de destino ¿y?:
¿El operador ternario ?: Se mejoró en C # 9.0. Anteriormente, requería el cumplimiento total de los tipos de devolución, pero ahora es más inteligente. Aquí hay un ejemplo de una expresión que no era válida en versiones anteriores del idioma, pero bastante legal en la novena:
int? result = b ? 0 : null; // nullable value type
Anteriormente, se requería convertir explícitamente de cero a int? .. Ahora no es necesario.
Además, en la nueva versión del idioma, está permitido utilizar la siguiente construcción:
Person person = student ?? customer; // Shared base type
Los tipos de clientes y estudiantes, aunque se derivan de Person, son técnicamente diferentes. La versión anterior del lenguaje no le permitía usar tal construcción sin conversión explícita de tipos. Ahora el compilador comprende perfectamente lo que se quiere decir.
Anulando el tipo de retorno de métodos
En C # 9.0, se permitió anular el tipo de retorno de los métodos anulados. Solo hay un requisito: el nuevo tipo debe heredarse del original (covariante). He aquí un ejemplo:
abstract class Animal
{
public abstract Food GetFood();
...
}
class Tiger : Animal
{
public override Meat GetFood() => ...;
}
En la clase Tiger, el valor de retorno del método GetFood se ha redefinido de Food to Meat. Ahora está bien si la carne se deriva de los alimentos.
las propiedades init no son miembros de solo lectura
En la nueva versión del lenguaje ha aparecido una característica interesante: init-properties. Estas son propiedades que solo se pueden establecer durante la inicialización inicial del objeto. Parecería que existen miembros de clase de solo lectura para esto, pero en realidad son cosas diferentes que le permiten resolver diferentes problemas. Para comprender cuál es la diferencia y cuál es la belleza de las propiedades init, aquí hay un ejemplo:
Person employee = new () {
Name = "Paul McCartney",
Company = "High Technologies Center",
CompanyAddress = new () {
Country = "Russia",
City = "Izhevsk",
Line1 = "246, Karl Marx St."
}
}
Esta sintaxis para declarar una instancia de clase es muy conveniente, especialmente cuando hay más objetos entre las propiedades de la clase. Pero esta sintaxis tiene limitaciones: las propiedades de clase correspondientes deben ser mutables . Esto se debe a que la inicialización de estas propiedades se produce después de la llamada al constructor. Es decir, la clase Person del ejemplo debe declararse así:
class Person {
//...
public string Name {get; set;}
public string Company {get; set;}
public Address CompanyAddress {get; set;}
//...
}
Sin embargo, de hecho, la propiedad Name es inmutable. Actualmente, la única forma de hacer esta propiedad de solo lectura es declarar un establecedor privado:
class Person {
//...
public string Name {get; private set;}
//...
}
Pero en este caso, perdemos inmediatamente la capacidad de usar la sintaxis conveniente para declarar una instancia de clase asignando valores a las propiedades dentro de llaves. Y podemos establecer el valor de la propiedad Name solo pasándola en parámetros al constructor de la clase. Ahora imagine que la propiedad CompanyAddress también tiene un significado inmutable. En general, me encontré en una situación así muchas veces, y siempre tuve que elegir entre dos males:
- constructores elegantes con un montón de parámetros, pero todas las propiedades de la clase de solo lectura;
- sintaxis conveniente para crear un objeto, pero todas las propiedades de la clase de lectura y escritura, y debo recordar esto y no cambiarlas accidentalmente en alguna parte.
En este punto, alguien podría recordar a los miembros de solo lectura de la clase y sugerir darle estilo a la clase Person de esta manera:
class Person {
//...
public readonly string Name;
public readonly string Company;
public readonly string CompanyAddress;
//...
}
A lo que respondo que este método no solo no está de acuerdo con el Feng Shui, sino que tampoco resuelve el problema de la inicialización conveniente: los miembros de solo lectura también se pueden configurar solo en el constructor, como propiedades con un establecedor privado.
Pero en C # 9.0 este problema está resuelto: si define una propiedad como una propiedad de inicio, obtiene una sintaxis conveniente para crear un objeto y una propiedad que es realmente inmutable en el futuro:
class Person {
public string Name { get; init; }
public string Company { get; init; }
public Address CompanyAddress { get; init; }
}
Por cierto, en init-properties, como en el constructor, puede inicializar miembros de clase de solo lectura, y puede escribir así:
public class Person
{
private readonly string name;
public string Name
{
get => name;
init => name = (value ?? throw new ArgumentNullException(nameof(Name)));
}
}
El registro es un DTO legalizado
Continuando con el tema de las propiedades inmutables, llegamos a la principal, en mi opinión, innovación del lenguaje: el tipo de registro. Este tipo está diseñado para crear convenientemente estructuras inmutables completas, no solo propiedades. El motivo de la aparición de un tipo separado es simple: trabajando de acuerdo con todos los cánones, creamos constantemente DTO para aislar diferentes capas de la aplicación. Los DTO suelen ser solo una colección de campos, sin ninguna lógica empresarial. Y, como regla, los valores de estos campos no cambian durante la vida útil de este DTO.
.
DTO – Data Transfer Object. (DAL, BL, PL) - . «». -DTO' DAL BL, , DTO-, , DTO-, - ( HTML- JSON-).
— DTO-, - -, .
DTO- - . DTO-, AutoMapper - .
, DTO- .
Entonces, después de muchos, muchos años, los desarrolladores de C # finalmente lograron la mejora realmente necesaria: legalizaron los modelos DTO como un tipo de registro separado.
Hasta ahora, todos los modelos DTO que creamos (y los creamos en grandes cantidades) eran clases ordinarias. Para el compilador y para el tiempo de ejecución, no eran diferentes de todas las demás clases, aunque no lo eran en el sentido clásico. Pocas personas han utilizado estructuras para los modelos DTO; esto no siempre fue aceptable por varias razones.
Ahora podemos definir el registro (en adelante, un registro), una estructura especial que está diseñada para crear modelos DTO inmutables. La grabación ocupa un lugar intermedio entre las estructuras y las clases en su sentido habitual. Es tanto una subclase como una superestructura. Un registro sigue siendo un tipo de referencia con todas las consecuencias consiguientes. Los registros casi siempre se comportan como una clase normal, pueden contener métodos, permiten la herencia (pero solo de otros registros, no de objetos, aunque si el registro no hereda explícitamente de nada, entonces hereda del objeto tan implícitamente como todo en C # ) puede implementar interfaces. Además, no es necesario que los registros sean completamente inmutables. ¿Y dónde, entonces, está el significado y cuál es la diferencia?
Creemos una entrada:
public record Person
{
public string LastName { get; }
public string FirstName { get; }
public Person(string first, string last) => (FirstName, LastName) = (first, last);
}
Ahora, aquí hay un ejemplo de cómo usarlo:
Person p1 = new ("Paul", "McCartney");
Person p2 = new ("Paul", "McCartney");
System.Console.WriteLine(p1 == p2);
Este ejemplo se imprimirá fiel a la consola. Si Person fuera una clase, entonces se imprimiría falso en la consola porque los objetos se comparan por referencia: dos variables de referencia son iguales solo si se refieren al mismo objeto. Pero ese no es el caso de las grabaciones. Los registros se comparan por el valor de todos sus campos, incluidos los privados.
Continuando con el ejemplo anterior, veamos este código:
System.Console.WriteLine(p1);
En el caso de una clase, recibiríamos el nombre completo de la clase en la consola. Pero en el caso de los registros, veremos esto en la consola:
Person { LastName = McCartney, FirstName = Paul}
El hecho es que para los registros, el método ToString () se anula implícitamente y no muestra el nombre del tipo, sino una lista completa de campos públicos con valores. Asimismo, para los registros, los Operadores == y! = Se redefinen implícitamente, lo que permite cambiar la lógica de comparación.
Juguemos con la herencia de registros:
public record Teacher : Person
{
public string Subject { get; }
public Teacher(string first, string last, string sub)
: base(first, last) => Subject = sub;
}
Ahora creemos dos publicaciones de diferentes tipos y comparémoslas:
Person p = new("Paul", "McCartney");
Teacher t = new("Paul", "McCartney", "Programming");
System.Console.WriteLine(p == t);
Aunque el registro del profesor se hereda de Person, las variables p y t no serán iguales, se imprimirá falso en la consola. Esto se debe a que la comparación se realiza no solo para todos los campos de registros, sino también para tipos, y los tipos aquí son claramente diferentes.
Y aunque se permite comparar tipos de registros heredados (pero no tiene sentido), comparar diferentes tipos de registros en general no está permitido en principio:
public record Person
{
public string LastName { get; }
public string FirstName { get; }
public Person(string first, string last) => (FirstName, LastName) = (first, last);
}
public record Person2
{
public string LastName { get; }
public string FirstName { get; }
public Person2(string first, string last) => (FirstName, LastName) = (first, last);
}
// ...
Person p = new("Paul", "McCartney");
Person2 p2 = new("Paul", "McCartney");
System.Console.WriteLine(p == p2); //
Las entradas parecen ser las mismas, pero habrá un error de compilación en la última línea. Solo puede comparar registros que sean del mismo tipo o tipos heredados.
Otra característica interesante de los registros es la palabra clave with, que facilita la creación de modificaciones en sus modelos DTO. Mira un ejemplo:
Person me = new("Steve", "Brown");
Person brother = me with { FirstName = "Paul" };
En este ejemplo, para el registro hermano, todos los valores de campo se completarán desde el registro yo, excepto el campo Nombre, que se cambiará a Paul.
Hasta ahora, ha visto la forma clásica de crear registros, con definiciones completas de constructores, propiedades, etc. Pero ahora también hay una forma lacónica:
public record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName,
string Subject)
: Person(FirstName, LastName);
public sealed record Student(string FirstName,
string LastName, int Level)
: Person(FirstName, LastName);
Puede definir registros de forma abreviada y el compilador creará las propiedades y el constructor por usted. Sin embargo, esta característica tiene una característica adicional: no solo puede usar una notación abreviada para definir propiedades y un constructor, sino que al mismo tiempo puede agregar su propio método a la entrada:
public record Pet(string Name)
{
public void ShredTheFurniture() =>
Console.WriteLine("Shredding furniture");
}
public record Dog(string Name) : Pet(Name)
{
public void WagTail() =>
Console.WriteLine("It's tail wagging time");
public override string ToString()
{
StringBuilder s = new();
base.PrintMembers(s);
return $"{s.ToString()} is a dog";
}
}
En este caso, las propiedades y el constructor de registros también se crearán automáticamente. Cada vez menos código repetitivo, pero solo aplicable a publicaciones. Esto no funciona para clases y estructuras.
Además de todo lo dicho, el compilador también puede crear automáticamente un deconstructor para registros:
var person = new Person("Bill", "Wagner");
var (first, last) = person; //
Console.WriteLine(first);
Console.WriteLine(last);
Sin embargo, a nivel de IL, los registros siguen siendo una clase. Sin embargo, hay una sospecha para la que aún no se ha encontrado confirmación: seguro, en el nivel de tiempo de ejecución, los registros se optimizarán enormemente en algún lugar. Muy probablemente, debido al hecho de que se sabrá de antemano que un registro en particular es inmutable. Esto abre oportunidades para la optimización, al menos en un entorno de subprocesos múltiples, y el desarrollador ni siquiera necesita poner esfuerzos especiales para esto.
Mientras tanto, estamos reescribiendo todos los modelos DTO, desde clases hasta registros.
Generadores de fuente .NET
Source Generator (en lo sucesivo, simplemente un generador) es una característica bastante interesante. Un generador es una pieza de código que se ejecuta en la etapa de compilación, tiene la capacidad de analizar el código ya compilado y puede generar código adicional que también se compilará. Si no está del todo claro, aquí hay un ejemplo bastante relevante cuando un generador puede tener demanda.
Imagine una aplicación web C # / .NET que escribe en ASP.NET Core. Cuando inicia esta aplicación, hay una gran cantidad de trabajo de fondo de inicialización para analizar de qué está hecha esta aplicación y qué debería hacer. La reflexión se usa frenéticamente. Como resultado, el tiempo desde el lanzamiento de la aplicación hasta el inicio del procesamiento de la primera solicitud puede ser obscenamente largo, lo cual es inaceptable en servicios muy cargados. El generador puede ayudar a reducir este tiempo: incluso en la etapa de compilación, puede analizar su aplicación ya compilada y, además, generar el código necesario que la inicializará mucho más rápido al inicio.
También hay una cantidad bastante grande de bibliotecas que usan la reflexión para determinar en tiempo de ejecución los tipos de objetos usados (entre ellos hay muchos paquetes Nuget principales). Esto abre un enorme margen para la optimización mediante generadores, y los autores de esta función esperan mejoras adecuadas de los desarrolladores de la biblioteca.
Los generadores de código son un tema nuevo y demasiado inusual para encajar dentro del alcance de esta publicación. Además, puede ver un ejemplo del más simple "¡Hola, mundo!" generador en esta revisión .
Hay dos características nuevas asociadas con los generadores de código, que se describen a continuación.
Métodos parciales
Las clases parciales en C # existen desde hace mucho tiempo, su propósito original es separar el código generado por un determinado diseñador del código escrito por el programador. Los métodos parciales se han adaptado en C # 9.0. Se ven así:
public partial class MyClass
{
public partial int DoSomeWork(out string p);
}
public partial class MyClass
{
public partial int DoSomeWork(out string p)
{
p = "test";
System.Console.WriteLine("Partial method");
return 5;
}
}
Este ejemplo sustituto demuestra que los métodos parciales no son esencialmente diferentes de los ordinarios: pueden devolver valores, pueden aceptar variables externas y pueden tener modificadores de acceso.
A partir de la información disponible, los métodos parciales estarán estrechamente relacionados con los generadores de códigos, donde se pretende que se utilicen.
Inicializadores de módulo
Hay tres razones para introducir esta funcionalidad:
- Permitir que las bibliotecas tengan algún tipo de inicialización única en el arranque con una sobrecarga mínima y sin necesidad explícita de que el usuario llame a nada;
- la funcionalidad existente de los constructores estáticos no es muy adecuada para este rol, porque el tiempo de ejecución debe averiguar primero si se usa una clase con un constructor estático (estas son las reglas), y esto da retrasos medibles;
- Los generadores de código deben tener algún tipo de lógica de inicialización que no necesita ser llamado explícitamente.
De hecho, el último punto parece haberse vuelto decisivo para que la función se incluya en el lanzamiento. Como resultado, se nos ocurrió un nuevo atributo que necesitamos para cubrir el método que es la inicialización:
using System.Runtime.CompilerServices;
class C
{
[ModuleInitializer]
internal static void M1()
{
// ...
}
}
Hay algunas restricciones en el método:
- debe ser estático;
- no debe tener parámetros;
- no debería devolver nada;
- no debería funcionar con genéricos;
- debe ser accesible desde el módulo contenedor, es decir:
- debe ser interno o público
- no tiene que ser un método local
Y funciona así: tan pronto como el compilador encuentra todos los métodos marcados con el atributo ModuleInitializer, genera un código especial que los llama a todos. No se puede especificar el orden de invocación de los métodos de inicialización, pero será el mismo en cada compilación.
Conclusión
Habiendo publicado ya la publicación, notamos que está más dedicada a las noticias en el lenguaje C # 9.0 que a las noticias del propio .NET. Pero resultó bien.