Estamos a 2 meses de la gran salida de .Net 5. Hace unos días, 13 de septiembre de este año, tenemos disponible la primera versión Release Candidate de .Net 5. Mientras esperamos la versión final que saldrá en noviembre, podemos ir investigando que tenemos en esta casi versión final.
C# 9 — Records
Esta es una de las nuevas características más importante de C# 9. Tenemos muchas mejoras para cada tipo de lenguaje, pero algunas solo están disponibles desde esta release candidate cómo record.ToSTring().
La visión de esta es que debemos pensar en lo records como clases inmutables, pero, con características cercanas a las tuplas, como una tupla inmutable.
Records como tipos de datos inmutables
Estos son ideales para definir tipos que almacenan pequeñas cantidades de datos o información. En el ejemplo podemos ver como llegan los datos de un inicio de sesión:
public record LoginResource(string Username, string Password, bool RememberMe);
Es muy similar pero veremos a las diferencias más adelante:
public class LoginResource { public LoginResource(string username, string password, bool rememberMe) { Username = username; Password = password; RememberMe = rememberMe; } public string Username { get; init; } public string Password { get; init; } public bool RememberMe { get; init; } }
Lo primero que vemos es que tenemos una nueva palabra reservada: init. Esta permite asignar una propiedad solamente durante la construcción del objeto convirtiéndola en inmutable.
Records como clases especializadas
Además del comportamiento anterior, records proporciona un comportamiento más especializado. Si comparamos un record con una clase que usa init en lugar de tener un set para la propiedades veremos que es casi lo mismo, tiene un constructor, la inmutabilidad y algunas clase semánticas.
¿Pero qué tiene de diferente? La igualdad de registros se basa en el contenido. Los records proporcionan un implementación GetHashCode() que se basa en el contenido del registro. También tenemos una implementación IEquatable<T> que utiliza el comportamiento GetHasCode() como mecanismo de comparación de igualdad basada en el contenido del records. Por último, ToSTring(), está anulado.
Veamos un código con las diferencias:
using System; using System.Linq; using static System.Console; var user = "Lion-O"; var password = "jaga"; var rememberMe = true; LoginResourceRecord lrr1 = new(user, password, rememberMe); var lrr2 = new LoginResourceRecord(user, password, rememberMe); var lrc1 = new LoginResourceClass(user, password, rememberMe); var lrc2 = new LoginResourceClass(user, password, rememberMe); WriteLine($"Test record equality -- lrr1 == lrr2 : {lrr1 == lrr2}"); WriteLine($"Test class equality -- lrc1 == lrc2 : {lrc1 == lrc2}"); WriteLine($"Print lrr1 hash code -- lrr1.GetHashCode(): {lrr1.GetHashCode()}"); WriteLine($"Print lrr2 hash code -- lrr2.GetHashCode(): {lrr2.GetHashCode()}"); WriteLine($"Print lrc1 hash code -- lrc1.GetHashCode(): {lrc1.GetHashCode()}"); WriteLine($"Print lrc2 hash code -- lrc2.GetHashCode(): {lrc2.GetHashCode()}"); WriteLine($"{nameof(LoginResourceRecord)} implements IEquatable<T>: {lrr1 is IEquatable<LoginResourceRecord>} "); WriteLine($"{nameof(LoginResourceClass)} implements IEquatable<T>: {lrr1 is IEquatable<LoginResourceClass>}"); WriteLine($"Print {nameof(LoginResourceRecord)}.ToString -- lrr1.ToString(): {lrr1.ToString()}"); WriteLine($"Print {nameof(LoginResourceClass)}.ToString -- lrc1.ToString(): {lrc1.ToString()}"); public record LoginResourceRecord(string Username, string Password, bool RememberMe); public class LoginResourceClass { public LoginResourceClass(string username, string password, bool rememberMe) { Username = username; Password = password; RememberMe = rememberMe; } public string Username { get; init; } public string Password { get; init; } public bool RememberMe { get; init; } }
El código produce el siguiente resultado:
rich@thundera records % dotnet run Test record equality -- lrr1 == lrr2 : True Test class equality -- lrc1 == lrc2 : False Print lrr1 hash code -- lrr1.GetHashCode(): -542976961 Print lrr2 hash code -- lrr2.GetHashCode(): -542976961 Print lrc1 hash code -- lrc1.GetHashCode(): 54267293 Print lrc2 hash code -- lrc2.GetHashCode(): 18643596 LoginResourceRecord implements IEquatable<T>: True LoginResourceClass implements IEquatable<T>: False Print LoginResourceRecord.ToString -- lrr1.ToString(): LoginResourceRecord { Username = Lion-O, Password = jaga, RememberMe = True } Print LoginResourceClass.ToString -- lrc1.ToString(): LoginResourceClass
Records sintaxis
Hay varios patrones para declarar records y cada uno tiene diferentes beneficios que podemos usar en nuestras aplicaciones. El más simple “a one liner”, pero también, es el menos flexible. Es útil cuando tenemos un registro con pocas propiedades requeridas.
Veamos la declaración en una sola línea:
public record LoginResource(string Username, string Password, bool RememberMe);
Vemos cómo construir el objeto:
var login = new LoginResource("Lion-O", "jaga", true);
O podemos hacerlo de la nueva forma:
LoginResource login = new("Lion-O", "jaga", true);
Para que las propiedades sean opcionales debemos proporcionar un constructor sin parámetros implícitos para el record:
public record LoginResource { public string Username {get; init;} public string Password {get; init;} public bool RememberMe {get; init;} }
La inicialización podría verse así:
LoginResource login = new() { Username = "Lion-O", TemperatureC = "jaga" };
Puede surgir la necesidad de que dos propiedades sean obligatorias y las otra opcional:
public record LoginResource(string Username, string Password) { public bool RememberMe {get; init;} } LoginResource login = new("Lion-O", "jaga"); LoginResource login = new("Lion-O", "jaga") { RememberMe = true }
Tengamos en cuenta que los records no son exclusivamente para datos inmutables. Podemos agregar propiedades mutables, veámoslo en el siguiente ejemplo:
using System; Battery battery = new Battery("CR2032", 0.235) { RemainingCapacityPercentage = 100 }; Console.WriteLine (battery); for (int i = battery.RemainingCapacityPercentage; i >= 0; i--) { battery.RemainingCapacityPercentage = i; } Console.WriteLine (battery); public record Battery(string Model, double TotalCapacityAmpHours) { public int RemainingCapacityPercentage {get;set;} }
El resultado es:
rich@thundera recordmutable % dotnet run Battery { Model = CR2032, TotalCapacityAmpHours = 0.235, RemainingCapacityPercentage = 100 } Battery { Model = CR2032, TotalCapacityAmpHours = 0.235, RemainingCapacityPercentage = 0 }
Records Mutación no destructiva
Como sabemos, la inmutabilidad tiene grandes beneficios, pero rápidamente aparece un caso donde necesite mutar un record. Para hacerlo tenemos una nueva palabra reservada, with, que nos permitirá crear un record de uno existente del mismo tipo.
LoginResource login = new("Lion-O", "jaga", true);LoginResource loginLowercased = login with {Username = login.Username.ToLowerInvariant()};
La modificación solamente afectará a loginLowerCased pero sigue siendo idéntico al anterior objeto. Podemos verificarlo en la consola:
Console.WriteLine(login); Console.WriteLine(loginLowercased); // Salida LoginResource { Username = Lion-O, Password = jaga, RememberMe = True } LoginResource { Username = lion-o, Password = jaga, RememberMe = True }
Records Herencia
Si necesita extender un récord es muy sencillo hacerlo. Tenemos el record anterior utilizado:
public record LoginResource(string Username, string Password) { public bool RememberMe {get; init;} }
La herencia se veria algo así:
public record LoginWithUserDataResource(string Username, string Password, DateTime LastLoggedIn) : LoginResource(Username, Password) { public int DiscountTier {get; init}; public bool FreeShipping {get; init}; }
Records Helpers
Supongamos el siguiente ejemplo, tenemos un balanza en internet, el pero es en kilogramos, pero tal vez en algún momento sea necesario proporcionar el peso en libras:
public record WeightMeasurement(DateTime Date, double Kilograms) { public double Pounds {get; init;} public static double GetPounds(double kilograms) => kilograms * 2.20462262; }
Como se vería:
var weight = 200; WeightMeasurement measurement = new(DateTime.Now, weight) { Pounds = WeightMeasurement.GetPounds(weight) };
Registros y nulabilidad
Como todo es inmutable no deberíamos tener null, pero, una propiedad inmutable puede ser nula, veamos sin la anulabilidad habilitada:
using System; using System.Collections.Generic; Author author = new(null, null); Console.WriteLine(author.Name.ToString()); public record Author(string Name, List<Book> Books) { public string Website {get; init;} public string Genre {get; init;} public List<Author> RelatedAuthors {get; init;} } public record Book(string name, int Published, Author author);
Esto arroja una excepcion NullReference ya que eliminamos la referencia author.NAme, que es nulo. Para habilitar la nulabilidad debemos hacerlo desde nuestro archivo de proyecto:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <LangVersion>preview</LangVersion> <Nullable>enable</Nullable> </PropertyGroup> </Project>
Ahora actualizaremos el registro Autor con una notación nula:
public record Author(string Name, List<Book> Books) { public string? Website {get; init;} public string? Genre {get; init;} public List<Author>? RelatedAuthors {get; init;} }
en ambos casos deben tener en cuenta que veremos warnings:
/Users/rich/recordsnullability/Program.cs(8,21): warning CS8618: Non-nullable property 'Website' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [/Users/rich/recordsnullability/recordsnullability.csproj] /Users/rich/recordsnullability/Program.cs(5,21): warning CS8625: Cannot convert null literal to non-nullable reference type. [/Users/rich/recordsnullability/recordsnullability.csproj]
Ahora veamos el ejemplo completo mejorado:
using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; Author lord = new Author("Karen Lord") { Website = "https://karenlord.wordpress.com/", RelatedAuthors = new() }; lord.Books.AddRange( new Book[] { new Book("The Best of All Possible Worlds", 2013, lord), new Book("The Galaxy Game", 2015, lord) } ); lord.RelatedAuthors.AddRange( new Author[] { new ("Nalo Hopkinson"), new ("Ursula K. Le Guin"), new ("Orson Scott Card"), new ("Patrick Rothfuss") } ); Console.WriteLine($"Author: {lord.Name}"); Console.WriteLine($"Books: {lord.Books.Count}"); Console.WriteLine($"Related authors: {lord.RelatedAuthors.Count}"); public record Author(string Name) { private List<Book> _books = new(); public List<Book> Books => _books; public string? Website {get; init;} public string? Genre {get; init;} public List<Author>? RelatedAuthors {get; init;} } public record Book(string name, int Published, Author author);
Este ejemplo no devolverá warning cuando compilamos. Author.RelatedAuthors puede ser nulo. Ahora si cambiamos el programa por el siguiente código:
Author GetAuthor() { return new Author("Karen Lord") { Website = "https://karenlord.wordpress.com/", RelatedAuthors = new() }; } Author lord = GetAuthor();
El compilador no sabrá qué RelatedAuthors no será nulo cuando cuando la construcción de los tipos esté dentro de un método separado. Será necesario usar alguno de los siguientes patrones:
lord.RelatedAuthors!.AddRange( if (lord.RelatedAuthors is object) { lord.RelatedAuthors.AddRange( ... }
System.Text.Json
Tenemos varias mejoras de rendimiento, confiabilidad y facilidad de uso en esta versión. También, se incluye soporte para deserializar objetos JSON con records.
HttpClient extension methods
Los métodos de extensión JsonSerializer ahora están expuestos en HttpClient y simplifican el uso de estas dos API juntas. En el siguiente ejemplo utilizamos el método GetFromJsonAsync<T>() para deserializar:
using System; using System.Net.Http; using System.Net.Http.Json; string serviceURL = "https://localhost:5001/WeatherForecast"; HttpClient client = new(); Forecast[] forecasts = await client.GetFromJsonAsync<Forecast[]>(serviceURL); foreach(Forecast forecast in forecasts) { Console.WriteLine($"{forecast.Date}; {forecast.TemperatureC}C; {forecast.Summary}"); } // {"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"} public record Forecast(DateTime Date, int TemperatureC, int TemperatureF, string Summary);
Soporte mejorado para tipos inmutables
Ahora tenemos soportes para tipos inmutables, en ejemplo, veremos la serialización con una estructura inmutable:
using System; using System.Text.Json; using System.Text.Json.Serialization; var json = "{\"date\":\"2020-09-06T11:31:01.923395-07:00\",\"temperatureC\":-1,\"temperatureF\":31,\"summary\":\"Scorching\"} "; var options = new JsonSerializerOptions() { PropertyNameCaseInsensitive = true, IncludeFields = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var forecast = JsonSerializer.Deserialize<Forecast>(json, options); Console.WriteLine(forecast.Date); Console.WriteLine(forecast.TemperatureC); Console.WriteLine(forecast.TemperatureF); Console.WriteLine(forecast.Summary); var roundTrippedJson = JsonSerializer.Serialize<Forecast>(forecast, options); Console.WriteLine(roundTrippedJson); public struct Forecast{ public DateTime Date {get;} public int TemperatureC {get;} public int TemperatureF {get;} public string Summary {get;} [JsonConstructor] public Forecast(DateTime date, int temperatureC, int temperatureF, string summary) => (Date, TemperatureC, TemperatureF, Summary) = (date, temperatureC, temperatureF, summary); }
El atributo JsonConstructor es necesario para especificar el constructor que se utilizará con las estructuras. Con las clases, si solo hay un único constructor, el atributo no es obligatorio. Lo mismo ocurre con los registros.
La salida:
rich@thundera jsonserializerimmutabletypes % dotnet run 9/6/2020 11:31:01 AM -1 31 Scorching {"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"}
Soporte para Records
El soporte es casi el mismo que en el punto anterior, ahora si tenemos un record que expone un constructor parametrizado y una propiedad opcional seria:
using System; using System.Text.Json; Forecast forecast = new(DateTime.Now, 40) { Summary = "Hot!" }; string forecastJson = JsonSerializer.Serialize<Forecast>(forecast); Console.WriteLine(forecastJson); Forecast? forecastObj = JsonSerializer.Deserialize<Forecast>(forecastJson); Console.Write(forecastObj); public record Forecast (DateTime Date, int TemperatureC) { public string? Summary {get; init;} };
Salida:
rich@thundera jsonserializerrecords % dotnet run {"Date":"2020-09-12T18:24:47.053821-07:00","TemperatureC":40,"Summary":"Hot!"} Forecast { Date = 9/12/2020 6:24:47 PM, TemperatureC = 40, Summary = Hot! }
Compatibilidad mejorada con Dictionary<K, V>
JsonSerializer admite diccionarios con claves que no son cadenas. Podemos ver cómo se ve esto en el siguiente ejemplo. En.NET Core 3.0, este código se compila pero arroja una NotSupportedException.
using System; using System.Collections.Generic; using System.Text.Json; Dictionary<int, string> numbers = new () { {0, "zero"}, {1, "one"}, {2, "two"}, {3, "three"}, {5, "five"}, {8, "eight"}, {13, "thirteen"}, {21, "twenty one"}, {34, "thirty four"}, {55, "fifty five"}, }; var json = JsonSerializer.Serialize<Dictionary<int, string>>(numbers); Console.WriteLine(json); var dictionary = JsonSerializer.Deserialize<Dictionary<int, string>>(json); Console.WriteLine(dictionary[55]);
Salida:
rich@thundera jsondictionarykeys % dotnet run {"0":"zero","1":"one","2":"two","3":"three","5":"five","8":"eight","13":"thirteen","21":"twenty one","34":"thirty four","55":"fifty five"} fifty five
Soporte para Fields
JsonSerializer ahora admite campos, veamos el ejemplo:
using System; using System.Text.Json; var json = "{\"date\":\"2020-09-06T11:31:01.923395-07:00\",\"temperatureC\":-1,\"temperatureF\":31,\"summary\":\"Scorching\"} "; var options = new JsonSerializerOptions() { PropertyNameCaseInsensitive = true, IncludeFields = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var forecast = JsonSerializer.Deserialize<Forecast>(json, options); Console.WriteLine(forecast.Date); Console.WriteLine(forecast.TemperatureC); Console.WriteLine(forecast.TemperatureF); Console.WriteLine(forecast.Summary); var roundTrippedJson = JsonSerializer.Serialize<Forecast>(forecast, options); Console.WriteLine(roundTrippedJson); public class Forecast{ public DateTime Date; public int TemperatureC; public int TemperatureF; public string Summary; }
Salida:
rich@thundera jsonserializerfields % dotnet run 9/6/2020 11:31:01 AM -1 31 Scorching {"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"}
Conservación de referencias object graphs en JSON
JsonSerializer ha agregado soporte para preservar referencias (circulares) en object graphs. Se almacena ID para que se pueden reconstituir cuando una cadena JSON al deserializar.
using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; Employee janeEmployee = new() { Name = "Jane Doe", YearsEmployed = 10 }; Employee johnEmployee = new() { Name = "John Smith" }; janeEmployee.Reports = new List<Employee> { johnEmployee }; johnEmployee.Manager = janeEmployee; JsonSerializerOptions options = new() { // NEW: globally ignore default values when writing null or default DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, // NEW: globally allow reading and writing numbers as JSON strings NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString, // NEW: globally support preserving object references when (de)serializing ReferenceHandler = ReferenceHandler.Preserve, IncludeFields = true, // NEW: globally include fields for (de)serialization WriteIndented = true,}; string serialized = JsonSerializer.Serialize(janeEmployee, options); Console.WriteLine($"Jane serialized: {serialized}"); Employee janeDeserialized = JsonSerializer.Deserialize<Employee>(serialized, options); Console.Write("Whether Jane's first report's manager is Jane: "); Console.WriteLine(janeDeserialized.Reports[0].Manager == janeDeserialized); public class Employee { // NEW: Allows use of non-public property accessor. // Can also be used to include fields "per-field", rather than globally with JsonSerializerOptions. [JsonInclude] public string Name { get; internal set; } public Employee Manager { get; set; } public List<Employee> Reports; public int YearsEmployed { get; set; } // NEW: Always include when (de)serializing regardless of global options [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public bool IsManager => Reports?.Count > 0; }
Performance
El rendimiento de JsonSerializer se mejora significativamente en .NET 5.0. Les dejo las capturas de las pruebas de rendimiento:
Collections (de)serialization
Búsqueda de propiedades: convención de nomenclatura
Uno problema común con el uso de JSON es la falta de coincidencia de las convenciones de nomenclatura con las pautas de diseño de .NET. Las propiedades JSON suelen ser propiedades de camelCase y .NET y los campos suelen ser PascalCase. El serializador que utiliza es responsable de establecer un puente entre las convenciones de nomenclatura. Eso no es gratis, al menos no con .NET Core 3.1. Ese costo ahora es insignificante con .NET 5.0.
Conclusiones
Hasta aquí todas las novedades que tenemos disponibles en esta versión release candidate. Como vemos, hay mucho que investigar y probar. Los invito a jugar con estas novedades. En siguiente post veremos las novedades de ASP.Net RC 1.