Les nouveautés de C# 10

Depuis la version .NET Core 3.1 en 2019, Microsoft a planifié de mettre à jour la version de son framework .NET chaque mois de novembre. 2020 a marqué la sortie de .NET 5, ainsi que de son langage phare C# en version 9.

Ce dernier a apporté de nouvelles fonctionnalités comme les classes d’enregistrements, les accesseurs set init only ou encore les instructions de niveau supérieur. Cette année, Microsoft continue sur sa lancée et annonce la sortie de .NET 6 accompagnée de C# 10 dont nous vous proposons de décortiquer les nouveautés et améliorations.

Nous vous précisons cependant qu’au moment où nous avons rédigé ces lignes, C# 10 n’était pas encore sorti dans sa version définitive.

C# 10 : Les structures d’enregistrement

En 2020, C# 9 a introduit le mot clé record qui permet de définir un type référence fournissant des fonctionnalités intégrées pour l’encapsulation des données. Il était jusqu’ici possible de l’affecter uniquement à des classes, ce que C# 10 étend désormais aux structures.

Les structures d’enregistrement sont de type valeur tout comme le sont les structures classiques. Cela signifie qu’elles répondent à des règles communes. A noter que C# 10 va lever certaines restrictions sur les structures. Il rend possible la création d’un constructeur sans paramètre, ainsi que l’initialisation des propriétés dès leur déclaration, comme le montre l’exemple suivant :

        public struct Point
        {
            public Point() // constructeur sans paramètre
            {
                X = 5;
            }

            public int X { get; set; }

            public int Y { get; set; } = 3; // initialisation à la déclaration
        }

La déclaration d’une structure d’enregistrement

Il devient dès lors possible de déclarer une structure d’enregistrement sur une seule ligne :

                 public readonly record struct Point(int X, int Y);

Notons plusieurs points dans cette déclaration :  tout d’abord la présence du mot clé readonly qui n’est pas disponible pour les classes d’enregistrement. Son ajout optionnel permet de spécifier que les propriétés de la structure d’enregistrement sont immuables. En effet, elles ne le sont pas par défaut, ce qui constitue l’une des différences majeures avec les classes d’enregistrement. De plus, les différents paramètres déclarés seront convertis en propriétés par le compilateur, et le constructeur ainsi généré les renseignera à l’initialisation d’une nouvelle instance. Cette syntaxe est donc équivalente à celle ci-dessous, qui utilise le mot clé init introduit en C# 9 rendant les propriétés immuables :

        public record struct Point
        {
      public Point(int x, int y)
            {
                X = x;
                Y = y;
            }
 

            public int X { get; init; }
 

            public int Y { get; init; }
        }

Ensuite, nous remarquons la présence du mot clé struct qui spécifie qu’il s’agit bien une structure d’enregistrement, et non une classe d’enregistrement. En C# 9 les classes d’enregistrement étaient déclarées sans le mot clé class:

        public record Person(string FirstName, string LastName);

Cela reste possible, mais C# 10 a rendu faisable l’ajout optionnel du mot clé class afin d’éviter les confusions.

        public record class Person(string FirstName, string LastName);

Les expressions with

Tout comme avec les classes d’enregistrement, les structures d’enregistrement peuvent être instanciées à l’aide des expressions with. Celles-ci permettent de créer une copie d’une structure d’enregistrement tout en lui spécifiant une ou plusieurs données différentes. Prenons l’exemple suivant :

        var firstPoint = new Point(1, 2);
        var secondPoint = firstPoint with { Y = 3 }; 

        Console.WriteLine(secondPoint);
        // sortie : Point { X = 1, Y = 3 }

La propriété X de secondPoint est issue de l’instance de firstPoint tandis que la propriété Y est définie grâce à l’utilisation de l’expression with.

La comparaison d’égalité

L’égalité entre deux structures d’enregistrement s’effectue en fonction de leurs valeurs. Nous pouvons donc utiliser le mot clé Equals afin de les comparer comme nous le ferions pour les structures classiques, mais également les opérateurs == et != qui ne sont utilisables uniquement pour les structures d’enregistrement :

var firstPoint = new Point(1, 2);
var secondPoint = new Point(1, 2);
Console.WriteLine(firstPoint.Equals(secondPoint));
// sortie : true
Console.WriteLine(firstPoint == secondPoint);
// sortie : true

Afficher les membres avec la méthode ToString

C# 9 a introduit la possibilité d’afficher facilement les membres d’une classe d’enregistrement grâce à la substitution automatique de la méthode ToString. Cette fonctionnalité a également été introduite avec C# 10 pour les structures d’enregistrement :

        public readonly struct record Point(int X, int Y);

        public void Main()
        {

            Point point = new (1, 2);
            Console.WriteLine(point);
            // sortie: Person { X = 1, Y = 2 }
        }

La valeur de sortie est obtenue grâce à la substitution automatique de la méthode ToString du type Point. La méthode itère sur chaque membre de la structure et indique sa valeur grâce à une méthode interne nommée PrintMembers et implémentée automatiquement. Nous verrons dans la section suivante, un autre ajout C# 10 qui concerne la possibilité de sceller cette méthode ToString pour le cas des classes d’enregistrement.

Les types d’enregistrements peuvent sceller ToString

Les classes d’enregistrement supportent l’héritage, ce qui est pris en compte par la substitution de la méthode ToString. Dans ce cas, la méthode itère sur l’ensemble des membres de la classe mère, puis de la classe fille :

        public record Person(string FirstName, string LastName);
        public record Employee(string FirstName, string LastName, int Grade)
            : Person(FirstName, LastName); 

        public void Main()
        {
            Employee employee = new("John", "Doe", 1);
            Console.WriteLine(employee);
            // sortie: Employee { FirstName = John, LastName = Doe, Grade = 1 }
        }

Considérons désormais que dans l’exemple précédant, nous souhaitions uniquement afficher la propriété FirstName des objets de type Person et des classes qui en héritent. Pour cela, nous allons modifier l’implémentation de la méthode ToString de la classe Person grâce au mot clé override. Cependant, cela n’aura pas d’impact sur les variables de type Employee. En effet, la méthode ToString de la classe fille sera tout de même automatiquement substituée et ne prendra pas en compte la modification apportée :

        public record Person(string FirstName, string LastName)
        {
            public override string ToString() => FirstName;
        }

        public record Employee(string FirstName, string LastName, int Grade)
            : Person(FirstName, LastName); 

        public void Main()
        {
            Person person = new("John", "Doe");
            Console.WriteLine(person);
            // sortie: John

            Employee employee = new("John", "Doe", 1);
            Console.WriteLine(employee);
            // sortie: Employee { FirstName = John, LastName = Doe, Grade = 1 }
        }

C# 10 résout cela en permettant de sceller la méthode ToString d’une classe mère grâce au mot clé sealed, afin de prévenir la substitution automatique de la méthode ToString pour les classes qui en héritent et ne pas avoir à modifier leur implémentation. Voici le résultat obtenu avec l’utilisation du mot clé sealed :

public record Person(string FirstName, string LastName) { public sealed override string ToString() => FirstName; } public record Employee(string FirstName, string LastName, int Grade) : Person(FirstName, LastName); public void Main() { Person person = new("John", "Doe"); Console.WriteLine(person); // sortie: John Employee employee = new("John", "Doe", 1); Console.WriteLine(employee); // sortie: John }

Assignation et déclaration dans une même déconstruction

Déconstruire un objet en C# permet d’assigner facilement les membres d’un objet à des variables. Pour cela, il faut tout d’abord implémenter une ou plusieurs méthodes Deconstruct dans une classe :

public class Person { public string FirstName { get; set; } public string LastName { get; set; } public void Deconstruct(out string firstName, out string lastName) { firstName = FirstName; lastName = LastName; } }

Sans C# 10, il faut choisir exclusivement entre deux syntaxes différentes pour déconstruire l’objet. La première syntaxe consiste à initialiser les variables puis déconstruire l’objet sur une seule et même ligne de code, ce qui a l’avantage d’être simple et concis :

        (string firstName, string LastName) = person;

La seconde syntaxe permet d’instancier au préalable les variables assignées lors de la déconstruction. Néanmoins cette seconde syntaxe est plus longue que la première :

        string firstName = "Jane", LastName;         (firstName, LastName) = person;

Il n’était jusqu’ici pas possible de mélanger ces deux syntaxes. Cela signifie que si nous avions besoin d’avoir la main sur la déclaration d’une seule des variables dédiées à la déconstruction d’une classe, nous étions tout de même obligés de déclarer chacune d’entre elles.

C# 10 permet dorénavant de mixer ces deux syntaxes en fonction des besoins :

        string firstName = "Jane";         (firstName, string LastName) = person;

Les directives global using

Dans un projet C#, il est fréquent d’utiliser les mêmes types issus des mêmes espaces de noms, ce qui provoque la multiplication de directives using similaires à travers les fichiers. Pour éviter cette répétition, C# 10 permet de rendre disponible des namespaces à l’ensemble des fichiers de code d’un projet. Pour cela, il suffit d’ajouter le mot clé global précédant une instruction using et l’espace de noms devient disponible globalement.

Par exemple, si l’on créé une application web à partir du modèle ASP.NET Core avec authentification, le HomeController généré ressemble à cela, sachant que le contenu de la classe est omis :

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using WebApplication.Models;

namespace WebApplication.Controllers;
[Authorize]
public class HomeController : Controller {}
// Le contenu de la classe est omis

Il y a de fortes chances pour que les autres contrôleurs du projet utilisent aussi les namespaces Microsoft.AspNetCore.Mvc et Microsoft.AspNetCore.Authorization.

Avec C# 10, nous pouvons créer un fichier GlobalUsings.cs à la racine du projet avec le code suivant :

global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Mvc;

Nous pouvons ainsi retirer ces usings dans le fichier HomeController.cs, qui devient donc - :

using System.Diagnostics; using WebApplication.Models; namespace WebApplication.Controllers; [Authorize] public class HomeController : Controller {} // Le contenu de la classe est omis

S’il est possible d’utiliser des global using à travers différents fichiers, peu importe l’endroit dans la structure du projet, la portée restera globale à l’ensemble du projet. Il est néanmoins préférable de regrouper tous les global using dans un même fichier pour en faciliter la lecture. GlobalUsings.cs n’est pas un nom obligatoire, mais la communauté semble avoir adopté en partie cette convention. Il est aussi fréquent de voir ce fichier se nommer usings.cs ou plus rarement import.cs. A noter que Visual Studio indiquera si une instruction devient facultative si l’espace de nom est déjà importé via un global using.

La déclaration d’espace de nom de portée de fichier

En C# il est possible d’utiliser plusieurs namespaces dans un même fichier :

namespace MyApp.Accounts { class Employee { } } namespace MyApp.Billing { class Bill { } }

Dans la plupart des projets les fichiers de code ne contiennent qu’un seul namespace. Une nouvelle syntaxe introduite avec C# 10 permet de déclarer un espace de nom en haut de fichier, et dont la portée s’étend à l’ensemble du fichier. Pour cela il suffit d’utiliser le mot clé namespace sans les accolades :

        namespace Acme.Software;

Cette syntaxe améliore la lisibilité horizontale du code en réduisant l’indentation d’un niveau.

Patterns de propriétés étendus

Historiquement, le mot clé is était un opérateur pour tester uniquement le type et convertir une donnée. C# 7 a introduit le pattern matching – se traduisant par « critères spéciaux » – qui permet de tester si une donnée correspond à un pattern. Le pattern matching a systématiquement continué à être amélioré depuis, et cette version ne déroge pas à la règle.

Jusqu’alors, pour faire un test de correspondance envers des propriété imbriquées, il nous fallait utiliser un modèle avec des objets imbriqués. Prenons l’exemple suivant : une voiture est composée d’une roue, et la roue possède une taille. Nous devons effectuer une action spéciale si l’on traite une voiture avec une roue de 17 pouces. Voici l’utilisation d’un pattern matching en C# 9 :

record Car(Wheel Wheel); record Wheel(int Size); if (vehicule is Car { Wheel: { Size: 17 } }) DoSomething();

Avec C# 10, la syntaxe pour accéder à une propriété dans un pattern a été simplifiée. Nous pouvons désormais référencer directement une propriété imbriquée.

if (vehicule is Car { Wheel.Size: 17 }) DoSomething();

La concaténation de constantes avec des chaînes interpolées

Avec C# 9, s’il est possible de concaténer des constantes pour former une autre constante en utilisant l’opérateur +,a contrario, le compilateur ne permet pas d’utiliser une expression d’interpolation pour effectuer cette opération.

        const string firstname = "toto";
        const string helloPlus = "Hello " + firstname;

        const string hello = $"hello {firstname}";
        // error CS0133: The expression being assigned to 'hello' must be constant
        // Cette erreur intervient car une chaine interpolée ne peut être assignée à une constante 

static readonly string helloStatic = $"Hello {firstname}";
// Cette syntaxe est valide dans une variable – ici une propriété statique

C# 10 nous permet aujourd’hui d’assembler des chaines de caractères via une chaîne interpolée et de stocker le résultat dans une constante. La valeur est définie à l’exécution, et non pas à la compilation. Mais il n’y a pas d’inquiétude à avoir pour l’influence de la culture car seules des concaténations de chaînes sont autorisées.

        const string hello = $"hello {firstname}"; // Valide avec C# 10 

        const float ratioMilesInKilometers = 1.60934f;
        const string mile = $"One mile is {ratioMilesInKilometers} km";
        // error CS0133: The expression being assigned to 'InterpolatedString.mile' must be constant

Nous obtenons ainsi un code plus lisible. L’autre avantage est de pouvoir utiliser l’interpolation de string à des emplacements où seules des constantes sont acceptées, comme dans un attribut.

        const string PRODUCT_VERSION = "Hirsute Hippo";

        [Osolete($"This method will be removed after version {PRODUCT_VERSION}")]
        void Foo() { }

Les améliorations des fonctions lambda

C# 10 apporte quelques améliorations autour des expressions lambda. L’inférence de type a été améliorée. Il n’est dorénavant plus nécessaire de préciser le type d’une variable contenant une fonction lambda. Nous pouvons simplement la déclarer avec le mot clé var.

        var returnOne = () => 1;

Jusqu’à C# 9, cela aurait produit une erreur de compilation (CS0815: Cannot assign 'expression' to an implicitly typed local).

Pour faciliter le travail du compilateur ou modifier le type de retour d’une expression lambda, celui-ci peut être précisé. Pour se faire, il faut préfixer la fonction lambda par le type de retour souhaité.

        Func<int, float> incrementAsFloat = float (int x) => x + 1;

Dans l’exemple précédent, la fonction lambda prend en paramètre un entier qu’elle additionne avec la valeur 1 qui est aussi un entier. Le résultat de la fonction lambda est donc lui-même un entier. Mais comme le type de retour est précisé, le résultat du calcul est retourné après avoir été converti en float. Il faut également noter que si la fonction lambda n’a qu’un seul paramètre, il est quand même obligatoire d’utiliser les parenthèses.

Les attributs peuvent désormais être ajoutés aux fonctions lambda et à leurs paramètres. Comme précédemment, l’utilisation d’attributs imposera l’utilisation des parenthèses pour les paramètres. Sans cela, il y aurait une confusion pour savoir si l’attribut s’applique sur la fonction ou sur un paramètre. L’exemple suivant illustre la création d’une fonction lambda avec l’attribut CustomDiagnostic appliqué au niveau de la fonction, et l’attribut CustomFormatter appliqué au paramètre msg.

        var lambda = [CustomDiagnostic] ([CustomFormatter]string msg) => Console.WriteLine(msg);


Quel avenir pour C# ?

Si la fonctionnalité de vérification de paramètres null, qui était l’une des plus attendues par la communauté, a pu être évoquée comme faisant partie C# 10 par de nombreux articles, elle est pour le moment toujours en cours de développement. Elle est actuellement identifiée comme une fonctionnalité faisant partie de la version suivante (voir C# vNext) et non pas dans C# 10. La prise en charge par le compilateur de cette fonctionnalité étant presque terminée, l’usage ne devrait plus évoluer. C’est pourquoi nous nous permettons de vous la présenter dès maintenant :

        // Vérification de la nullité du paramètre en C# 9
        void Foo(string bar)
        {
            if (bar == null) throw new ArgumentNullException(nameof(bar));
        } 

        // Equivalent avec la fonctionnalité de vérification de la nullité
        void Foo(string bar!!) { }

L’opérateur !! permettra de s’assurer que le paramètre de la fonction est non null. Attention toutefois à ne pas confondre cette fonctionnalité avec la notion de types références nullables introduite avec C# 8. Pour rester concis, les types références nullables donnent des avertissements à la compilation sur des déréférencements qui pourraient potentiellement provoquer des NullRerefenceException. Ici l’opérateur !! n’a aucune incidence à la compilation, mais uniquement à l’exécution. Il simplifie la vérification de la nullité des paramètres, ce qui allège le code et facilite le travail des adeptes de la programmation défensive.

La fonctionnalité de propriétés obligatoires – propriétés préfixées par le mot clé required – a également pu être annoncée comme faisant partie des nouveautés de C#. Or, il n’en est rien. Cette fonctionnalité est toujours envisagée, mais il n’y a pas encore de spécifications abouties sur les usages de cette fonctionnalité.

Une autre fonctionnalité évoquée, et finalement absente est le mot clé field pour accéder au champ privé d’une propriété sans avoir besoin de le déclarer :

        public Datetime Birthday { get; init => field = value.Date(); }

C# 10 nous apporte un lot de nouveautés intéressantes. Il continue en effet d’évoluer en intégrant de nouvelles fonctionnalités utiles comme l’extension des types d’enregistrements aux structures tout en s’efforçant d’améliorer continuellement sa syntaxe grâce notamment à la déclaration d’espace de nom de portée de fichier. Cela s’intègre dans la continuité des mises à jour .NET récentes qui diminuent la quantité de code nécessaire et améliorent sa lisibilité.

D’autres nouveautés n’ont pas été présentées dans cet article - comme l’amélioration de l’analyse de nullabilité et du déterminisme des assignations, la directive #line, ou les améliorations de générations de code - car nous trouvions qu’elles apportent trop peu de changements ou couvrent des cas d’utilisations trop spécifiques.

Microsoft continue donc d’enrichir son langage d’année en année et nous sommes enthousiastes à l’idée de pouvoir l’utiliser dans nos développements futurs.

 

Baptiste Bazureau et François Lefebvre, Ingénieurs Concepteurs Développeurs

Article initialement paru dans Programmez! #249

Nos articles techno