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

Avancé

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 FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

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

Avancé

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).

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    
    // ... 
  } 
} 

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.
Références

Ref struct:

GC:

Managed pointers:

Span:

Stackalloc:

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

ValueTask (C# 7)

Avancé

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 FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Pattern matching (C# 7)

Basique

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 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 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…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.

Utilisation de var avec is ou switch…case

C# 7.0

Le 4e motif de filtre utilisable avec C# 7.0 avec is et switch...case est var (i.e. var pattern). 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; 
} 

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 FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Tuple et ValueTuple (C# 7)

Basique

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).

Les tuples sont des structures de données permettant de stocker un nombre variable d’objets de type différent. L’intêret est d’éviter à avoir à déclarer la structure explicitement. Les objets sont stockés dans les membres du tuple. Les membres contenant les objets sont .Item1, .Item2, …, .Item<N>.

Historiquement, les tuples sont de type System.Tuple (apparu avec le framework .NET 4.0). System.Tuple est un type permettant de créer des objets de type référence.

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

Tuple<int, string, float> tuple = new Tuple<int, string, float>(5, "5", 5.0f); 

Dans cet exemple, le tuple contient 3 membres:

  • Item1 de type int initialisé à la valeur 5
  • Item2 de type string initialisé à la valeur "5"
  • Item3 de type float initialisé à la valeur 5.0f

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

Tuple<int, string, float> tuple = Tuple.Create(5, "5", 5.0f);

Avant C# 7.0, les tuples devaient être utilisés exclusivement avec des noms de membres génériques (i.e. .Item1, .Item2, …, .Item<N>) ce qui rendait le code peu clair:

Tuple<int, string, float> tuple = new Tuple<int, string, float>(5, "5", 5.0f); 
Console.WriteLine(tuple.Item1); 
Console.WriteLine(tuple.Item2); 
Console.WriteLine(tuple.Item3); 

System.ValueTuple

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

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

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

Au niveau de la syntaxe, C# 7.0 apporte des améliorations pour faciliter l’initialisation des objets de type System.ValueTuple.

Pour utiliser le type System.ValueTuple en utilisant le framework .NET 4.6.2 ou antérieur, il faut installer le package NuGet System.ValueTuple:

install-package System.ValueTuple

Amélioration à partir de C# 7.0

C# 7.0

C# 7.0 permet de rendre la syntaxe plus compacte pour initialiser des objets de type System.ValueTuple et rends plus clair l’accès à ses membres.

Initialisation

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

(int, string, float) tuple = (5, "5", 5.0f); 
Console.WriteLine(tuple.Item1); 
Console.WriteLine(tuple.Item2); 
Console.WriteLine(tuple.Item3); 

Utiliser des noms de membres explicites

Pour rendre l’accès aux membres plus explicite, on peut désormais nommer les membres:

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

Une autre syntaxe est possible à l’initialisation pour indiquer les noms de membres:

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

Noms de membres déterminés par des variables existantes

C# 7.1

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

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

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

Utiliser .Item1, .Item2, …, .Item<N> est toujours possible

C# 7.0

Même si on utilise des noms de membres dont le nom est explicite, les anciens membres .Item1, .Item2, …, .Item<N> restent toujours utilisables:

(int ValueAsInt, string ValueAsString, float ValueAsFloat) tuple = (5, "5", 5.0f); 
Console.WriteLine(tuple.Item1); 
Console.WriteLine(tuple.Item2); 
Console.WriteLine(tuple.Item3); 
System.ValueTuple est mutable

Bien-que System.ValueTuple est une structure permettant de créer des objets de type valeur, il est mutable à l’inverse de System.Tuple qui est immutable. Il est, ainsi, possible de modifier la valeur des membres .Item1, .Item2, …, .Item<N> après instanciation d’un objet de type System.ValueTuple:

(int, string, float) valObjectTuple = (5, "5", 5.0f); 
valObjectTuple.Item1 = 7; // OK 

A l’inverse, modifier un objet System.Tuple n’est pas possible:

Tuple<int, string, float> refObjectTuple = new Tuple<int, string, float>(5, "5", 5.0f);
refObjectTuple.Item1 = 7; // ERROR 

Affectation entre System.ValueTuple

C# 7.0

On peut effectuer des affectations entre objets de type System.ValueTuple en modifiant les noms des membres:

var tuple = (ValueAsInt: 5, ValueAsString: "5", ValueAsFloat: 5.0f); 
(int NewValueAsInt, string NewValueAsString, float NewValueAsFloat) newTuple = tuple; 
Console.WriteLine(newTuple.NewValueAsInt); 
Console.WriteLine(newTuple.NewValueAsString); 
Console.WriteLine(newTuple.NewValueAsFloat); 

Cette syntaxe n’est pas possible avec des objets de type System.Tuple.

Déconstruction

C# 7.0

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

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

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

var (valueAsInt, valueAsString, valueAsFloat) = tuple; 
Console.WriteLine(valueAsInt); 
Console.WriteLine(valueAsString); 
Console.WriteLine(valueAsFloat); 

Si on utilise des variables existantes:

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

Ignorer une variable inutile

Lors d’une déconstruction d’un tuple, il est possible d’ignorer une variable inutile en utilisant le caractère _ de façon à alléger la syntaxe:

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 ces variables lors de la déconstruction:

var (ValueAsInt, _, _) = tuple; // Déconstruction 
Console.WriteLine($"Int value is {ValueAsInt}."); 

Cette syntaxe est possible pour les types System.Tuple et System.ValueTuple.

Comparaison entre tuples

La comparaison entre tuples en utilisant les opérateurs == ou != n’est pas la même suivant si on utilise des objets de type System.Tuple ou System.ValueTuple.

Comparaison entre System.Tuple

L’utilisation des opérateurs == et =! avec des tuples de type System.Tuple respecte les mêmes règles que pour tous les objets de type référence en .NET: par défaut la comparaison s’effectue sur la référence des objets:

var refTuple1 = Tuple.Create(5, "5", 5.0f); 
var refTuple2 = Tuple.Create(5, "5", 5.0f); 
Console.WriteLine(refTuple1 == refTuple2); // False 

Pour effectuer une comparaison entre les membres des objets, il faut utiliser la surcharge Equals():

Console.WriteLine(refTuple1.Equals(refTuple2); // True 

Comparaison entre System.ValueTuple

C# 7.3

Dans le cas de System.ValueTuple (avant C# 7.3), l’utilisation des opérateurs == et =! n’est pas possible, il faut utiliser CompareTo() ou Equals():

var valTuple1 = (5, "5", 5.0f); 
var valTuple2 = (5, "5", 5.0f); 
Console.WriteLine(valTuple1.CompareTo(valTuple2)); // 0 en cas d'égalité 
Console.WriteLine(valTuple1.Equals(valTuple2)); // True 

A partir de C# 7.3, il est possible d’utiliser les opérateurs == et =! pour effectuer une comparaison des membres des tuples:

Console.WriteLine(valTuple1 == valTuple2); // True

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page