Les fonctionnalités C# 10.0

@jaymantri

Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 10.0. Dans un premier temps, on explicitera le contexte de C# 10.0 par rapport aux autres composants (frameworks, IDE, compilateur etc…) qui permettent de l’utiliser. Ensuite, on rentrera dans le détail des fonctionnalités.
Les fonctionnalités les plus rapides à expliquer se trouvent dans cet article. Les autres fonctionnalités nécessitant davantage d’explications se trouvent dans des articles séparés.

Précisions sur les versions de C#

Depuis C# 8.0, les évolutions fonctionnelles de C# se font pour .NET seulement (anciennement appelé .NET Core). Le framework .NET est toujours supporté toutefois les nouvelles fonctionnalités ne sont pas implémentées pour cet environnement.
Comme les environnements du framework .NET et de .NET ne subsistent plus en parallèle, l’approche .NET Standard n’a plus d’intérêt. .NET Standard s’arrête donc à la version 2.1. Les versions 5.0, 6.0, 7.0 et 8.0 de .NET implémentent .NET Standard de la version 1.0 à 2.1 toutefois il est conseillé de cibler une version de .NET plutôt que .NET Standard.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties de C# 10.0, de Visual Studio, du compilateur Roslyn et des versions .NET.

Date Version C# Version Visual Studio Compilateur Version .NET Version Framework .NET
Septembre 2019 C# 8.0 VS2019 (16.3) Roslyn 3.2(1) .NET Core 3.0
(NET Standard 1.0⇒2.1)
.NET 4.8(2)(3)
(NET Standard 1.0⇒2.0)
Novembre 2019 VS2019 (16.4)
Décembre 2019 .NET Core 3.1(4)
(NET Standard 1.0⇒2.1)
Mars 2020 VS2019 (16.5)
Mai 2020 VS2019 (16.6) Roslyn 3.7
Juillet 2020 VS2019 (16.7)
Novembre 2020 C# 9.0 VS2019 (16.8) Roslyn 3.8 .NET 5.0
(NET Standard 1.0⇒2.1)(5)
Février 2021 VS2019 (16.9) Roslyn 3.9
Mai 2021 VS2019 (16.10) Roslyn 3.10
Août 2021 VS2019 (16.11)
Novembre 2021 C# 10.0 VS2022 (17.0) Roslyn 4.0 .NET 6.0
(NET Standard 1.0⇒2.1)(5)
Décembre 2021 Roslyn 4.1
Février 2022 VS2022 (17.1)
Avril 2022 Roslyn 4.2
Mai 2022 VS2022 (17.2)
Août 2022 VS2022 (17.3)
Novembre 2022 C# 11.0 VS2022 (17.4) .NET 7.0
(NET Standard 1.0⇒2.1)(5)
  • (1): Roslyn 3.2 est sorti en août 2019
  • (2): Le framework .NET 4.8 est sorti en avril 2019
  • (3): .NET 4.8 est la dernière version du framework .NET. Les nouvelles fonctionnalités ne seront plus développées dans cet environnement.
  • (4): La dénomination .NET Core est remplacée par .NET. L’environnement correspondant au framework .NET s’arrête à la version 4.8. Les versions .NET 5.0 et supérieurs correspondent à l’environnement .NET Core.
  • (5): .NET Standard n’est plus nécessaire puisque les 2 environnements framework .NET et .NET Core n’évoluent plus fonctionnellement. Ils ont laissé place à l’environnement uniformisé .NET (voir .NET 5+ and .NET Standard pour plus de détails).

Lien entre la version C# et le compilateur

Le tableau précédent permet d’indiquer la version de C# dans le contexte des frameworks de façon à avoir une idée des sorties des autres éléments de l’environnement .NET. Toutefois, la version de C# est liée à la version du compilateur C#. Le compilateur est ensuite livré avec Visual Studio (depuis Visual Studio 2017 15.3) ou avec le SDK .NET.

Le chemin du compilateur est lié au composant avec lequel il est livré:

  • Avec Visual Studio: par exemple pour Visual Studio 2022 Professional: C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec les Build tools: par exemple pour les Build Tools for Visual Studio 2022: C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec le SDK .NET:
    • Sur Linux: /usr/share/dotnet/sdk/<version>/Roslyn/bincore/csc.dll
    • Sur Windows: C:\Program Files\dotnet\sdk\<version>\Roslyn\bincore\csc.dll

On peut connaître la version du compilateur en tapant:

csc -help

On peut savoir quelles sont les versions de C# que le compilateur peut gérer en exécutant:

csc -langversion:? 

Limiter la version C# à compiler

Par défaut, le compilateur compile dans les versions suivantes de C#:

  • .NET 6.0: C# 10.0
  • .NET 5.0: C# 9.0
  • Framework .NET: C# 7.3
  • .NET Core 3.x: C# 8.0
  • .NET Core 2.x: C# 7.3
  • .NET Standard 2.1: C# 8.0
  • .NET Standard 2.0: C# 7.3
  • .NET Standard 1.x: C# 7.3

On peut volontairement limiter la version C# que le compilateur va traiter:

  • Dans Visual Studio: dans les propriétés du projet ⇒ Onglet Build ⇒ Advanced ⇒ Paramètre Language version.
  • En éditant directement le fichier .csproj du projet et en indiquant la version avec le paramètre LangVersion:
    <Project Sdk="Microsoft.NET.Sdk"> 
      <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>net6.0</TargetFramework> 
        <LangVersion>10.0</LangVersion>
      </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 10

Les fonctionnalités les plus basiques de C# 10.0 sont présentées dans cet article. Les autres fonctionnalités nécessitant davantage d’explications sont présentées dans d’autres articles:

Déclaration des namespaces à la portée du fichier

Pour simplifier la syntaxe des déclarations d’objets dans un fichier .cs, à partir de C# 10, il est possible d’indiquer le namespace auquel appartient l’objet en utilisant la déclaration:

namespace <nom du namespace>;

Cette déclaration est valable pour tous les objets déclarés dans le fichier, elle remplace la syntaxe avec les accolades. Ainsi la syntaxe suivante:

namespace CS10Syntax;

internal class FileScopedNamespaceDemo
{

}

Est équivalente à:

namespace CS10Syntax
{
  internal class FileScopedNamespaceDemo
  {

  }
} 

Lorsqu’on utilise une déclaration de namespace avec la portée du fichier, on ne peut pas effectuer plusieurs déclarations, une seule déclaration par fichier .cs est possible.

namespace CS10Syntax1;

internal class FirstClass
{

}

// ⚠ Cette 2e déclaration génère une erreur ⚠
namespace CS10Syntax2;

internal class SecondClass
{

}

De même l’emplacement de la déclaration du namespace a une importance, il faut qu’elle soit après les using... et avant la déclaration des objets:

// ⚠ Cette déclaration génère une erreur ⚠
namespace CS10Syntax1;

using System;

De même:

using System;

internal class SecondClass
{

}

// ⚠ Cette déclaration génère une erreur ⚠
namespace CS10Syntax1;

L’emplacement correct est:

using System;

namespace CS10Syntax1;

internal class SecondClass
{

}

Enfin il est possible d’utiliser la déclaration du namespace sans déclarer d’objets après:

using System;

namespace CS10Syntax1;

Motif property étendu (pattern matching)

Cette fonctionnalité vise à améliorer la syntaxe du property pattern dans le cadre du pattern matching.

Le motif property (i.e. property pattern) permet de tester des conditions sur les propriétés d’un objet dans le cadre du pattern matching.

Par exemple, si on considère les objets suivants:

public class Vehicle
{
  public string Name;
  public int PassengerCount;
  public Engine Engine;
}

public class Engine
{
  public string EngineType; 
  public int Horsepower; 
}

Ensuite, on instancie 2 objets Vehicle de cette façon:

var fordMustang = new Vehicle { Name = "Ford Mustang", PassengerCount = 4, Engine = new Engine { EngineType = "V8", Horsepower = 480 } };
var renault4l = new Vehicle { Name = "Renault 4L", PassengerCount = 4, Engine  = new Engine { EngineType = "Straight-four", Horsepower = 27 } };

Pour appliquer des conditions sur la propriété Name, on peut utiliser le code suivant:

var vehicle = fordMustang;
string engineSize = string.Empty;
if (vehicle.Name == "Ford Mustang")
  engineSize = "Big engine";
else if (vehicle.Name == "Renault 4L")
  engineSize = "Little engine";
else
  engineSize = "No matches";

Si on utilise la syntaxe correspond au motif property:

string engineSize = vehicle switch
{
  Vehicle { Name: "Ford Mustang" } => "Big engine",
  Vehicle { Name: "Renault 4L" } => "Little engine",
  _ => "No matches"
};

Si on applique des conditions sur des propriétés de Engine de la classe Vehicle, la syntaxe est un peu plus lourde:

string engineSize = vehicle switch
{
  Vehicle { Engine: { EngineType: "V8" } } => "Big engine",
  Vehicle { Engine: { EngineType: "Straight-four" } } => "Little engine",
  _ => "No matches"
};
C# 10.0

A partir de C# 10, la syntaxe pour accéder aux propriétés est améliorée:

string engineSize = vehicle switch
{
  Vehicle { Engine.EngineType: "V8" } => "Big engine",
  Vehicle { Engine.EngineType: "Straight-four" } => "Little engine",
  _ => "No matches"
}; 

Le motif property peut aussi être utilisé avec l’opérateur is, par exemple:

if (vehicle is Vehicle { Engine.EngineType:"V8" })
  Console.WriteLine("Big engine");
else
  Console.WriteLine("Little engine");

Amélioration des expressions lambda

Une série d’améliorations a été apportée aux expressions lambda pour faciliter leur utilisation. L’amélioration la plus utile est de permettre au compilateur d’essayer de déduire un type concret pour une expression lambda. La documentation évoque la notion de type naturel (i.e. natural type) toutefois il faut avoir en tête que dans l’absolu le terme “type”, dans ce cas, est utilisé de façon abusive puisqu’une expression lambda n’a pas, en soit, de type, il s’agit d’une déclaration sous la forme d’un delegate. Plus concrétement, quand on parle de Func<> ou Action<>, il ne s’agit pas de type mais de déclarations de delegate. Par abus de langage, on parle de “type” pour faciliter la compréhension ou pour évoquer le type delegate.

Avant de rentrer plus dans le détail de cette amélioration, on peut rappeler la définition de quelques termes.

Déduction du type de delegate

Delegate

Il s’agit du type d’une référence vers une méthode comportant une signature particulière. Le delegate définit donc le type de la référence et non pas la référence elle-même. Par exemple, en C# un delegate peut se déclarer de cette façon:

public delegate int AddDelegate(int a, int b);

La méthode suivante possède une signature compatible avec le delegate:

public static int Add(int a, int b) 
{ 
  return a + b; 
}

On peut donc instancier le delegate et l’exécuter de cette façon:

AddDelegate delegateInstance = Add; 
int result = delegateInstance(3, 5);

A partir du C# 2.0, il est possible d’avoir une notation plus directe pour déclarer les delegates:

AddDelegate delegateInstance = delegate(int a, int b)  
{
  return a + b; 
};

Expression lambda

Une expression lambda est une notation permettant de créer des types de delegates ou d’arbres d’expression. Les expressions lambda sont déclarées en utilisant l’opérateur =>.

Si on prend l’exemple précédent, on peut utiliser une expression lambda pour déclarer le delegate:

AddDelegate delWithLambda = (a, b) => a + b;

Cette notation est un raccourci pour:

AddDelegate delWithLambda = (a, b) => { return a + b; };

Le delegate s’exécute de la même façon que précédemment:

int result = delWithLambda(3, 5);

Les expressions lambda sont apparues avec C# 3.0.

Action<T> et Func<T, TResult>

Action<T> et Func<T, TResult> sont des delegates prédéfinis pour faciliter l’utilisation de delegates et d’expression lambda. L’inconvénient de l’exemple précédent est qu’il nécessite la déclaration du delegate AddDelegate:

public delegate int AddDelegate(int a, int b);

Pour éviter de déclarer des delegates avant d’utiliser des expressions lambda, on peut utiliser Action et Func:

  • Action<T> correspond à des delegates de méthodes (pas de type de retour) de 0 ou plusieurs arguments.
  • Func<T, TResult> correspond à des delegates de fonctions de 0 ou plusieurs arguments avec un résultat.

Dans l’exemple précédent, si on utilise Func<T, TResult>:

Func<int, int, int> addWithFunc = (a, b) => a + b;

Une autre notation est équivalente (peu utilisée car plus lourde):

Func<int, int, int> addWithFunc = delegate(a, b) { return a + b; };

Les types de delegate Action<T> et Func<T, TResult> sont apparus avec le framework .NET 3.5.

Expression

En C#, le type Expression désigne un objet permettant de représenter une expression lambda sous la forme d’un arbre d’expressions (i.e. expression tree). Ce type se trouve dans le namespace System.Linq.Expressions, il s’utilise sous la forme:Expression<Func<TResult>> ou Expression<TDelegate>TDelegate est un delegate déclaré au préalable.

Ainsi Expression<Func<TResult>> correspond à la représentation fortement typée d’une expression lambda, elle ne contient pas seulement sa déclaration mais aussi toute sa description. Expression<TDelegate> dérive de la classe abstraite System.Linq.Expressions.LambdaExpression qui correspond à la classe de base pour représenter une expression lambda sous forme d’un arbre d’expressions:

public sealed class Expression<TDelegate> : LambdaExpression
C# 10.0

Déduction du “type” de l’expression lambda

Précédemment lorsqu’une expression lambda était déclarée, il fallait explicitement indiquer quel était le nom du delegate utilisé. Par exemple si on considère l’expression lambda suivante:

Func<int, int, int> addWithFunc = (a, b) => a + b;

Le delegate Func<int, int, int> est précisé explicitement. Avant C# 10, cette précision était obligatoire. A partir de C# 10, on peut utiliser var et laisser le compilateur déduire une déclaration de delegate. On peut désormais écrire:

var addWithFunc = (int a, int b) => a + b;

Implicitement le compilateur va considérer addWithFunc comme étant un delegate Func<int, int, int>.

Func<> n’est pas un type mais une déclaration de delegate

Malgré le fait qu’on utilise le mot-clé var, il faut avoir en tête que le compilateur ne déduit pas un type possible pour l’expression lambda mais une déclaration de delegate. Par abus de langage, la documentation parle de Func<> comme étant un type de delegate toutefois il s’agit de la déclaration d’un delegate parmi d’autres. Par opposition à un type qui est précis, il peut exister une infinité de ces déclarations de delegate.

Par exemple, si on déclare le delegate suivant:

public delegate int AddDelegate(int a, int b);

Alors on peut aussi écrire:

AddDelegate addWithFunc = (a, b) => a + b;

Dans l’absolu, Func<int, int, int> et AddDelegate ne sont donc pas des types mais 2 déclarations différentes d’un même delegate toutefois dans la documentation, on parlera de type de delegate.

Dans le cadre de cette amélioration, le compilateur déduit une déclaration sous la forme Func<> ou Action<> quand cela est possible. Dans certains cas, il n’est pas possible de déduire un delegate précis, par exemple:

var addWithFunc = (a, b) => a + b;

Dans ce cas, il n’est pas possible de déduire le type des arguments a et b.

Pour que la déclaration précédente soit possible, il faut préciser les types de a et b:

var addWithFunc = (int a, int b) => a + b;

De la même façon, il peut être impossible de déduire le type de retour, par exemple:

var compareInt = (int a, int b) => a > b ? 1 : "not";

Le type de retour peut être un entier ou une chaîne de caractères, il faut préciser explicitement le type de retour pour que le compilateur puisse déduire le type de delegate:

var compareInt = object (int a, int b) => a > b ? 1 : "not";

Le type de delegate sera alors Func<int, int, object>.

object et System.Delegate

Si on précise explicitement les types object ou System.Delegate à la place de var, le compilateur peut aussi considérer ces types plus généraux plutôt que les types de delegate:

Delegate addWithFunc = (int a, int b) => a + b;

ou

object addWithFunc = (int a, int b) => a + b;

Appliquer des attributs aux expressions lambda

C# 10.0

A partir de C# 10, on peut désormais appliquer des attributs sur les arguments et la valeur de retour d’une expression lambda.

Par exemple si on considère l’expression lambda suivante:

var addWithFunc = (int a, int b) => a + b;

Le type de cette expression lambda est Func<int, int, int>.
On déclare l’attribut suivant applicable sur les arguments d’une méthode et sur une valeur de retour d’une fonction (grâce à l’attribut System.AttributeUsage):

[AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)]
public class CustomAttribute: Attribute
{
  public CustomAttribute(string innerProperty)
  {
    this.InnerProperty = innerProperty;
  }

  public string InnerProperty { get; set; }
}

Si on redéclare l’expression lambda en l’aggrémentant d’attributs:

var addWithFunc = [return: CustomAttribute("Lambda return attribute")] 
    ([CustomAttribute("1st param")] int a, [CustomAttribute("2nd param")] int b) => a + b;

Dans la classe CustomAttribute, on ajoute les fonctions statiques suivantes pour récupérer les attributs s’ils sont présents:

[AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)]
public class CustomAttribute: Attribute
{
  // ...

  public static IDictionary<string, CustomAttribute?> FindArgumentCustomAttributes(Delegate func)
  {
    return func.Method.GetParameters()
      .Where(p => p.Name != null)
      .ToDictionary(p => $"{p.Name}", p => p.GetCustomAttribute<CustomAttribute>());
  }

  public static CustomAttribute? FindReturnValueCustomAttributes(Delegate func)
  {
    return func.Method.ReturnParameter.GetCustomAttribute<CustomAttribute>();
  }
}

On peut récupérer la valeur de ces attributs en exécutant:

var addWithFunc = [return: CustomAttribute("Lambda return attribute")] 
  ([CustomAttribute("1st param")] int a, [CustomAttribute("2nd param")] int b) => a + b;

var argumentCustomAttributes = CustomAttribute.FindArgumentCustomAttributes(addWithFunc);
foreach (var argumentAttribute in argumentCustomAttributes)
{
  if (argumentAttribute.Value != null)
    Console.WriteLine($"{argumentAttribute.Key}: {argumentAttribute.Value.InnerProperty}");
}

var returnValueCustomAttribute = CustomAttribute.FindReturnValueCustomAttributes(addWithFunc);
if (returnValueCustomAttribute != null)
  Console.WriteLine(returnValueCustomAttribute.InnerProperty);

On obtient le résultat:

a: 1st param
b: 2nd param
Lambda return attribute

Permettre l’affectation et la déclaration de variables lors d’une déconstruction de tuple

Les tuples sont apparus avec le framework .NET 4.0 (voir Tuple et ValueTuple (C# 7) pour plus de détails), ce sont des structures de données permettant de stocker un nombre variable d’objets de type différent. L’intérêt est d’éviter à avoir à déclarer la structure explicitement. Les objets sont stockés dans les membres du tuple. Les tuples sont des objets de type System.Tuple qui sont des objets de type référence.

Le type et le nombre de membres contenus dans le tuple sont indiqués à l’initialisation:

Tuple tuple = new Tuple(5, "5", 5.0f); 

On peut aussi instancier un tuple de type System.Tuple en utilisant la syntaxe:

Tuple tuple = Tuple.Create(5, "5", 5.0f);

Historiquement, les membres contenant les objets sont .Item1, .Item2, …, .Item<N>:

Console.WriteLine(tuple.Item1); 
Console.WriteLine(tuple.Item2); 
Console.WriteLine(tuple.Item3); 

A partir de C# 7.0, on peut choisir le nom des membres et les membres nommés .Item1, .Item2, …, .Item<N> ne sont plus obligatoires:

(int ValueAsInt, string ValueAsString, float ValueAsFloat) tuple = (5, "5", 5.0f); 

Console.WriteLine(tuple.ValueAsInt); 
Console.WriteLine(tuple.ValueAsString); 
Console.WriteLine(tuple.ValueAsFloat); 

Une autre syntaxe possible:

var tuple = (ValueAsInt: 5, ValueAsString: "5", ValueAsFloat: 5.0f); 

System.ValueTuple

A partir du framework .NET 4.7 est apparu le type System.ValueTuple permettant de créer des objets équivalent à System.Tuple. La principale différence entre ces 2 types est:

System.ValueTuple est fonctionnellement très proche de System.Tuple. Par exemple, on peut initialiser des objets System.ValueTuple avec une syntaxe semblable en utilisant la méthode statique ValueTuple.Create():

var tuple = ValueTuple.Create(5, "5", 5.0f); 

A partir de C# 7.0, on peut initialiser les objets de type System.ValueTuple de cette façon:

(int, string, float) tuple = (5, "5", 5.0f); 

On peut nommer les membres comme pour les objets de type System.Tuple:

(int ValueAsInt, string ValueAsString, float ValueAsFloat) tuple = (5, "5", 5.0f); 

ou:

var tuple = (ValueAsInt: 5, ValueAsString: "5", ValueAsFloat: 5.0f); 

A partir de C# 7.1, lors de l’initialisation d’un tuple, il n’est pas obligatoire de préciser le nom et le type des éléments du tuple si on l’initialise à partir de variables déjà existantes. Le nom et le type sont déterminés à partir des variables existantes:

int valueAsInt = 5; 
string valueAsString = "5"; 
float valueAsFloat = 5.0f; 
var tuple = (valueAsInt, valueAsString, valueAsFloat); // Le nom et le type des éléments du tuple 
                                                       // sont déterminés en fonction des noms et types des variables. 

Console.WriteLine(tuple.valueAsInt); 
Console.WriteLine(tuple.valueAsString); 
Console.WriteLine(tuple.valueAsFloat); 

Déconstruction

La déconstruction permet d’affecter les membres d’un tuple dans des variables distinctes (ces syntaxes sont possibles pour les types System.Tuple et System.ValueTuple):

var tuple = ValueTuple.Create(5, "5", 5.0f); 
(int valueAsInt, string valueAsString, float valueAsFloat) = tuple; 

Une autre syntaxe est équivalente en utilisant le mot clé var:

var (valueAsInt, valueAsString, valueAsFloat) = tuple; 

Si on utilise des variables existantes:

int valueAsInt; 
string valueAsString; 
float valueAsFloat; 
(valueAsInt, valueAsString, valueAsFloat) = tuple; 

Affectation + déclaration dans la même ligne

C# 10.0

A partir de C# 10, lors d’une déconstruction on peut déclarer des variables et affecter d’autres variables dans la même ligne.

Par exemple dans le cas d’objet de type System.Tuple:

Tuple tuple = Tuple.Create(5, "5", 5.0f);

string newValueAsString;
float newValueAsFloat;
(int newValueAsInt, newValueAsString, newValueAsFloat) = tuple; // Affectation + déclaration

Console.WriteLine(newValueAsInt);
Console.WriteLine(newValueAsString);
Console.WriteLine(newValueAsFloat);

Dans cet exemple, on déclare la variable newValueAsInt et on effectue des affectations sur les variables newValueAsString et newValueAsFloat.

Cette amélioration est aussi possible dans le cadre des objets de type System.ValueTuple:

var valueTuple = (ValueAsInt: 5, ValueAsString: "5", ValueAsFloat: 5.0f); // Objet de type System.ValueTuple

string newValueAsString;
float newValueAsFloat;
(int newValueAsInt, newValueAsString, newValueAsFloat) = valueTuple;

Autres fonctionnalités

Les autres fonctionnalités sont traitées dans d’autres articles:

Leave a Reply