Disposer des objets de façon asynchrone (C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 8.0.

C# 8.0 apporte un cas d’utilisation supplémentaire au pattern async/await en permettant de disposer des objets de façon asynchrone avec using (i.e. asynchronous disposable).

Rappels sur async/await

Pour rappel async/await est apparu avec C# 5 de façon à faciliter l’implémentation de traitements asynchrones. Brièvement la syntaxe de cette implémentation doit être utilisée de cette façon:

  • async est placé dans la signature de la fonction comportement un traitement pouvait être exécuté de façon asynchrone.
  • await est placé devant l’instruction générant une tâche dont l’exécution est lancée et dont il faudra attendre la fin de l’exécution. L’instruction doit générer un objet dont le type est “awaitable” comme Task, Task<T>, ValueTask ou ValueTask<T> (ValueTask et ValueTask<T> apparus à partir de C# 7.0 sont des objets de type valeur équivalent à Task et Task<T>).
  • Dans une méthode asynchrone, tout ce qui se trouve après l’instruction await sera exécutée sous la forme d’une continuation.
  • Une méthode dont le corps contient une ou plusieurs instructions avec await doit comporter async dans sa signature.
  • Si une méthode contient async, il n’est pas obligatoire que le corps de la méthode contienne await. Si une méthode async ne contient aucune instruction avec await, l’exécution sera effectuée de façon synchrone.

Par exemple, si on considère la fonction suivante:

public async Task ExecuteAsynchronously() 
{ 
  await Task.Delay(10000); // On simule l'exécution d'un traitement dans une Task 
} 

On peut appeler cette méthode de cette façon:

// La méthode appelante doit comporter async si on utilise await
public async Task CallingMethod()  
{ 
  await ExecuteAsynchronously(); 
} 

Ou

// async n'est pas nécessaire si on n'utilise pas await 
public void CallingMethod()  
{ 
  // On ne fait qu'attendre le résultat d'une Task 
  ExecuteAsynchronously().Wait();  
  // ou 
  ExecuteAsynchronously().Result; 
} 

Le code dans ExecuteAsynchronously() est exécuté de façon asynchrone par rapport au code se trouvant à l’extérieur de ExecuteAsynchronously(). Cela signifie que:

  • L’appel await ExecuteAsynchronously() rendra la main immédiatement au code appelant de façon à ne pas bloquer son exécution.
  • Le code dans ExecuteAsynchronously() sera exécuté dans une Task différente du code appelant. Concrêtement et suivant le contexte d’exécution, cette Task pourra être exécutée dans un thread différent du thread appelant mais ce n’est pas obligatoire.
  • Dans l’appel, await signifie que le code appelant va attendre la fin de l’exécution de la Task dans ExecuteAsynchronously() pour continuer l’exécution.

Exécution asynchrone ne signifie pas forcément que l’exécution s’effectue en parallèle:

  • Exécution asynchrone signifie que les exécutions de tâches sont effectuées sans qu’elles ne soient synchronisées. Une exécution de tâches asynchrones peut être effectuée par un seul thread de façon séquentielle.
  • Exécution en parallèle signifie que des tâches peuvent être exécutées éventuellement dans des threads différents et tirer parti d’un processeur multithreadé ou d’une machine multiprocesseurs.

Si on souhaite exécuter du code de façon concurrente avec le code dans ExecuteAsynchronously(), on peut utiliser la syntaxe suivante:

Task asyncTask = ExecuteAsynchronously(); 
// Code exécuté de façon concurrente 
// ... 

await asyncTask; 
// Code exécuté sous forme de continuation 
// ... 

On peut se rendre de l’aspect asynchrone de l’exécution de code avec await dans le cas d’une implémentation d’une interface graphique avec une boucle de messages comme en WPF ou en WinForms:

  • Si on lance des traitements de façon synchrone dans le thread graphique, la boucle de message de l’interface est bloquée et les éléments graphiques sont inaccessibles ou bloqués pendant le traitement.
  • Si on lance des traitements de façon asynchrone avec await dans le thread graphique, la boucle de message est toujours exécutée et les éléments graphiques restent accessibles pendant le traitement.

Disposer des objets de façon asynchrone

Le garbage collector (GC) permet de gérer la durée de vie des objets managés d’un processus .NET: lorsqu’un objet n’apparait plus dans le graphe des objets indiquant les dépendances entre les objets, il est “supprimé”. Cette suppression se fait en récupérant l’espace mémoire occupé lors de l’étape Collect.

Finalize() et Dispose()

Avant l’étape Collect, il peut être nécessaire d’exécuter du code pour libérer des dépendances de l’objet à collecter:

  • Méthode Finalize(): cette méthode n’est pas directement exposée au développeur. Le garbage collector l’exécute avant l’étape Collect. Une partie du code de cette méthode peut être personnalisé en implémentant un destructeur, par exemple pour libérer des dépendances non managées. En effet, le garbage collector n’intervient que sur les objets managés du processus, si le processus manipule des objets non managés, il faut les libérer explicitement.
  • Méthode Dispose(): cette méthode est implémentée dans les objets satisfaisant l’interface IDisposable:
    public class DisposableObject : IDisposable  
    {  
      public void Dispose()  
      {
        // ...
      }
    }
    

Le garbage collector n’exécute pas la méthode Dispose(), elle doit être appelée explicitement par du code de façon à libérer des dépendances managées ou non managées d’un objet. L’appel à Dispose() peut se faire en appelant directement cette méthode ou en utilisant using:

using (<objet satisfaisant IDisposable>)  
{  
  // ...
}  

A la fin du bloc de code using, la méthode Dispose() de l’objet satisfaisant IDisposable sera exécutée.

Par exemple:

var disposableObject = new DisposableObject(); 
using (disposableObject) 
{ 
  // ... 
} 
// ATTENTION: à ce stade disposableObject ne doit pas être utilisé car Dispose() a été exécuté 

Ou plus directement:

using (var disposableObject = new DisposableObject()) 
{ 
  // ... 
} 

L’exécution de la méthode Dispose() ne dispense pas le garbage collector d’exécuter la méthode Finalize() lorsque l’objet est libéré. Pour éviter au garbage collector d’exécuter inutilement Finalize(), on peut rajouter GC.SuppressFinalize() dans la méthode Dispose() de façon à indiquer au GC qu’il n’est pas nécessaire d’exécuter Finalize().

Dans le cas où il est nécessaire de libérer des dépendances d’un objet, une bonne implémentation doit:

  1. Satisfaire IDisposable
  2. L’implémentation de la méthode Dispose() doit indiquer au garbage collector de ne pas exécuter Finalize() avec GC.SuppressFinalize(),
  3. Eventuellement libérer des dépendances managées et non managées dans la méthode Dispose().
  4. Comporter un destructeur si des dépendances non managées doivent être libérées. L’implémentation du destructeur peut être nécessaire si la méthode Dispose() n’est pas appelée explicitement dans tous les cas de figure.
  5. Le destructeur peut libérer les dépendances non managées et supprimer les liens entre l’objet et d’autres objets managés en affectant null aux données membres. Supprimer ce lien permet au garbage collector de supprimer les instances inutiles dans le graphe des objets.
  6. L’appel à Dispose() ne doit être fait qu’une seule fois: l’implémentation doit être idempotente c’est-à-dire qu’elle doit permettre d’être exécutée plusieurs fois mais les objets ne doivent être libérés qu’une seule fois. Ainsi avant d’exécuter Dispose(), il est nécessaire de vérifier que l’exécution n’a pas été déjà effectuée (c’est l’intérêt du membre disposed dans l’implémentation de DisposableObject plus bas).

Dans la documentation, on peut trouver un exemple d’implémentation prenant en compte tous ces éléments. Si on prend l’exemple d’une dépendance de type SqlConnection à libérer (System.Data.SqlClient.SqlConnection dérive de System.Data.Common.DbConnection qui satisfait IDisposable):

class DisposableObject : IDisposable // (1) 
{  
  bool disposed = false; 
  private SqlConnection dependency = new SqlConnection("Test connection");  

  public void Dispose()  
  {  
    this.Dispose(true); // (3)

    GC.SuppressFinalize(this); // (2) 
  }  
  
  private void Dispose(bool disposing)  
  {  
    if (this.disposed) // (6) 
      return;  

    if (disposing) 
    {  
      // Libération de dépendances managées  
      if (this.dependency != null) 
        this.dependency.Dispose(); 
    }  

    // On supprime le lien entre DisposableObject et l’instance de SqlConnection. 
    this.dependency = null; // (5)

    // Libération de dépendances non managées  
    // ... 

    this.disposed = true;  
  }   

  // Personnalisation de Finalize() si nécessaire 
  ~DisposableObject() // (4)
  {  
    this.Dispose(false); // (5) 
  }  
}  

Avec la syntaxe using, l’exécution de Dispose() est synchrone.

IAsyncDisposable

C# 8.0

A partir de C# 8.0, il est possible d’exécuter du code permettant la libération de ressources de façon asynchrone en utilisant le pattern async/await. L’implémentation est similaire à celle avec IDisposable:

  • Il faut satisfaire IAsyncDisposable
  • Implémenter une méthode dont la signature est:
    public ValueTask DisposeAsync();  
    

    Par exemple:

    public class DisposableObject : IAsyncDisposable  
    {  
      public async ValueTask DisposeAsync()  
      {  
        // ...
      }  
    }  
    

    La méthode DisposeAsync() doit retourner un objet de type ValueTask qui est un objet de type valeur équivalent à l’objet Task.

  • Utiliser la syntaxe await using pour que l’exécution de la méthode DisposeAsync() soit asynchrone:
    await using (<objet satisfaisant IAsyncDisposable>)  
    {  
      // ...  
    }  
    

    Par exemple:

    var disposableObject = new DisposableObject(); 
    await using (disposableObject) 
    { 
      // ... 
    } 
    // ATTENTION: à ce stade disposableObject ne doit pas être utilisé car DisposeAsync() a été exécuté 
    

    Ou plus directement:

    await using (var disposableObject = new DisposableObject()) 
    { 
      // ... 
    } 
    

Une autre syntaxe utilisant ConfigureAwait(false) permet d’optimiser le code en évitant d’utiliser le contexte d’exécution d’origine lors de l’exécution de la continuation avec async/await:

await using (<objet satisfaisant IAsyncDisposable>.ConfigureAwait(false))  
{  
  // ...  
}  

Par exemple:

var disposableObject = new DisposableObject(); 
await using (disposableObject.ConfigureAwait(false)) 
{ 
  // ... 
} 

Ou

var disposableObject = new DisposableObject(); 
await using (System.Runtime.CompilerServices.ConfiguredAsyncDisposable configuredDisposable = 
  disposableObject.ConfigureAwait(false)) 
{ 
  // ... 
} 
// ATTENTION: à ce stade disposableObject ne doit pas être utilisé car DisposeAsync() a été exécuté 

Comme pour IDisposable, l’implémentation doit respecter quelques recommandations:

  1. Indiquer au garbage collector de ne pas exécuter Finalize() avec GC.SuppressFinalize(),
  2. De libérer des dépendances managées de façon asynchrone dans la méthode DisposeAsync().
  3. L’implémentation doit être idempotente.

Dans la documentation, on peut ainsi trouver un exemple d’implémentation. Si on prend l’exemple d’une dépendance de type SqlConnection à libérer (System.Data.SqlClient.SqlConnection dérive de System.Data.Common.DbConnection qui satisfait IAsyncDisposable):

class DisposableAsyncObject : IAsyncDisposable  
{  
  private SqlConnection dependency = new SqlConnection("Test connection");  
   
  public async void DisposeAsync()  
  {  
    await this.DisposeAsyncCore(); // (b)

    GC.SuppressFinalize(this); // (a) 
  }  

  private async ValueTask DisposeAsyncCore() 
  { 
    if (this.dependency != null) 
      await this.dependency.DisposeAsync(); 

    this.dependency = null; 
  } 
}  

Dans le cas d’une implémentation satisfaisant IDisposable et IAsyncDisposable:

class DisposableAsyncObject : IAsyncDisposable, IDisposable 
{  
  bool disposed = false;  
  private SqlConnection dependency = new SqlConnection("Test connection");  

  public async void DisposeAsync()  
  {  
    await this.DisposeAsyncCore(); // (b)
  
    this.Dispose(false); // false pour ne pas disposer les dépendances de façon synchrone. 
    GC.SuppressFinalize(this); // (a)  

  }  

  public void Dispose()  
  {  
    this.Dispose(true); // (3)

    GC.SuppressFinalize(this); // (2) 
  }

  private async ValueTask DisposeAsyncCore() 
  { 
    if (this.dependency != null) 
      await this.dependency.DisposeAsync(); 

    this.dependency = null; 
  }

  private void Dispose(bool disposing)  
  {  
    if (this.disposed) // (c) 
      return;  

    if (disposing) 
    {  
      // Libération de dépendances managées de façon synchrone 
      if (this.dependency != null) 
        this.dependency.Dispose(); 
    }  

    // On supprime le lien entre DisposableObject et l’instance de SqlConnection. 
    this.dependency = null; // (5)

    // Libération de dépendances non managées  
    // ...   

    this.disposed = true;  

  }  

  // Personnalisation de Finalize() si nécessaire 
  ~DisposableAsyncObject() // (4)
  {  
    this.Dispose(false); // (5)
  }  
}  

Quelques remarques supplémentaires concernant l’implémentation:

  • L’implémentation asynchrone avec IAsyncDisposable ne remplace pas l’implémentation avec IDisposable. La première méthode convient pour libérer des dépendances de façon asynchrone quand cela est possible. Si ce n’est pas possible, la meilleure implémentation est d’utiliser la version synchrone.
  • Si la classe implémente IAsyncDisposable, il n’est pas obligatoire d’implémenter IDisposable.
  • Si on libère des dépendances de façon synchrone dans DisposeAsync(), ces exécutions seront synchrones et il n’y aura pas de libération asynchrone des dépendances.

Utilisation de using sans {…}

C# 8.0

Avant C# 8.0, using devait obligatoirement être suivi d’un bloc de code:

using (<objet satisfaisant IDisposable>) 
{ 
  // ... 
} 

C# 8.0 permet d’utiliser using sans bloc de code. La portée de l’objet concerné par using correspond au bloc de code dans lequel se trouve using. La méthode Dispose() sera exécutée à la sortie de ce bloc de code.

Par exemple, dans le cas d’une méthode:

public void UseDisposableObject() 
{ 
  using var disposableObject = new DisposableObject(); 

  // Utilisation de disposableObject 
  // ... 

  // disposableObject.Dispose() est exécuté juste avant la sortie de la méthode 
} 

Dans le cas d’un bloc de code:

public void UseDisposableObject() 
{ 
  {
    using var disposableObject = new DisposableObject(); 
    // Utilisation de disposableObject 
    // ...

    // disposableObject.Dispose() est exécuté juste avant la sortie du bloc 
  } 

  // A ce niveau disposableObject est hors de portée 
} 

Avec await using

await using est compatible avec cette syntaxe:

public void UseDisposableObject() 
{ 
  await using var disposableAsyncObject = new DisposableAsyncObject(); 

  // Utilisation de disposableAsyncObject 
  // ... 

  // disposableAsyncObject.DisposeAsync() est exécuté juste avant la sortie de la méthode 
} 

Avec ConfigureAwait()

Avec ConfigureAwait(), on peut utiliser la syntaxe:

var disposableObject = new DisposableObject(); 
await using System.Runtime.CompilerServices.ConfiguredAsyncDisposable useless = 
  disposableObject.ConfigureAwait(false); 

Ou plus simplement:

await using var _ = disposableObject.ConfigureAwait(false); 
DisposeAsync() peut ne pas être exécuté en cas d’exception avec ConfigureAwait(false)

Suivant le pattern utilisé dans le cas où on utilise 2 objets satisfaisant IAsyncDisposable, DisposeAsync() peut ne pas être exécuté.

Par exemple, si on considère la classe suivante:

public class DisposableAsyncObject: IAsyncDisposable 
{ 
  public DisposableAsyncObject(int id) 
  { 
    this.Id = id; 
  } 

  public int Id { get; } 

  public async ValueTask DisposeAsync() 
  { 
    Console.WriteLine($"Instance {this.Id} of DisposableAsyncObject disposed"); 
  } 
} 

Si on exécute le code suivant dans lequel une exception survient:

var instanceOne = new DisposeAsyncObject(1); 
var instanceTwo = new DisposeAsyncObject(2); 

// On lance volontairement une exception 
if (instanceTwo.Id == 2) 
  throw new InvalidOperationException(); 

await using var uselessVarOne = instanceOne.ConfigureAwait(false); 
await using var uselessVarTwo = instanceTwo.ConfigureAwait(false); 

Console.WriteLine(instanceOne.Id); 
Console.WriteLine(instanceTwo.Id); 

A l’exécution, on peut se rendre compte qu’aucune des 2 instances n’est disposée car les lignes await ne sont pas atteintes au moment de l’exception:

Unhandled exception. SystemOperationException: ...

En utilisant await using juste après l’instanciation de l’objet instanceOne, DisposeAsync() sera exécuté même si l’exception survient:

var instanceOne = new DisposeAsyncObject(1); 
await using var uselessVarOne = instanceOne.ConfigureAwait(false); 

var instanceTwo = new DisposeAsyncObject(2); 

// On lance volontairement une exception 
if (instanceTwo.Id == 2) 
  throw new InvalidOperationException();  

await using var uselessVarTwo = instanceTwo.ConfigureAwait(false); 

Console.WriteLine(instanceOne.Id); 
Console.WriteLine(instanceTwo.Id); 

instanceOne.DisposeAsync() est exécutée:

Instance 1 of DisposableAsyncObject disposed
Unhandled exception. SystemOperationException: ... 

Imbriquer les blocs await using permet d’exécuter DisposeAsync() dans le cas des 2 instances:

var instanceOne = new DisposeAsyncObject(1); 
await using (var uselessVarOne = instanceOne.ConfigureAwait(false)) 
{ 
  var instanceTwo = new DisposeAsyncObject(2); 
  await using (var uselessVarTwo = instanceTwo.ConfigureAwait(false)) 
  { 
    // On lance volontairement une exception 
    if (instanceTwo.Id == 2) 
      throw new InvalidOperationException(); 

    Console.WriteLine(instanceOne.Id); 
    Console.WriteLine(instanceTwo.Id); 
  } 
} 

DisposeAsync() est exécuté pour les 2 instances:

Instance 1 of DisposableAsyncObject disposed
Instance 2 of DisposableAsyncObject disposed 
Unhandled exception. SystemOperationException: ... 

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Références nullables (C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 8.0.

Cette fonctionnalité fait partie des fonctionnalités les plus importantes de C# 8.0. Elle vise à éviter que la référence d’un objet soit nulle par inadvertance.

En C#, les objets de type référence sont manipulés en utilisant une référence permettant d’atteindre l’objet stocké dans le tas managé. Les opérations les plus courantes sur une référence sont:

  • L’initialisation d’une nouvelle référence faite par copie d’une autre référence (la référence est copiée mais pas l’objet en mémoire) ou en utilisant l’opérateur new pour instancier un nouvel objet:
    Circle circle = new Circle();
    

    La classe Circle est:

    public class Circle
    {
      public int Radius;
    
      public void UpdateRadius(int newRadius)
      {
        this.radius = newRadius;
      }
    }
    
  • Le passage d’une référence en argument d’une fonction: lors de l’appel de la fonction, une copie de la référence est effectuée si elle est passée en argument (sauf si on utilise le mot-clé ref).
  • Le déférencement: cette opération permet de déférencer une référence pour utiliser l’objet dont elle fait référence en mémoire. Le déférencement permet, par exemple, d’appeler une méthode de l’objet ou d’accéder à une propriété:
    Circle circle = new Circle(); 
    circle.UpdateRadius(3); // déférencement pour appeler une fonction
    circle.Radius = 5; // déférencement pour accéder à une propriété
    

En C#, l’initialisation, l’affectation, le passage de référence sont des opérations réalisables avec une référence nulle. Le déférencement ne l’est pas, la référence nulle ne pointant sur aucun objet en mémoire. C’est ce dernier cas qui provoque le plus d’erreurs d’inadvertance puisqu’elles provoquent des erreurs de type NullReferenceException. Tony Hoare, qui est le scientifique lauréat du prix Turing à l’origine des références nulles avait qualifié son invention d’erreur à un milliard de dollars à cause de toutes les erreurs que les références nulles ont pu provoquer.

Les références nullables est une fonctionnalité de C# 8.0 visant à empêcher l’utilisation de références nulles en générant des warnings à la compilation lorsque des références potentiellement nulles sont détectées dans le code. Une référence est potentiellement nulle si elle n’est pas initialisée avec une valeur différente de null ou si elle est affectée par la valeur null.

Transformer les warnings en erreurs de compilation

Les warnings générés par le compilateur à la suite d’utilisation de référence nulles ne sont pas bloquants. Il est possible de les rendre bloquant en transformant ces warnings en erreurs en activant l’option TreatWarningsAsErrors. Pour activer cette option:

  • Dans un projet .NET Core: il faut éditer le fichier .csproj du projet et ajouter le nœud <TreatWarningsAsErrors>:
    <Project Sdk="Microsoft.NET.Sdk">  
      <PropertyGroup>  
        <OutputType>Exe</OutputType>  
        <TargetFramework>netcoreapp3.1</TargetFramework>  
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>  
    </Project> 
    
  • Dans Visual Studio, il faut éditer les options du projet:
    Dans les propriétés du projet ⇒ Onglet “Build” ⇒ dans la partie “TreatWarningsAsErrors” ⇒ sélectionner “All”.

Cette option permettra de générer des erreurs bloquantes à la compilation au lieu des warnings.

Référence nullable vs référence non-nullable

Par défaut, le comportement du compilateur est le même que pour les versions précédents C# 8.0 c’est-à-dire:

  • Le compilateur n’affiche pas de warnings à la compilation dans le cas où une référence est assignée avec null ou si un déférencement est effectué pour une référence potentiellement nulle.
  • Les références nullables ne sont pas possibles: les objets de type valeur nullables (i.e. nullable value types) sont apparus en C# 2.0. Ils sont notés avec la syntaxe <type>? par exemple int? pour un entier nullable. Cette fonctionnalité n’était valable que pour les objets de type valeur et non les objets de type référence (puisqu’une référence peut être nulle).
C# 8.0

A partir de C# 8.0, il est possible de créer des références nullables de la même façon que les objets de type valeur avec la notation <type>?, par exemple:

Circle? Circle = null; 

Le compilateur considère ainsi 2 types de références:

  • Les références non-nullables: ce sont les références normales, elles sont appelées “non-nullables” toutefois, par défaut sans modification de la configuration, elles peuvent être assignées avec la valeur nulle.
  • Les références nullables: elles sont déclarées avec la notation <type>? et elles sont nullables comme les références non-nullables toutefois, par défaut, elles génèrent des warnings si l’option Nullable n’est pas activée:
    Warning CS8632: The annotation for nullable reference types should 
      only be used in code within a '#nullable' annotations context.
    

Ces 2 types de référence prennent leur sens si on active l’option Nullable.

Activer l’option de configuration Nullable

Cette option permet de changer le comportement du compilateur vis-à-vis des références nullables et non-nullables. Il existe plusieurs niveaux de d’activation de cette option. Pour l’activer, il faut ajouter enable, warnings ou annotations dans un nœud Nullable dans le fichier .csproj du projet:

<Project Sdk="Microsoft.NET.Sdk">  
  <PropertyGroup>  
    <OutputType>Exe</OutputType>  
    <TargetFramework>netcoreapp3.1</TargetFramework>  
    <Nullable>enable</Nullable>
  </PropertyGroup>  
</Project>  

Les différents niveaux d’activation de cette option peuvent être résumés de cette façon:

Niveau Comportement général Référence nullable Référence non-nullable
enable Les références nullables sont utilisables et
des warnings sont générés si des références sont potentiellement nulles.
  • L’utilisation de références nullables ne génère pas de warnings.
  • Le déférencement d’une référence nullable potientiellement nulle génère un warning (CS8602).
  • L’affectation de null à une référence non-nullable génère un warning (CS8600).
  • Le déférencement d’une référence non-nullable potentiellement nulle génère un warning (CS8602).
warnings Les références nullables génèrent des warnings et
des warnings sont générés si des références sont potentiellement nulles.
  • L’utilisation de références nullables génère des warnings (CS8632).
  • Le déférencement d’une référence nullable potientiellement nulle génère un warning (CS8602).
annotations Les références nullables sont utilisables et
les warnings ne sont pas générés si des références sont potentiellement nulles
Pas de warnings Pas de warnings
disable
(valeur par défaut)
Les références nullables génèrent un warning et
des warnings ne sont pas générés si des références sont potentiellement nulles.
  • L’utilisation de références nullables génère des warnings (CS8632).
  • Le déférencement d’une référence nullable potientiellement nulle ne génère pas de warning.

Contexte nullable

Le contexte nullable correspond au contexte dans lequel le code est compilé. Ce contexte permet au compilateur de savoir:

  • quelles sont les règles à appliquer pour vérifier la syntaxe,
  • si des références sont nullables et
  • si des références peuvent contenir une valeur nulle et sont déférencées.

Le contexte nullable comporte 2 contextes sous-jacents:

  • Le contexte d’annotation nullable dans lequel l’utilisation des références nullables est possible et ne provoque pas de warnings.
  • Le contexte des warnings pour les références nulles dans lequel des warnings sont générés dans le cas où:
    • Une référence non-nullable est affectée avec la valeur null.
    • Une référence non-nullable est potentiellement nulle et est déférencée.

Il existe 2 méthodes pour indiquer ces contextes:

  • Par configuration: en indiquant le paramètre Nullable au niveau du fichier .csproj. De cette façon, on indique le contexte nullable dans tout le projet.

    Si on reprend le tableau plus haut, on obtient:

    Configuration Contexte d’annotation nullable Contexte des warnings pour les références nulles Référence nullable Référence non-nullable
    enable Activé Activé OK
    Warning en cas de déférencement d’une valeur nulle
    Warnings si nulle
    warnings Désactivé Non autorisé (provoque un warning)
    annotations Activé Désactivé OK
    Pas de warnings en cas de déférencement d’une valeur nulle
    Pas de warnings si nulle
    disable
    (valeur par défaut)
    Désactivé Non autorisé (provoque un warning)
  • Par code: la configuration permet d’indiquer un paramétrage pour tout le projet, ensuite il est possible d’affiner au niveau du code pour indiquer un contexte sur une portion de code. Dans le code, on peut indiquer un contexte sur une portion avec les annotations suivantes:
    Annotation Contexte d’annotation nullable Contexte des warnings pour les références nulles
    #nullable enable Activé Activé
    #nullable disable Désactivé Désactivé
    #nullable restore Les contextes sont ceux indiqués dans la configuration du projet
    #nullable enable warnings Pas de changement Activé
    #nullable disable warnings Désactivé
    #nullable restore warnings Contexte indiqué dans la configuration du projet
    #nullable enable annotations Activé Pas de changement
    #nullable disable annotations Désactivé
    #nullable restore annotations Contexte indiqué dans la configuration du projet

Se prémunir contre les valeurs nulles

La syntaxe C# prévoit quelques opérateurs pour se prémunir contre les valeurs nulles.

Opérateur !. (null-forgiving)

C# 8.0

Cet opérateur utilisable à partir de C# 8.0, est autorisé dans un contexte d’annotation nullable (c’est-à-dire quand il est possible d’utiliser des références nullables <nom variable>?). Il vise à éviter d’avoir le warning correspondant au déférencement d’une référence nullable potentiellement nulle et quand le compilateur ne peut pas déterminer si la référence est nulle ou non. Il n’a pas d’incidence sur l’exécution du code.

Par exemple, si on exécute le code suivant avec les warnings pour les références nulles activés:

int intValue = 5; 
Circle? nullableRef = null; 
if (intValue > 4) 
  nullableRef = new Circle{ Radius = 3 }; 

Console.WriteLine(nullableRef.Radius); // Warning 

A la compilation, ce code produit le warning suivant indiquant un déférencement de la référence nullableRef qui pourrait être nulle:

warning CS8602: Deference of a possible null reference. 

Pour éviter ce warning, on peut:

  • Désactiver les warnings pour les références nulles en modifiant le code de cette façon:
    #nullable disable warnings 
    
    Console.WriteLine(nullableRef.Radius); // Pas de warning
    
    #nullable enable warnings 
    
  • Utiliser l’opérateur null-forgiving:
    Console.WriteLine(nullableRef!.Radius);
    

Comme indiqué plus haut l’opérateur null-forgiving ne protège pas d’erreurs en cas de déférencement d’une référence nullable effectivement nulle:

Circle? nullableRef = null; 
Console.WriteLine(nullableRef!.Radius); // NullReferenceException 

D’autres opérateurs permettent d’éviter des erreurs provenant de références nulles.

Autres opérateurs contre les NullReferenceException

Pour se prémunir des NullReferenceException, on peut utiliser les opérateurs (la plupart de ces opérateurs existent avant C# 8.0):

Opérateur ?. (null-conditional)

L’opérateur null-conditional ?. (à partir de C# 6) s’utilise avec la syntaxe circle?.Radius.

Le comportement est:

  • si circle contient null alors la valeur de circle?.Radius est null.
  • si circle est différent de null alors circle?.Radius correspond à la valeur de Radius.

Opérateur ?[] (null-conditional)

L’opérateur null-conditional ?[] (à partir de C# 6) s’utilise avec des objets contenant des index.

Par exemple dans le cas d’un tableau:

Circle[] circles = new Circle[] { new Circle(), null }; 
Console.WriteLine(circles?[1].Radius); 

Le comportement est:

  • si circles[1] contient null alors la valeur de circles?[1].Radius est null.
  • si circles[1] est différent de null alors circles?[1].Radius correspond à la valeur de Radius.

Opérateur ?? (null-coalescing)

L’opérateur ?? permet d’évaluer si une variable est nulle.

Cet opérateur s’utilise de cette façon:

<variable à évaluer> ?? <expression si la variable est nulle> 

Ainsi:

  • Si <variable à évaluer> contient null alors l’expression retournée est <expression si la variable est nulle>.
  • Si <variable à évaluer> est différent de null alors l’expression retournée est la valeur de <variable à évaluer>.

Par exemple:

Circle firstCircle = new Circle{ Radius = 1}; 
Circle secondCircle = null; 

var radius = (secondCircle ?? firstCircle).Radius; 
Console.WriteLine(radius); // 1 c'est-à-dire la valeur de firstCircle.Radius

secondCircle = new Circle{ Radius = 2}; 

radius = (secondCircle ?? firstCircle).Radius; 
Console.WriteLine(radius); // 2 c'est-à-dire la valeur de secondCircle.Radius

A partir de C# 8.0, pour utiliser l’opérateur ?? la variable à évaluer ne peut pas être un objet de type valeur non nullable.

Opérateur ??=

C# 8.0

L’opérateur ??= permet d’affecter le résultat d’une expression à une variable si cette variable contient null (à partir de C# 8.0).

La syntaxe est du type <variable à évaluer> ??= <expression à affecter si variable null>.

Par exemple:

Circle firstCircle = new Circle{ Radius = 1 }; 
Circle? secondCircle = null; 

secondCircle ??= firstCircle; 
Console.WriteLine(secondCircle.Radius); // 1 

Dans ce code secondCircle ??= firstCircle est équivalent à:

if (secondCircle == null) 
  secondCircle = firstCircle;

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Membre d’une structure en lecture seule avec readonly (C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 8.0.

Cette fonctionnalité permet d’indiquer que des membres d’une structure ne modifient aucune données membres de cette structure. On peut ne pas comprendre au premier abord l’utilité de cette fonctionnalité car d’autres fonctionnalités déjà existantes (comme readonly struct apparue en C# 7) permettent déjà de rendre une structure immutable. Pour comprendre son intérêt, il faut avoir en tête quelques éléments:

  • Une structure struct est un objet de type valeur stocké le plus souvent sur la pile toutefois elle peut être stockée dans le tas managé si elle satisfait une interface ou si elle est le membre d’un objet de type référence.
  • Une structure ref struct est un objet de type valeur toujours stocké sur la pile.
  • Les affectations ou les passages en argument de méthode d’une structure entraînent une copie par valeur de l’objet. Cette copie peut avoir un impact sur les performances durant l’exécution dans le cas où certaines opérations sont effectuées fréquemment et si la structure contient beaucoup de membres.
  • L’utilisation des mot-clés in ou ref (apparus en C# 7) permettent de manipuler des structures par référence et ainsi éviter des copies lors des affectations ou des passages en argument si la structure est immutable. Dans le cas où la structure n’est pas immutable, le runtime peut effectuer des defensive copies (on explique par la suite ce qu’est une defensive copy) dégrandant elles-aussi les performances.
  • Pour qu’une structure soit immutable par syntaxe, on peut utiliser les mots-clés readonly struct (ou readonly ref struct dans le cas d’une ref struct).

Le gros inconvénient de readonly struct et readonly ref struct est qu’ils rendent la structure complètement immutable et qu’il n’y a pas d’autre granularité possible. C# 8.0 permet d’utiliser readonly à un niveau plus fin en autorisant à l’appliquer sur une méthode membre, des propriétés ou des index.

readonly ne s’applique qu’aux objets struct et ref struct

On peut utiliser le mot-clé readonly sur des méthodes, sur des propriétés ou sur des index d’une structure ou d’un objet de type ref struct pour indiquer au compilateur que l’opération ne modifie pas la structure. Il n’est pas possible d’appliquer ce mot-clé dans le cas d’une classe.

En effet, readonly permet de se prémunir des defensive copies qui peuvent se produire dans le cas d’objet de type valeur comme les structures. Les classes sont des objets de type référence stockés dans le tas managé et manipulés avec des références. Elles ne sont pas concernées par les defensive copies.

A l’opposé, readonly au niveau d’une donnée membre peut s’appliquer dans le cas d’une structure et d’une classe.

Utilisation de readonly sur les membres d’une structure

readonly sur des méthodes membres

readonly peut être appliquer sur des méthodes membres d’une structure.

Par exemple, si on considère la structure suivante:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  // Modifie une donnée membre
  public void UpdateRadius(int newRadius)
  {
    this.radius = newRadius;
  }

  // Ne modifie pas la structure
  public int AddToRadius(int number)
  {
    return this.radius + number;
  }
}

On souhaite pouvoir utiliser cette structure de façon à ce qu’elle soit immutable et en évitant dans certains cas les defensive copies:

  • Si on appelle seulement AddToRadius(), la structure reste immutable toutefois il peut y avoir quand même des defensive copies car le compilateur ne sait pas si AddToRadius() réellement ou non la structure.
  • Si on rend la structure immutable en la déclarant avec readonly:
    public readonly struct Circle
    { ... }
    

    Il y aura une erreur de compilation car l’affectation dans UpdateRadius() n’est plus possible.

Permettre de rajouter readonly au niveau des fonctions membres, des propriétés ou des index permet d’indiquer l’aspect immutable d’une opération sur une structure à un niveau plus fin pour éviter que toute la structure soit immutable.

Dans le cas de l’exemple précédent, si on modifie le code de cette façon:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  public void UpdateRadius(int newRadius)
  {
    this.radius = newRadius;
  }

  public readonly int AddToRadius(int number)
  {
    return this.radius + number;
  }
}

Si on exécute seulement la fonction AddToRadius(), la structure est immutable et il n’y a pas de defensive copies. On peut, toutefois, effectuer des opérations rendant la structure mutable avec UpdateRadius().

readonly sur des propriétés

On peut appliquer readonly sur des propriétés de façon à indiquer que l’utilisation de la propriété ne modifie pas la structure.

Par exemple, si on considère l’exemple précédent de la structure Circle, on peut appliquer readonly au niveau d’un accesseur:

  • En lecture seulement:
    public struct Circle
    {
      private int radius;
    
      public int Radius
      {
        readonly get => this.radius;
        set => this.radius = value;
      }
    
      // ...
    }
    
  • En écriture seulement:
    public struct Circle
    {
      private int radius;
    
      public int Radius
      {
        get => this.radius;
        readonly set => Console.WriteLine(value);
      }
    
      // ...
    }
    

    Une erreur survient à la compilation si on applique une opération en écriture avec readonly set => ....

  • En lecture et en écriture en mettant readonly au niveau de la propriété plutôt que des accesseurs:
    public struct Circle
    {
      private int radius;
    
      public readonly int Radius
      {
        get => this.radius;
        set => Console.WriteLine(value);
      }
    
      // ...
    }
    

readonly au niveau d’un index

readonly peut être appliqué au niveau d’un index de la même façon que pour les propriétés. Si on considère la structure suivante:

public struct Numbers
{
  private int[] numbers;

  private Numbers(int count)
  {
    this.numbers = new int[count];
  }

  public int this[int i]
  {
    get => this.numbers[i];
    set => this.numbers[i] = value;
  }
}

On peut indiquer que l’utilisation de l’index ne modifie pas la structure:

  • En lecture seulement:
    public int this[int i]
    {
      readonly get => this.numbers[i];
      set => this.numbers[i] = value;
    }
    
  • En écriture seulement:
    public int this[int i]
    {
      get => this.numbers[i];
      readonly set => this.numbers[i] = value;
    }
    
  • En lecture et en écriture:
    public readonly int this[int i]
    {
      get => this.numbers[i];
      set => this.numbers[i] = value;
    }
    
Ne pas confondre readonly et ref readonly

Même si le mot-clé readonly est utilisé dans les 2 cas, readonly utilisé pour indiquer qu’une opération ne modifie pas une structure et ref readonly sont 2 notions différentes:

  • readonly sur les membres d’une structure permet d’éviter les defensive copies lors d’opérations appliquées à la structure.
  • ref readonly permet d’indiquer qu’un objet de type valeur est manipulé par référence et non par valeur.

Par exemple, si on considère la structure suivante:

public struct IntRefWrapper
{
  private int[] numbers;

  public IntRefWrapper(int count)
  {
    this.numbers = new int[count];
  }

  public readonly ref readonly int GetIntByRef(int index)
  {
    return ref this.numbers[index];
  }
}

Dans la fonction GetIntByRef(), on utilise les 2 notions:

  • public readonly permet d’indiquer que la méthode ne modifie pas la structure.
  • ref readonly int indique le type de retour de la fonction est un objet de type int retourné par référence.

Précisions sur les defensive copies

Pour se rendre compte des defensive copies, on peut considérer l’exemple de la structure suivante:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  public void UpdateRadius(int newRadius)
  {
    this.radius = newRadius;
  }
}

Cette structure est mutable à cause de la méthode UpdateRadius() qui permet de modifier la donnée membre radius.

Si on considère la méthode suivante:

public static void ChangeRadius(int newRadius, in Circle circle)
{
  circle.UpdateRadius(newRadius);
  Console.WriteLine(circle.radius);
}

Cette méthode utilise le paramètre Circle circle avec le mot-clé in de façon à ce que ce soit une référence du paramètre en lecture seule qui soit utilisée et éviter une copie par valeur de l’objet (pour plus de détails sur in voir Manipuler des objets de type valeur par référence). Le gros inconvénient de in est qu’il entraîne un defensive copy, on peut s’en rendre compte si on exécute le code suivant:

var circle = new Circle(4);
ChangeRadius(3, circle); // 4
Console.WriteLine(circle.radius); // 4

radius contient toujours 4 car in impose que circle dans ChangeRadius() soit en lecture seule. Le compilateur effectue une defensive copy pour assurer que circle n’est effectivement pas modifié dans le corps de ChangeRadius(). On modifie le code de la structure Circle pour afficher l’adresse de l’objet:

public struct Circle
{
  // ...

  public void UpdateRadius(int newRadius)
  {
    // On commente volontairement cette ligne de façon à rendre la structure immutable
    //this.radius = newRadius;

    // Permet d’afficher l’adresse mémoire de l’instance
    unsafe
    {
      fixed (Circle* ptr = &this)
      {
        Console.WriteLine(new IntPtr(ptr));
      }
    }
  }
}

Si on exécute le même code, on s’aperçoit que l’adresse est différente à cause de la defensive copy:

var circle = new Circle(4);

// Permet d’afficher l’adresse mémoire de circle
unsafe
{
  fixed (Circle* ptr = &circle)
  {
    Console.WriteLine(new IntPtr(ptr));
  }
}

ChangeRadius(3, circle);
Console.WriteLine(circle.radius);

Le résultat est:

347086513304
347086512744
4
4

L’adresse est différente à cause de la copie même si la structure est maintenant immutable. Pour empêcher cette copie, on peut indiquer au compilateur que la structure est immutable en modifiant sa déclaration en readonly struct:

public readonly struct Circle
{
  // ...
}

Si on re-éxécute le code, les adresses sont maintenant identiques car il n’y a plus de defensive copy:

950267405944
950267405944
4
4

Comme on l’a indiqué plus haut, on peut éviter de rendre toute la structure immutable avec readonly struct. On peut se contenter d’indiquer au compilateur que l’appel à UpdateRadius() ne modifie pas la structure en rajoutant readonly au niveau de la fonction uniquement. L’implémentation de la structure devient:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  public readonly void UpdateRadius(int newRadius)
  {
    //this.radius = newRadius;

    // Permet d’afficher l’adresse mémoire de l’instance
    unsafe
    {
      fixed (Circle* ptr = &this)
      {
        Console.WriteLine(new IntPtr(ptr));
      }
    }
  }
}

Si on exécute le code suivant:

ChangeRadius(3, circle);
Console.WriteLine(circle.radius);

Le résultat est:

950267448258
950267448258
4
4

Les adresses mémoire de la structure sont les mêmes avant et après exécution de la méthode UpdateRadius(). Dans ce cas là, il n’y a pas non plus de defensive copy. L’utilisation de readonly au niveau de la méthode permet d’éviter de rendre toute la structure immutable. On peut, ainsi, implémenter dans la même structure:

  • des méthodes qui ne modifient pas de données membres qui seront réservées aux endroits critiques où il ne faut pas que le runtime effectue des defensive copies.
  • d’autres méthodes modifiant éventuellement des données membres dans la structure.

Associer ces 2 types de méthodes n’est pas possible avec une readonly struct.

readonly protège seulement des affectations

Utiliser readonly au niveau des membres d’une structure permet d’empêcher les nouvelles affectations de données membres dans la structure. Si on tente d’effectuer ce type d’affectation, une erreur de compilation surviendra. En revanche, si on tente de modifier une donnée membre sans effectuer d’affectation, il n’y aura pas d’erreur de compilation.

Si on considère le code suivant proche de l’exemple précédent:

public struct Numbers
{
  private int id; // Objet de type valeur
  private List<int> numbers; // Référence vers un objet de type référence

  public Numbers(int id, IEnumerable<int> numbers)
  {
    this.id = id;
    this.numbers = new List<int>(numbers);
  }

  public readonly int ID => this.id;
  public readonly IList<int> numbers => this.numbers;

  public readonly void AddNumber(int newNumber)
  {
    this.numbers.Add(newNumber);
  }

  public void UpdateID(int newId)
  {
    this.id = newId,
  }
}

// ...
public static void ChangeIDAndAddNumber(in Numbers numbers, int newId, int newNumber)
{
  numbers.UpdateID(newId);
  numbers.AddNumber(newNumber);
  Console.WriteLine($" ID: {numbers.ID}; item count: {numbers.Items.Count()}");
}

Dans l’implémentation de la structure, on peut constater que la signature public readonly void AddNumber() n’empêche pas de modifier le membre numbers avec this.numbers.Add(newNumber). Il n’y a pas d’erreur de compilation.

Si on exécute ce code:

var numbers = new Numbers(1, new int[] { 1, 2, 3});
Console.WriteLine($" ID: {numbers.ID}; item count: {numbers.Items.Count()}");
ChangeIDAndAddNumber(numbers, 2, 4);

Le résultat est:

ID: 1; item count: 3
ID: 1; item count: 4

Le résultat est similaire à l’exemple de la partie précédente. La defensive copy qui est effectuée dans la méthode ChangeIDAndAddNumber() entraîne que l’objet d’origine n’est pas modifié, la valeur de ID est toujours la même. En revanche le nombre d’éléments dans la liste numbers est différent car cette liste est modifiée.

Ainsi, readonly ne sert à se prémunir que des defensive copies qui peuvent se produire dans le cas d’objets de type valeur. Le membre numbers dans la structure Numbers est une référence vers une liste qui est un objet de type référence. La liste est stockée dans le tas managée mais la référence numbers (la référence est un objet de type valeur) est stockée dans la pile comme la structure Number. La liste peut être modifiée dans le tas managée toutefois la référence dans la structure n’est pas modifiée. C’est la raison pour laquelle malgré la defensive copy, le nombre d’éléments de la liste change.

Pour résumer, readonly au niveau des membres d’une structure permet d’empêcher de modifier les membres de la structure par affectation toutefois, les membres de type référence peuvent être modifiés.

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Fonctionnalités C# 7

La version 7 de C# a été très riche en fonctionnalités, le but de cet article est de les résumer et de les expliquer. Dans un premier temps, on explicitera le contexte de C# 7 par rapport aux différents frameworks 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#

En parallèle de la sortie des différentes versions de C# 7, l’environnement .NET s’est étauffé avec .NET Core. La possibilité de pouvoir compiler du code C# à partir de plusieurs frameworks et la sortie du compilateur Roslyn ont entraîné plusieurs modifications dans la façon dont l’environnement de développement est installé. Le but de cette partie est d’expliciter quelques-unes de ces modifications.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties des versions de C# 7, de Visual Studio, du compilateur historique implémenté en C++, du compilateur Roslyn, des versions du framework .NET et de .NET Core.

Date Version C# Version Visual Studio Compilateur Version Framework .NET Version .NET Core
Juillet 2015 C# 6.0 VS 2015 (14.0) 6.0
Roslyn 1.0
.NET 4.6
(.NET Standard 1.0⇒1.3)
N/A
Novembre 2015 .NET 4.6.1
(.NET Standard 1.0⇒2.0)
Juin 2016 6.0
Roslyn 1.3
Aout 2016 .NET 4.6.2
(.NET Standard 1.0⇒2.0)
.NET Core 1.0
(.NET Standard 1.0⇒1.6)
Novembre 2016 .NET Core 1.1
(.NET Standard 1.0⇒1.6)
Mars 2017 C# 7.0 VS 2017 (15.0) 7.0
Roslyn 2.0
Avril 2017 VS 2017 (15.1) 7.0
Roslyn 2.1
.NET 4.7
(.NET Standard 1.0⇒2.0)
Mai 2017 VS 2017 (15.2) 7.0
Roslyn 2.2
Aout 2017 C# 7.1 VS 2017 (15.3) 7.1
Roslyn 2.2
Septembre 2017 7.1
Roslyn 2.3
.NET Core 2.0
(.NET Standard 1.0⇒2.0)
Octobre 2017 VS 2017 (15.4) 7.1
Roslyn 2.4
.NET 4.7.1
(.NET Standard 1.0⇒2.0)
Décembre 2017 C# 7.2 VS 2017 (15.5) Roslyn 2.5
Mars 2018 VS 2017 (15.6) Roslyn 2.6
Avril 2018 .NET 4.7.2
(NET Standard 1.0⇒2.0)
Mai 2018 C# 7.3 VS 2017 (15.7) Roslyn 2.7/2.8 .NET Core 2.1
(NET Standard 1.0⇒2.0)
Aout 2018 VS 2017 (15.8) Roslyn 2.9
Novembre 2018 VS 2017 (15.9) Roslyn 2.10 .NET Core 2.2
(NET Standard 1.0⇒2.0)
Avril 2019 VS 2019 (16.0) Roslyn 3.0 .NET 4.8
(NET Standard 1.0⇒2.0)
Mai 2019 VS 2019 (16.1) Roslyn 3.1
Aout 2019 VS 2019 (16.2) Roslyn 3.2
Septembre 2019 C# 8.0 VS 2019 (16.3) .NET Core 3.0
(NET Standard 1.0⇒2.1)

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. Cependant il faut garder en tête que la version de C# est liée, en premier lieu, à la version du compilateur C# csc qui va générer le code exécutable pour un runtime donné. Ensuite la version du compilateur peut être liée au framework installé, à Visual Studio ou au SDK .NET Core.

La livraison du compilateur C# a changé suivant les versions des frameworks toutefois pour toutes les versions du compilateur, il suffit de taper la commande suivante pour que la version soit indiquée:

csc.exe -help

Par exemple:

C:\Program Files\Microsoft Visual Studio\2017\Community>csc -help
Compilateur Microsoft (R) Visual C# version 2.10.0.0 (b9fb1610)
Copyright (C) Microsoft Corporation. Tous droits réservés.

Le chemin de csc.exe change suivant la version de Visual Studio ou celle du framework .NET.

Avant Visual Studio 2017

Avant Visual Studio 2017, le compilateur C# était livré avec le framework .NET. Le framework .NET a lui-même été livré de façon différente avant et après .NET 4.0:

  • Avant le framework .NET 4.0: chaque framework faisait l’objet d’une installation séparée et chaque nouvelle version était rajoutée aux versions existantes sur une machine donnée. Ainsi les chemins des compilateurs dans les différentes versions de framework étaient:
    • .NET v2.0: C:/Windows/Microsoft.NET/Framework/v2.0.50727/csc.exe
    • .NET v3.5: C:/Windows/Microsoft.NET/Framework/v3.5/csc.exe
    • .NET v4.0: C:/Windows/Microsoft.NET/Framework/v4.0.30319/csc.exe
  • A partir du framework 4.5: pour chaque nouvelle installation, les fichiers du frameworks sont remplacés par ceux de la nouvelle version. L’ancien framework est donc remplacé par le nouveau, c’est la version des assemblies du framework (par exemple clr.dll) qui peuvent indiquée quelle est la version du framework installée. Le chemin du compilateur est:
    • Avant Visual Studio 2013: le chemin est le même qu’auparavant: C:/Windows/Microsoft.NET/Framework/v4.0.30319/csc.exe.
    • Après Visual Studio 2013:
      • Sur un système d’exploitation 32-bit: C:/Program Files/MSBuild/<version de MsBuild>/Bin/csc.exe.
      • Sur un système d’exploitation 64-bit: C:/Program Files (x86)/MSBuild/<version MsBuild>/Bin/csc.exe

Pour les compilateurs C# livrés avec des frameworks avant Visual Studio 2017, en tapant csc -help, on peut voir la version C# qui est gérée:

C:\Windows\Microsoft.NET\Framework\v4.0.30319>csc.exe -help
Microsoft (R) Visual C# Compiler version 4.7.3062.0 for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.

This compiler is provided as part of the Microsoft (R) .NET Framework, but only
supports language versions up to C# 5, which is no longer the latest version. Fo
r compilers that support newer versions of the C# programming language, see http
://go.microsoft.com/fwlink/?LinkID=533240

Pour toutes les versions du compilateur avant Visual Studio 2017, la version évolue suivant les frameworks installés:

  • 1.0 pour le framework .NET 1.0
  • 2.0 pour le framework .NET 2.0 (Visual Studio 2005)
  • 3.5 pour le framework .NET 3.5 (Visual Studio 2008)
  • 4.0 pour le framework .NET 4.0
  • 4.x pour les frameworks .NET de 4.5 à 4.7.1.

Comme indiqué précédemment, toutes ces versions du framework impliquent que plusieurs versions du compilateur peuvent cohabiter sur la même machine toutefois si on utilise la ligne de commandes Développeur (i.e. Developer Command Prompt) c’est la dernière version qui sera utilisée. Pour savoir le chemin du compilateur disponible à la ligne de commandes, il faut exécuter la commande:

where csc 

A partir de Visual Studio 2017

Visual Studio 2017 a marqué un changement notable pour le compilateur C# puisque l’ancien compilateur implémenté en C++ a été remplacé par Roslyn qui est open source et implémenté en C#. Ce compilateur n’est pas apparu dans les premières versions de Visual Studio 2017 mais à partir de la version 15.3 (août 2017). Les versions précédentes de Visual Studio 2017 (15, 15.1 et 15.2) utilisaient l’ancien compilateur.

Avec Roslyn, le compilateur n’est plus livré avec le framework mais avec Visual Studio, avec les Build tools ou avec le SDK .NET Core. Le chemin du compilateur n’est plus lié au framework comme auparavant:

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

Avec le nouveau compilateur Roslyn, la version du compilateur est reparti à 1 par rapport au compilateur précédent:

  • 1.x pour les 1ère versions livrées avec Visual Studio 2015 supportant C# 6.0 et versions précédentes.
  • 2.x pour les versions livrées avec Visual Studio 2017 et supportant C# 7.x.

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, les versions C# traitées par le compilateur sont:

  • 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 versions 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>netcoreapp2.0</TargetFramework> 
            <LangVersion>7.1</LangVersion> 
        </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 7.x

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

Fonctions locales

C# 7.0

On peut déclarer des fonctions à l’intérieur d’autres fonctions.

Avant C# 7.0, on pouvait utiliser des expressions lambda, par exemple:

IEnumerable<int> GetPositiveNumber(IEnumerable<int> numbers, bool strictComparison) 
{ 
  var isPositive => n => { 
    if (strictComparison) 
      return n > 0; 
    else 
      return n >= 0; 
    } 

  return numbers.Where(n => isPositive(n)); 
} 

A partir de C# 7.0, on peut déclarer une fonction directement dans le corps d’une autre fonction:

IEnumerable<int> GetPositiveNumber(IEnumerable<int> numbers, bool strictComparison) 
{ 
  return numbers.Where(n => isPositive(n)); 

  bool isPositive(int number) 
  { 
    if (strictComparison) 
      return number > 0; 
    else 
      return number >= 0; 
  } 
} 

La fonction locale fait partie du contexte de sa fonction parente, il est donc possible d’accéder dans la fonction locale aux arguments et variables locales de la fonction parente.

Arguments out d’une méthode

C# 7.0

Avant C# 7.0, pour utiliser le mot-clé out dans les arguments d’une méthode, il fallait déclarer le paramètre avant l’appel à la méthode:

public static bool TryFindFirstPositiveNumber(IEnumerable<int> numbers, out firstPositiveNumber) 
{ 
  var positiveNumbers = numbers.Where(n => n > 0); 
  if (positiveNumbers.Any()) 
  { 
    firstPositiveNumber = positiveNumbers.First(); 
    return true; 
  } 
  else 
  { 
    firstPositiveNumber = 0; 
    return false; 
  } 
} 

Avant C# 7.0, l’appel est du type:

int[] numbers = {-1, -2, 0, 5, 8}; 
int firstPositiveNumber; 
 
if (TryFindFirstPositiveNumber(numbers, out firstPositiveNumber)) 
{ 
  Console.WriteLine(firstPositiveNumber); 
} 

A partir de C# 7.0, on peut déclarer la variable directement lors de l’appel avec out:

if (TryFindFirstPositiveNumber(numbers, out int firstPositiveNumber)) 
{ 
  Console.WriteLine(firstPositiveNumber); 
} 

Dans le cas de cette fonction, le scope de la variable firstPositiveNumber déclarée avec out est le même que si la variable est déclarée en dehors de l’appel.

Eviter les déclarations de variables inutiles

C# 7.0

Certaines syntaxes imposent de devoir définir des variables même si on ne souhaite pas s’en servir par la suite, par exemple si on utilise un méthode avec un argument out et qu’on ne souhaite pas utiliser cet argument:

string valueAsString = "6" 
if (int.TryParse(valueAsString, out int valueAsInt)) 
  Console.WriteLine("Value is an integer."); 

Dans cet exemple valueAsInt ne sert pas, toutefois on est obligé de le déclarer à cause de la signature de la fonction. A partir de C# 7.0, il est possible d’ignorer l’argument en utilisant le caractère _ de façon à alléger la syntaxe:

if (int.TryParse(valueAsString, out _)) 
  Console.WriteLine("Value is an integer."); 

D’autres cas de figure permettent d’ignorer une variable:

  • Lors de la déconstruction d’un tuple:
    var tuple = (6, "6", 6.0f); 
    var (ValueAsInt, ValueAsString, ValueAsFloat) = tuple; // Déconstruction du tuple 
    Console.WriteLine($"Int value is {ValueAsInt}."); // ValueAsString et ValueAsFloat sont inutiles 
    

    On peut ignorer de déclarer des variables inutilement lors de la déconstruction:

    var (ValueAsInt, _, _) = tuple; // Déconstruction 
    Console.WriteLine($"Int value is {ValueAsInt}."); 
    
  • Pour une variable locale (possible mais pas très utile):

    Par exemple si on considère le code suivant:

    private static async Task UselessTask() 
    { 
      await Task.Delay(10000); 
      Console.WriteLine("Completed"); 
    } 
    
    static void Main() 
    { 
      UselessTask(); // WARNING: CS4014
      Console.ReadLine(); 
    } 
    

    Ce code n’attends pas la fin de l’exécution de la task asynchrone exécutée dans UselessTask(). A la compilation, il entraîne le message de Warning:

    Warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the ‘await’ operator to the result of the call. 
    

    Si on souhaite éviter le message de warning et ne pas attendre l’exécution de la tâche, on peut ignorer le retour de la fonction:

    static void Main() 
    { 
      _ = UselessTask(); // Pas de warning 
      Console.ReadLine(); 
    } 
    

    Il est possible d’utiliser _ en tant que nom de variable. Toutefois si _ est utilisé pour désigner une variable, il ne sera plus possible d’utiliser ce caractère pour indiquer une variable:

    var _ = 5; // OK
    
    _ = UselessTask(); // ERROR: cette ligne est considéré comme étant une affectation. 
    // _ est de type int, l'affectation n'est pas possible.
    

  • Avec is:
    L’opérateur is permet de tester si une expression correspond à un type, par exemple si on considère la classe suivante:

    class Square 
    { 
      public int Size; 
    } 
    

    is permet de tester si l’objet squareAsObj est de type Square:

    object squareAsObj = new Square { Size = 6 }; 
    if (squareAsObj is Square) // permet de tester le type de squareAsObj 
    { 
      Square typedSquare = squareAsObj as Square; 
      Console.WriteLine($"{nameof(squareAsObj)} is a square of size {typedSquare.Size}."); 
    } 
    

    A partir de C# 7.0, il est possible de simplifier l’utilisation de is et as en une seule ligne:

    if (squareAsObj is Square typedSquare) // permet de tester le type et d'initialiser la variable typée typedSquare 
      Console.WriteLine($"{nameof(squareAsObj)} is a square of size {typedSquare.Size}."); 
    

    Par suite, on peut ignorer la création d’une variable avec _:

    if (squareAsObj is Square _) // La création de la variable est ignorée 
      Console.WriteLine($"{nameof(squareAsObj)} is a square."); 
    

    L’exemple précédent complexifie inutilement la syntaxe puisqu’il suffirait d’écrire if (squareAsObj is Square) pour obtenir le même résultat. L’intérêt de cette syntaxe est de simplifier l’écriture avec switch (point suivant).

  • Avec switch...case:
    A partir de C# 7.0, il est possible d’utiliser switch...case pour tester le type d’une variable, par exemple (la classe Square est déclarée plus haut):

    object squareAsObj = new Square { Size = 6 }; 
    switch (squareAsObj) 
    { 
      case Square typedSquare: // permet de tester le type et d'initialiser la variable typée typedSquare 
        Console.WriteLine($"{nameof(squareAsObj)} is a square of size {typedSquare.Size}."); 
        break; 
      default: 
        Console.WriteLine($"{nameof(squareAsObj)} is not a square."); 
        break; 
    } 
    

    case Square typedSquare est équivalent à if (squareAsObj is Square typedSquare). De la même façon que is, il est possible d’ignorer la création d’une variable en utilisant _:

    switch (squareAsObj) 
    { 
      case Square _: // permet de seulement tester le type 
        Console.WriteLine($"{nameof(squareAsObj)} is a square."); 
        break; 
      default: 
        Console.WriteLine($"{nameof(squareAsObj)} is not a square."); 
        break; 
    } 
    

Support de async dans le Main

C# 7.1

Avant C# 7.1, quand la méthode Main() d’une application appelait une méthode async, on devait utiliser une syntaxe similaire à celle-ci:

class Program 
{ 
  static void Main(string[] args) 
  { 
    UselessTask().GetAwaiter().GetResult(); 
    // ou 
    UselessTask().Wait(); 
    // ou 
    int result = UselessTaskWithResult().GetAwaiter().GetResult(); 
    // ou 
    int result = UselessTaskWithResult().Result; 
  } 
 
  private static async Task UselessTask() 
  { 
    await Task.Delay(10000); 
    Console.WriteLine("Completed"); 
  } 

  private static async Task<int> UselessTaskWithResult() 
  { 
    await Task.Delay(10000); 
    Console.WriteLine("Completed"); 
    return 0; 
  } 
} 

A partir de C# 7.1, la fonction Main() supporte la notation async/await, il est possible d’utiliser une syntaxe plus simple:

static async Task Main(string[] args) 
{ 
  await UselessTask(); 
} 

ou

static async Task<int> Main(string[] args) 
{ 
  return await UselessTaskWithResult(); 
} 

D’autres syntaxes pour définir la fonction Main() sont possibles:

Avant C# 7.0
static void Main(string[] args)
static int Main(string[] args)
static void Main()
static int Main()
A partir de C# 7.1 En plus de surcharges précédentes, on peut utiliser:

static async Task Main(string[] args)
static Task Main(string[] args)
static async Task<T> Main(string[] args)
static Task<T> Main(string[] args)
static async Task Main()
static Task Main()
static async Task<T> Main()
static Task<T> Main()

Mot-clé default

C# 7.1

A partir de C# 7.1, on peut simplifier la syntaxe default(T) par default. default et default(T) permettent au compilateur de produire une valeur par défaut suivant le type voulu obtenu par déduction:

  • Pour les types références, la valeur par défaut sera null.
  • Les chaînes de caractères bien qu’étant des objets de type référence, ont pour valeur par défaut une chaîne vide (i.e. string.Empty);
  • Les types valeurs usuels ont pour valeur par défaut la valeur 0 correspondant au type exact: 0 pour int, uint, long ou ulong; 0f pour float; 0m pour decimal; 0d pour double; false pour bool etc…

Par exemple:

int intValue = default; // 0 même comportement que default(int).
float floatValue = default; // 0f ATTENTION à la comparaison entre un flottant et 0
string strValue = default; // Chaîne vide (string.Empty)

Dans le cas d’un objet de type référence, la valeur par défaut est null:

class EmptyClass {}

EmptyClass instance = default; // null

Dans le cas d’une structure, la valeur par défaut n’est pas null mais une instance dans laquelle les membres ont une valeur par défaut:

struct SimpleStruct
{
  public int InnerMember;
}

// ...
SimpleStruct instance = default; // N’est pas égal à null
Console.WriteLine(instance.InnerMember); // 0

default ou default(T) peuvent être utilisés en dehors de l’initialisation comme par exemple dans une comparaison:

Console.WriteLine(default == 0); // true. C’est le compilateur qui déduit la valeur de default en fonction de 0

Un des intérêts de default ou default(T) est de pouvoir utiliser une valeur par défaut quand l’utilisation de type générique ne permet pas de déterminer le type exact, par exemple:

T GetValue<T>()
{
  return default; // Le type réel de T n’est pas connu
}

Opérateur de portée private protected

C# 7.2

A partir de C# 7.2, l’opérateur de portée private protected a été rajouté, si un membre est décoré de cet opérateur dans une classe, il ne sera accessible que par les membres et les classes dérivant de cette classe se trouvant dans la même assembly.

Pour résumer:

  • protected internal: l’accès est limité aux objets de l’assembly courante ou aux classes dérivant de la classe où se trouve l’opérateur (les classes dérivées peuvent se trouver dans une autre assembly).
  • private protected: l’accès est limité aux classes dérivant de la classe où se trouve l’opérateur. Les classes dérivées doivent se trouver obligatoirement dans la même assembly.

Utilisations plus larges des expressions

Une application C# est constituée d’instructions (i.e. statements) faites de mot-clés, d’expressions et d’opérateurs. Une expression correspond à une instruction permettant d’obtenir une valeur comme par exemple une constante, une variable, le résultat d’une fonction ou une suite d’opération avec des opérandes. Le résultat d’une expression peut être affecté à un variable, servir d’argument à une méthode ou à une autre opération.
A l’opposé, les instructions qui ne sont pas des expressions peuvent être des déclarations ou des assignations de variables etc…

Dans les 1ères versions de C#, les expressions étaient réservées aux corps des méthodes. A partir de C# 6, la syntaxe a permis d’utiliser des expressions dont le corps peut définir:

  • des expressions lambda,
  • directement des méthodes ou
  • des propriétés en lecture seule avec une notation sans accolades (i.e. expression-bodied).

Cette syntaxe est du type:

<élément> => <expression>

L’élément pouvant être une expression lambda, une méthode à déclarer dans une classe ou une propriété en lecture seule.

Par exemple:

// Pour déclarer une méthode
private int innerVariable = 5;
public void DisplayInnerVariable() => Console.WriteLine(this.innerVariable); // les accolades sont omises

// Pour déclarer une fonction
public int AddToInnerVariable(int add) => this.innerVariable + add; // return est omis

// Pour déclarer une propriété en lecture
public int InnerVariable => this.innerVariable; // return est omis

Syntaxe réduite du corps d’une expression

C# 7.0

C# 7 a permis d’étendre l’utilisation des expressions à d’autres types d’instructions comme le constructeur, les propriétés en écriture ou les exceptions lancées avec throw.

On peut utiliser des expressions avec une syntaxe réduite directement pour:

  • Propriété en écriture:
    La syntaxe permet d’utiliser des expressions directement dans des propriétés, par exemple:

    public int InnerVariable
    {
      get => this.innerVariable; // En lecture, pas d’accolade et return est omis
      set => this.innerVariable = value; // En écriture, la valeur à paramétrer est dans value
    }
    
  • Constructeur:
    public class Circle
    {
      private int Radius;
    
      public Circle(int radius) => this.Radius = radius; // Les accolades sont omises
    }
    
  • Destructeur:
    Même syntaxe que pour le constructeur:

    public class Circle
    {
      private int Radius;
    
      ~Circle() => this.Radius = 0;
    }
    
  • Index:
    Les surcharges d’index sur un objet peuvent aussi utiliser une syntaxe sous la forme d’expressions en lecture/écriture:

    public class Séquence
    {
      private readonly List<int> sequence = new List<int> {
        0, 1, 1, 2, 3, 5, 8, 13, 21, 34
      }
    
      public int this[int i]
      {
        get => this.sequence[i];
        set => this.sequence[i] = value;
      }
    }
    

throw <exception> est désormais une expression

C# 7.0

Auparavant throw <exception> n’était pas considéré comme une expression (c’est-à-dire qu’on ne peut pas extraire de l’instruction throw <exception> une valeur assignable). Il n’était donc possible d’utiliser throw <exception> que dans un bloc de code:

int uselessValue = -1;
if (uselessValue < 0)
  throw new InvalidOperationException(); // Dans un bloc de code particulier

throw <exception> ne pouvait pas être utilisé en tant qu’expression, par exemple dans une expression ternaire:

// Avant C# 7.0, cette syntaxe n’est pas possible
  int newValue = uselessValue > 0 ? uselessValue : throw new InvalidOperationException();

A partir de C# 7.0, le compilateur traite throw <exception> comme une expression de façon à ne pas avoir d’erreur de syntaxe à la compilation quand on l’utilise en tant qu’expression.

Par exemple:

  • Quand on définit une expression lambda:
    Func<float, int> convertToInt = (number) => throw new NotImplementedException();
    
  • Dans un constructeur:
    public class Circle
    {
      private int Radius;
    
      public Circle(int radius) => throw new NotImplementedException();
    }
    

    Cette syntaxe est possible pour tous les exemples d’utilisation de syntaxe réduite du corps d’une expression présentés plus haut.

  • Si on utilise une expression ternaire:
    bool intValue = 1;
    string exitCode = intValue > 0 ? "OK" : throw new InvalidOperationException();
    
  • etc…

Déclaration de variables

C# 7.3

A partir de C# 7.3, il est possible d’implémenter la déclaration de variables avec out var <variable> directement dans la version réduite du corps d’une expression.

Par exemple, si on considère la fonction suivante:

public static bool CanExtractIntIfOdd(string inputStr, out int extractedNumber)
{
  return int.TryParse(inputStr, out extractedNumber)
    && extractedNumber % 2 != 0;
}

Il est possible d’utiliser la construction out var <variable> dans une des formes réduites utilisées plus haut, par exemple dans le constructeur:

public class Test
{
  private readonly bool isNumberOddAndPositive;

  public Test(string intAsString) => this.isNumberOffAndPositive = CanExtractIntIfOdd(intAsString, out int extractedNumber)
    && extractedNumber > 0;
}

Autres fonctionnalités

Les fonctionnalités suivantes ne sont pas présentées dans cet article:

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

Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Amélioration de “fixed” (C# 7)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3).

Durant la phase de compactage du garbage collector (GC), des objets présent dans le tas managé peuvent être déplacés de façon à ne pas avoir un espace mémoire trop morcelé pour rendre les allocations plus rapides. Le déplacement des objets impliquent qu’ils changent d’adresses. Ces changements d’adresses sont gérés par le GC qui corrige les références ou les pointeurs vers ces objets.

Certains traitements peuvent nécessiter de travailler sur des adresses par l’intermédiaire de pointeurs, par exemple si on souhaite effectuer des appels à du code non managé en utilisant Platform Invoke ou si on souhaite effectuer des opérations sur les adresses des pointeurs. Si les adresses manipulées pointent vers des objets managés, elles peuvent être amenées à changer après une exécution du GC. Etant donné qu’il n’est pas facile de savoir de façon sure quand le GC sera exécuté, on ne peut pas prévoir quand les adresses manipulées sont corrompues à cause des changements d’adresse.

Avant C# 7.3

Pour éviter les changements d’adresse des objets manipulés, une possibilité est d’indiquer au GC qu’il ne doit pas modifier l’emplacement dans le tas managé des objets durant l’exécution d’une portion de code. Le mot clé fixed permet, par syntaxe, d’indiquer quelle est la portion du code dans laquelle le GC ne déplacera pas certains objets:

int[] items = new int[10]; // Allocation dans le tas managé 
fixed (int* pointer = items) 
{ 
  // Portion de code dans laquelle le tableau items ne sera pas déplacé par le GC. 
} 

Avant C# 7.3, il était possible d’utiliser fixed pour:

  • Des chaînes de caractères System.string:
    string simpleString = "Test"; // Allocation dans le managé car System.String est un objet de type référence. 
    fixed (char* pointer = &simpleString) 
    { 
      //... 
    } 
    

    Comme en C++, pour obtenir un pointeur à partir d’une référence, on peut utiliser &<nom référence>.

  • Des variables non managées ou
  • Des tableaux (exemple plus haut).

GCHandle

System.Runtime.InteropServices.GCHandle donne aussi la possibilité de travailler sur des pointeurs après avoir épinglé (i.e. pinned) l’objet pointé:

  • GCHandle.Alloc() permet d’instancier une référence GCHandle à partir d’un objet managé et de l’épingler pour que le GC ne le déplace pas pendant toute l’existence de la référence.
  • GCHandle.Free() permet de libérer la référence pour que le GC puisse de nouveau intervenir sur l’objet en mémoire si nécessaire.

Par exemple:

int[] items = new int[10]; 
GCHandle itemGcHandle = GCHandle.Alloc(items, GCHandleType.Pinned); // L'objet est désormais épinglé 

IntPtr pointer = itemGcHandle.ToIntPtr(); // On peut effectuer des traitements avec le pointeur 

ItemGcHandle.Free(); permet de libérer la référence et de ne plus épingler l'objet 

fixed permet de faciliter l’implémentation et vise à apporter une alternative à GCHandle.

Comment compiler du code unsafe ?

Pour compiler du code unsafe et autoriser le compilateur à utliser le mot-clé unsafe, il faut l’autoriser dans les propriétés du projet:

  • Dans les propriétés du projet dans Visual Studio, il faut cocher la propriété “Allow unsafe code” dans l’onglet Build.
  • En éditant directement le fichier .csproj, il faut rajouter le nœud AllowUnsafeBlocks dans PropertyGroup:
    <Project Sdk="Microsoft.NET.Sdk"> 
      <PropertyGroup> 
        <!—- ... -—> 
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks> 
      </PropertyGroup> 
    </Project> 
    

Fixed pattern

C# 7.3

Avant C# 7.3, fixed ne pouvait être utilisé qu’avec des types d’objets bien précis. Pour étendre l’utilisation de fixed à d’autres types y compris des types déclarés en dehors du framework, C# 7.3 introduit le pattern fixed. Ce pattern permet à n’importe quel objet d’être utilisé avec fixed s’il contient une référence managée qui permettra:

  • D’épingler une instance de l’objet et
  • D’initialiser le pointeur utilisé par fixed pour épingler l’objet.

Lors de l’utilisation, de façon à fournir cette référence managée à fixed, l’objet devra comporter une fonction publique:

  • ref T GetPinnableReference() ou
  • ref readonly T GetPinnableReference()

T doit être le type d’une variable qui sera fixe en mémoire c’est-à-dire qu’elle ne sera pas affectée par l’exécution du garbage collector (GC). Les variables fixes en mémoire sont:

  • Des variables locales ou des paramètres de fonction de type valeur: les variables locales ou les paramètres de fonction de type valeur sont stockés sur la pile, ils ne sont pas affectés par le GC.

    Par exemple:

    int localVar = 5; 
    unsafe 
    { 
      int* varPtr = &localVar; // Opérateur address-of & (même signification qu’en C++) 
    }
    
  • Des variables résultants d’un buffer dans une structure (voir plus bas): ces variables sont fixes en mémoire par construction.

    Par exemple:

    unsafe struct StructWithFixedVar 
    { 
      public fixed int FixedVar[5]; 
    } 
    
    // ...
    var structInstance = new StructWithFixedVar(); 
    int* ptr = structInstance.FixedVar; 
    
  • Variable provenant d’indirection de pointeur *p, d’accès à un membre d’un pointeur p->m ou accès à un élément d’un pointeur p[i], par exemple:
    struct SimpleStruct 
    { 
      public int InnerVar; 
    } 
    
    // ...
    SimpleStruct simpleStruct = new SimpleStruct(); 
    unsafe 
    { 
      SimpleStruct* ptr = &simpleStruct; 
    
      // Pointer indirection 
      SimpleStruct ptrIndirection = *ptr; // Opérateur * (même signification qu’en C++) 
    
      // Pointer member access 
      int innerVar = ptr->InnerVar; // Opérateur -> 
    
      // Pointer element access 
      char* strPtr = stackalloc char[5]; // Allocation sur la pile 
      char elementPtr = strPtr[2]; // Opérateur [ ] 
    } 
    

Ainsi si la fonction GetPinnableReference() renvoie une référence d’une variable fixe en mémoire d’un objet, fixed sera capable d’épingler cet objet en mémoire. Par suite l’implémentation pourra être du type:

public class PinnableClass 
{ 
  public ref T GetPinnableReference() 
  { ... } 
} 

// ... 
var pinnableClass = new PinnableClass(); 
unsafe 
{ 
  fixed(T* ptr = pinnableClass) 
  { 
    // ... 
    // L’instance pinnableClass restera fixe en mémoire si le GC s’exécute 
  } 
} 
Manipuler des pointeurs en C#

Les notations utilisées pour manipuler les pointeurs en C# sont les mêmes qu’en C++ (pour plus de détails voir Aide-mémoire sur les pointeurs et références en C++):

  • Obtenir un pointeur à partir d’un objet de type valeur ou d’une référence avec l’opérateur &:
    struct StructObjet {} 
    
    // ...
    StructObject instance = new StructObject(); // Instanciation sur la pile 
    StructObject* ptr = &instance; // fixed n’est pas nécessaire 
    
  • Obtenir l’objet pointé ou une référence vers cet objet avec l’opérateur *:
    StructObject realObject = *ptr; 
    
  • Accéder aux membres d’un objet à partir d’un pointeur avec l’opérateur ->:
    struct StructObjet 
    { 
      public int InnerVariable; 
    } 
    
    // ...
    StructObject instance = new StructObject(); // Instanciation sur la pile 
    StructObject* ptr = &instance; // fixed n’est pas nécessaire 
    int innerValue = ptr->InnerVariable; 
    

Implémentation de GetPinnableReference()

Le choix de la variable retournée par GetPinnableReference() n’est pas anodin car si elle est déplacée par le GC dans le bloc de code suivant fixed, les pointeurs pourraient rediriger vers de mauvaises adresses. Des erreurs de compilation peuvent éviter certaines erreurs comme par exemple utiliser un pointeur provenant d’un objet de type référence qui n’est pas fixe en mémoire:

class ClassObject {} 

// ...
ClassObject instance = new ClassObject(); // Instance dans le tas managé 
ClassObject* ptr = &instance; // ERREUR: fixed est nécessaire 

L’erreur générée sera du type:

"CS0208: Cannot take the adresse of, get the size of, or declare a pointer to a managed type"

Quelques conseils d’implémentations pour GetPinnableReference():

  • Retourner un pointeur vers un objet natif: l’utilisation de code unsafe est le plus souvent motivée par la nécessité de manipuler des pointeurs dans le but d’effectuer des appels à du code non managé. Avec la fonction GetPinnableReference() on peut renvoyer un pointeur d’un objet alloué dans le tas non managé. Dans ce cas, le GC ne déplacera pas l’objet et son adresse ne sera pas modifiée.
  • Ne pas retourner un membre d’un objet de type référence: on pourrait être tenter de retourner un objet membre de type valeur. Si un objet de type valeur est membre d’un objet de type référence, le membre sera stocké dans le tas managé et non sur la pile. Lors de son exécution, le GC pourra affecter l’adresse du membre de la même façon que l’objet parent de type référence. Ce type d’implémentation est, donc, à éviter:
    class BadImplementation 
    { 
      public int InnerValueObject; 
    
      public ref int GetPinnableReference() 
      { 
        return ref this.InnerValueObject; // A éviter: ne pas utiliser 
      } 
    } 
    
    // ...
    var refTypeObject = new BadImplemetation(); 
    unsafe 
    { 
      fixed (int* ptr = refTypeObject) 
      { 
        // ... 
      } 
    } 
    
  • S’aider de Span<T>: dans le cas où on n’utilise pas de pointeurs vers un objet natif, on peut s’aider de Span<T>. Cet objet, par construction, épingle l’objet qu’il utilise, on peut donc être sûr qu’il ne sera pas déplacé:
    class PinnableClass 
    { 
      private readonly int[] InnerVariable = new int[] {0}; 
    
      public ref int GetPinnableReference() 
      { 
        Span<int> pinnableRef = this.InnerVariable.AsSpan(); // Span est construit sans effectuer de copie 
    
        return ref pinnableRef[0]; // L’object ref est retourné sans effectuer de copie 
      } 
    } 
    
  • Si on utilise le membre d’une classe, implémenter GetPinnableReference() n’est pas nécessaire: si on souhaite seulement épingler un membre d’un objet managé, il n’est pas nécessaire d’implémenter GetPinnableReferences(), on peut se contenter d’extraire le pointeur directement à partir du membre (disponible avant C# 7.3):
    public class MoveableObject 
    { 
      public int InnerVariable; 
    } 
    
    // ...
    var moveableObject = new MoveableObject(); 
    unsafe 
    { 
      fixed(int* ptr = &moveableObject.InnerVariable) 
      { 
    
        // Lecture en utilisant le pointeur 
        Console.WriteLine(*ptr); 
    
        // Écriture en utilisant le pointeur 
        *ptr = 5; 
      } 
    } 
    

    Il faut, toutefois, garder à l’esprit que ptr devient un pointeur fixe sur le membre d’un objet managé. Si on modifie les membres de l’objet managé notamment avec des tableaux d’objets, même s’il n’est pas déplacé par le GC, l’organisation des membres dans l’objet peut être modifié et le pointeur peut ne plus pointer à la bonne adresse mémoire. Dans l’exemple précédent, la valeur du membre est modifiée mais la taille du type de InnerVariable n’est pas modifiée, son adresse pointe donc bien vers le même objet.

Utiliser fixed pour déclarer un buffer

C# 7.3

A partir de C# 7.3, pour faciliter l’implémentation de buffers utilisés avec du code non managé, il est possible d’indiquer qu’un tableau membre d’une structure est fixe:

unsafe struct BufferWrapper 
{ 
  public fixed int buffer[5]; 
} 

Le tableau doit satisfaire certaines conditions:

  • Le type des éléments du tableau doit être un type primitif (bool, byte, short, ushort, int, uint, long, ulong, float, double ou char).
  • La taille du tableau est fixe et doit être déclarée au moyen d’une constante.
  • Il faut faire attention à la déclaration, la taille du tableau doit être indiquée après le nom du membre:
    public fixed int buffer[5]; 
    

    Et non:

    public fixed int[] buffer; // ERREUR
    

    ou

    public fixed int buffer[]; // ERREUR
    

On peut se demander l’intérêt de cette fonctionnalité en sachant que le plus souvent, l’instance d’une structure et son membre sont stockés sur la pile. L’instance d’une structure ne sera donc pas impactée par le GC. Toutefois si la structure est elle-même le membre d’un objet parent de type référence, elle sera allouée dans le tas managé et non sur la pile. Dans ce cas là, elle peut être amenée à être déplacée par le GC. Ainsi pour éviter d’alourdir l’implémentation en épinglant l’objet parent de type référence, il est possible d’indiquer que le membre de la structure est fixe de façon à ce qu’il soit utilisé dans un contexte unsafe éventuellement avec du code natif. Il n’est pas nécessaire ensuite d’utiliser fixed:

unsafe struct BufferWrapper 
{ 
  public fixed int buffer[5]; 
} 

unsafe class MoveableRefObject 
{ 
  public BufferWrapper InnerBuffer; 

  public MoveableRefObject() 
  { 
    this.InnerBuffer = new InnerBuffer(); 
  } 
} 

// ...
var moveableRefObject = new MoveRefObject(); 
unsafe 
{ 
  // Écriture classique 
  moveableRefObject.InnerBuffer.buffer[0] = 42; 

  // Lecture 
  Console.WriteLine(moveableRefObject.InnerBuffer.buffer[0]); 
} 

L’utilisation du pointeur du membre peut toujours être utilisé:

unsafe 
{ 
  fixed (int* ptr = moveableRefObject.InnerBuffer.buffer) 
  {
    *ptr = 42; 
  } 
} 

Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Structure exclusivement stockée dans la pile: “ref struct” (C# 7, C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3) et C# 8.0.

Les objets de type référence sont stockés dans le tas managé (i.e. managed heap) et les objets de type valeur sont le plus souvent stockés sur la pile (i.e. stack). Quand on manipule des objets de type référence, le plus souvent on manipule des références vers ces objets. A l’opposé, quand on manipule des objets de type valeur, des copies par valeur de ces objets peuvent être effectuées suivant la façon dont on les manipule.

L’utilisation d’objets dans la pile peut être plus performante que dans le tas managé à condition que les manipulations de ces objets n’entraînent pas trop de copies par valeur. Certains éléments de syntaxe ont été rajoutés en C# 7.2 de façon à limiter les copies d’objets de type valeur et ainsi garantir une utilisation plus optimale de ces objets:

  • in: permet de définir des arguments de fonction qui seront copiés par référence en empêchant leur modification dans le corps de la fonction (cf. mot-clé in à partir de C# 7.2).
  • readonly struct pour définir des structures et garantir qu’elles sont immutables,
  • ref struct: pour déclarer des structures qui seront stockées exclusivement sur la pile.

Tous ces éléments servent au compilateur pour effectuer des optimisations pour améliorer la vitesse d’exécution.

Pour optimiser l’utilisation des objets de type valeur dans la pile (i.e. stack), il faut:

  • Eviter d’effectuer des copies par valeur de ces objets et privilégier les manipulations par référence.
  • Utiliser des objets immutables ce qui permet au compilateur d’effectuer des optimisations.
Utilisation de variables temporaires avec in

Utiliser des copies par référence ou rendre les objets immutables sont des modifications qui ne suffisent pas pour optimiser les performances si elles sont appliquées l’une sans l’autre.

Par exemple utiliser des structures dont les membres sont rendus immutables en utilisant readonly peut, en réalité, dégrader les performances:

public struct ImmutableStruct  
{  
  private readonly int innerMember;  

  public int ReadOnlyMember => this.innerMember;  
}  

public class StructWrapper  
{  
  private readonly ImmutableStruct innerStruct;  

  // ...  
}  

En effet pour prendre en compte l’aspect immutable des membres de la structure, pour chaque utilisation d’un membre, le compilateur utilise une variable temporaire qui sera une copie du membre à utiliser. Ainsi à chaque utilisation d’un membre de la structure, une copie par valeur sera effectuée vers une variable temporaire. Cette opération dégrade les performances et peut doubler le temps d’exécution par rapport à l’absence du mot clé readonly:

public struct MutableStruct  
{  
  public int innerMember;  
}  

public class StructWrapper  
{  
  private MutableStruct innerStruct;  

  // ...  
}  

Le même mécanisme est appliqué par le compilateur quand on utilise in pour des arguments de fonctions qui sont des structures:

public void UseStruct(in ImmutableStruct)  
{...}  

Pour empêcher au compilateur d’effectuer ces copies, il faut que la structure soit immutable par construction en utilisant readonly struct.

readonly struct

C# 7.2

Historiquement le mot-clé readonly pouvait être utilisé pour indiquer qu’un membre d’une classe ou d’une structure ne peut être initialisé que par un initializer (avant l’exécution du constructeur) ou par le constructeur.

A partir de C# 7.2, le mot-clé readonly peut être placé devant struct de façon à indiquer au compilateur que la structure doit être immutable. Par suite le compilateur vérifiera que les membres de la structure ne peuvent pas être modifiés:

  • Une propriété ne pourra pas avoir d’accesseurs en écriture:
    public readonly struct MyStruct  
    {  
      public int WritableProp { get; set; } // ERREUR  
    
      public int ReadOnlyProp { get; } // OK  
    }
    
  • Les variables membres publiques doivent utiliser le mot-clé readonly:
    public readonly struct MyStruct  
    {  
      public int WritableMember; // ERREUR  
    
      public readonly int ReadOnlyMember; // OK  
    }
    
  • La déclaration d’évènements dans la structure n’est pas autorisée:
    public readonly struct MyStruct  
    {  
      public event EventHandler Event; // ERREUR  
    }
    

Ainsi la syntaxe permet de garantir que la structure est immutable.

ref struct

C# 7.2

ref peut être utilisé quand on déclare un objet struct pour indiquer qu’une instance de la structure ne peut se trouver que dans la pile et ne pourra pas correspondre à une allocation dans le tas managé, par exemple:

ref struct StackOnlyStruct  
{ ... }  

Une structure étant un objet de type valeur, elle est, le plus souvent, stockée dans le pile (i.e. stack). Toutefois dans certains cas, une instance provenant d’une structure peut être stockée ailleurs:

  • Si une structure est déclarée en tant qu’objet statique, l’instance sera allouée dans un tas particulier (loader heap ou high frequency heap).
  • Si la structure est un membre d’un objet de type référence alors la structure sera stockée dans le tas managé (i.e. managed heap):
    public class StructWrapper  
    {  
      public CustomStruct InnerStruct { get; set; }  
    }
    

    avec:

    struct CustomStruct { ... }
    

    Dans le cas du boxing, une structure peut aussi être amenée à être stockée dans le tas managé:

    CustomStruct customStruct = new CustomStruct();  
    object structAfterBoxing = customStruct; // Boxing  
    

Ainsi suivant les utilisations, une structure peut faire l’objet d’une allocation dans le tas managé. Or dans certains cas, il peut être nécessaire d’indiquer au compilateur que les instances de la structure doivent exclusivement se trouver dans la pile. ref struct vise à l’assurer par construction dans le cas contraire une erreur de compilation sera émise:

ref struct CustomStruct {}  

// ...  
var customStruct = new CustomStruct();  
object boxedStruct = (object)customStruct; // ERROR: Cannot concert type 'CustomStruct' to 'object'  

Pour garantir que la structure ne sera stockée que dans le pile, d’autres restrictions s’ajoutent si la structure est déclarée avec ref struct. Ainsi certains cas d’utilisation ne sont pas possibles pour ce type de structure:

  • Elément d’un tableau: CustomStruct[].
  • Membres d’une classe.
  • Satisfaire une interface (pourrait entraîner un boxing).
  • Argument de type générique par exemple pour une méthode:
    public void UseStruct<T>(T argument) {}  
    
    // ...  
    var customStruct = new CustomStruct();  
    UseStruct<CustomStruct>(customStruct); // ERROR: The type 'CustomStruct' may not be used as a type argument  
    
  • Argument d’une expression lambda ou d’une fonction locale:
    Func<CustomStruct> lambda = () => new CustomStruct(); // ERROR  
    
  • Utilisation dans une méthode async:
    Task UseStruct()  
    {  
      var customStruct = new CustomStruct();  
      return Task.FromResult(0); // OK pas d'erreur  
    }  
    

    Mais:

    async Task UseStruct()  
    {  
      var customStruct = new CustomStruct(); // ERROR: parameters or locals of type 'CustomStruct' cannot be declared in async methods or lambda expressions.  
      await Task.FromResult(0);  
    }
    
  • Utilisation dans un itérateur.

L’utilisation d’une structure déclarée avec ref struct est seulement réservée:

  • Au paramètre d’une méthode;
  • Au type de retour d’une méthode
  • A une variable locale.

D’autre part, les accès au ref struct ne peut se faire qu’à partir du thread qui a créé l’instance. Il n’est pas possible de passer des adresses de ref struct d’un thread à l’autre.

L’utilisation de ref struct pour déclarer les structures réservées exclusivement à la pile prête beaucoup à confusion car elle laisse penser qu’il s’agirait de structure de type référence (ce type de structure existe en C++/CLI). ref est utilisé car il fait référence aux objets de type ByReference (byref ou byref-like types) qui sont liés aux pointeurs managés (i.e. managed pointers).

Enfin, on pourrait se poser la question de savoir quelle pourrait être le cas d’utilisation de ce type de structure. Une utilisation est de les utiliser avec des pointeurs managés (i.e. managed pointer) qui sont des objets exclusivement stockés sur la pile.

Managed pointer

Les pointeurs managés (i.e. managed pointer) sont des pointeurs particuliers utilisés en .NET de façon sous-jacente mais non exposés aux développeurs en C#. Il s’agit de pointeurs permettant de pointer vers des objets managés, des objets non managés ou des objets se trouvant sur la pile. Ils sont différents des pointeurs natifs car ils permettent de pointer sur les objets managés. De la même façon, ils ne peuvent pas être considérés comme des références car ils peuvent pointer vers des objets non managés et surtout, ils peuvent pointer à l’intérieur d’objets. Durant l’exécution normale, les pointeurs managés sont utilisés pour lire ou écrire dans les emplacements mémoires vers lesquels ils pointent.

Une spécificité importante qui découle des caractéristiques de ces pointeurs les différenciant des références est qu’ils peuvent pointer vers des objets se trouvant à l’intérieur d’objets. Ainsi lorsque des objets sont passés en argument par référence en utilisant ref, ce sont des pointeurs managés qui sont utilisés (ref n’est pas un pointeur managé mais il wrappe un pointeur managé). Les différentes manipulations possibles de l’utilisation d’objets de type valeur par référence découlent directement des caractéristiques du pointeur managé:

Les pointeurs managés étant capable de pointer vers l’intérieur d’un objet, ils sont aussi appelés interior pointer. Par exemple, c’est sous cette appellation qui sont disponibles en C++/CLI: interior_ptr.

Les interior pointers sont exclusivement stockés dans la pile et non dans le tas managé pour 2 raisons:

  • Les interior pointers peuvent pointer vers un objet de la pile: si un interior pointer pointant vers un objet de la pile était stocké dans le tas managé, comment savoir quand le pointeur n’est plus valide quand l’objet pointé ne se trouve plus dans la stack frame ? Dans le cas où l’interior pointer est stocké dans la pile, par construction le principe de fonctionnement des stack frames permet de gérer la durée de vie du pointeur en accord avec celle de l’objet vers lequel il pointe.
  • Les interior pointers peuvent pointer à l’intérieur d’objets managés: c’est ce cas de figure qui pose le plus de problème. Lorsqu’un objet managé est déplacé en mémoire par le garbage collector (GC) lors de la phase de compactage, il faut répercuter ce changement dans le pointeur managé de façon à ce qu’il pointe vers le nouvel emplacement mémoire. Ce mécanisme est effectué par le GC avant l’étape de compactage et il est quasiment le même pour les références.

    La différence entre le changement des adresses des références et des interior pointers est que ces derniers pointent vers l’intérieur d’objets et il n’est pas direct de savoir qu’elle est l’objet parent de l’objet pointé par l’interior pointer (car les objets ne sont pas stockés de façon continue en mémoire). Cette étape nécessite de parcourir le graphe des objets en utilisant le mécanisme d’arbre “bricks and plugs”(1) (i.e. bricks and plugs tree mechanism). Ainsi pour un interior pointer donné, il sera possible de déterminer quel est l’objet parent de l’objet qui est pointé et marquer l’objet parent comme étant actif lors de l’étape de marquage du GC.

    Il n’est pas non plus direct pour le GC de connaître quel est l’objet parent d’un interior pointer. Si l’interior pointer était stocké dans le tas managé, trouver l’objet parent representerait un coût non négligeable puisqu’il faudrait parcourir l’arbre des objets de la même façon que pour le mécanisme d’arbre “bricks and plugs” après la phase de marquage du GC. Pour éviter ce parcours supplémentaire, le choix a été fait de limiter le stockage des interior pointers à la pile. Ainsi il est plus facile de modifier les adresses vers lesquelles pointent les interior pointers après la phase de compactage du GC.

Phases du garbage collector

Lorsque le garbage collector s’exécute, il stoppe l’exécution et applique 3 phases successives:

  1. Marquage (mark): au début de son exécution le GC considère tous les objets comme étant des objets à supprimer. Le but de cette étape est de marquer les objets utilisés. Le GC commence par référencer tous les objets se trouvant sur la pile, dans les registres et les objets statiques, ensuite il parcours toutes les références ou pointeurs se trouvant dans ces objets. Tous les objets atteignables au moyen d’une réference ou d’un pointeur sont considérés comme actif. A la fin de cette étape, le GC possède une cartographie des objets du processus sous la forme d’un graphe d’objets.
  2. Collecte (collect ou sweep): cette étape consiste à supprimer tous les objets qui ne sont pas actifs c’est-à-dire qui sont inatteignables. Après suppression des objets, la mémoire risque d’être morcellée ce qui peut rendre les nouvelles allocations d’objets plus difficiles. C’est la raison pour laquelle, à la fin de l’étape de collecte, le GC détermine quels sont les objets qu’il devra déplacer en mémoire par copie de façon à limiter le morcellement de la mémoire par les objets encore actifs. A la fin de l’étape de collecte, le GC corrige les adresses des références et de pointeurs managés en affectant les nouvelles adresses des objets après déplacement en mémoire.
  3. Compactage (compact): cette étape consiste à déplacer les objets en mémoire pour éviter le morcellement et optimiser les nouvelles allocations d’objets.

Pour toutes ces raisons les pointeurs managés sont stockés sur la pile. Par suite les objets contenant des pointeurs managés doivent aussi être stockés sur la pile. C’est le cas de l’objet Span<T> qui est apparu avec .NET Core 2.1.

Span<T>

C# 7.2 / .NET Core 2.1

Span<T> est un objet apparu avec .NET Core 2.1 (il n’est pas directement disponible avec le framework .NET). L’utilisation de cet objet ainsi que ReadOnlySpan<T> justifie de devoir construire des structures avec ref struct. En effet Span<T> est une ref struct et ne peut être utilisée que sur la pile ou dans une autre structure ref struct. Par exemple, si on essaie de l’utiliser dans une classe:

class SpanWrapper 
{ 
    public Span<int> InnerSpan; 
} 

On aura une erreur du type:

"Field or auto-implemented property cannot be of type 'Span<int>' unless it is an instance member. of a ref struct."

Span<T> est un objet de type valeur permettant de traiter des espaces contigus en mémoire sans avoir à effectuer d’allocation. L’intérêt de cet objet est de pouvoir travailler sur des objets managés, non managés ou sur la pile sans forcément être dans un contexte unsafe et d’optimiser les performances en évitant d’effectuer des allocations dans le tas managé.

Concrétement si on doit travailler sur des objets en mémoire, il sera nécessaire d’allouer des tableaux d’objets ou d’effectuer des copies explicites d’objets, par exemple:

byte[] byteArray = new byte[256]; // Allocation 

ou

// Lecture d'un espace en mémoire à partir d'un pointeur: 
IntPtr objectPointer = ...; 
byte[] byteArrayBuffer = new byte[256]; // Allocation 
Marshal.Copy(objectPointer, byteArrayBuffer, 0, byteArrayBuffer.Length); // Copie  

ou

// Ecrire dans un bloc de mémoire non managé: 
SimpleStruct testStruct = new SimpleStruct(); 
IntPtr structPointer = Marshal.AllocHGlobal(Marshal.SizeeOf(textStruct)); // Allocation d'un espace en mémoire non managée 
Marshal.StructureToPtr(testStruct, structPointer, false); // Copie de l'objet dans la mémoire non managée 

Toutes ces opérations ont en commun qu’il est nécessaire d’effectuer des copies pour travailler sur les blocs mémoire ce qui est couteux en performance par rapport à une lecture/écriture directe dans la mémoire. Span<T> vise à éviter ces copies en mettant à disposition une “porte d’entrée” plus directe à des objets en mémoire.

Utilisation de Span<T> suivant le framework

L’objet Span<T> est directement utilisable à partir du .NET Core 2.1 et de C# 7.2 toutefois il n’est pas disponible dans le framework .NET. Pour l’utiliser avec le framework .NET, il faut installer le package NuGet System.Memory.

Il existe 2 implémentations de Span<T> pour lesquelles les performances ne sont pas les mêmes:

  • Pour les frameworksSpan<T> n’est pas disponible nativement comme le framework .NET ou .NET Core 1.1 par exemple: l’implémentation est moins performante (15% moins performante(2)).
  • Pour les frameworksSpan<T> est disponible sans package additionnel : le runtime prends en compte l’objet de façon optimale (6 à 8 fois plus rapide qu’une utilisation sans Span<T>(3)).

Fonctionnement

Span<T> permet de travailler sur des espaces mémoire contenant un nombre fixe d’objets de même type à partir d’une adresse de départ (pas forcément l’adresse du premier objet) et en connaissant la taille des objets. Ainsi les contructeurs de Span<T> autorisent:

  • Un tableau d’objets (new Span<T>(T[] array, int start, int length)): le tableau correspond à l’espace mémoire, start permet de préciser le point de départ à partir duquel on souhaite travailler et length indique le nombre d’objets à traiter. La taille de chaque objet est connue puisque le type des objets est connu.

    Par exemple:

    int[] array = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; 
    Span<byte> span = new Span<byte>(array, 2, 5); 
    foreach (var item in span) 
      Console.WriteLine(item); // 2, 3, 4, 5, 6 
    
  • Un pointeur et la taille explicite de l’objet (new Span<T>(void* pointer, int length)): le pointeur permet d’indiquer l’adresse de départ de l’espace mémoire, la taille indique le nombre d’objets sur lesquels on veut travailler. La taille des objets est connu puisque le type des objets est connu.

    Par exemple:

    int[] array = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; 
    unsafe // La manipulation de pointeur impose un contexte unsafe 
    { 
      fixed (int* start = array) // fixed permet d'éviter que l'objet ne soit déplacé par le Garbage Collector. 
      { 
        Span<int> span = new Span<int>(start, 5): 
        foreach (var item in span) 
          Console.WriteLine(item); // 0, 1, 2, 3, 4 
      } 
    } 
    
.AsSpan()

Il est possible de créer une instance de Span<T> en utilisant la méthode d’extension AsSpan():

  • A partir d’un tableau d’objets: au lieu d’écrire
    int[] array = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; 
    Span<byte> span = new Span<byte>(array); 
    

    On peut écrire directement:

    int[] array = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; 
    ReaedOnlySpan<int> span = array.AsSpan();
    
  • A partir d’une chaine de caractères:
    ReadOnlySpan<char> span = "Example".AsSpan(); 
    

Toutes ces surcharges de constructeur ont en commun d’avoir une adresse correspondant au point de départ de l’espace mémoire. Cette adresse peut correspondre à un objet managé, un objet sur la pile ou un objet natif. Sachant que Span<T> permet de travailler en commençant à un certain point de l’espace mémoire sans forcément être le premier objet de cet espace, l’adresse peut aussi être un objet à l’intérieur de l’espace mémoire:


Ainsi dans le Span<T>, l’adresse est stockée sous la forme d’un interior pointer. Comme on l’a vu précédemment, un interior pointer (objet ByReference) permet de pointer vers un objet de la pile, un objet managé, un objet non managé ou à l’intérieur d’un autre objet:

public readonly ref struct Span<T> 
{ 
  private readonly ref T pointer;

  private readonly int length; 

  // ... 
} 

ref T pointer est l’interior pointer, pour rappel ce type de pointeur n’est pas directement exposé en C# et il doit être stocké exclusivement sur la pile. C’est la raison pour laquelle Span<T> est une ref struct.

stackalloc

stackalloc est un mot-clé qui existe depuis la 1ère version de C#, il permet d’allouer un tableau sur la pile et de retourner un pointeur vers ce tableau. Étant donné que cet opérateur renvoie un pointeur, il ne pouvait être exécuté que dans un contexte unsafe. Par exemple:

unsafe 
{ 
  int* buffer = stackalloc int[256]; // Allocation d’un tableau de 256 entiers sur la pile 
} 

L’intérêt principal d’allouer un tableau sur la pile est de permettre un gain en performance par rapport à une allocation plus classique dans le tas managé, l’allocation sur la pile ne sollicitant pas le garbage collector:

int[] arrayInHeap = new int[256]; 

Le but d’utiliser un pointeur vers un tableau est de pouvoir effectuer des appels dans du code natif en utilisant un pointeur vers un objet de type tableau, par exemple en effectuant un appel Platform Invoke ou un appel dans du code C++/CLI.

C# 7.2

Une innovation a été apportée à stackalloc à partir de C# 7.2: le mot-clé peut, désormais, renvoyer un objet Span<T> ou ReadOnlySpan<T>. Comme on l’a vu plus haut, Span<T> permet d’avoir un point d’entrée performant vers un tableau sans effectuer d’allocations supplémentaires et surtout sans nécessiter d’utiliser un pointeur. Ainsi comme il n’y a pas de pointeurs, il n’est pas forcément nécessaire d’être dans un contexte unsafe:

// unsafe n’est pas nécessaire 

Span<int> buffer = stackalloc int[256]; // les objets sont alloués seulement sur la pile 

La syntaxe est identique pour ReadOnlySpan<T>:

ReadOnlySpan<int> buffer = stackalloc int[256];  

Span<T> est une ref struct qui est un objet de type valeur strictement alloué sur la pile. Il permet donc de tirer partie de l’allocation sur la pile avec stackalloc sans manipuler des pointeurs.

Pour comprendre l’intérêt de l’utilisation de stackalloc et Span<T>, il faut comparer la ligne précédente avec l’exemple:

Span<int> buffer = new int[256]; // les objets sont alloués dans le tas 

Cet exemple est équivalent fonctionnellement à Span<int> buffer = new int[int], ils permettent tous les deux d’accéder aux objets d’un tableau. De plus Span<T> est toujours stocké sur la pile. La différence est qu’avec stackalloc, le tableau est alloué sur la pile alors qu’avec l’opérateur new, le tableau est alloué dans le tas.

Comme on l’a vu précédemment, les surcharges du constructeur de Span<T> autorisent l’utilisation de pointeurs, on peut donc décomposer Span<int> buffer = stackalloc int[256] en plusieurs lignes, la différence étant qu’il faut être dans un contexte unsafe à cause du pointeur:

unsafe 
{ 
  int* buffer = stackalloc int[256]; 
  Span<int> wrapperBuffer = new Span<int>(buffer, 256); 
} 

De même, on peut décomposer la ligne Span<int> buffer = new int[256] en plusieurs lignes en utilisant un pointeur. Il faut en plus, utiliser le mot-clé fixed. En effet, le tableau étant alloué dans le tas managé, fixed va permettre de rendre le tableau fixe en mémoire de façon à empêcher son déplacement par le garbage collector:

int[] buffer = new int[256]; 
unsafe 
{ 
  fixed (int* ptr = buffer) 
  { 
    Span<int> wrapperBuffer = new Span<int>(ptr, buffer.Length); 
  } 
} 

Quantité de mémoire allouée sur la pile

La taille de la pile est limitée en mémoire à:

  • 1 MO pour les processus 32 bits et
  • 4 MO pour les processus 64 bits.

Contrairement au tas managé, il faut prendre des précautions pour limiter les allocations sur la pile en utilisant stackalloc et éviter une exception de type StackOverflowException si la taille de la pile est dépassée.

Par exemple:
Il est préférable de limiter la taille du buffer à une constante ou une valeur bornée:

Span<int> buffer = stackallow int[256]; // OK 

void MakeProcessWithBuffer(int bufferSize) 
{ 
  Span<int> buffer = stackalloc int[bufferSize]; // A éviter 
  // ... 
} 

Dans le cas précédent, la valeur de bufferSize peut être grande, il faut regarder le corps de la méthode pour comprendre que cette valeur est utilisé par stackalloc. On peut utiliser des garde-fous pour borner la valeur de bufferSize, par exemple:

bool TryMakeProcessWithBufferOnStack(int bufferSize) 
{ 
  if (bufferSize > 1024) return false; 

  Span<int> buffer = stackalloc int[bufferSize];  
  // ... 
} 

On alloue le buffer dans le tas managé dans le cas où bufferSize dépasse une certaine valeur:

bool MakeProcessWithBuffer(int bufferSize) 
{ 
  Span<int> buffer = bufferSize > 1024 ? new int[bufferSize] : stackalloc int[bufferSize];  
  // ... 
} 

De la même façon, il est préférable d’éviter d’utiliser stackalloc à l’intérieur d’une boucle, on peut effectuer l’allocation à l’extérieur de la boucle et utiliser le même objet pour toutes les boucles.

Par exemple:

void MakeProcessInLoop(int loopNumber) 
{ 
  for (int i = 0; i < loopNumber; i++) 
  { 
    Span<int> buffer = stackalloc int[256];  
    // ... 
  } 
} 

Les performances de la méthode sont liées au nombre de boucle. En allouant le tableau à l’extérieur de la boucle, la méthode sera moins tributaire de ce nombre de boucle, quitte à réinitialiser la tableau:

void MakeProcessInLoop(int loopNumber) 
{ 
  Span<int> buffer = stackalloc int[256];  
  for (int i = 0; i < loopNumber; i++) 
  { 
    buffer.Clear(); // Permet de réinitialiser les valeurs du tableau avec une valeur par défaut 

    buffer.Fill(0); // Permet d'affecter une même valeur à tous les éléments du tableau    
    // ... 
  } 
} 

Utilisation de stackalloc dans une expression

C# 8.0

Quand stackalloc est apparu en C# 7.2, il ne permettait d’être utilisé que pour initialiser une variable de type Span<T> ou ReadOnlySpan<T>:

Span<int> buffer = stackalloc int[256];
ReadOnlySpan<int> readOnlyBuffer = stackalloc int[256];

A partir de C# 8.0, il est possible d’utiliser stackalloc dans une expression en dehors d’une affectation.
Ainsi, Span<T> et ReadOnlySpan<T> étant énumérables, on peut, par exemple, utiliser le résultat de stackalloc directement dans une boucle foreach:

foreach (var number in stackalloc[] {1, 2, 3, 4, 5})
{
  // ...
}

De même on peut utiliser directement le résultat de stackalloc dans une expression utilisant les méthodes d’extension dans System.MemoryExtensions sans passer par une variable intermédiaire, par exemple:

Span<int> numbers = stackalloc[] { 1, 2, 3, 4, 5 };
bool comparisonResult = numbers.SequenceEqual(stackalloc[] { 1, 2, 3, 4, 5, 6 });

readonly ref struct

C# 7.2

On peut utiliser à la fois readonly et ref devant struct pour cumuler les caractéristiques de ref struct et readonly struct:

  • ref struct pour contraindre une structure à être stockée exclusivement sur la pile et
  • readonly struct pour rendre une structure immutable.

Quand on déclare la structure, le mot-clé readonly doit se trouver obligatoirement avant ref:

readonly ref struct ImmutableStackOnlyStruct  
{ ... }  

Ainsi une structure de ce type comporte de nombreuses restrictions correspondant aux restrictions pour ref struct et readonly struct:

  • Ne peut pas être stockée dans un tableau (car elle doit être stockée exclusivement sur la pile).
  • Ne peut pas être membre d’une classe (sinon elle serait stockée dans le tas managé).
  • Ne peut pas satisfaire une interface (car elle pourrait entraîner du boxing).
  • Ne doit pas comporter des arguments génériques.
  • Ne peut pas être un argument d’une expression lambda.
  • Ne peut pas être utilisée dans une méthode async.
  • Ne peut pas être utilisée dans un itérateur.
  • Ne doit pas comporter des accesseurs autorisant l’écriture.
  • Ne doit pas comporter des variables membres publiques.
  • Ne doit pas contenir des déclarations d’évènements.

ref struct et readonly ref struct disposable

C# 8.0

A partir de C# 8.0, les structures de type ref struct ou readonly ref struct peuvent être disposable. Sachant que les ref struct et les readonly ref struct sont stockées seulement sur la pile, il n’est pas possible de les faire satisfaire une interface, on ne peut donc pas implémenter IDisposable. A partir de C# 8.0, si une ref struct ou une readonly ref struct implémente une méthode publique void Dispose() alors la structure sera disposable sans avoir à rajouter explicitement : IDisposable.

Par exemple, si on considère la structure suivante:

ref struct DisposableStruct
{
  public Dispose()
  {
    Console.WriteLine("Disposed");
  }
}

En exécutant le code suivant, la méthode Dispose() est bien exécutée:

using (new DisposableStruct())
{
}

Références

Ref struct:

GC:

Managed pointers:

Span:

Stackalloc:

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

ValueTask (C# 7)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3).

A partir de C# 7.0, quelques améliorations ont été effectuées concernant les Task de façon à augmenter les performances en diminuant les allocations qui sont faites dans le tas managé.

Avant de commencer…

Les objets Task apparus avec le framework .NET 4, offrent une grande flexibilité pour l’exécution de traitement asynchrone (Utilisation des “Task” en 5 min):

On peut simplement créer une Task et attendre la fin de son traitement:

Task task = Task.Run({ 
    // ... 
    // Traitement asynchrone
}); 

// Autre traitement 
// ...
task.Wait(); // Attente de la fin du traitement

On peut récupérer le résultat si le traitement le nécessite:

Task taskWithResult = Task.Run({ 
    // ... 

    return 42; 
}); 

// ... 
int result = taskWithResult.Result; 
// ou 
int result = taskWithResult.GetAwaiter().GetResult(); 

Il est possible d’effectuer des traitements suivant la façon dont s’est déroulée l’exécution de la Task (i.e. continuation, par exemple:

Task continuationTask = task.ContinueWith(() => { 
    // Autre traitement 
}, TaskContinuationOptions.OnOnRanToCompletion); 

async/await

Avec C# 5 est apparu les mot-clés async/await pour faciliter l’implémentation de traitements asynchrones: tout ce qui se trouve après await est considéré comme une continuation, par exemple:

public static async Task ExecuteAsynchronously() 
{ 
    await Task.Run(() => { 
        // ... 
    }); 

    Console.WriteLine("Task completed"); 
} 

static void Main() 
{ 
    Task task = ExecuteAsynchronously(); 
    // ... 
    task.Wait(); 
}

ou avec la gestion de async dans la fonction Main() à partir de C# 7.1:

static async Task Main() 
{ 
    await ExecuteAsynchronously(); 
} 

La construction async/await permet de faciliter les traitements asynchrones en améliorant la syntaxe, par exemple:

public static async Task ExecuteAsynchronously() 
{ 
    Task runningTask Task.Run(() => { 
        // ... 
    }); 

    // Autres traitements concurrents effectués en même temps que runningTask. 
    await runningTask; 

    Console.WriteLine("Task completed"); 
} 

Ainsi, si le mot-clé async est présent dans une fonction mais qu’elle ne comporte pas await, un warning est généré pour indiquer qu’il n’y a pas d’utilisation de await pour indiquer une opération asynchrone non bloquante.

En revanche l’utilisation d’await dans une fonction impose l’utilisation d’async dans sa signature pour signaler la présence d’un traitement asynchrone.

Pour utiliser await avec une fonction, il faut que le retour de cette fonction soit un type “Awaitable” comme Task/Task<T> ou ValueTask/ValueTask<T>.

FromResult(), FromException() et FromCanceled()

Un statut est associé à une tâche lors de la durée de vie d’une instance, par exemple:

  • TaskStatus.Running pour une tâche en cours d’exécution,
  • TaskStatus.Faulted si une exception a été déclenchée lors de l’exécution,
  • TaskStatus.Canceled si un signal d’interruption a été lancé à partir d’un CancellationToken.
  • TaskStatus.RanToCompletion si la tâche a terminé son exécution correctement.

Il est possible de tester ces statuts d’exécution à partir de l’objet Task:

  • task.IsCanceled pour tester l’annulation,
  • task.IsCompleted pour vérifier si l’exécution est terminée
  • task.IsCompletedSuccessfully pour vérifier si l’exécution s’est terminée correctement.
  • task.IsFaulted si une exception s’est produite.

Certaines implémentations peuvent nécessiter de devoir renvoyer un statut d’exécution concernant une tâche dans le cas où l’exécution est annulée ou si une exception est survenue. A partir du framework .NET 4.6 est apparu:

  • Task.FromResult() pour créer un objet Task directement avec le résultat et avec le statut TaskStatus.RanToCompletion.
  • Task.FromException() pour créer un objet Task dont le statut est TaskStatus.Faulted.
  • Task.FromCancelled() pour créer un objet Task dont le statut est TaskStatus.Canceled.

Le but de Task.FromResult(), Task.FromException() et Task.FromCanceled() est de faciliter le respect des signatures de fonctions renvoyant un objet Task ou Task<T>.

Par exemple:

public Task GetResult(CancellationToken cancellationToken) 
{ 
    if (cancellationToken.IsCancellationRequested) 
    { 
        // Exécution annulée 
        return Task.FromCanceled(cancellationToken); 
    } 

    try 
    { 
        // Retour immédiat d’un résultat 
        return Task.FromResult(0); 
    } 
    catch (Exception exception) 
    { 
        // Retour avec une exception 
        return Task.FromException(exception); 
    } 

} 

Ces signatures de fonction renvoyant Task ou Task<T> permettent d’utiliser async/await:

public async Task GetResult(CancellationToken cancellationToken) 
{ 
    if (cancellationToken.IsCancellationRequested) 
    { 
        return await Task.FromCanceled(cancellationToken); 
    } 

    try 
    { 
        return await Task.FromResult(0); 
    } 
    catch (Exception exception) 
    { 
        return await Task.FromException(exception); 
    } 
} 

ConfigureAwait()

ConfigureAwait() peut être utilisé avec await pour indiquer quel est le contexte utilisé lors de la synchronisation de traitements asynchrones.

Contexte de synchronisation

Quand on parle de synchronisation dans le cadre de traitements asynchrones, on fait référence à la synchronisation qu’il peut être nécessaire d’effectuer entre les traitements pour accéder à une ou plusieurs ressources communes. Généralement ces ressources ne peuvent être sollicitées de façon concurrente par plusieurs traitements.

Par exemple, si on implémente une continuation qui doit être exécutée après l’exécution d’une task, il s’agit d’un cas particulier de synchronisation. En effet, la continuation ne pourra pas commencer son exécution avant que l’exécution de la task ne soit terminée.

Ainsi d’une façon générale, un contexte de synchronisation vise à indiquer tous les éléments nécessaires à la synchronisation entre plusieurs tasks. L’objet SynchronizationContext possède des éléments de contexte d’exécution sans les éléments relatifs à un thread:

  • La propriété statique SynchronizationContext.Current permet d’obtenir le contexte d’exécution du thread courant.
  • La méthode Post() permet de fournir une callback qui sera exécutée de façon asynchrone dans un contexte donné.
  • La méthode Send() permet de fournir une callback qui sera exécutée de façon synchrone dans un contexte donné.

Par exemple, dans le cas de WinForm, si on effectue un traitement asynchrone dont le résultat doit être affiché dans une TextBox, l’exécution se déroule de cette façon:

  • Lancement du traitement, par exemple, en cliquant sur un bouton dans le thread graphique (thread du control graphique permettant d’exécuter la boucle de message Win32).
  • Un traitement asynchrone est lancé dans un thread différent du thread de l’interface.
  • Quand le traitement asynchrone est terminé, le résultat doit être affecté dans la TextBox en utilisant le thread graphique et non dans le thread utilisé pour le traitement. Dans le cas contraire, on peut obtenir des erreurs du type:
    • “Cross-thread operation not valid. Control accessed from a thread other than the thread it was created on”.
    • “The calling thread cannot access this object because a different thread owns it”.
  • Pour mettre à jour le résultat dans la TextBox, il faut solliciter le thread graphique en y postant un nouveau traitement à effectuer, par exemple en exécutant:
    textBox.Invoke(new Action(() => { 
        textBox.Text = ... ; // Affectation du résultat 
    })); 
    

L’affectation du résultat dans la TextBox est donc considérée comme une continuation du traitement asynchrone. Si on utilise le contexte d’exécution à partir duquel on lance le traitement, une implémentation pourrait être:

var uiThreadContext = SynchronizationContext.Current; // Contexte du thread graphique 
Task.Run(() => { 

    // Traitement asynchrone 
    ... 
    // Affectation du résultat 

    uiThreadContext.Post(_ => { 
        textBox.Text = ... ; // Affectation du résultat en utilisant le contexte du thread graphique 
    }); 
}); 

Une implémentation différente permettrait de rendre la continuation plus évidente:

var uiThreadContext = SynchronizationContext.Current; 

Task workerTask = Task.Factory.StartNew(() => { 
    // Traitement asynchrone 
    ... 
}); 

workerTask.ContinueWith(() => { 
    uiThreadContext.Post(_ => { 
        textBox.Text = ... ;  
    }); 
}) 

TaskScheduler

L’objet SynchronizationContext est une abstraction pour permettre de lancer des traitements dans un contexte d’exécution donné. TaskScheduler est aussi une abstraction permettant de lancer des traitements sous la forme de Task. SynchronizationContext est général et TaskScheduler est plus spécifique aux tasks.

Certaines propriétés de TaskScheduler permettent de récupérer des instances particulières:

  • La propriété statique TaskScheduler.Current permet de retourner l’instance courante.
  • La propriété TaskScheduler.Default permet de retourner une instance permettant de s’interfacer avec le thread pool.
  • TaskScheduler.FromCurrentSynchronizationContext() permet de renvoyer une instance de TaskScheduler qui exécutera les tasks en utilisant SynchronizationContext.Current.

Même s’il n’est pas possible de paramètrer le TaskScheduler courant (la propriété TaskScheduler.Current ne permet pas de paramétrer une instance en entrée), il est possible de lancer une Task en indiquant un TaskScheduler particulier:

var customTaskScheduler = ... ; 
Task.Factory.StartNew(() => { ... }, default, TaskCreationOptions.None, customTaskScheduler); 

Un exemple équivalent au précédent permettrait d’affecter le résultat d’un traitement asynchrone en utilisant une continuation qui sera exécutée dans le thread graphique:

Task workerTask = Task.Factory.StartNew(() => { 
    // Traitement asynchrone 
    ... 
}); 

// La continuation est exécutée de le thread graphique: 
workerTask.ContinueWith(() => { 
    textBox.Text = ... ;  
}, TaskScheduler.FromCurrentSynchronizationContext()); 

await

La syntaxe async/await permet implicitement de gérer les cas d’une continuation. Dans le cas de l’exemple précédent, il suffirait d’écrire le code suivant pour que la continuation soit exécutée directement dans le thread de l’interface:

private async Task<TResult> AsyncProcess() { ... } 

var result = await AsyncProcess(); // Traitement asynchrone 
textBox.Text = result ; // Affectation du résultat directement dans le thead de l'interface. 

Ainsi ce qui se trouve après l’appel await est considéré comme une continuation. Cette continuation est exécutée avec le contexte de synchronisation courant (i.e. SynchronizationContext.Current) ou s’il est nul, le TaskScheduler courant (i.e. TaskScheduler.Current).

Implicitement la construction avec await capture le contexte d’exécution courant avant l’exécution de la tâche asynchrone et utilise ce contexte lors de l’exécution de la continuation.

ConfigureAwait(false)

ConfigureAwait() permet d’indiquer si on souhaite capturer le contexte d’exécution quand on utilise await:

private async Task<TResult> AsyncProcess() { ... } 

var result = await AsyncProcess().ConfigureAwait(...); 

ConfigureAwait() peut être utilisé avec les objets Task et ValueTask.

Par défaut, le contexte d’exécution est capturé, ainsi les syntaxes suivantes sont équivalentes:

var result = await AsyncProcess(); 

Et

var result = await AsyncProcess().ConfigureAwait(true); 

Pour éviter d’utiliser la contexte d’exécution d’origine lors de l’exécution de la continuation avec async/await, on peut utiliser ConfigureAwait(false). Il s’agit d’une petite optimisation pour simplifier le code généré par async/await de façon à indiquer au compilateur qu’il n’est pas nécessaire de capturer le contexte d’origine et que la continuation peut être exécutée dans un contexte différent du contexte d’origine.

Ce type d’optimisation ne peut être faite que lorsque l’utilisation du contexte d’origine n’est pas nécessaire (les mises à jour d’éléments graphiques sont exclues puisqu’elles doivent être exécutées dans le thread de l’interface).

L’intérêt principal de cette optimisation est d’améliorer sensiblement les performances puisqu’il n’est plus nécessaire d’exécuter le code des continuations dans le contexte d’origine. La dégradation en performance liée à l’ajout d’une tâche dans un autre contexte d’exécution n’est pas indispensable, la tâche pouvant, par exemple, être exécutée dans le même contexte que celui du traitement asynchrone.

Eviter des deadlocks
L’autre intérêt à utiliser ConfigurationAwait(false) est d’éviter un potentiel deadlock qui pourrait se produire dans certains cas d’utilisation d’async/await.

Certains contextes n’autorisent l’exécution que d’un seul thread à la fois: le thread graphique par exemple ou le contexte d’une requête ASP.NET MVC.

Par exemple, si on effectue un appel asynchrone avec await et qu’on attends le résultat de cet appel avec Wait(), Result ou GetAwaiter().GetResult():

private async Task<string> AsyncProcess() { ... }  

private async Task<string> GetResultFromAsyncProcess()  
{ 
    string result = await AsyncProcess(); 
    return $"Result is: {result}"; // Continuation exécutée dans le contexte d'origine 
} 

Task<string> processResult = GetResultFromAsyncProcess(); 
textBox.Text = processResult.Result; // Appel bloquant 

Dans le cas où ce traitement est effectué et qu’un seul thread ne peut être exécuté à la fois:

  1. La fonction GetResultFromAsyncProcess() est lancée dans le contexte d’origine.
  2. Le traitement de AsyncProcess() est lancé, toutefois il n’est pas terminé et la méthode renvoie une Task dont l’exécution n’est pas terminée.
  3. L’appel de .Result est bloquant et le thread du contexte d’origine est bloqué.
  4. La Task lancée par AsyncProcess() toutefois elle n’est pas terminée.
  5. La continuation après l’appel await est prête à être exécutée et attends que le contexte d’exécution soit disponible pour que la Task soit exécutée dans ce contexte.
  6. Un deadlock peut se produire car le thread du contexte d’origine est bloqué en attendant le résultat avec .Result et la continuation attends que le contexte soit disponible pour être exécuté.

Ce problème survient car le contexte d’exécution est le même entre l’appel d’origine et l’exécution de la continuation. Pour éviter le deadlock, une solution consiste à ne pas capturer le contexte d’origine lors de l’appel au traitement asynchrone. Les exécutions peuvent, ainsi, être exécutées dans des contextes différents:

private async Task<string> GetResultFromAsyncProcess()  
{ 
    string result = await AsyncProcess().ConfigureAwait(false); 
    return $"Result is: {result}";  
} 

ValueTask

C# 7.0 / .NET Core 2.0

L’inconvénient majeur à utiliser des fonctions renvoyant des instances des objets de type Task ou Task<T> est qu’elles nécessitent des allocations en mémoire. Dans le cas où des appels fréquents sont faits nécessitant le retour d’objets de type Task et sachant que Task est un objet de type référence, le coût en performance provoqué par les allocations dans le tas managé peut être significatif.

A partir de C# 7.0, l’objet ValueTask a été introduit de façon à éviter ces allocations dans le tas managé. ValueTask est un objet de type valeur et il est alloué sur la pile ce qui réduit le coût en performance à utiliser Task.

Pour utiliser l’objet ValueTask, il faut installer le package NuGet System.Threading.Tasks.Extensions dans le cas du framework .NET (ce n’est pas nécessaire avec .NET Core).

Compatibilité avec async/await

ValueTask est compatible avec async/await. Pour que la construction async/await soit possible, il faut que l’objet se trouvant après await possède la fonction:

public class AwaitableClass 
{ 
    public Awaiter GetAwaiter() { ... } 
} 

L’objet Awaiter doit satisfaire l’interface INotifyCompletion:

public class Awaiter: INotifyCompletion 
{ 
    public void GetResult() { ... } 
    public bool IsCompleted { get; } 
    public void OnCompleted(Action continuation) { ... } 
} 

C’est le cas pour l’objet ValueTask: ValueTask.GetAwaiter() renvoie un objet de type ValueTaskAwaiter qui satisfait INotifyCompletion. Symétriquement par rapport à Task.GetAwaiter() qui retourne TaskAwaiter.

Ainsi de la même façon que pour Task, il est possible d’utiliser une construction async/await avec ValueTask, par exemple:

public async ValueTask<int> GetResult() { ... } 

On peut effectuer des appels:

int result = await GetResult(); 

Il est possible d’utiliser ConfigureAwait() pour ne pas capturer le contexte d’exécution:

int result = await GetResult().ConfigureAwait(false); 

Il est ainsi possible d’utiliser des constructions impliquant simultanément les types Task/Task<T> ou ValueTask/ValueTask<T>:

public async ValueTask GetResultAsync(CancellationToken cancellationToken) 
{ 
    if (cancellationToken.IsCancellationRequested) 
    { 
        // Exécution annulée 
        return await Task.FromCanceled(cancellationToken); 
    } 

    try 
    { 
        // Retour immédiat d’un résultat 
        return await new ValueTask(0); 
    } 
    catch (Exception exception) 
    { 
        // Retour avec une exception 
        return await Task.FromException(exception); 
    } 

} 

ValueTask ne convient pas à tous les usages

ValueTask ne remplace pas les objets Task. ValueTask est plus approprié dans certains cas d’utilisation précis mais il ne couvre pas tous les usages de Task. Comme on l’a indiqué, le but de ValueTask est d’éviter d’allouer beaucoup d’objets dans le tas managé dans le cas où des appels à une méthode effectuant des tâches asynchrones seraient fréquents. Toutefois ValueTask est un objet de type valeur qui, dans certains cas, peut s’avérer moins performant à manipuler qu’un objet de type référence.

Par exemple, si le type T de retour dans une fonction renvoyant ValueTask est particulièrement volumineux, chaque retour de fonction va occasionner une copie par valeur de l’objet ValueTask (car ValueTask est un objet de type valeur). Cette copie peut être moins performante que si on utilisait Task. Il faut donc comparer les performances d’exécution en utilisant ValueTask et Task pour être sûr d’utiliser l’objet le plus approprié.

Cas synchrone

Le cas d’utilisation le plus simple est le cas synchrone c’est-à-dire que l’asynchronisme n’est pas nécessaire et un résultat peut être renvoyé dans l’immédiat. Ce cas de figure est semblable à l’utilisation de Task.FromResult(). On peut utiliser directement le constructeur:

T result = ...; 
return new ValueTask(result); 

Dans les cas plus rares d’une exception ou d’une tâche annulée, il est possible de créer un objet ValueTask à partir d’une Task:

new ValueTask(Task.FromCancelled(cancellationToken)); 

ou

new ValueTask(Task.FromException(exception)); 

Cas asynchrone

Dans le cas asynchrone, plusieurs cas d’utilisation ne sont pas possibles ou proscrits avec ValueTask. Ainsi d’une façon générale, un objet ValueTask:

  • Ne peut être appelé qu’une seule fois avec await.
  • Ne peut pas être appelé de façon concurrente.
  • Utiliser ValueTask.GetAwaiter().GetResult() n’est pas bloquant contrairement à Task. Utiliser ValueTask.GetAwaiter().GetResult() dans le cas où l’exécution n’est pas terminé peut conduire à des comportements imprévus.

Dans le cas où on est confronté à ces cas de figure, il faut:

  • Privilégier l’utilisation des objets Task
  • Utiliser la fonction ValueTask.AsTask() pour extraire un objet Task de la ValueTask.

Tous ces cas d’utilisation peuvent réduire grandement l’intérêt ValueTask.

Pour éviter les mauvaises utilisations des objets ValueTask en retour de fonction, il est préférable de l’utiliser directement avec await sans stocker le retour d’une fonction async dans une variable, par exemple si on considère la fonction:

public ValueTask GetAsyncResult() 
{ 
    // ... 
} 

Il faut privilégier les utilisations avec await:

int result = await GetAsyncResult(); 

Stocker le retour dans une variable peut inciter à implémenter des cas d’utilisations à proscrire avec ValueTask, par exemple:

ValueTask resultValueTask = GetAsyncResult(); 

// Plusieurs appels avec await: 
int result1 = await resultValueTask; 
int result2 = await resultValueTask; // A EVITER 
 
// Appels concurrents 
Task.Run(async () => await resultValueTask); 
Task.Run(async () => await resultValueTask); // A EVITER 

// Utiliser GetAwaiter().GetResult() sur une exécution non terminée 
int result = resultValueTask.GetAwaiter().GetResult(); // A EVITER 

Dans le cas asynchrone, toutes ces restrictions et le fait que ValueTask est manipulé le plus souvent par copie (étant un objet de type valeur), peuvent rendre ce type d’objet moins performant que Task. Ainsi on pourrait de demander l’intérêt à utiliser ValueTask dans le cas asynchrone. Cet intérêt se trouve principalement dans la possibilité d’utiliser des objets satisfaisant IValueTaskSource.

IValueTaskSource

C# 7.0 / .NET Core 2.1

L’intérêt principal de ValueTask est de pouvoir l’utiliser avec des objets satisfaisant l’interface IValueTaskSource. Cette possibilité est apparue à partir de .NET Core 2.1 (dans le cas du framework .NET, il suffit d’utiliser le package System.Threading.Tasks.Extensions (comme indiqué plus haut).

La prise en charge de IValueTaskSource par le constructeur de ValueTask n’est pas, à proprement parlé, une innovation de C# 7. Cette évolution est apportée par .NET Core 2.1 ou par l’utilisation du package System.ThreadingTask.Extensions (qui est compatible à partir du framework .NET 4.5).

Il est ainsi possible de construire une instance de ValueTask en utilisant le constructeur:

IValueTaskSource valueTaskSource = ...; 
int token = ...; 
ValueTask valueTask = new ValueTask(valueTaskSource, token); 

IValueTaskSource permet d’ajouter une abstraction pour permettre de gérer l’exécution d’une tâche asynchrone en séparant le comportement de la tâche avec l’obtiention de son résultat. IValueTaskSource se présente de cette façon:

public interface IValueTaskSource 
{ 
    T GetResult(short token); 
    ValueTaskSourceStatus GetStatus(short token); 
    void OnCompleted(Action) continuation, objet state, short token, ValueTaskSourceOnCompletedFlags flags); 
} 

avec:

  • L’argument token est utilisé pour identifier le traitement dans le cas où plusieurs traitements sont effectués de façon concurrente.
  • T GetResult(short token): la fonction permettant d’obtenir le résultat du traitement (le token permet d’identifier le traitement).
  • ValueTaskSourceStatus GetStatus(short token): renvoie le statut d’exécution d’un traitement (identifié grâce au token). Les statuts possibles sont Pending, Succeeded, Faulted ou Canceled.
  • void OnCompleted(Action) continuation, objet state, short token, ValueTaskSourceOnCompletedFlags flags) permet d’exécuter une continuation faisait suite à l’exécution d’un traitement identifié par un token. Cette fonction ne doit qu’exécuter la continuation suivant le résultat du traitement.

Pour utiliser ValueTask dans le cas asynchrone, il faut donc implémenter une classe satisfaisant IValueTaskSource. L’intérêt est d’utiliser une seule instance d’un objet IValueTaskSource pour exécuter une série de traitements asynchrones. Le résultat est renvoyé sous la forme ValueTask après avoir instancié un objet de ce type avec le constructeur permettant d’utiliser une instance IValueTaskSource. Tout ce mécanisme permet de diminuer le nombre d’allocations effectuées dans le tas managé (puisque IValueTaskSource est instancié une seule fois et ValueTask est un objet de type valeur instancié sur la pile).

On peut, ainsi, renvoyer plusieurs fois un résultat en utilisant async/await pour des traitements effectués de façon concurrente:

public ValueTask RunAsync() 
{ 
    // ... 
    return new ValueTask(this.valueTaskSource, token); 
} 

T result = await RunAsync(); 

On remarque qu’une nouvelle instance de ValueTask est créée même si plusieurs appels sont faits à RunAsync(). Ainsi même si il n’est pas conseillé d’effectuer plusieurs appels ou des appels concurrents à une même instance de ValueTask, il est possible de créer une nouvelle instance pour chaque utilisation. Comme ValueTask est alloué sur la pile et non dans le tas managé, le Garbage Collector n’est pas sollicité dans le cas où des appels sont effectués de façon très répétitive.

Ainsi l’objet satisfaisant IValueTaskSource est instancié une fois et ajouté au pool de tâche. A chaque exécution asynchrone d’un traitement dans cet objet, un objet ValueTask est créé et retourné en utilisant await. Ainsi chaque exécution du traitement rajoute un objet ValueTask au pool correspondant à un traitement asynchrone. Le paramètre token fourni en paramètre du constructeur de ValueTask permet de différencier chaque exécution d’un traitement qui sera rajouté au pool (chacun de ces traitements créant une instance différente de ValueTask):

public class AwaitableRecurringAsyncProcess : IValueTaskSource 
{ 
    public ValueTask LaunchFirstProcess() { ... } 

    public ValueTask LaunchSecondProcess() { ... } 
 
    // ... 
} 

L’implémentation d’un objet satisfaisant IValueTaskSource n’est pas triviale et expose à une complexité en terme de traitement parallèle. Le but de ValueTask dans ce cadre n’est pas d’éviter cette complexité. En revanche, le pattern async/await associé à ValueTask permet d’apporter une solution technique pour éviter d’instancier trop d’objets sur le tas managé dans le cas où les traitements sont répétitifs et doivent être exécuté de façon optimale.

Plusieurs implémentations d’objets satisfaisant IValueTaskSource ont été faite pour .NET Core 2.1 dans le cadre de communication par socket avec AwaitableSocketAsyncEventArgs:

internal sealed class AwaitableSocketAsyncEventArgs: SocketAsyncEventArgs, IValueTaskSource, IValueTaskSource<int> 
{ 
    public ValueTask<int> ReceiveAsync(Socket socket, CancellationToken cancellationToken) 
    { ... } 

    public ValueTask<int> SendAsync(Socket socket, CancellationToken cancellationToken) 
    { ... } 
} 

Dans cet exemple, tant que la socket n’est pas fermée et que IValueTaskSource.GetStatus() ne retourne pas ValueTaskSourceStatus.Succeeded, plusieurs appels peuvent être faits pour envoyer et recevoir des paquets de façon asynchrone en utilisant SendAsync() et ReceiveAsync().

D’autres cas d’utilisation peuvent servir d’exemples pour implémenter un objet satisfaisant IValueTaskSource:

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Pattern matching (C# 7, C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3) et C# 8.0.

A partir de C# 7.0, quelques notions de programmation fonctionnelle sont introduites. Dans la documentation, même si l’expression “pattern matching” (i.e. “filtre avec motif”) est utilisée pour qualifier ces notions, il ne s’agit que de quelques améliorations pour simplifier certains éléments de syntaxe. Il n’y a pas, à proprement parlé, d’introduction de nouveaux concepts, il s’agit juste de raccourcis pour simplifier la syntaxe.

Ces notions de pattern matching peuvent être mises en place avec les mot-clés is et switch...case, elles permettent d’implémenter un filtre dans lequel on pourra indiquer des conditions. Suivant si une condition est vraie, une action spécifique pourra être exécutée.

Pour la suite, on considère les classes suivantes:

abstract class Vehicle  
{  
  public string Name;
  public abstract int GetWheelCount();  
}  


class MotoBike : Vehicle  
{  
  public int Power => 100;  
  
  public override int GetWheelCount()  
  {  
    return 2;  
  }
}  

class Car : Vehicle  
{  
  public int PassengerCount => 4;  
  
  public override int GetWheelCount()  
  {  
    return 4;  
  }  
}  

Avec is

C# 7.0

L’opérateur is permet de tester une expression pour savoir si elle satisfait une condition particulière. Chaque type de condition correspond à un motif (i.e. pattern). En C# 7.0, les motifs possibles sont:

  • Null pattern: test par rapport à une valeur nulle, par exemple:
    Vehicle vehicle = new Car();  
    if (vehicle is null)  
      Console.WriteLine($"{nameof(vehicle)} is null.");  
    else  
      Console.WriteLine($"{nameof(vehicle)} is not null.");  
    

    Cette fonctionnalité est disponible à partir de C# 7.0.

  • Constant pattern: test en comparant par rapport à une constante, par exemple:
    object carAsObj = new Car();  
    if (carAsObj is "45")  
      Console.WriteLine($"{nameof(carAsObj)} is 45.");  
    else  
      Console.WriteLine($"{nameof(carAsObj)} is not 45.");  
    

    Cette fonctionnalité est disponible à partir de C# 7.0.

  • Type pattern: l’expression est testée suivant un type particulier (possible avant C# 7.0):
    Vehicle vehicle = new Car();  
    if (vehicle is Car)  
      Console.WriteLine($"{nameof(vehicle)} is a car.");  
    else if (vehicle is Motobike)  
      Console.WriteLine($"{nameof(vehicle)} is a motobike.");  
    else  
      Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    

Dans le cas où on utilise is pour tester le type d’une expression, il est possible de combiner is et as en une seule ligne pour simplifier la syntaxe. Pour remplacer les 2 lignes:

<expression à tester> is <type voulu>  
var <variable typée> = <variable> as <type voulu>  

On peut simplifier la syntaxe en écrivant:

<expression à tester> is <type voulu> <nom variable>  

Par exemple, si on considère le code suivant:

Vehicle vehicle = new Car();  
if (vehicle is Car)  
{  
  var car = vehicle as Car;
  Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassangerCount} passagers.");  
}  
else if (vehicle is Motobike)  
{  
  var motobike = vehicle as Motobike;
  Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
}  
else  
  Console.WriteLine($"{nameof(vehicle)} has not been identified.");  

La syntaxe peut être simplifier:

if (vehicle is Car car)  
  Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassangerCount} passagers.");  
else if (vehicle is Motobike motobike)  
  Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
else  
  Console.WriteLine($"{nameof(vehicle)} has not been identified.");  

Avec switch

Switch…case

C# 7.0

L’intérêt du pattern matching est de pouvoir simplifier la syntaxe en utilisant les apports de is dans une clause switch...case. Le type de condition applicable à is sont les mêmes pour switch...case. En une seule ligne, on peut tester les motifs suivant:

  • Si une expression est nulle (i.e. null pattern),
  • Si une expression est constante (i.e. constant pattern) et
  • Si une expression correspond à un type particulier (i.e. type pattern).

Si on prend l’exemple précédent, le code équivalent en utilisant switch...case pourrait être:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
  case Car car: // Type pattern
    Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} passagers.");  
    break;  
  case Motobike motobike:  // Type pattern
    Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    break;  
}  

D’autres conditions peuvent être utilisées notamment en testant la nullité ou l’égalité à une constante:

object carAsObj = new Car();  
switch (casAsObj)  
{  
  case null:  // Null pattern
    Console.WriteLine("Is null");  
    break;  
  case "45":  // Constant pattern 
    Console.WriteLine("Is a constant, not a vehicle.");  
    break;  
  case Car car:  // Type pattern
    Console.WriteLine($"{nameof(carAsObj)} is a car with {car.PassengerCount} passagers.");  
    break;  
  case Motobike motobike:  // Type pattern
    Console.WriteLine($"{nameof(carAsObj)} is a motobike of {motobike.Power} horsepower.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(carAsObj)} has not been identified.");  
    break;  
}  

when avec switch…case

C# 7.0

Au-delà des tests sur la nullité, un type ou l’égalité par rapport à une constante, il est possible de tester d’autres conditions en utilisant le mot-clé when.

Par exemple, si on souhaite ajouter des conditions quand un objet est de type Car:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
  case Car car when car.PassengerCount < 1:  
    Console.WriteLine($"{nameof(vehicle)} is an empty car.");  
    break;  
  case Car car when car.PassengerCount > 3 && car.PassengerCount <= 5:  
    Console.WriteLine($"{nameof(vehicle)} is a fully loaded car.");  
    break;  
  case Car car when car.PassengerCount > 8:  
    Console.WriteLine($"{nameof(vehicle)} is a heavy loaded car.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    break;  
}  

Dans le cas où l’ordre des conditions ne permet pas à certains cas d’être atteint, une erreur est émise à la compilation.

Par exemple:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
  case Car car: // Provoque une erreur de compilation  
    Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} passagers.");  
    break;  
  case Car car when car.PassengerCount < 1:  
    Console.WriteLine($"{nameof(vehicle)} is an empty car.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    break;  
}  

Dans ce cas, la condition when car.PassengerCount < 1 n’est jamais atteinte car elle est occultée par la ligne case Car car.

Toutefois dans certains cas, du code peut ne jamais être atteint et aucune erreur de compilation ne sera générée, par exemple:

switch (vehicle)  
{  
  case Car car when car.PassengerCount > 1:  
    Console.WriteLine($"{nameof(vehicle)} with {car.PassengerCount} passenger(s).");  
    break;  
  case Car car when car.PassengerCount > 3: // ce code ne sera jamais atteinte  
    Console.WriteLine($"{nameof(vehicle)} is full.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    break;  
}  

La condition when car.PassengerCount > 1 s’applique avant when car.PassengerCount > 3 donc cette partie du code ne sera jamais atteinte.

var pattern avec is ou switch…case

C# 7.0

var pattern est un filtre utilisable à partir de C# 7.0 avec is et switch...case. La syntaxe générale avec is est:

<expression> is var <nom de la variable> 

Cette syntaxe correspond, d’une part à une condition appliquée à <expression> et d’autre part, elle permet de créer une variable contenant le résultat d’une expression.

La condition est toujours vraie même si le résultat de l’expression testée est nulle. Le type de la variable correspond au type de l’expression et si l’expression est nulle alors la variable sera nulle.

L’intérêt de cette construction est de créer une variable temporaire qui pourra servir pour d’autres traitements, par exemple:

List<Vehicle> vehicles = new List<Vehicle>{ new Car() }; 
if (vehicles.FirstOrDefault(v => v.GetWheelCount() > 3) is var bigVehicle) 
{ 
  if (bigVehicle.GetWheelCount() == 4) 
    Console.WriteLine("The vehicle is a car"); 
  else if (bigVehicle.GetWheelCount() == 6) 
    Console.WriteLine("The vehicle is a little truck"); 
  else if (bigVehicle.GetWheelCount() > 6) 
    Console.WriteLine("The vehicle is a big truck"); 
} 

Dans cet exemple, la ligne vehicles.FirstOrDefault(...) is var bigVehicle permet de créer la variable bigVehicle qui pourra être utilisée dans la clause if.

De la même façon que pour les autres types de motifs, le motif var peut être utilisé avec switch...case, par exemple:

switch(vehicles.FirstOrDefault(v => v.GetWheelCount() > 3) 
{ 
  case null: 
    Console.WriteLine("No big vehicle round"); 
    break; 
  case var car when car.GetWheelCount() == 4: 
    Console.WriteLine("The vehicle is a little truck"); 
    break; 
  case var truck when truck.GetWheelCount() == 6: 
    Console.WriteLine("The vehicle is a little truck"); 
    break; 
  case var bigTruck when bigTruck.GetWheelCount() > 6: 
    Console.WriteLine("The vehicle is a big truck"); 
    break; 
} 

Expression switch

C# 8.0

A partir de C# 8.0, switch peut être utilisé dans une expression avec une syntaxe équivalente à switch...case et plus concise.

Une expression est une instruction dont l’évaluation permet d’obtenir une valeur qui peut être assignable à une variable:

var <variable assignée> = <expression>;

switch sous forme d’expression est utilisé avec une variable et doit retourner une valeur assignable.

La forme générale est:

<variable assignée> = <variable> switch
{
  <condition 1> => <expression 1>,
  <condition 2> => <expression 2>,
  // ...
};

Cette forme est équivalente à:

switch (<variable>)
{
  case <condition 1>:
    <variable assignée> = <expression 1>;
    break;
  case <condition 2>:
    <variable assignée> = <expression 2>;
    break;
  // ...
}

Ainsi, les conditions sont appliquées à l’objet <variable> et le résultat des expressions est affecté à l’objet <variable assignée>.

Par exemple:

Vehicle vehicle = new Car{ Name = "Car1"  };
string text = vehicle switch
{
  Car car => $"The vehicle is a car: {car.Name}",
  Motobike moto => $"The vehicle is a motobike: {moto.Name}",
  null => "No vehicle", // null pattern
  _ => throw new InvalidOperationException("Vehicle is unknown"), // default case
};

Dans cet exemple:

  • null => ... correspond au null pattern, il est activé quand la variable est nulle.
  • _ => ... correspond au discard pattern, il est appliqué par défaut quand toutes les autres lignes ne peuvent pas s’appliquer (discard pattern).

Cette syntaxe est équivalente à:

switch(vehicle)
{
  case Car car:
    text = $"The vehicle is a car: {car.Name}";
    break;
  case Motobike moto:
    text = $"The vehicle is a motobike: {moto.Name}";
    break;
  case null:
    text = "No vehicle";
    break;
  default:
    throw new InvalidOperationException("Vehicle is unknown");
}

Discard pattern

Le motif discard (i.e. discard pattern) correspond au cas par défaut (équivalent à default dans la syntaxe switch...case), par exemple:

_ => <expression>,

Ce pattern peut aussi s’appliquer dans le cas d’un cast de type (cf. type pattern) et si on ne veut pas utiliser la variable après le cast, par exemple:

string text = vehicle switch
{
  Car _ => "The vehicle is a car", // discard pattern
  Motobike _ => "The vehicle is a motobike", // discard pattern
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};

when avec une expression switch

On peut aussi utiliser when avec une expression switch, par exemple:

string text = vehicle switch
{
  Car car when string.IsNullOrEmpty(car.Name) => $"The vehicle is a car",
  Car car when car.Name.Equals("Car1") => $"The vehicle is the first car",
  Car car => $"The vehicle is a car: {car.Name}",
  _ => throw new InvalidOperationException("Vehicle is unknown"), // default case
};

var pattern

Le motif var (i.e. var pattern) peut aussi s’appliquer à l’expression switch. Ce pattern s’applique quelque soit le type de la variable (c’est-à-dire que la condition est toujours vraie), par exemple:

string text = vehicle switch
{
  Car car => $"The vehicle is a car: {car.Name}",
  Motobike moto => $"The vehicle is a motobike: {moto.Name}",
  null => "No vehicle", // null pattern
  var unknownType => "The vehicle type is unknown",  // var pattern
  // _ => throw new InvalidOperationException("Vehicle is unknown"), Unreachable code
};

Si on place la ligne correspondant au motif var avant les autres, elle sera appliquée en priorité par rapport aux autres. Une erreur de compilation sera générée car les lignes après le motif var ne sont pas accessibles.

Tuple pattern

Dans le cas où on applique l’expression switch avec un tuple, il est possible d’appliquer des conditions aux différentes valeurs du tuple, par exemple:

(int valueAsInt, string valueAsString, float valueAsFloat) tuple = (5, "5", 5f);
string result = tuple switch
{
  (5, "5", 5f) => "All values are 5",
  (6, "5", 5f) => "Int is 6",
  (7, "7", 7f) => "All values are 7",
  (_, _, _) => "No matches", // Cas par défaut 
};

Cette syntaxe permet de tester tous les éléments du tuple en appliquant une condition sur chaque élément.

Positional pattern

Le motif positional (i.e. positional pattern) permet d’appliquer des conditions sur les éléments d’un tuple en prenant en compte la position de chaque élément dans le tuple. Au lieu d’indiquer une condition précise pour chaque élément (comme pour le tuple pattern), on peut créer un nouveau tuple et appliquer des conditions sur un ou plusieurs éléments.

Par exemple:

(int valueAsInt, string valueAsString, float valueAsFloat) tuple = (5, "5", 5.0f);
string result = tuple switch
{
  (5, "5", 5.0f) => "All values are equal",  // la condition porte sur tous les éléments
  (5, _, _) => "Ints are equal",             // la condition porte seulement sur le 1er élément
  (_, "5", _) => "Strings are equal",        // la condition porte seulement sur le 2e élément
  (_, _, 5.0f) => "Floats are equal",        // la condition porte seulement sur le 3e élément
  (_, _, _) => "No matches",                 // cas par défaut
};

On peut créer un nouveau tuple pour l’utiliser dans l’expression et appliquer une condition avec when:

var tuple = (5, "6", 6f);
string result = tuple switch
{
  (5, "5", 5f) => "All values are equal",
  (5, _, _) tupleWithSameInt => 
      $"Ints are equal (string values are {tupleWithSameInt.Item2})", // Utilisation du nouveau tuple dans l’expression
  (_, _, _) matchingTuple when matchingTuple.Item1 == 5 && matchingTuple.Item2 == "6" => 
      "Ints and strings are equal", // Utilisation du nouveau tuple avec une condition when
  (_, _, _) => "No matches",
};

Quelques détails sur les conditions utilisées:

  • (5, _, _) tupleWithSameInt: la condition porte seulement sur le 1er élément qui doit être égal à 5. Le tuple tupleWithSameInt est instancié et utilisable dans le reste de la condition si on utilise when ou dans l’expression.
  • On peut créer un nouveau tuple contenant des éléments dont les noms sont différents du tuple d’origine.
    Par exemple, si on utilise la condition (var x, var y, var z) => $"{x} {y} {z}", on crée un tuple dont les éléments sont nommés x, y, z qui sont utilisable dans une condition when et dans l’expression.
  • On peut utiliser le caractère discard (i.e. _) si on crée un nouveau type de tuple.
    Par exemple, avec la condition (var x, _, _) => $"{x}". Le caractère _ permet d’ignorer les autres éléments.
  • Il n’est pas possible d’utiliser une condition avec un tuple dont le nombre d’éléments n’est pas égal à celui du tuple d’origine.
    Par exemple, la condition (var x, var y) => ... provoque une erreur de compilation.

Le motif positional ne s’applique pas seulement au tuple, il peut s’appliquer sur des objets quelconques si ces derniers peuvent être déconstruits en tuple (avec une méthode Deconstruct()).

Par exemple, si on considère la classe suivante:

public class Car: Vehicle
{
  public string Name;
  public int PassengerCount;

  public Car(string name, int passengerCount)
  {
    this.Name = name;
    this.PassengerCount = passsengerCount;
  }

  public void Deconstruct(out string name, out int passengerCount)
  {
    name = this.Name;
    passengerCount = this.PassengerCount;
  }
}

On peut utiliser cet objet de cette façon:

var car = new Car("Berline", 4);
var (name, passengerCount) = car;

La déconstruction peut servir pour appliquer des conditions à appliquer sur l’objet à tester en utilisant la position des éléments du tuple obtenu après déconstruction, par exemple:

Car unknownCar = ...;
bool isBigCar = unknownCar switch
{
  (_, var seatCount) when seatCount >= 4 => true, // déconstruction et utilisation de passengerCount seulement
  (var carName, _) when carName == "4WD"  => true, // déconstruction et utilisation de name seulement
  (var carName, var seatCount) => false, // Instanciation d’un tuple avec de nouveaux noms d’éléments
};

Dans le cas précédent, les conditions créent un tuple pour lequel on indique des noms d’élément particulier.

Property pattern

La motif property (i.e. property pattern) est une version plus générale du positional pattern. Il permet d’appliquer des conditions sur des propriétés d’un objet (la déconstruction n’est pas nécessaire car on applique les conditions sur un nouvel objet du même type que l’objet d’origine).

Par exemple si on considère les classes suivantes:

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

  public Vehicle(string name, int passengerCount)
  {
    this.Name = name;
    this.PassengerCount = passsengerCount;
  }
}

public class Car: Vehicle
{
  public int Power;

  public Vehicle(string name, int passengerCount, int power):
    base(name, passengerCount)
  {
    this.Power = power;
  }
}

public class Motobike: Vehicle
{
  public int Displacement;

  public Motobike(string name, int passengerCount, int displacement):
    base(name, passengerCount)
  {
    this.Displacement = displacement;
  }
}

On peut appliquer des conditions sur les propriétés de l’objet:

Vehicle vehicle = new Car("Berline", 4);
string result = vehicle switch
{
  Car { Name: "4WD" } => "Vehicle is a 4WD",
  Car { Name: "4WD", PassengerCount: 4 } => "Vehicle is a 4WD with 4 passengers",
  Car { Name: "Berline" } berline when berline.PassengerCount > 4 => 
      $"The car is a berline with {berline.PassengerCount} passengers.", // Utilisation d’une condition avec when
  Car { Name: "Berline" } berline => 
      $"The car is a berline with  {berline.PassengerCount} passengers.", // Utilisation d’une nouvelle variable
  _ => "No matches" // cas par défaut
};

Le détail de la syntaxe des conditions est:

  • Car { Name: "4WD" }: cette propriété permet de tester si l’objet vehicle est de type Car et si la propriété Name contient la valeur "4WD".
  • Car { Name: "4WD", PassengerCount: 4 }: cette condition permet de vérifier une condition sur la propriété Name et sur la propriété PassengerCount.
  • Car { Name: "Berline" } berline when berline.PassengerCount > 4: en plus de vérifier une condition d’égalité sur la propriété Name, une condition est vérifiée sur la propriété PassengerCount avec la syntaxe when.
  • La variable berline créée peut servir dans la condition when et dans l’expression.

Objets de type valeur

Certaines implémentations peuvent dégrader les performances si on utilise le pattern matching avec des objets de type valeur. Dans le cas où le code est exécuté fréquemment, il convient d’éviter ces constructions.

Par exemple, comparer un objet de type valeur avec une constante avec l’opérateur is occasionne du boxing:

int number = 6; 
if (number is 42) // Boxing 
{ ... } 

Cette implémentation occasionne 2 cas de boxing: pour la constante et pour la variable number.

Il n’y a pas de boxing si on utilise une construction similaire avec switch...case:

int number = 6; 
switch(number) 
{ 
  case 42: // Pas de boxing 
  ... 
  break 
} 

En revanche si on utilise variable de type object, il peut y avoir de l’unboxing:

object number = 6; // Boxing 
switch(number) 
{ 
  case 42: // Unboxing 
  ... 
  break 
} 

Si on utilise une variable de type int sans passer par une variable de type object, il n’y a pas d’unboxing:

int number = 5; 
// ... 
switch (number) 
{ 
  case 42: 
    Console.WriteLine("OK"); 
    break; 
  case int positiveInt when positiveInt > 0: 
    Console.WriteLine("Positive number"); 
    break; 
  case int negativeInt when negativeInt < 0: 
    Console.WriteLine("Negative number"); 
    break; 
  case int nullInt when nullInt == 0: 
    Console.WriteLine("Number is null"); 
    break; 
} 

Support des génériques

C# 7.1

Le pattern matching supporte les génériques à partir de C# 7.1.

Par exemple, si on considère les classes suivantes:

abstract class Vehicle 
{ 
  public int PassengerCount  { get; set; } 
} 

class Car : Vehicle 
{ } 

class MotoBike : Vehicle 
{ } 

Des conditions du pattern matching peuvent s’appliquer sur le type générique, par exemple:

public void DisplayVehicleDetail<TVehicle>(TVehicle vehicle)  
where TVehicle: Vehicle 
{ 
  switch (vehicle) 
  { 
    case Car car: 
      Console.WriteLine($"Vehicle is a car with {car.PassengerCount} passengers."); 
      break; 
    case MotoBike moto: 
      Console.WriteLine($"Vehicle is a moto."); 
      break; 
    default: 
      Console.WriteLine($"Vehicle has not been identified."); 
      break;
  } 
} 

Point de vue d’architecture

Quand on utilise la programmation orientée objet comme on le fait en C#, une pratique courante est de tenter de généraliser des traitements pour en déduire une abstraction pour utiliser cette abstraction dans une classe parente. Les traitements plus spécifiques pourront être implémentés dans des classes enfants qui dérivent de la classe parente.

L’intérêt de cette abstraction est d’identifier les comportements similaires, d’en déduire une implémentation générale dans le but d’éviter des duplications des comportements et du code. Quand on instancie un objet enfant et qu’on exécute un traitement, l’implémentation de la classe va ainsi effectuer une partie de traitement en s’appuyant sur l’implémentation générique de la classe parente, et une autre partie sur l’implémentation spécifique à la classe enfant suivant les règles d’héritage et d’encapsulation. Du point de vue externe à la classe, on peut considérer:

  • Le type correspondant à la classe enfant et accéder aux fonctions spécifiques à cette classe enfant.
  • Le type de la classe parente et ainsi accéder seulement aux fonctions génériques non spécialisées.

Par exemple, si on considère les classes suivantes:

abstract class Vehicle 
{ 
  public void MoveForward() { … } 

  public void MoveReverse() { … } 

  public abstract void AddPassenger(); 
} 

class Car : Vehicle 
{ 
  public override void AddPassenger() { ... }  

  public void PutInTrunk() { ... }  
} 

class MotoBike : Vehicle 
{ 
  public override void AddPassenger() { ... }  

  public bool IsHelmetAvailable() { ... }  
} 

Dans cet exemple, le type Vehicle permet d’appeler de l’extérieur:

  • Des fonctions génériques comme MoveForward() ou MoveReverse().
  • Des fonctions dont l’implémentation est spécifique comme AddPassenger().

Vu de l’extérieur à l’objet, suivant le type instancié, on ne peut considérer que le type parent ou le type enfant. Il devient plus difficile de considérer les 2 types à la fois:

  • Effectuer un traitement sur le type de la classe parente et
  • Appliquer des comportements spécifiques suivant le type précis de l’objet.

Ainsi, dans le cas de l’exemple:

  • Soit on considère le type Vehicle:
    Vehicle vehicle = new Car();
    

    Exposer le type Vehicle permet d’éviter d’exposer la complexité de l’implémentation toutefois on ne peut pas accéder aux fonctions spécifiques au type Car.

  • Soit on considère directement le type précis de la classe:
    Car car = new Car(); 
    

    On perd l’intérêt d’avoir créé un type générique car on expose un type trop précis.

Les solutions à ce problème pourraient être:

  • D’effectuer des “casts” pour avoir les types précis et accéder aux fonctions spécifiques:
    Car car = vehicle as Car;  
    car.PutInTrunk(); 
    

    ou

    MotoBike moto = vehicle as MotoBike; 
    moto.IsHelmetAvailable(); 
    
  • Une autre possibilité est d’utiliser le pattern Visiteur:

Le pattern matching offre une 3e solution dont l’avantage est de présenter le code de façon plus synthétique:

switch (vehicle) 
{ 
  case Car car: 
    car.AddPassenger(); 
    car.MoveForward(); 
    break; 
  case MotoBike moto when moto.IsHelmetAvailable(): 
    moto.AddPassenger(); 
    car.MoveForward(); 
    break; 
  default: 
    vehicle.MoveReverse(); 
    break; 

} 
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page