.Net

Nouveautés du C# 9 – Partie 1

Nov 3, 2020

Romain LAPREE-KAMINSKI

Cette série de deux articles a pour but de présenter à travers des exemples, les nouvelles fonctionnalités du C# 9, version du langage présent dans le framework .Net 5 dont la sortie est prévue courant Novembre 2020. Cette nouvelle version du framework, placée sous le signe de l’unification, reste la continuité de .Net Core 3.1 mais marque l’arrêt de .Net Framework (la version 4.8 sera encore maintenue quelques temps) et .Net Standard. Exit donc .Net « Framework », .Net « Standard » et .Net « Core » pour laisser place à .Net, tout simplement. Le numéro 5 de cette version vient là aussi marquer cette unification (version majeure 3 pour Core mais 4 pour Framework).

Pour pouvoir tester ces nouveautés, il vous faudra :

Nous allons présenter dans cette première partie :

Top-level Statements

La première des nouveautés s’appelle les « Top-level statements » . Vous le savez sans doute, tout point d’entrée d’un programme écrit en C# est une méthode Main d’une classe Program. Prenons l’exemple d’une application console qui affiche le premier argument du programme puis les données saisies par l’utilisateur. Ce programme se compose d’une méthode waitForInput statique locale à la fonction et d’une classe statique Printer avec une méthode asynchrone Print. Notez que la méthode Main utilise ses arguments et plus spécifiquement args[0] . Alors qu’en C#8 (C#8 Program.cs) l’écriture de la méthode Main était obligatoire, le C# 9 (C#9 Program.cs) nous permet, grâce aux ‘Top-level statements’, de supprimer :

  • le namespace (cela était déjà possible)
  • la classe Program
  • la méthode Main avec son type de retour et ses paramètres
using System;
using TopLevelPrograms;

Console.WriteLine("Hello New World!");
await Printer.Print("First Argument", args[0] ?? "no argument provided");

Console.WriteLine("Say something please :");
string input = waitForInput();
await Printer.Print("User", input);

waitForInput();

static string waitForInput()
{
    return Console.ReadLine();
}
using System;
using System.Threading.Tasks;

namespace TopLevelPrograms
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Console.WriteLine("Hello Old World!");
            await Printer.Print("First Argument", args[0] ?? "no argument provided");

            Console.WriteLine("Say something please :");
            string input = waitForInput();
            await Printer.Print("User", input);

            waitForInput();

            static string waitForInput()
            {
                return Console.ReadLine();
            }
        }
    }
}
    public static class Printer
    {
        public async static Task Print(string src, string stringToPrint)
        {
            await Task.Delay(1000);
            Console.WriteLine($"{src} says : {stringToPrint}");
        }
    }

Cette facilité d’écriture est valable pour une application console mais aussi pour tout type de programme comme par exemple une API Asp.Net.

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using WebApplicationTest;

CreateHostBuilder(args).Build().Run();

static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
        });

Cette fonctionnalité est ce que l’on appelle du « sucre syntaxique », une facilité de lecture et d’écriture pour nous développeurs. En revanche, une fois compilé, le code MSIL sera exactement le même que l’on utilise les « top-level statements » ou non. Quelques précisions :

  • les « top-level statements » doivent précéder tout type (classe, structure, record, …) et tout namespace. Dans le cas contraire, votre code ne compilera pas
  • de la même façon que l’on peut n’avoir qu’une seule méthode Main par programme, on ne peut avoir qu’un fichier avec « top-level statements » par programme
  • si votre programme contient une méthode Main ET un fichier avec « top-level statements », ce dernier prendra le dessus

Target-type new  Expression

Seconde nouveauté, toujours du sucre syntaxique, mais que l’on devrait utiliser bien plus souvent, les « target-type new expressions ». Elles nous permettent d’omettre le type à instancier juste après l’instruction new, le compilateur ira le chercher lui même en fonction du contexte, le plus souvent ce qu’il trouvera à gauche du « = »

Company company = new();

Dans cet exemple, le compilateur instanciera company à partir du constructeur sans paramètre du type Company. Cela fonctionne de la même façon pour un constructeur avec paramètres, qu’ils soient obligatoires, facultatifs ou nommés. Vous vous dîtes que cette facilité d’écriture, vous l’aviez déjà avec le mot-clé var et vous avez raison, le code suivant donne exactement le même code MSIL

var company = new Company();

Cependant, je pense que la facilité de lecture a été complètement oubliée avec var au détriment de la facilité d’écriture. Beaucoup d’entre nous l’utilisons plus souvent que de raison, Microsoft préconise d’ailleurs d’utiliser var uniquement lorsque le type est évident dans l’assignation. Je suis par exemple sûr que 99.99% d’entre vous auront recours au F12 ou à la souris pour connaitre le type de result ici

var result = FindByName("toto");

Au delà de mon avis personnellement subjectif qui m’est propre à propos de var , les target-type new expressions vont vous permettre plus de choses notamment :

  • initialisation d’une propriété
  • initialisation d’un champ
  • initialisation d’un paramètre dans les arguments d’une méthode

Le code suivant montre ces différentes possibilités qui s’offrent à vous avec cette nouvelle fonctionnalité (j’ai laissé en commentaire le code à supprimer pour mettre en évidence l’apport de la fonctionnalité du C#9)

public class Company
    {
        public Company() { }

        public Company(string name, string address)
        {
            Name = name;
            Address = address;
        }

        private int _indexDeparture = 0;
        //initialisation d'un champ
        private Dictionary<int, Employee> _departs = new/*Dictionary<int, Employee>*/();

        public string Name { get; set; }
        public string Address { get; set; }
        //initialisation d'une propriété via un initialiseur automatique
        public List<Employee> Employees { get; private set; } = new/*List<Employee>*/();

        public void AddEmployee(string firstName, string lastName)
        {
            //instanciation d'objet via un constructeur avec paramètres nommés
            //pour le fournir en tant que paramètre d'une méthode
            Employees.Add(new/*Employee*/(firstName, lastName));
        }

        public void AddEmployee(Employee employee)
        {
            Employees.Add(employee);
        }

        public void RemoveEmployee(string v1, string v2)
        {
            Employee employee = Employees.FirstOrDefault(employee => 
                employee.FirstName == v1 && employee.LastName == v2);

            Employees.Remove(employee);
            _departs.Add(++_indexDeparture, employee);
        }
    }
public class Employee
{
    public Employee() 
    {
        //initialisation d'une propriété au sein d'un constructeur
        JobDepartments = new/*List<string>*/();
    }

    public Employee(string firstName, string lastName)
        : this()
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public string FirstName { get; set; }
    public string LastName { get; set; }
    public List<string> JobDepartments { get; set; }
}
using System.Collections.Generic;
using TargetTypedNewExpression;

//instanciation d'objet via un constructeur avec paramètre obligatoire
Company company = new /*Company*/("dcube", "170 rue Raymond Losserand - 75014 Paris");
company.AddEmployee("John", "Doe");

//instanciation d'objet via un constructeur avec paramètres nommés
//pour le fournir en tant que paramètre d'une méthode
//ici le compilateur retrouve le type en fonction du type du paramètre de la méthode
company.AddEmployee(new /*Employee*/(lastName: "Doe", firstName: "Jane"));


/*company.AddEmployee(new Employee 
{ 
    FirstName = "Bob",
    LastName = "L'éponge",
    JobDepartments = new List<string> { "Cuisinier" } 
});*/

//ici le target-type new utilisé dans un initialiseur d'objet impose le () après le new
company.AddEmployee(new ()
{
    FirstName = "Bob",
    LastName = "L'éponge",
    //Initialisation d'une propriété au sein d'un initialiseur d'objet
    JobDepartments = new () { "Cuisinier" }
});
company.RemoveEmployee("John", "Doe");

Init-only Properties

Microsoft a annoncé que .Net 5 allait contribuer à améliorer l’écriture de micro-services en .Net. Parmi les contributions, on peut citer la réduction de la taille des images Docker, l’amélioration des performances notamment sur la sérialisation JSON et sur le protocole gRPC mais aussi des améliorations au sein de C#9. Les deux fonctionnalités suivantes s’inscrivent dans cette logique. En effet, on associe souvent une conception pilotée par le domaine ou DDD (Domain Driven Design) avec une architecture en micro-services. Un des principes de base d’une conception DDD est l’utilisation d’objets immuables, c’est à dire des objets dont les propriétés sont fixées à leur instanciation. Jusqu’ici il était fastidieux de créer des propriétés ou des objets immuables en C# (mais ça, c’était avant l’arrivée de C#9 !), il fallait créer une propriété avec uniquement un getter accompagné d’un ou plusieurs constructeurs pour l’initialiser.

    public class Company
    {
        public Company(string name, string address)
        {
            Name = name;
            Address = address;
        }

        public string Name { get; }
        public string Address { get; }
    }

Cela pouvait être très long à écrire en fonction du nombre de propriétés, de la présence de propriétés facultatives… On pouvait se retrouver avec beaucoup de constructeurs. En effet, les propriétés n’ayant pas de setter, il était impossible d’utiliser un initialiseur d’objet.

La première des deux nouveautés permet de créer des propriétés immuables, des « init-only properties », des propriétés qui ne peuvent plus être modifiées après leur initialisation.

public string Name { get; init; }

Pour cela, on utilise simplement le mot-clé init, qui est un setter spécifique, on ne peut donc pas avoir set et init ensemble. Ainsi, il est possible d’initialiser la propriété « init-only » de trois façons :

  • en utilisant un constructeur
Company company = new(name:"dcube");
  • en utilisant un initialiseur de propriété automatique
public string Name { get; init; } = "unset";
  • en utilisant un initialiseur d’objet
Company company = new() { Name = "dcube" };

C’est cette dernière possibilité qui va grandement nous aider dans l’utilisation de propriétés immuables : éviter l’écriture de constructeur !

Sachez qu’il est également possible de combiner init et champ readonly, notamment pour vérifier qu’une valeur respecte certains prérequis :

        private readonly string _address;
        public string Address
        {
            get => _address;
            init => _address = string.IsNullOrWhiteSpace(value)
                ? throw new ArgumentException("Please provide a valid value",
                    nameof(Address))
                : value;
        }

Records

On remonte d’un cran pour passer au niveau des objets avec l’avant dernière nouveauté de cet article, les records qui introduisent un nouveau mot-clé « record » » . Un record permet la création d’objets immuables mais pas seulement…

public record Employee(string FirstName, string LastName); 

Cette syntaxe qui permet de créer un record positionnel est la plus concise. Si vous avez l’œil attentif, vous avez prêté attention à la casse et vu que FirstName et LastName sont écrits en PascalCase et non en camelCase. Il s’agit en fait de propriétés et non de paramètres. Le compilateur traduit cela en une classe qui contient :

  • deux propriétés init-only
  • un constructeur à deux paramètres : Employee(string firstName, string lastName) qui explique le terme positionnel (la position d’écriture d’une propriété dans le record correspond à la position du paramètre dans le constructeur)
  • un constructeur par copie
  • un déconstructeur
  • des surcharges des méthode GetHashCode et Equals
  • une surcharge des opérateurs == et !=
  • une surcharge de la méthode ToString

Création d’un record

En utilisant un record positionnel, l’instanciation est très simple : il faut utiliser le constructeur fourni.

Employee employee = new("Romain", "LAPREE-KAMINSKI")

Il est également possible de gérer des propriétés optionnelles, soit en leur affectant une valeur par défaut dans un record positionnel

public record Employee(string FirstName, string LastName, string JobTitle = default);

Employee employeeWithJobTitle = new("Romain", "LAPREE-KAMINSKI", "Azure Cloud Developer");
Employee employeeWithoutJobTitle = new("Romain", "LAPREE-KAMINSKI");

soit en utilisant une propriété init-only, dans ce cas il faut mixer constructeur et initialiseur d’objet

public record Employee(string FirstName, string LastName) 
{
    public string JobTitle { get; init; }
}

Employee employeeWithJobTitle = new("Romain", "LAPREE-KAMINSKI")
{
    JobTitle = "Azure Cloud Developer"
};
Employee employeeWithoutJobTitle = new("Romain", "LAPREE-KAMINSKI");

Comparaison de records

Les records sont des classes et donc ce sont des types références et non des types valeurs. Cependant, pour deux objets immuables, ce sont les propriétés que l’on souhaite comparer et non les références. Ainsi, la méthode Equals d’un record a été surchargée pour que ce soit le cas : ce sont les valeurs de leurs propriétés qui sont comparées une à une au travers d’instances de EqualityComparer<T> . De la même façon la méthode GetHashCode ainsi que les opérateurs == et != fonctionnent avec les propriétés. Cela donne dans l’exemple Employee (extrait du code MSIL)

public virtual bool Equals(Employee other)
{
    return (object)other != null
            && EqualityContract == other.EqualityContract
            && EqualityComparer<string>.Default.Equals(FirstName, other.FirstName)
            && EqualityComparer<string>.Default.Equals(LastName, other.LastName);
}

Copie d’un record

Les records offrent également une grande facilité pour la copie d’instance grâce au mot-clé with. Alors qu’avant C#9, nous devions effectuer la copie propriété par propriété

Employee employeeCopy = new Employee {
    FirstName = employee.FirstName, 
    LastName = employee.LastName,
    JobTitle = "FullStack Developer"
};

la copie s’effectue bien plus facilement avec les records

Employee employeeCopy = employee with { JobTitle = "FullStack Developer" };

NB : Si pour une raison ou une autre (par exemple si vous insérez une propriété modifiable dans votre record!) vous souhaitez faire une copie à l’identique de votre objet, il vous faudra penser à utiliser with

Employee employeeCopyValue = employee with { }; // copie par valeur
Employee employeeCopyRef = employee; // copie par référence

Affichage d’un record

La méthode ToString est surchargée dans les records, elle s’appuie sur une méthode PrintMembers qui affiche toutes les propriétés en mode clé/valeur. Ainsi l’exemple précédent donne

Déconstruction d’un record

Un record comporte également un déconstructeur, fonctionnalité apparue dans C#7 avec les Tuples, qui reprend l’ordre des propriétés du constructeur.

var (employeeFirstName, employeeLastName) = employee;

Attention le déconstructeur fourni ne fonctionne qu’avec un record positionnel, si vous avez intégré des propriétés init-only optionnelles, vous ne pourrez les récupérer, au delà du getter bien sûr, que via votre propre implémentation de la méthode Deconstruct

Héritage

Les records permettent d’utiliser l’héritage : un record peut hériter d’un autre record mais ne peut hériter d’une classe (hormis Object). A l’inverse, une classe ne peut hériter d’un record.

public record Developer(string FirstName, string LastName, string FavoriteLanguage = null) 
    : Employee(FirstName, LastName);

Attention, la comparaison d’égalité est basée, on l’a vu plus haut, sur la valeur des propriétés mais aussi sur les types, ainsi nous avons

Employee employeeBase = new("Romain", "LAPREE-KAMINSKI");
Developer developerInherit = new("Romain", "LAPREE-KAMINSKI");
Console.WriteLine(employeeBase == developerInherit); // false

Enfin, deux petites choses à noter :

  • un record peut implémenter une interface
  • on peut affecter le résultat d’un JsonSerializer à un type record

Null Reference Types

Les Null Reference Types ou NRT ne sont pas réellement une nouveauté de langage du C#9, en revanche cette fonctionnalité du C#8 a été implémentée dans plus de 80% du framework (90% si on compte le code en pull request au moment de l’écriture de cet article). Petite piqûre de rappel donc, les NRT offrent la possibilité de définir un type référence, une classe, comme étant nullable, «Employee? » , ou comme étant non nullable, « Employee ». Le fonctionnement des types valeurs a en quelque sorte été repris sur les types référence : quand on déclare une variable int, on sait pertinement qu’elle ne vaudra jamais null, et si l’on souhaite qu’elle soit null on la définit en int?. C’est maintenant la même chose avec les NRT. Quand on déclarera un Employee, cela voudra dire que la variable ne pourra jamais valoir null (et il faudra initialiser la variable!) et si on souhaite qu’elle puisse prendre la valeur null, alors on la déclarera en Employee? (et il faudra vérifier qu’elle ne vaut pas null avant tout déférencement!)

La fonctionnalité s’active directement dans le fichier *.csproj en ajoutant dans la section PropertyGroup

<Nullable>enable<Nullable>

En travaillant avec les NRT, on réduit de manière considérable la possibilité que notre code lève nos amies les NullReferenceException (en réalité seul le code des bibliothèques tierces pourra être mis en cause). Un petit exemple vaut mieux qu’un long discours, voici un petit programme écrit en activant la fonctionnalité NRT

using System;
using System.Text.Json;

Employee employeeDeserialize = JsonSerializer.Deserialize<Employee>(
    "{"FirstName":"Romain", "LastName":"LK", "JobTitle":"FullStack Dev"}");
Console.WriteLine(employeeDeserialize.JobTitle.ToUpper());

Company company = new();
Console.WriteLine(company);

public record Employee(string FirstName, string LastName, string JobTitle = null);

public class Company
{
    public string Name { get; set; }
    public string Address { get; set; }

    public override string ToString()
    {
        return $"companyName: {Name.ToUpper()}, companyAddress: {Address.ToLower()}";
    }
}
using System;
using System.Text.Json;

Employee? employeeDeserialize = JsonSerializer.Deserialize<Employee>(
    "{"FirstName":"Romain", "LastName":"LK", "JobTitle":"FullStack Dev"}");
Console.WriteLine(employeeDeserialize?.JobTitle?.ToUpper() ?? "undefined");

Company company = new("dcube");
Console.WriteLine(company);

public record Employee(string FirstName, string LastName, string? JobTitle = null);

public class Company
{
    public Company(string name)
    {
        Name = name;
    }

    public string Name { get; set; }
    public string? Address { get; set; }

    public override string ToString()
    {
        return $"companyName: {Name.ToUpper()}, companyAddress: {Address?.ToLower() ?? "not set"}";
    }
}

Sur cet exemple, il y a une instance d’une classe Company et une instance d’un record Employee récupérée depuis un JSON. La classe Company contient deux propriétés Name et Address ainsi qu’une surcharge de la méthode ToString qui affiche le nom en majuscule et l’adresse en minuscule. Le record Company contient deux propriétés obligatoires : FirstName et LastName et une propriété facultative : JobTitle que je souhaite afficher en majuscule dans mon programme.

L’onglet Activation NRT laisse apparaître en gris clair les warnings qui proviennent de potentielles NullReferenceException, traitons les une par une :

  • la méthode Deserialize de JsonSerializer retourne un TValue? donc une reference nullable => il faut donc ajouter un « ? » à notre Employee, pour que celui-ci puisse valoir null. Cette modification entraîne l’apparition d’un nouveau warning à la ligne 6, sur le déférencement de employeeDeserialize. En effet, cette variable peut être null et pour corriger cela, deux solutions : soit faire précéder cet appel d’un classique « if (employeeDeserialize != null) » soit utiliser l’opérateur de propagation de null « ? »
  • le record a une propriété optionnelle JobTitle que l’on affecte par défaut à null => il faut donc ajouter un « ? » au string. Cette modification entraine l’apparition d’un nouveau warning à la ligne 6 sur le déférencement de JobTitle. Même chose que le point précédent et donc même correctif. On peut également ajouter une valeur par défaut dans le cas où employeeDeserialize ou JobTitle valent null.
  • les propriétés Name et Address de Company peuvent également être null (ce serait d’ailleurs le cas dans notre exemple!). On fait le choix que Address soit facultatif donc on opère les mêmes modifications que sur JobTitle à savoir l’ajout de « ? » à la ligne 16 mais également à la ligne 20. En revanche, Name est obligatoire. Plusieurs options s’offrent à nous, on peut soit définir une valeur par défaut via un initialiseur automatique, soit ajouter un constructeur qui initialiserait la propriété, soit prendre le risque de maintenir la référence null possible en utilisant l’opérateur null-forgiving « ! » : public string Name { get; set; } = null!; (cela peut être utilisé notamment si nos instances sont récupérées depuis une entité en base et que la colonne est définie comme non nullable)

Pour la deuxième partie sur les fonctionnalités apportées par C#9, Etienne présentera des nouveautés autour du pattern matching, des covariant return, des partial methods ou encore des améliorations du JsonSerializer.

0 commentaires

Soumettre un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Découvrez nos autres articles

Aller au contenu principal