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

Tuple et ValueTuple (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).

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 RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Manipuler des objets de type valeur par référence (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).

Avant de commencer…

Quelques indications en préambule concernant les objets de type valeur, les objets de type référence, le boxing et l’unboxing.

Type valeur vs type référence

D’une façon générale, il existe des objets de type valeur et des objets de type référence. Ces objets peuvent être manipulés par valeur ou par référence. En .NET, dans la plupart des cas et par défaut:

  • les objets de type référence sont manipulés par référence toutefois il est possible de manipuler des objets de type référence en utilisant des pointeurs avec du code unsafe.
  • les objets de type valeur sont manipulés par valeur, dans certaines conditions on peut manipuler des objets de type valeur en utilisant des références avec les mot-clés ref, in (à partir de C# 7.2) ou out.

Parmi les types valeur, on peut trouver les structures, les enums et les types primitifs comme bool, int, double, float etc… La plupart du temps les objets de type valeur sont immutables c’est-à-dire qu’il n’est pas possible d’en modifier des propriétés sans devoir créer une nouvelle instance de ces objets. Par exemple, l’affectation d’un objet de type valeur effectue une copie par valeur:

int firstValue = 5; 
int secondValue = firstValue; // Copie par valeur 
List<int> values = new List<int>(firstValue); // Copie par valeur 
int thirdValue = values.First(); // Copie par valeur 

L’opposé de immutable est mutable. Des objets sont dit mutables s’il est possible de les modifier sans devoir créer une nouvelle instance. Il existe quelques cas où un objet de type valeur est mutable. Si on considère la structure suivante:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

Cette structure est mutable si:

  • Une propriété locale est modifiée:
    var circle = new Circle(); 
    circle.Radius = 6; // Pas de copie, circle est modifié 
    
  • Si la structure se trouve dans un objet de type référence comme une classe:
    class Wrapper 
    { 
      public Circle InnerCircle; 
    } 
    
    var wrapper = new Wrapper{ InnerCircle = circle }; // Copie par valeur de circle 
    wrapper.InnerCircle.Radius; // Pas de copie, la copie de circle est modifiée directement 
    
  • Si on utilise un objet de type valeur dans un tableau:
    Circle[] circles = new Circle[] { circle }; // Copie par valeur 
    circles[0].Radius = 7; // Pas de copie, la copie dans le tableau est modifiée directement 
    circles.First().Radius = 4;// ATTENTION: First() effectue une copie. 
    var newCircle = circles[0]; // ATTENTION: une copie est effectuée.
    

Implicitement, les objets de type référence dérivent de System.Object et les objets de type valeur dérivent de System.ValueType. En réalité System.ValueType dérive de System.Object, la différence entre les objets de type valeur et les objets de type référence est artificielle du point de vue de la hiérarchie des classes. C’est le runtime qui différenciera ces 2 types d’objets à l’exécution. Ainsi, 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 stockés le plus souvent dans la pile (i.e. stack).

Objets de type référence

L’intérêt principal d’utiliser des objets de type référence est de les stocker dans le tas managé et de pouvoir allouer des quantités variables de mémoire suivant la taille des objets de façon dynamique. L’accès à ces objets se fait par l’intermédiaire d’une référence. Une référence permet de pointer vers un objet stocké en mémoire en utilisant son adresse. La référence d’un objet de type référence est elle-même un objet de type valeur. Par définition, une référence ne peut pas être nulle (en revanche, une variable contenant une référence peut être nulle).
La manipulation d’une référence n’est pas très couteuse car sa taille est fixe et égale à la valeur de la constante System.IntPtr.Size (la taille varie suivant la taille des adresses mémoire 32 ou 64 bits du système). La référence d’un objet stocké dans le tas managé peut donc facilement être stockée dans une variable sur la pile ou être passée en argument d’une fonction.

Référence .NET vers un objet de type référence

L’inconvénient majeur des objets de type référence est qu’ils sont couteux à manipuler car stockés dans le tas managé, les objets devant y être alloués et désalloués. La gestion de la mémoire dans le tas est assurée par le Garbage Collector (GC), ce qui nécessite de nombreuses opérations pour différencier les objets utilisés des objets qui ne le sont plus ou pour réorganiser les zones allouées en mémoire pour optimiser les temps d’allocation. Toutes ces opérations peuvent avoir un impact non négligeable sur les temps d’exécutions.

Le GC divise les objets managés en 2 catégories:

  • Les petits objets (< 85000 octets): ils sont gérés par génération (génération 0, génération 1 et génération 2) et leur allocation est rapide. Quand il change de génération (promotion), ils sont copiés dans la mémoire. La désallocation de ces objets est non déterministe et bloquante. Les objets dont la durée de vie est faible sont supprimés rapidement et se trouvent dans la génération 0 ou 1. Les objets dont la durée de vie est longue se trouvent dans la génération 2 et leur manipulation est plus longue.
  • Les gros objets (≥ 85000 octets): ils sont alloués dans un tas appelé Large Object Heap (LOH) dont le contenu n’est jamais déplacé. Cette caractéristique peut mener à une fragmentation de cette partie de la mémoire et allonge le temps d’allocation pour les nouveaux objets.

Objets de type valeur

Les objets de type valeur sont stockés dans la pile (i.e. stack) toutefois ce n’est pas tout le temps le cas, par exemple:

  • Les objets statiques de type valeur sont stockés dans un tas particulier (loader heap ou high frequency heap).
  • Un objet de type valeur membre d’un objet de type référence peut être stocké dans le tas managé.
  • Les objets de type valeur peuvent aussi être stockés dans un registre CPU suivant les optimisations du JIT.

L’intérêt des objets de type valeur est qu’ils peuvent être alloués et désalloués de la pile rapidement. Quand une fonction est appelée, un bloc appelé stack frame est réservé au sommet de la pile pour les variables locales. Quand une fonction a terminé son exécution, le bloc n’est plus utilisé et peut être utilisé lors de l’appel à une autre fonction suivant l’ordre LIFO (i.e. Last In First Out). La libération de ce bloc de mémoire est simple et beaucoup plus rapide qu’avec le tas managé. D’autre part, la taille de la pile est très petite (< 1 MO) et peut facilement entrer dans le cache d’un CPU. La manipulation des objets dans la pile est donc rapide et convient bien aux opérations répétitives.

Le plus gros inconvénient des objets de type valeur stockés dans la pile est que leur durée de vie est liée à la durée de vie de la fonction qui les manipule et que ces objets sont souvent copiés par valeur quand ils sont manipulés. La durée d’exécution de ces copies n’est pas fixe et varie suivant la taille des objets copiés. Il est possible de manipuler des références vers ces objets suivant certaines conditions.

Boxing vs Unboxing

Le boxing et l’unboxing sont 2 opérations courantes en .NET.

Boxing

En .NET, l’opération de boxing consiste à convertir un objet de type valeur vers le type object. Le plus souvent cette opération est effectuée implicitement.

Par exemple, si considère la structure suivante:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

Le boxing intervient si on stocke la structure dans un objet de type object:

Circle circle = new Circle(); 
object circleAsObj = circle; // boxing

La conversion en object est effectuée de façon implicite.

Techniquement l’opération de boxing est couteuse (20 fois plus longue qu’une simple assignation) car des nombreuses opérations sont effectuées. En .NET, 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 stockés, la plupart du temps, dans la pile. La conversion de l’objet de type valeur en objet de type référence entraîne que cet objet doit être stocké dans le tas managé au lieu de la pile. Le boxing entraîne:

  • La création d’un wrapper de type référence qui va encapsuler l’objet de type valeur,
  • L’objet de type valeur est supprimé de la pile,
  • Il est placé dans le wrapper qui lui-même est placé dans le tas managé.

Outre l’opération de boxing à proprement parlé, d’autres utilisations de l’objet après boxing s’avère plus couteuse que l’utilisation directe de l’objet de type valeur puisqu’il faut accéder à l’objet dans le tas managé en utilisant sa référence alors que l’accès dans la pile était directe.

L’opération de boxing peut être mise en œuvre de façon moins évidente, par exemple, dans le cas où on stocke un objet de type valeur dans une liste générique d’object:

Circle circle = new Circle(); 
List<object> objects = new List<object>(); 
objects.Add(circle); // Boxing implicite 

D’autres opérations peuvent entraîner un boxing sans que l’on s’en rende compte, par exemple, si on appelle ToString() sur une structure, si on stocke un objet de type valeur dans une ArrayList ou une Hashtable:

ArrayList list = new ArrayList(); 
list.Add(circle); // Boxing implicite 

string circleAsStr = circle.ToString(); // Boxing implicite 

Le boxing peut intervenir aussi de façon implicite si une conversion intervient entre l’objet de type valeur et une interface.

Enfin, si observe le code décompilé avec IL DASM, l’opération de boxing apparaît sous la forme box, par exemple pour le code suivant:

var circle = new Circle(); 
object circleAsObj = circle; 
Console.WriteLine(circleAsObj.ToString()); 

La sortie de IL DASM est:

.method private hidebysig static void Main(string[] args) cil managed 
{ 
  .entrypoint 
  .maxstack 1 
  .locals init ([0] valuetype Test.Circle V_0) 
  IL_0000: ldloca.s V_0 
  IL_0001: initobj Test.Circle 
  IL_0008: ldloc.0 
  IL_0009: box Test.Circle

  IL_000e: callvirt instance string [mscorlib]System.Object::ToString() 
  IL_0013: call void [mscorlib]System.Console::WriteLine(string) 
  IL_0018: ret 
} 

Unboxing

A l’opposé du boxing, l’unboxing permet d’effectuer une conversion d’un type object vers un objet de type valeur. Le coût en performance est aussi significatif que pour le boxing c’est-à-dire que la ligne dans le code mettant en oeuvre l’unboxing entraîne techniquement plusieurs opérations qui la rendent plus couteuse qu’une simple affectation. La différence avec le boxing est que les opérations d’unboxing sont explicites donc il est plus facile de s’en rendre compte.

Par exemple, le code suivant implique de l’unboxing:

var circle = new Circle(); 
object circleAsObj = circle; // Boxing implicite 
var unboxedCircle = (Circle)circleAsObj; // Unboxing explicite 
Console.WriteLine(unboxedCircle.ToString()); 

D’un point de vue technique, étant donné que les objets de type valeur sont stockés dans la pile et que les objets de type référence sont stockés dans le tas managé (i.e. managed heap), l’opération d’unboxing implique davantage qu’une simple affectation. L’objet de type valeur se trouvant dans une variable object stockée dans le tas managé doit être extrait de son wrapper de type référence et déplacé dans la pile.

Si on observe le code décompilé avec IL DASM, l’opération d’unboxing apparaît sous la forme unbox. Par exemple pour le code plus haut, la sortie IL DASM est:

.method private hidebysig static void Main(string[] args) cil managed 
{ 
  .entrypoint 
  .maxstack 1 
  .locals init ([0] valuetype Test.Circle unboxedCircle, [1] valuetype Test_Circle V_1) 
  IL_0000: ldloca.s V_1 
  IL_0001: initobj Test.Circle 
  IL_0008: ldloc.0 
  IL_0009: box Test.Circle 
  IL_000e: unbox.any Test.Circle

  IL_0013: stloc.0 
  IL_0014: ldloca.s unboxedCircle 
  IL_0016: constrained. Test.Circle 
  IL_0021: callvirt instance string [mscorlib]System.Object::ToString() 
  IL_0026: call void [mscorlib]System.Console::WriteLine(string) 
  IL_0026: ret 
} 

Passage d’argument par référence d’un objet de type valeur

C# 7 permet d’effectuer des passages d’argument dans des fonctions d’objets de type valeur par référence.

C# 7.0

A partir de C# 7.0, il est possible d’effectuer des passages d’arguments d’objets de type valeur par référence. En effet, par défaut le passage d’objets de type valeur en argument de fonction se fait par valeur c’est-à-dire que les objets sont copiés lors de l’appel de fonction. L’utilisation du mot-clé ref dans un argument de fonction permet de passer un objet de type valeur par référence.

Techniquement lors du passage d’un objet de type valeur par référence, la référence passée en argument correspond à un objet permettant de pointer vers l’objet dans la pile. Il n’y a pas de boxing.

Par exemple, si on considère l’exemple suivant:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

private static void ChangeRadius(int newRadius, Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

static void Main() 
{ 
  var circle = new Circle{ Radius = 4 }; 
  Console.WriteLine(circle.Radius); // 4 
  ChangeRadius(2, circle); // circle est dupliquée 
  Console.WriteLine(circle.Radius); // 4 
} 

Cet exemple ne fonctionne pas, la valeur de la propriété Radius est bien modifiée par ChangeRadius() toutefois il modifie la propriété d’une copie de l’objet d’origine. L’instance circle d’origine n’est pas modifiée.

Si on passe l’objet circle par référence, il n’y aura pas de copie et l’instance circle est réellement modifiée:

private static void ChangeRadius(int newRadius, ref Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

static void Main() 
{ 
  var circle = new Circle{ Radius = 4 }; 
  Console.WriteLine(circle.Radius); // 4 
  ChangeRadius(2, ref circle); // circle est passé par référence 
  Console.WriteLine(circle.Radius); // 2 
} 

Si on regarde plus en détails l’implémentation:

  • ref désigne un objet permettant de pointer vers un objet de type valeur, ainsi la signature de la méthode ChangeRadius() comprend l’argument ref Circle circle. Le type est donc une référence vers un objet de type valeur Circle.
  • L’appel vers la méthode ChangeRadius() doit être modifié puisqu’il faut utiliser une référence et non directement le type valeur: ChangeRadius(2, ref circle).
Passage d’argument “par référence”

On utilise le terme “par référence” toutefois il ne s’agit pas de références .NET vers des objets managés. Quand le mot clé ref est utilisé pour des paramètres de fonction, un retour de fonction ou une variable locale, il s’agit d’un objet “ref” (i.e. ByRef objects) dans lequel se trouve un pointeur managé. Comme les références classiques, les pointeurs managés sont connus par le garbage collector, et ils permettent de pointer vers des objets managés. Toutefois techniquement ils sont très différents car ils ne contiennent qu’une adresse mémoire sans information supplémentaire et peuvent pointer, en plus des objets managés, vers des objets non managés, des objets se trouvant sur la pile ou à l’intérieur d’objets. D’autre part, à la différence des références classiques, les pointeurs managés sont exclusivement stockés sur la pile.

Objet ref vers un objet de type valeur

Passage en argument d’une référence par référence

Une référence est un objet de type valeur qui, par défaut, est manipulée par valeur. Manipuler une référence par référence permet, par exemple, d’éviter d’effectuer des copies de la référence. La référence de la référence correspond à un objet ref pointant vers la référence .NET d’un objet de type référence:

Objet ref pointant vers la référence d’un objet de type référence
C# 7.0

Lors du passage d’argument, utiliser ref pour des objets de type référence permet d’éviter la copie de la référence vers l’objet. En effet, la plupart du temps, les objets de type référence sont manipulés par référence, ainsi si on considère la classe suivante:

class Square 
{ 
  public int Size { get; set; } 
} 

var square = new Square { Size = 4 }; 

La variable square contient une référence vers un objet de type Square stocké dans le tas managé. La référence est un objet de type valeur bien que l’objet référencé est de type référence. La référence est stockée dans la pile alors que l’objet référencé est stockée dans le tas managé. Si on écrit:

var newSquare = square; // Copie de la référence 

Une nouvelle variable est créée et initialisée avec une copie de la référence vers l’objet newSquare. L’objet référencée n’est pas copié, seule la référence est copiée.

Ainsi si on considère la méthode suivante:

public static void IncreaseSize(Square square) 
{ 
  square.Size++; 
} 

Si on effectue un appel de ce type:

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(initialSquare); 
Console.WriteLine(initialSquare.Size); // 3 

L’argument square contient une référence vers un objet de type Square. Lors de l’appel, la référence initialSquare est copiée dans l’argument square. Lorsqu’on manipule l’objet référencé avec square.Size, on manipule directement l’objet. Si on effectue une affectation à l’intérieur de la fonction sur l’argument square, l’objet initial n’est pas modifié et la variable otherSquare contient une référence vers l’objet initial:

public static void IncreaseSize(Square square) 
{ 
  square  = new Square{ Size = 8 }; // Création d'un nouvel objet et d'une nouvelle référence 
} 

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(initialSquare); 
Console.WriteLine(initialSquare.Size); // 2 

Si on utilise ref dans l’argument de IncreaseSize(), on indique qu’on ne veut pas dupliquer la référence mais passer la référence en argument par référence:

public static void IncreaseSize(ref Square square) 
{ 
  square  = new Square{ Size = 8 }; // Création d'un nouvel objet et affectation à la référence existante 
} 

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(ref initialSquare); // La référence n'est pas copiée 
Console.WriteLine(initialSquare.Size); // 8 

Dans cet exemple, la référence est passée par référence. La référence n’est donc pas copiée lors du passage en argument. Modifier la référence dans le corps de la méthode, va entraîner la modification de la référence à l’extérieur de la méthode.

Manipuler une variable locale par référence

C# 7.0

A chaque nouvelle affectation, les objets de type valeur sont copiés.

Par exemple:

var circle = new Circle { Radius = 4 }; 
var newCircle = circle; // Duplication de l’objet 
circle.Radius = 2; 
Console.WriteLine(circle.Radius); // 2 
Console.WriteLine(newCircle.Radius); // 4 

L’affection, le passage d’argument ou le retour de fonction sont des opérations qui effectuent une copie par valeur d’un objet de type valeur. L’affectation var newCircle = circle crée une nouvelle instance newCircle qui est une copie de l’instance circle.

L’affectation d’une nouvelle propriété Radius sur l’instance circle ne modifie pas l’instance newCircle puisqu’il s’agit d’objets différents.

A partir de C# 7.0, il est possible de manipuler des variables locales d’objet de type valeur par référence. Ainsi, les affectations effectuent une copie de référence au lieu d’effectuer une copie par valeur.

Si on reprends l’exemple précédent, il faut déclarer une référence d’un objet de type valeur:

var circle = new Circle { Radius = 4 }; 
ref var circleRef = ref circle; // On considère la référence de l’objet 
circleRef.Radius = 2; 
Console.WriteLine(circle.Radius); // 2 
Console.WriteLine(circleRef.Radius); // 2 

Si on regarde plus en détails la syntaxe de l’exemple:

  • var circle désigne un objet de type valeur Circle.
  • ref var circleRef désigne une référence vers un objet de type valeur Circle, ainsi les lignes:
    • var circle = new Circle { Radius = 4 } permet d’instancier un objet de type Circle.
    • ref circle permet de récupérer une référence vers l’objet circle dans la pile (i.e. stack). Plus précisemment, cet objet se trouve dans la stack frame de la méthode. Cette stack frame et les objets qu’elle contient existent tant que la méthode existe.
C# 7.3

A chaque fois, qu’on manipule une référence d’un objet de type valeur, il faut penser à utiliser le mot-clé ref, par exemple si on écrit:

Circle firstCircle = new Circle { Radius = 2 }; // Initialisation 
Circle secondCircle = new Circle { Radius = 4 }; // Initialisation 
ref Circle firstCircleRef = ref firstCircle;
ref Circle secondCircleRef = ref secondCircle;
secondCircleRef = firstCircleRef; // Affectation par valeur, une copie est effectuée 

Même si firstCircleRef et secondCircleRef sont définis avec ref Circle, la dernière affectation effectue une copie par valeur. Pour manipuler les références, il faut utiliser ref (disponible à partir de C# 7.3):

secondCircleRef = ref firstCircleRef; // Affectation par référence (C# 7.3) 

Avant C# 7.3, il n’est pas possible d’effectuer l’affectation d’une référence vers un objet de type valeur sur une variable existante, seules les initialisations sont possibles:

ref Circle otherFirstCircleRef = ref firstCircleRef; 

Manipuler une variable locale par référence en lecture seule (ref readonly)

C# 7.2

A partir de C# 7.2, il est possible d’indiquer que le membre d’une variable locale manipulée par référence n’est pas modifiable en déclarant la variable avec ref readonly. Ainsi si une variable est déclarée avec ref readonly, ses membres éventuels ne seront utilisables qu’en lecture seule, toute tentative d’affectation d’un membre conduira à une erreur de compilation:

var circle = new Circle { Radius = 4 };
ref readonly var readOnlyCircleRef = ref circle; // Initialisation d'une variable par référence en lecture seule

readOnlyCircleRef.Radius = 2; // ERREUR: modification d'un membre est non autorisée

La variable en lecture seule est, toutefois, réaffectable:

var otherCircle = new Circle { Radius = 2 };

readonlyCircleRef = ref otherCircle; // OK autorisée
readonlyCircleRef.Radius = 4; // ERREUR

Une variable déclarée avec ref readonly ne peut pas être passé en argument d’une méthode par référence. Si on considère la méthode:

void ChangeRadius(ref Circle circleToUpdate) {}

// ...
ChangeRadius(ref readonlyCircleRef); // ERREUR

Lors de l’initialisation de la variable locale avec ref readonly, la valeur d’initialisation utilisée peut aussi correspondre à un paramètre in d’une méthode, par exemple:

static void PrintCircleRadius(in Circle circle)
{
  ref readonly var readOnlyCircle = ref circle; // OK
  Console.WriteLine(readOnlyCircle.Radius);
}

Si une variable déclarée par référence est initialisée avec le membre d’un objet déclaré avec ref readonly ou avec le retour d’une fonction de type ref readonly T alors la variable doit être déclarée avec ref readonly.
Par exemple, si on considère la classe suivante:

class CircleWrapper
{
  private ref readonly circle = new Circle { Radius = 4 };

  public void PrintCircleRadius()
  {
    ref readonly var readOnlyLocalCircle = ref this.circle; // OK

    ref var localCircle = ref this.circle; // ERREUR
  }
}

Si on considère la méthode suivante:

static Circle circle = new Circle { Radius = 4 };
static ref readonly Circle GetCircle()
{
  return ref circle;
}

Une variable initialisée avec le retour de la fonction doit être de type ref readonly Circle:

ref readonly var circle = GetCircle(); // OK

ref var circle = GetCircle();  // ERREUR

Enfin, la valeur d’initialisation d’une variable déclarée avec ref readonly doit être une LValue c’est-à-dire que la variable doit correspondre à la référence d’un objet nommé. La référence d’un objet nommé signifie qu’une variable désigne cet objet en mémoire.

Par exemple si on écrit:

ref readonly var circle = ref default(Circle); // ERREUR

ref default(Circle) ne correspond pas à la référence d’un objet nommé, il n’y a pas une variable appelée default(Circle) correspondant à un objet en mémoire. Il s’agit d’une valeur sans variable associée.

Utiliser ref readonly avec des objets de type valeur mutables est plus couteux en performance

Il existe une différence entre utiliser ref et ref readonly avec des structures mutables. Pour garantir que la structure mutable déclarée avec ref readonly n’est pas modifiée, le compilateur effectue une copie par valeur de la structure pour chaque déclaration d’une variable ref readonly (i.e. defensive copy). Cette copie est effectuée si la structure est mutable (c’est-à-dire qu’elle n’est pas déclarée avec readonly struct).

La copie de la structure peut être évitée si la structure est immutable en la déclarant avec readonly struct. Dans ce cas, le compilateur effectue des optimisations en évitant d’effectuer des copies par valeur à chaque déclaration d’une variable ref readonly.

Par exemple, la structure Circle est mutable:

var circle = new Circle();
ref readonly var circleRef = ref circle; // Une copie est effectuée

Si Circle est immutable:

struct readonly ImmutableCircle
{
  public int Radius { get; }

  void Circle(int radius)
  {
    this.Radius = radius;
  }
}

// ...
var circle = new ImmutableCircle(4);
ref readonly var circleRef = ref circle; // OK, le compilateur effectue une optimisation

Retour de fonction par référence

C# 7.0

A partir de C# 7.0, il est possible de retourner la référence d’un objet de type valeur. Toutefois tout n’est pas possible car techniquement il faut comprendre ce que signifie retourner la référence d’un objet de type valeur.

Ainsi, comme on a pu le voir précédemment, les arguments d’une fonction, ses variables locales et sa valeur de retour sont stockés dans la stack frame de la fonction. Cette stack frame disparaît lorsqu’on quitte la fonction. Par exemple, si on retourne une référence d’une variable locale de type valeur, après exécution de la fonction la référence ne correspondra plus à l’objet retourné puisque la stack frame de la fonction est perdue.

Retourner une référence vers un objet de type valeur est possible si la référence reste disponible à la sortie de la fonction. Ceci est possible si:

  • L’objet de type valeur est un membre d’un objet de type référence: dans ce cas il est stockée dans le tas managé et non dans la pile. La référence reste disponible à la sortie de la fonction.
  • L’objet de type valeur est statique: il n’est pas stocké dans la pile mais dans un tas particulier (loader heap ou high frequency heap). La référence vers l’objet statique de type valeur reste disponible à la sortie de la fonction.

Par exemple, si on considère l’exemple suivant:

public static ref Circle FindCircle(Circle[] circles, int circleIndex) 
{ 
  return ref circles[circeIndex]; 
} 

static void Main() 
{ 
  Circle[] circles = { 
    new Circle { Radius = 4 }, 
    new Circle { Radius = 2 } 
  }; 
 
  ref Circle foundCircle = ref FindCircle(circles, 1); 
  Console.WriteLine(foundCircle.Radius); // 2 
  foundCircle.Radius = 1; 
  Console.WriteLine(circles[1].Radius); // 1 
} 

C’est bien l’objet se trouvant dans le tableau circles qui est modifié et non une copie de l’objet. En effet, le retour de FindCircle() est une référence d’un objet dans le tableau circles.

Liste générique et LINQ

Dans l’exemple plus haut, l’utilisation du tableau Circle[] n’est pas anodine car cette structure permet de manipuler reéllement une référence de l’objet dans le tableau. Si on utilise une liste ou LINQ on manipulera une copie de l’objet dans la liste et non l’objet se trouvant dans la liste:

public static ref Circle FindCircle(List circles, int circleRadius) 
{ 
  return ref circles.First(c => c.Radius.Equals(circleRadius)); // ERROR: 
  // An expression cannot be used in the context because it may not be passed or 
  // returned by reference 
} 

Dans cet exemple, on obtient une erreur de syntaxe car circles.First() effectue une copie de l’objet et ne permet pas de récupérer une référence de l’objet dans la structure.

Pas de référence nulle

Il n’existe pas de référence nulle vers un objet de type valeur, on ne peut donc pas écrire une fonction de ce type:

public static ref Circle FindCircle(Circle[] circles, int circleRadius) 
{ 
  for (int i = 0; i < circles.Length; i++) 
  { 
    ref var foundCircle = ref circles[i]; 
    if (circles[i].Radius.Equals(circleRadius)) 
      return ref foundCircle; 
  } 

  return null; // ERROR: the return expression must be of type 'Circle' 
  // because the method returns by reference. 
} 

La solution peut consister à définir en avance une objet nul pour l’utiliser si le recherche échoue:

public static ref Circle FindCircle(Circle[] circles, int circleRadius, ref Circle notFoundCircle) 
{ 
  for (int i = 0; i < circles.Length; i++) 
  { 
    ref var foundCircle = ref circles[i]; 
    if (circles[i].Radius.Equals(circleRadius)) 
      return ref foundCircle; 
  } 

  return ref notFoundCircle; 
} 

L’appel peut se faire de cette façon:

static void Main() 
{ 
  Circle[] circles = { 
    new Circle { Radius = 4 }, 
    new Circle { Radius = 2 } 
  }; 

  var notFoundCircle = new Circle { Radius = 0 }; 
  ref var notFoundCircleRef = ref notFoundCircle; 

  ref Circle foundCircle = ref FindCircle(circles, 6, ref notFoundCircleRef); 
  if (foundCircle.Equals(notFoundCircleRef)) 
    Console.WriteLine("Not found"); // Not found 
}

Utilisation d’un objet statique

Un objet de type valeur stocké dans une variable statique se trouve techniquement dans un tas particulier (loader heap ou high frequency heap). Il est donc possible d’utiliser une référence vers cet objet statique sans se préoccuper de la durée de vie de la stack frame d’une fonction.

Par exemple:

private static Circle mediumCircle; 

public static ref Circle GetMediumCircle() 
{ 
  return ref mediumCircle; 
} 

static void Main() 
{ 
  mediumCircle = new Circle { Radius = 5 }; 
  ref var foundCircle = ref GetMediumCircle(); 
} 

L’objet mediumCircle n’est pas stocké dans la pile de la fonction GetMediumCircle(), on peut donc retourner une référence vers cet objet.

Utilisation d’un membre d’un objet de type référence

Un objet de type valeur étant un membre d’un objet de type référence est stocké dans le tas managé. On peut donc utiliser une référence vers cet objet de type valeur en retour d’une fonction.

Par exemple:

internal class CircleRepository 
{ 
  public Circle InnerCircle; 
} 

public static ref Circle SetNewRadius(CircleRepository circleRepository, int newRadius) 
{ 
  ref var circle = ref circleRepository.InnerCircle; 
  circle.Radius = newRadius; 
  return ref circle; 
} 

static void Main() 
{ 
  var circleRepo = new CircleRepository{ 
    InnerCircle = new Circle { Radius = 4 } 
  }; 

  ref var updatedCircle = ref SetNewRadius(circleRepo, 2); 
  Console.WriteLine(circleRepo.InnerCircle.Radius); // 2 
} 

Etant donné que InnerCircle est membre de la classe CircleRepository, même s’il s’agit d’un objet de type valeur, il est stocké dans le tas managé. On peut donc manipuler cet objet par référence et l’utiliser en retour de la fonction SetNewRadius().

Si on utilise un liste générique d’objet de type valeur, on ne peut pas extraire de la liste des éléments sans effectuer de copie par valeur.

Retour de fonction par référence en lecture seule

C# 7.2

A partir de C# 7.2, on peut indiquer qu’un objet retourné par une fonction par référence est en lecture seule en utilisant le type de retour ref readonly T:

ref readonly Circle GetCircle();

Le but d’avoir un objet retournée par référence en lecture seule est de sécuriser le code et éviter des modifications involontaires d’un objet.
Par exemple, si on retourne par référence le membre d’une classe, il est possible de modifier directement ce membre à l’extérieur de la classe, ce qui peut mener à un defaut d’encapsulation. Empêcher la modification du membre en utilisant sa référence permet d’éviter des erreurs.

L’utilisation de ref readonly en retour d’une fonction est simple: tous les objets pour lesquels on peut effectuer un retour par référence avec ref peuvent être retournés par référence en lecture seule:

class CircleRepository
{
  private Circle InnerCircle;

  ref readonly Circle GetCircle()
  {
    return ref this.InnerCircle; // ATTENTION: return ref et non return ref readonly
  }
}

L’inverse n’est pas possible: un objet retourné par référence en lecture ne peut pas être affecté à un variable qui n’est pas en lecture seule:

var circleRepo = new CircleRepository{ InnerCircle = new Circle() };
ref readonly circleRef = ref CircleRepo.GetCircle(); // OK
ref circleRef = ref CircleRepo.GetCircle(); // ERREUR
L’utilisation de ref readonly avec des objets de type valeur mutables entraîne un coût en performance

Comme les variables locales ref readonly, si ref readonly est utilisé avec une objet de type valeur mutable, le compilateur effectue des copies (i.e. defensive copy) pour préserver l’objet d’origine. Pour éviter ces copies, il est préférable que l’objet soit immutable par exemple en utilisant readonly struct.

Utiliser ref avec l’opérateur ternaire

C# 7.2

L’opérateur ternaire permet d’écrire des expressions du type:

<condition> ? <code si condition vraie> : <code si condition fausse> 

On peut effectuer des initialisations de ce type:

Circle[] circules = { 
  new Circle{ Radius = 4 }, 
  new Circle{ Radius = 2 }, 
}; 

var firstCircle = circles.First(); 
var bigCircle = foundCircle.Radius > 2 ? circles[0] : circles[1]; 

Dans la dernière ligne, des copies par valeur des objets sont effectuées car Circle est un objet de type valeur.

Pour effectuer des copies par référence, il est possible à partir de C# 7.2 d’utiliser le mot-clé ref avec l’opérateur ternaire:

ref var bigCircle = ref (foundCircle.Radius > 2 ? ref circles[0] : ref circles[1]); 

L’affectation est, ainsi, effectuée par référence.

Mot-clé in

C# 7.2

Lors d’appel de fonction, le mot-clé ref autorise le passage en argument d’objets de type valeur par référence. La modification de l’objet passé en argument est possible dans le corps de la fonction appelée. Comme pour ref, le mot-clé in permet d’indiquer qu’un objet de type valeur doit être passé par référence toutefois il interdit la modification de l’argument dans le corps de la fonction.

Par rapport à ref, les avantages à utiliser in sont les suivants:

  • On indique explicitement que l’objet ne pourra pas être modifié dans le corps de la fonction.
  • Le compilateur optimise le code généré en sachant que l’objet n’est pas modifié dans le corps de la fonction. Ainsi l’appel à la fonction est effectué de façon plus rapide qu’avec ref.

Par exemple, si on considère la classe Circle et la fonction suivante

struct Circle  
{ 
  public int Radius { get; set; } 

  public void SetNewRadius(int newRadius)  
  { 
    this.Radius = newRadius; 
  } 
} 

private static void ChangeRadius(int newRadius, ref Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

Si on effectue l’appel suivant:

var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadius(2, ref circle); // circle est passé par référence 
Console.WriteLine(circle.Radius); // 2 

Il n’y a pas de copie de circle lors du passage en argument quand on appelle ChangeRadius(). La même instance est passée en argument et modifiée dans le corps de ChangeRadius().

Si on modifie l’implémentation de ChangeRadius():

private static void ChangeRadius(int newRadius, in Circle circle) 
{ 
  // circle.Radius = newRadius; // ERREUR car on ne peut pas modifier circle dans le corps de la méthode 
  circle.SetNewRadius(newRadius); // Pas d'erreur 
  Console.WriteLine(circle.Radius);  
} 

Si on effectue l’appel suivant:

var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadius(2, circle); // circle est passé par référence 

// Dans le corps de ChangeRadius(), Console.WriteLine(circle.Radius) affiche 4 
Console.WriteLine(circle.Radius); // 4 
Circle.SetNewRadius(3); 
Console.WriteLine(circle.Radius); // 3 

Avec in, l’argument circle est passé par référence toutefois le compilateur effectue des optimisations en sachant que circle ne peut être modifié dans le corps de la fonction. circle.SetNewRadius() ne modifie pas l’objet circle. Le résultat est toujours 4 quand on essaie de modifier circle à l’intérieur de ChangeRadius().

Appel à une méthode avec in

Quand un argument comporte le modificateur in, si la méthode ne comporte pas de surcharge, il est possible d’effectuer l’appel avec ou sans in. Si in est omis, il est considéré comme implicite et le comportement est le même que l’appel contenant in.

Par exemple dans le cas de l’exemple précédent, les appels suivants ont le même comportement:

ChangeRadius(2, circle); // Passage par référence implicite 
ChangeRadius(2, in circle); // Passage par référence explicite 

Surcharge des méthodes avec in

Les surcharges de méthode avec ref et in ne sont pas possibles, une seule surcharge est possible.

Par exemple, si on crée 2 méthodes de ce type:

private static void ChangeRadius(int newRadius, ref Circle circle) { ... } 
private static void ChangeRadius(int newRadius, in Circle circle) { ... } // ERREUR: généré une erreur de compilation 

Il est possible d’avoir 2 surcharges avec et sans in:

private static void ChangeRadius(int newRadius, Circle circle) { … } 
private static void ChangeRadius(int newRadius, in Circle circle) { … } // OK 

Dans ce dernier cas, l’utilisation de in lors des appels est importante puisqu’elle va permettre d’indiquer quelle surcharge sera utilisée:

ChangeRadius(2, circle); // la surchage ChangeRadius(int newRadius, Circle circle) est appelée => passage par valeur 
ChangeRadius(2, in circle); // la surchage ChangeRadius(int newRadius, in Circle circle) est appelée => passage par référence 
Utiliser in peut être plus coûteux en performance que ref

Outre la différence fonctionnelle entre in et ref (ref autorise la modification de l’argument alors que in ne l’autorise), il existe une différence en terme de performance: suivant la façon dont on l’utilise in peut être beaucoup plus coûteux que ref lors d’appel de méthodes.

Pour garantir qu’un objet passé en paramètre d’une méthode est en lecteur seule, le compilateur effectue une copie par valeur de cet objet (i.e. defensive copy). Cette copie sera temporaire et réservé à l’utilisation du paramètre dans la méthode.

Pour s’en convaincre, il suffit d’exécuter le code suivant:

public struct Circle 
{ 
  public Circle(int radius) 
  { 
    this.Radius = radius; 
  } 
 
  public int Radius { get; private set; } 

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

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

Ensuite, on effectue les appels suivants:

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

Ainsi bien dans le corps de la méthode ChangeRadius() qu’à l’extérieur la valeur de circle.Radius est 4 même si on tente de modifier la valeur de Radius. La raison est que in entraîne la création d’une copie temporaire pour éviter d’affecter l’instance d’origine de Circle.

La conséquence de cette copie est une dégradation des performances par rapport à l’utilisation de ref. ref permet d’utiliser une référence toutefois il n’y a pas de copie dans une variable temporaire des arguments de la méthode.

Pour permettre au compilateur d’effectuer des optimisations quand on utilise in, il faut que la structure de l’objet passé en paramètre soit immutable en utilisant readonly:

public readonly struct Circle 
{ 
  // ... 
} 

Pour davantage de détails, voir readonly struct.

Pour résumer…

Le mot-clé ref permet d’effectuer des manipulations d’objets de type valeur par référence:

  • Pour un objet de type valeur, on manipule une référence vers cet objet (plus précisément on utilise un objet ref qui pointe vers l’objet de type valeur).
  • Pour un objet de type référence, on manipule une référence de la référence vers l’objet (on utilise un objet ref qui pointe vers la référence de l’objet de type référence, la référence d’un objet de type référence étant elle-même un objet de type valeur).
Syntaxe Remarques
Passage en argument
par référence
(ref)
void MethodName(ref <type> argument)
{ ... }
Par exemple:

private static void ChangeRadius(int newRadius, 
    ref Circle circle)
{
  circle.Radius = newRadius;
}
Manipulation
d’une variable locale
(ref)
ref var refVariable = 
  ref <value variable>;
Par exemple:

Circle circle = new Circle();
ref Circle circleRef = ref circle;

Réaffectation d’une référence:

// NE PAS OUBLIER ref
<variable par référence 2 > = 
  ref <variable par référence 1> 

Par exemple:

Circle firstCircle = new Circle ...
// ...
// Affectation d’une référence
secondCircleRef = ref firstCircleRef; 
// Affectation par valeur, une copie est effectuée
secondCircleRef = firstCircleRef; 
Manipulation
d’une variable locale
en lecture seule
(ref readonly)
ref readonly var refVariable = 
  ref <value variable>;
Toutes les variables ref peuvent être affectées en readonly.
L’affectation d’un membre sur une variable ref en lecture seule n’est pas possible.

Par exemple:

Circle circle = new Circle { Radius = 2; }
// Référence en lecture seule à partir 
// d’un objet de type valeur
ref readonly readOnlyCircleRef = ref circle; 
readOnlyCircleRef.Radius = 4; // ERREUR

// Référence en lecture/écriture
ref var circleRef = ref circle; 
// Référence en lecture seule à partir 
// d’une autre référence
ref readonly otherReadOnlyCircleRef = ref circleRef; 

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Retour de fonction
par référence
(return ref)
ref <type> FunctionName()
{
  // ...
  return ref <variable>;
}
La variable retournée par référence doit être accessible à l’extérieur de la stack frame correspondant à la fonction, il peut s’agir:

  • D’un membre d’un objet de type référence:
    class CircleWrapper
    {
      private Circle InnerCircle = 
        new Circle { Radius = 2 };
    
      public ref Circle GetCircle()
      {
        return ref this.InnerCircle; // OK l’objet 
        // est membre d’un objet de type référence
      }
    }
  • D’un objet de type valeur statique:
    static Circle staticCircle = new Circle { Radius = 2};
    ref Circle GetCircle()
    {
      return ref staticCircle; // OK l’objet retourné 
      // est statique
    }
  • D’un objet stocké dans un tableau:
    ref Circle FindCircle(Circle[] circles, 
      int circleIndex)
    {
      return ref circles[circleIndex]; // OK l’objet 
      // appartient à un tableau
    }

Par contre, on ne peut pas retourner une variable locale d’une fonction:

ref Circle GetCircle()
{
  Circle localCircle = new Circle { Radius = 4 };
  return ref localCircle; // ERREUR, l’objet est perdu 
  // à la sortie de la stack frame
}
Retour de fonction
par référence
en lecture seule
(return ref)
ref readonly <type> FunctionName()
{
  // ...
  return ref <variable>;
}
Même restriction que pour un retour de fonction par référence simple.
Un membre en readonly doit obligatoirement être retourné en readonly:

class CircleWrapper
{
  private readonly Circle InnerCircle = 
    new Circle { Radius = 2 };

  // OK retour en readonly
  public readonly ref Circle GetReadOnlyCircle() 
  {
    return ref this.InnerCircle;
  }

  // ERREUR le retour doit être en readonly
  public ref Circle GetCircle()
  {
    return ref this.InnerCircle;
  }
}

L’affectation dans une variable d’une fonction en readonly doit obligatoirement être en readonly.

private static Circle staticCircle = 
  new Circle { Radius = 2};
public static ref readonly Circle GetCircle()
{
  return ref staticCircle;
}

// ...
ref readonly var readOnlyCircle = ref GetCircle(); // OK
ref var readOnlyCircle = ref GetCircle(); // ERREUR

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Indiquer qu’un argument
de méthode est en
lecture seule avec in
void MethodName(in <type> argument) 
{ ... }
L’affectation d’un membre d’un argument avec in n’est pas possible.
Par exemple:

struct Circle
{
  public int Radius;

  public void SetRadiusFromInside(int newRadius)
  {
    this.Radius = newRadius;
  }
}

static void ChangeRadiusFromOutside(in Circle circle, 
  int newRadius)
{
  // ERREUR à cause de in
  circle.Radius = newRadius; 

  // Pas d’erreur mais la valeur n’est pas modifiée 
  // à cause de la defensive copy
  circle.SetRadiusFromInside(newRadius); 
  // ATTENTION: il faut éviter de modifier 
  // la valeur de l’argument dans le corps de la méthode
}

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

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

Requêter les éléments d’une vue d’un composant Angular

Pour récupérer l’instance d’un objet se trouvant sur la vue dans la classe d’un composant, il est possible d’effectuer des requêtes auprès de cette vue et renseigner un membre ou une propriété de la classe avec l’instance de cet objet. L’objet requêté peut être un composant enfant, une directive ou un objet du DOM.

@juanster

Pour effectuer un requêtage sur la vue, on peut s’aider de plusieurs décorateurs: @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentChildren().

Ces décorateurs se placent devant la propriété ou le membre de la classe du composant dans lesquels l’instance doit être renseignée.

En Javascript, l’équivalent de ces décorateurs pourrait être:

document.getElementById('id-element'); 

Le choix du décorateur à utiliser dépend du type d’objet à requêter:

  • @ViewChild() et @ViewChildren() permettent de requêter un objet de la vue. Cet objet peut être un objet Angular ou un objet du DOM. @ViewChild() retourne le 1er objet correspondant aux identifiants indiqués en argument; @ViewChildren() renvoie une liste d’objets correspondants.
  • @ContentChild() et @ContentChildren() retournent un ou plusieurs objets se trouvant dans le composant par projection de contenu. A la différence de @ViewChild(), le contenu par projection n’est pas initialisé au même moment que les autres éléments de la vue d’un composant. @ContentChild() retourne le 1er objet correspondant aux identifiants; @ContentChildren() renvoie une liste d’objets correspondants.

Requêter un élément d’une vue

Pour requêter un élément de la vue d’un composant, il faut utiliser @ViewChild() ou @ViewChildren().

@ViewChild()

Le décorateur @ViewChild() permet d’accéder à un élément implémenté dans le template d’un composant:

  • Si l’élément est un composant enfant alors @ViewChild() permettra d’accéder à l’instance de ce composant.
  • Si l’élément est un objet du DOM alors @ViewChild() permettra d’accéder à cet objet par l’intermédiaire d’un objet de type ElementRef.

Pour comprendre l’intérêt de @ViewChild(), dans un premier temps imaginons que l’on souhaite imbriquer un composant dans un autre. Plusieurs syntaxes sont possibles:

Ces 2 méthodes permettent d’effectuer l’imbrication directement à partir des fichiers templates sans effectuer d’implémentation particulière du coté des classes des composants.

Par exemple, si on considère 2 composants ChildComponent et ParentComponent utilisés respectivement pour être le composant enfant et le composant parent. Pour imbriquer le composant ChildComponent dans le composant ParentComponent en utilisant le paramètre selector, l’implémentation pourrait être:

  • Pour le composant enfant:
    Template
    <p>Child component</p>
    Classe du composant
    import { Component } from '@angular/core';
    
    @Component({
      selector: 'app-child',
      templateUrl: './child.component'
    })
    export class ChildComponent() {}
  • Pour le composant parent:
    Template
    <p>Parent component</p>
    <app-child></app-child>
    Classe du composant
    import { Component } from '@angular/core';
    
    @Component({
      templateUrl: './parent.component'
    })
    export class ParentComponent() {}

Avec cette implémentation, on peut faire référence au composant enfant à partir du template du composant parent en utilisant une variable référence. Si on souhaite accéder à une propriété du composant enfant pour l’afficher, on peut utiliser l’implémentation suivante:

  • Pour le composant enfant:
    Template
    <p>Child component</p>
    Classe du composant
    import { Component } from '@angular/core';
    
    @Component({
      selector: 'app-child',
      templateUrl: './child.component'
    })
    export class ChildComponent() {
      internalValue = 'Value to display';
    }
  • Pour le composant parent:
    Template
    <p>Parent component</p>
    <app-child #child></app-child>
    <p>Child internal value: {{child.internalValue}}</p>
    Classe du composant
    import { Component } from '@angular/core';
    
    @Component({
      templateUrl: '/parent.component'
    })
    export class ParentComponent() {}

Si on souhaite accéder au membre internalValue du composant enfant à partir de la classe du composant parent, il n’y a pas de méthode directe.

Le but du décorateur @ViewChild() est de donner une méthode pour accéder à un composant utilisé dans le template.

Plus généralement @ViewChild() permet d’accéder à un composant, une directive ou un objet du DOM implémenté dans le template à partir de la classe du composant. Ainsi en préfixant une propriété avec le décorateur, la propriété sera automatiquement bindée avec l’objet se trouvant dans le template.

Requêter un composant enfant

En reprenant l’exemple précédent, on ajoute dans la classe du composant parent le membre childReference avec le décorateur @ViewChild():

Template
<p>Parent component</p>
<app-child #child></app-child>
<p>Child internal value: {{child.internalValue}}</p>
Classe du composant
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { ChildComponent } from '../child/child.component';

@Component({
  templateUrl: './parent.component'
})
export class ParentComponent implements AfterViewInit {
  @ViewChild(ChildComponent, { static: false }) childReference: ChildComponent;

  ngAfterViewInit() {
    console.log(this.childReference.internalValue);
  }
}

Ainsi la propriété childReference est automatiquement bindée avec l’instance du composant enfant seulement quand l’évènement AfterViewInit ou OnInit est déclenché suivant la valeur de static.

Callbacks ngAfterViewInit() ou ngOnInit()

Quand on utilise le décorateur @ViewChild(), le binding de l’élément n’est pas effectué dès la construction de la classe mais après le déclenchement des callbacks ngAfterViewInit() ou ngOnInit() suivant la valeur du paramètre static (pour plus de détails voir Paramètre static):

  • @ViewChild(<type de l'objet>, { static: false }) le binding sera effectué quand la callback ngAfterViewInit() est déclenchée. La classe du composant doit dériver de AfterViewInit:
    export class ParentComponent implements AfterViewInit {
      ngAfterViewInit(): void {}
    }
    
  • @ViewChild(<type de l'objet>, { static: true }) le binding sera effectué quand la callback ngOnInit() est déclenchée. La classe du composant doit dériver de OnInit:
    export class ParentComponent implements OnInit {
      ngOnInit(): void {}
    }
    

Requêter une directive

La syntaxe est indentique à celle utilisée avec les composants. Par exemple si on considère la directive suivante:

import { Directive, ElementRef, Renderer2 } from '@angular/core';

@Directive({
  selector: '[contentFiller]'
})
export class SimpleDirective {
  constructor(private elem: ElementRef, private renderer: Renderer2) { 
    let newText = renderer.createText('Content from directive');  
    renderer.appendChild(elem.nativeElement, newText); 
  }
}

Il s’agit d’une directive attribut (i.e. attribute directive) rajoutant le texte 'Create from directive' dans son élément hôte.

Pour appeler la directive à partir du composant hôte, le template du composant est:

<p>Parent component</p>
<p contentFiller #directiveHost></p>

En utilisant @ViewChild() avec la variable référence #directiveHost dans la classe du composant, on obtient l’implémentation suivante:

import { Component, AfterViewInit, ViewChild } from '@angular/core';
import { SimpleDirective } from '../simple.directive';

@Component({
  templateUrl: './parent.component.html',
  styleUrls: ['./parent.component.css']
})
export class ParentComponent implements AfterViewInit {
  @ViewChild('directiveHost', { read: SimpleDirective }) innerDirective: SimpleDirective;

  constructor() { }

  ngAfterViewInit(): void {
    console.log(this.innerDirective);
  }
}

On remarque quand dans cet exemple, on utilise l’option { read: SimpleDirective } dans la directive @ViewChild() de façon à effectuer la résolution avec le directive. Si on ne précise pas cette option, la résolution se fera sur l’élément hôte de la directive.
Dans cet exemple, l’élément hôte de la directive est une élément HTML <p></p>.

Requêter un objet du DOM

@ViewChild() permet de binder un objet du DOM avec un membre du composant.

Par exemple, si on considère le code suivant pour la classe parente:

Template
<p>Parent component</p>
<span #spanElement>Span content</span>
Classe du composant
import { Component, ViewChild, AfterViewInit, ElementRef } from '@angular/core';

@Component({
  templateUrl: './parent.component'
})
export class ParentComponent implements AfterViewInit {
  @ViewChild('spanElement', { static: false }) spanReference: ElementRef;

  ngAfterViewInit() {
    console.log(this.spanReference.nativeElement);
  }
}

Ainsi, avec une variable référence pour désigner l’objet span, on peut utiliser le décorateur @ViewChild() pour binder l’objet du DOM avec le membre spanReference. Le binding sera effectué quand la callback ngAfterViewInit() ou ngOnInit() est déclenchée suivant la valeur de static.

L’objet du DOM est wrappé dans un objet de type ElementRef. Cet objet permet de récupérer un élément du DOM en utilisant la propriété nativeElement.

@ViewChildren()

Le décorateur @ViewChildren() permet de requêter la vue pour retourner les instances d’objets s’y trouvant. La différence avec ViewChild() est que @ViewChildren() renvoie tous les objets satisfaisants les conditions de la requête (@ViewChild() ne retourne que le 1er objet).

@ViewChildren() peut être utilisé pour retourner un composant ou une directive en précisant le type de l’objet dans le paramètre selector. Il est possible de requêter plusieurs objets en précisant plusieurs noms.

Requêter les objets suivant leur type

Si le paramètre selector est un type alors tous les objets correspondant à ce type seront retournés.

Par exemple, si on considère l’exemple suivant:

  • Le composant ChildComponent:
    Template
    <p>Child Component</p>
    Classe du composant
    @Component({ 
      selector: 'child',
      templateUrl: './child.component' 
    }) 
    export class ChildComponent {}
  • Le composant ParentComponent:
    Template
    <p>Parent Component</p> 
    <child></child> 
    <child></child> 
    <child></child>
    Classe du composant
    import { Component, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; 
    import { ChildComponent } from '../child/child.component';
    
    @Component({ 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent implements AfterViewInit { 
      @ViewChildren(ChildComponent) childReferences: QueryList<ChildComponent>;
    
      ngAfterViewInit(): void { 
        console.log(this.childReferences); 
      } 
    }

Dans ce cas, childReferences contient les 3 références de composant enfant ChildComponent.

Le résultat est:

Parent Component
Child Component
Child Component
Child Component

Requêter les objets suivant leur nom

Il est possible de requêter les objets en précisant les noms de ces objets en utilisant la syntaxe:

@ViewChildren('<nom variable ref 1>, <nom variable ref 2>, ..., <nom variable ref N>') references: QueryList<T>; 

Par exemple:

Template
<p>Parent Component</p> 
<child #child1></child> 
<child #child2></child> 
<child #child3></child>
Classe du composant
import { Component, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; 
import { ChildComponent } from '../child/child.component'; 

@Component({ 
  templateUrl: './parent.component.html' 
}) 
export class ParentComponent implements AfterViewInit { 
  @ViewChildren('child1, child2, child3') childReferences: QueryList<ChildComponent>;
 
  ngAfterViewInit(): void { 
    console.log(this.childReferences); 
  } 
}

Le résultat est le même que précédemment.

Requêter un contenu projeté

Pour requêter un contenu projeté dans la vue d’un composant, il faut utiliser @ContentChild() ou @ContentChildren() (voir Les composants enfant pour plus de détails sur la projection de contenu).

@ContentChild()

Le décorateur @ContentChild() permet de requêter la vue d’un composant dans le cadre d’une projection de contenu. Comme le contenu projeté provient de l’extérieur du composant, utiliser @ViewChild() ne permettra pas de requêter le contenu projeté car @ViewChild() effectue la recherche parmi les éléments du composant définis directement dans sa vue.

Si on prend l’exemple suivant:

  • Le composant ParentComponent projette un contenu dans le composant ChildComponent.
  • Le composant ChildComponent affiche le contenu projeté en utilisant <ng-content>.
  • Le code de ChildComponent est:
    Template
    <p>Child Component</p> 
    <ng-content></ng-content>
    Classe du composant
    @Component({ 
      selector: 'child', 
      templateUrl: './child.component.html' 
    }) 
    export class ChildComponent {}
  • Le code de ParentComponent est:
    Template
    <p>Parent Component</p> 
    <child> 
      <p #projectedContent>Projected content</p> 
    </child>
    Classe du composant
    @Component({ 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent {}

L’affichage de cet exemple est:

Parent Component
Child Component
Projected Content

Dans ChildComponent, on pourra requêter le contenu projeté grâce à <ng-content></ng-content> avec le décorateur @ContentChild():

Template
<p>Child Component</p> 
<ng-content></ng-content>
Classe du composant
import { Component, AfterContentInit, ContentChild, ElementRef } 
  from '@angular/core'; 

@Component({ 
  selector: 'child', 
  templateUrl: './child.component.html' 
}) 
export class ChildComponent implements AfterContentInit { 
  @ContentChild('projectedContent') projectedContent: ElementRef; 
 
  ngAfterContentInit(): void { 
    console.log(this.projectedContent); 
  } 
}

Le résultat sera affiché dans la console du browser.

Le requêtage est effectué juste avant ngAfterContentInit()

Quand on utilise @ContentChild(), le membre ou la propriété sera affecté juste avant le déclenchement de la callback ngAfterContentInit().

Requêter les objets suivant leur type

L’exemple précédent permettait d’effectuer une requête en utilisant le nom d’une variable référence toutefois, comme pour @ViewChild(), il est possible d’utiliser un type dans le paramètre selector de @ContentChild().

Par exemple, si on ajoute le composant OtherComponent:

Template
<p>Other Component</p>
Classe du composant
@Component({ 
  selector: 'other', 
  templateUrl: './other.component.html' 
}) 
export class OtherComponent {}

On peut effectuer une requête pour récupérer l’instance du composant OtherComponent se trouvant dans le contenu projeté:

Template
<p>Child Component</p> 
<ng-content></ng-content>
Classe du composant
import { Component, AfterContentInit, ContentChild, ElementRef } from '@angular/core'; 
import { OtherComponent } from '../other/other.component'; 

@Component({ 
  selector: 'child', 
  templateUrl: './child.component.html' 
}) 
export class ChildComponent implements AfterContentInit { 
  @ContentChild(OtherComponent) projectedContent: OtherComponent;

  ngAfterContentInit(): void { 
    console.log(this.projectedContent); 
  } 
}

Le code de ParentComponent est:

Template
<p>Parent Component</p> 
<child> 
  <other></other> 
</child>
Classe du composant
@Component({ 
  templateUrl: './parent.component.html' 
}) 
export class ParentComponent {}
Le requêtage par type s’applique aussi sur des directives

Comme pour @ViewChild(), le requêtage en utilisant un type s’applique plus généralement aux directives et pas seulement sur les composants. Dans l’exemple précédent, on aurait pû utiliser une directive au lieu d’utiliser le composant OtherComponent.

@ContentChildren()

Le décorateur @ContentChildren() permet de requêter le contenu projeté d’un composant pour retourner toutes les instances des objets satisfaisants aux conditions de la requête. La différence avec @ContentChild() est que @ContentChildren() renvoie toutes les instances satisfaisants aux conditions et pas seulement le 1er objet trouvé comme pour @ContentChild().

@ContentChildren() peut être utilisé pour retourner un composant ou une directive en précisant le type de l’objet dans le paramètre selector. Il est possible de requêter plusieurs objets en précisant plusieurs noms.

Requêter les objets suivant leur type

L’exemple suivant permet de montrer comment requêter des objets suivant leur type. Dans cet exemple, plusieurs instances de OtherComponent sont projetés dans le composant ChildComponent. En utilisant le décorateur @ContentChildren(), on peut retourner une liste de toutes les instances de OtherComponent qui ont été projetées:

Template
<p>Other Component</p>
Classe du composant
@Component({ 
  selector: 'other', 
  templateUrl: './other.component.html' 
}) 
export class OtherComponent {}

On peut effectuer une requête pour récupérer l’instance du composant OtherComponent se trouvant dans le contenu projeté:

  • Le code du composant ChildComponent:
    Template
    <p>Child Component</p> 
    <ng-content></ng-content>
    Classe du composant
    import { Component, AfterContentInit, ContentChildren, 
      ElementRef, QueryList } from '@angular/core'; 
    import { OtherComponent } from '../other/other.component'; 
    
    @Component({ 
      selector: 'child', 
      templateUrl: './child.component.html' 
    }) 
    export class ChildComponent implements AfterContentInit { 
      @ContentChildren(OtherComponent) projectedContent: QueryList<OtherComponent>;
    
      ngAfterContentInit(): void { 
        console.log(this.projectedContent); 
      } 
    }
  • Le code de ParentComponent est:
    Template
    <p>Parent Component</p> 
    <child> 
      <!-- Instance 1 --> 
      <other></other>  
      <!-- Instance 2 --> 
      <other></other> 
      <!-- Instance 3 --> 
      <other></other> 
    </child>
    Classe du composant
    @Component({ 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent {}

Le résultat est:

Parent Component
Child Component
Other Component
Other Component
Other Component

Requêter les objets suivant leur nom

Il est possible de requêter les objets en précisant les noms de ces objets en utilisant la syntaxe:

@ContentChildren('<nom variable ref 1>, <nom variable ref 2>, .., <nom variable ref N>') references: QueryList<T>; 

Par exemple:

Template
<p>Other Component</p>
Classe du composant
@Component({ 
  selector: 'other', 
  templateUrl: './other.component.html' 
}) 
export class OtherComponent {}

On peut effectuer une requête pour récupérer l’instance du composant OtherComponent se trouvant dans le contenu projeté:

  • Le code de ChildComponent:
    Template
    <p>Child Component</p> 
    <ng-content></ng-content>
    Classe du composant
    import { Component, AfterContentInit, ContentChildren, 
      ElementRef, QueryList } from '@angular/core'; 
    import { OtherComponent } from '../other/other.component'; 
    
    @Component({ 
      selector: 'child', 
      templateUrl: './child.component.html' 
    }) 
    export class ChildComponent implements AfterContentInit { 
      @ContentChildren('instance1, instance2, instance3') 
        projectedContent: QueryList<OtherComponent>;
    
      ngAfterContentInit(): void { 
        console.log(this.projectedContent); 
      } 
    }
  • Le code de ParentComponent est:
    Template
    <p>Parent Component</p> 
    <child> 
      <other #instance1></other>  
      <other #instance2></other> 
      <other #instance3></other> 
    </child>
    Classe du composant
    @Component({ 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent {}

Le résultat est le même que précédemment.

@ContentChild() et @ContentChildren() effectuent une recherche dans le DOM

Les décorateurs @ContentChild() et @ContentChildren() permettent d’effectuer une requête dans le contenu projeté dans le DOM. Cela ne veut pas dire que le contenu doit être affiché dans la vue correspondante ou que <ng-content></ng-content> doit être présent.

Par exemple, si on écrit le code suivant:

  • Pour un composant enfant:
    Template
    <p>Child component</p>
    Classe du composant
    import { Component, ElementRef, ContentChild, AfterContentInit } from '@angular/core'; 
    
    @Component({ 
      selector: 'child', 
      templateUrl: './child.component.html' 
    }) 
    export class ChildComponent implements AfterContentInit { 
      @ContentChild('content') content: ElementRef; 
    
      ngAfterContentInit(): void { 
        console.log(this.content); 
      } 
    }
  • Pour le composant parent:
    Template
    <p>Parent component</p> 
    <child> 
      <p #content>Content</p> 
    </child>
    Classe du composant
    import { Component } from '@angular/core'; 
    
    @Component({ 
      selector: 'parent', 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent {}

On peut voir qu’il n’y a pas <ng-content></ng-content> et que le contenu projeté n’est pas visible:

Parent Component
Child Component

Pourtant le contenu projeté se trouve dans le DOM et @ContentChild() permet de récupérer l’objet correspondant se trouvant dans le DOM.

L’objet du DOM se trouve dans la propriété this.content.nativeElement. On peut voir que la valeur de la propriété this.content.nativeElement.isConnected est false et que this.content.nativeElement.parentNode est undefined expliquant pourquoi cet objet n’est pas visible.

Paramètre descendants dans @ContentChildren()

Le paramètre descendants permet d’indiquer si la requête porte sur les éléments se trouvant directement dans le contenu projeté ou s’il faut descendre parmi les descendants dans la hiérarchie HTML des éléments.

Par exemple si on considère la directive suivante:

import { Directive, ElementRef, ContentChildren, AfterContentInit, QueryList } from '@angular/core'; 

@Directive({ 
  selector: 'custom-directive', 
  templateUrl: './custom.component.html' 
}) 
export class CustomDirective implements AfterContentInit { 
  @ContentChildren('content') content: QueryList<ElementRef> 

  ngAfterContentInit(): void { 
    console.log(this.content); 
  } 
} 

Et le composant suivant:

Template
<p>Parent component</p> 
<custom-directive> 
  <div> 
    <p #content>Content</p> 
  </div> 
</custom-directive>
Classe du composant
import { Component } from '@angular/core'; 

@Component({ 
  selector: 'parent', 
  templateUrl: './parent.component.html' 
}) 
export class ParentComponent {}

Dans ce cas, @ContentChildren() ne permettra pas de récupérer l’élément HTML p qui est projeté car il se trouve dans un élément HTML div:

<div> 
  <p #content>Content</p> 
</div> 

Par défaut, @ContentChildren() ne descend pas dans l’arbre hiérarchique des éléments HTML et la valeur du paramètre descendants est false: { descendants: false }. Pour que @ContentChildren() puisse effectuer la requête en considérant des éléments HTML plus bas dans la hiérarchie, il faut rajouter l’option { descendants: true } dans @ContentChildren():

@Directive({ 
  ... 
}) 
export class CustomDirective implements AfterContentInit { 
  @ContentChildren('content', { descendants: true }) content: QueryList<ElementRef>  

  ngAfterContentInit(): void { 
    console.log(this.content); 
  } 
} 

Avec l’option { descendants: true }, l’élément p sera récupéré par @ContentChildren().

Paramètre read

Ce paramètre s’utilise avec les décorateurs @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentChildren(). Il permet d’ajouter un critère à la requête faite pour retourner l’objet de la vue. Le paramètre read permet d’indiquer un type de l’objet qui sera retourné. Ainsi si plusieurs objets existent avec le même nom de variable référence, l’objet retourné correspondra au type précisé par read.

Pour chaque élément se trouvant dans la vue, il existe un objet Angular correspondant de type ElementRef<any> ou ViewContainerRef. Avec le paramètre read, on peut alors préciser le type ElementRef<any> ou ViewContainerRef et obtenir l’instance de l’objet correspondant.

Si cet élément est un composant, en précisant le type du composant avec read, il est possible de retourner directement l’instance du composant.

L’exemple suivant permet de montrer que des éléments avec les mêmes identifiants dans la vue peuvent être utilisés de façon différente suivant la valeur du paramètre read.

On considère le composant suivant qui servira de composant enfant:

Template
<p>Child component</p>
Classe du composant
import { Component } from '@angular/core'; 

@Component({ 
  selector: 'child-component', 
  templateUrl: './child.component.html' 
}) 
export class ChildComponent {}

On implémente un 2e composant qui servira de parent:

  • Le fichier template est:
    <p>Read example component</p> 
      <p #content>Element content</p> 
    <child-component #child></child-component> 
    
  • La classe du composant est:
    import { Component, ViewChild, ElementRef, ViewContainerRef, AfterViewInit, ViewContainerRef } 
      from '@angular/core'; 
    import { ChildComponent } from '../child/child.component'; 
    
    @Component({ 
      templateUrl: './readexample.component.html' 
    }) 
    export class ReadExampleComponent implements AfterViewInit { 
      @ViewChild('content', { read: ElementRef }) contentElementRef: ElementRef; 
      @ViewChild('content', { read: ViewContainerRef }) contentViewContainerRef: ViewContainerRef; 
    
      @ViewChild('child', { read: ChildComponent }) child: ChildComponent; 
      @ViewChild('child', { read: ElementRef }) childElementRef: ElementRef; 
      @ViewChild('child', { read: ViewContainerRef }) childViewContainerRef: ViewContainerRef;
    
    
      ngAfterViewInit(): void { 
        console.log(this.contentElementRef); 
        console.log(this.contentViewContainerRef); 
    
        console.log(this.child); 
        console.log(this.childElementRef); 
        console.log(this.childViewContainerRef); 
      } 
    } 
    

A l’affichage du composant ReadExampleComponent, on peut voir dans la console du browser les différentes formes des objets affichés:

  • Dans le cas de l’élément p avec la variable référence content:
    • La propriété contentElementRef de type ElementRef qui est un objet Angular wrappant l’objet du DOM correspondant.
    • La propriété contentViewContainerRef de type ViewContainerRef qui est un objet Angular wrappant la vue correspondant à l’élément p.
  • Dans le cas du composant enfant ChildComponent:
    • Les objets équivalents de type ElementRef et ViewContainerRef.
    • child contenant l’instance du composant ChildComponent. La ligne @ViewChild() permettant d’effectuer le binding avec child peut être simplifié en:
      @ViewChild(ChildComponent) child: ChildComponent;
      

Paramètre static

Le paramètre static peut être utilisé avec les décorateurs @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentChildren(). Il permet d’indiquer quand doit être effectué la requête sur la vue. Pour comprendre pleinement le sens de ce paramètre, il faut avoir en tête le cycle de vie d’un composant et le fonctionnement de la détection de changements d’Angular. Le choix de la valeur de ce paramètre n’est pas anodin, un mauvais choix de sa valeur peut entraîner un échec de la requête sur la vue.

Pour résumer, Angular construit la vue d’un composant suivant 2 étapes:

  • Création du contenu statique de la vue: cette étape n’est effectuée qu’à l’initialisation d’un composant. Elle permet de créer les éléments statiques de la vue c’est-à-dire les éléments qui ne seront pas modifiés par la détection de changements. Cette étape n’est exécutée qu’une seule fois de façon à optimiser le traitement, étant donné que les éléments sont statiques, il n’est pas nécessaire de les mettre à jour.
  • Mise à jour du contenu dynamique: cette étape est répétée à chaque exécution de la détection de changements dans le cas où un changement a été détecté. Elle permet de mettre à jour les éléments d’une vue pouvant être affectée après la mise à jour d’une propriété du composant.

Le cycle de vie d’un composant découle directement du mécanisme de détection de changements. L’algorithme de détection de changements effectue les mises à jour des éléments graphiques suivant un ordre précis. Tout au long de ces mises à jour et suivant les éléments qui sont mis à jour, il va aussi exécuter les callbacks du cycle de vie des composants (i.e. Lifecycle hooks). L’ordre de déclenchement de ces callbacks est le suivant:

  • A l’initialisation du composant:
    1. ngOnChanges(): cette callback est exécutée si le composant contient des propriétés en entrée (notamment avec le décorateur @Input()).
    2. ngOnInit(): déclenchée après l’exécution du constructeur. Il permet d’initialiser le composant avec le 1er affichage des données de la vue ayant un binding avec des propriétés de la classe du composant. Le cas échéant, il permet d’affecter les paramètres en entrée du composant. Cette callback est déclenchée une seule fois à l’initialisation du composant même si ngOnChanges() n’est pas déclenchée.
    3. ngDoCheck() permet d’indiquer des changements si Angular ne les a pas détecté.
    4. ngAfterContentInit() est déclenchée à l’initialisation après la projection de contenu. Elle est déclenchée même s’il n’y a pas de contenu à projeter.
    5. ngAfterContentChecked(): déclenchée après la détection de changement dans le contenu projeté. Cette callback est déclenchée même s’il n’y a pas de projection de contenu.
    6. ngAfterViewInit(): déclenchée après l’initialisation de la vue du composant et après l’initialisation de la vue des composants enfant.
    7. ngAfterViewChecked() est déclenchée après détection d’un changement dans la vue du composant et dans la vue des composants enfant.
    8. ngOnDestroy() est déclenchée avant la destruction du composant.
  • A chaque détection de changements, les callbacks déclanchées sont, dans l’ordre:
    1. ngOnChanges() si les paramètres en entrée du composant sont modifiés.
    2. ngDoCheck()
    3. ngAfterContentChecked() est déclenchée même s’il n’y a pas de contenu projeté.
    4. ngAfterViewChecked()

A l’initialisation d’un composant, le DOM est mis à jour à 2 reprises: lors de la création du contenu statique de la vue et lors de la mise à jour du contenu dynamique. On peut résumer cette mise à jour, l’exécution des callbacks du cycle de vie et l’exécution des requêtes sur les vues dans le schéma suivant:

Légende du schéma
  1. A l’initialisation, le DOM est mis à jour avec le contenu statique de la vue.
  2. Les requêtes avec le paramètre { static: true } sur le contenu projeté (avec @ContentChild() ou @ContentChildren()) et sur la vue (avec @ViewChild() ou @ViewChildren()) sont exécutées sur le contenu statique de la vue.
  3. A l’exécution de la callback ngOnInit(), les requêtes sur le contenu statique ont été exécutées.
  4. Les requêtes avec le paramètre { static: false } sur le contenu projeté (avec @ContentChild() ou @ContentChildren()) sont exécutées. Le lien avec le composant parent n’apparaît pas sur ce schéma toutefois à ce state, le contenu dynamique du DOM du composant parent a été mise à jour.
  5. A chaque détection de changements, le contenu dynamique de la vue est mis à jour.
  6. Les requêtes avec le paramètre { static: false } sur la vue (avec @ViewChild() ou @ViewChildren()) sont exécutées sur le contenu dynamique de la vue.
  7. A l’exécution de la callback ngAfterViewInit(), les requêtes sur le contenu dynamique ont été exécutées.

Ce schéma permet de se rendre compte à quelle stade les requêtes sont exécutées suivant la valeur du paramètre static.

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

Template
<p>Example component:</p> 
<p #numberElement>{{miscNumber}}</p> 
Classe du composant
import { Component, OnInit, AfterContentInit, AfterViewInit, ViewChild, ElementRef }  
  from '@angular/core';  

@Component({  
  selector: 'example',  
  templateUrl: './example.component.html'  
})  
export class ExampleComponent implements OnInit, AfterContentInit, AfterViewInit {  
  miscNumber = 5; 

  @ViewChild('numberElement') numberElementRef: ElementRef<HTMLParagraphElement>;

  ngOnInit(): void {      
    if (this.numberElementRef)  
      console.log('From ngOnInit():', 
        this.numberElementRef.nativeElement.textContent); 
  }  

  ngAfterContentInit(): void {  
    if (this.numberElementRef)  
      console.log('From ngAfterContentInit():', 
        this.numberElementRef.nativeElement.textContent); 
  }  
 
  ngAfterViewInit(): void {      
    if (this.numberElementRef)  
      console.log('From ngAfterViewInit():', 
        this.numberElementRef.nativeElement.textContent); 
  }  
} 

Dans le template, on affiche la propriété miscNumber du composant. Dans le classe du composant, on effectue une requête sur la vue en utilisant la variable référence #numberElement. On implémente les callbacks ngOnInit(), ngAfterContentInit() et ngAfterViewInit() pour afficher le contenu de l’élément p identifié par la variable #numberElement.

Après exécution, seule la ligne suivante apparaît dans la console du browser:

From ngAfterViewInit(): 5 

Par défaut, si le paramètre static n’est pas renseigné dans la requête @ViewChild(), sa valeur est false. Cela signifie que la requête sera exécutée peu avant le déclenchement de la callback ngAgterViewInit(). Ainsi, le résutat de l’exécution s’explique par le fait que pour les autres callbacks, la requêtes @ViewChild() n’a pas été exécutée et la propriété numberElementRef est encore indéfinie. Le contenu de l’élément #numberElement ne peut donc pas être affiché.

Si on modifie la valeur du paramètre static telle que:

@ViewChild('numberElement', { static: true }) numberElementRef: 
    ElementRef<HTMLParagraphElement>;  

Le résultat de l’exécution devient:

From ngOnInit():  
From ngAfterContentInit():  
From ngAfterViewInit(): 5 

Ainsi, avec { static: true }, la requête @ViewChild() est exécutée juste avant le déclenchement de la callback ngOnInit() sur le contenu statique de la vue. Ce contenu contient l’élément p toutefois le résultat de l’interpolation {{miscNumber}} ne fait pas partie du contenu statique de la vue. La priopriété this.numberElementRef est définie mais this.numberElementRef.nativeElement.textContent est vide car le DOM n’a pas été mis à jour avec le contenu dynamique de la vue.

Une fois que le DOM a été mis à jour avec le contenu dynamique de la vue juste avant le déclenchement de la callback ngAfterViewInit(), this.numberElementRef.nativeElement.textContent contient le résultat de l’interpolation. Ainsi ngAfterViewInit() permet d’afficher le résultat de l’interpolation.

Cet exemple permet de montrer que suivant la valeur du paramètre static, l’exécution des requêtes sur les vues n’est pas effectuée au même stade:

  • Si static est true alors la requête est effectuée sur le contenu statique. Le résultat de la requête est disponible en amont du cycle de vie du composant.
  • Si static est false alors la requête est effectuée sur le contenu dynamique (elle peut aussi être exécutée sur le contenu statique). Le résultat de la requête est disponible plus tard dans le cycle de vue du composant.

Paramètre queries de @Directive()

Le paramètre queries est utilisable dans les décorateurs @Directive() et @Component() (puisque @Component() hérite de @Directive()). Ce paramètre permet d’effectuer les mêmes traitements qu’avec les décorateurs @ViewChild(), @ViewChildren(), @ContentChild() et @ContentChildren(), toutefois la syntaxe rend la lecture moins facile.

Au lieu d’utiliser les décorateurs devant les membres ou propriétés, on instancie les objets qui vont renseigner les valeurs des membres ou des propriétés. Les objets instanciés sont équivalents aux décorateurs: @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentChildren().

Par exemple, si considère 2 composants ChildComponent et ParentComponent tels que:

  • ChildComponent:
    Template
    <p>Child component</p>
    Classe du composant
    import { Component, ElementRef, ContentChild, AfterContentInit } from '@angular/core'; 
    
    @Component({ 
      selector: 'child', 
      templateUrl: './child.component.html', 
      queries: { 
        content: new ContentChild('content') 
      } 
    }) 
    export class ChildComponent implements AfterContentInit { 
      content: ElementRef; 
    
      ngAfterContentInit(): void { 
        console.log(this.content); 
      } 
    }
  • Pour le code du composant parent:
    Template
    <p>Parent component</p> 
    <child #child> 
      <p #content>Content</p> 
    </child>
    Classe du composant
    import { Component, ViewChild, AfterViewInit } from '@angular/core'; 
    import { ChildComponent } from '../child/child.component'; 
    
    @Component({ 
      selector: 'parent', 
      templateUrl: './parent.component.html' 
      queries: { 
        child: new ViewChild(ChildComponent) 
      }
    }) 
    export class ParentComponent implements AfterViewInit { 
      child: ChildComponent; 
    
      ngAfterViewInit(): void { 
        console.log(this.child); 
      } 
    }

Dans cet exemple, on utlise le paramètre queries dans des décorateurs @Component() pour instancier:

  • ContentChild() dans ChildComponent pour renseigner le membre content de type ElementRef contenant un objet Angular correspondant à l’élément HTML <p #content></p> projeté à partir de ParentComponent.
  • @ViewChild() dans ParentComponent pour renseigner le membre child de type ChildComponent contenant l’instance du composant enfant se trouvant dans le template de ParentComponent.

Pour résumer…

Requêter la vue d’un composant permet de récupérer l’instance d’un objet de façon à l’exploiter dans la classe du composant. Les objets requêtés peuvent être un composant enfant, une directive ou objet quelconque du DOM.

Le résultat des requêtes permet d’affecter des propriétés dans la classe du composant. Pour indiquer qu’une propriété doit être initialisée avec le résultat d’une requête sur la vue, il faut utiliser des décorateurs particuliers:

  • @ViewChild() pour effectuer une requête sur un seul objet directement dans la vue du composant. Seul le 1er objet de la vue satisfaisant la requête est renvoyé.
  • @ViewChildren() pour effectuer une requête sur une liste d’objets directement dans la vue du composant. Tous les objets satisfaisant la requête sont renvoyés.
  • @ContentChild() pour effectuer une requête sur un seul objet se trouvant dans du contenu projeté (i.e. content projection) sur la vue du composant. Seul le 1er objet de la vue satisfaisant la requête est renvoyé.
  • @ContentChildren() pour effectuer une requête sur une liste d’objets se trouvant dans du contenu projeté (i.e. content projection) sur la vue du composant. Tous les objets satisfaisant la requête sont renvoyés.

Les critères de la requête peuvent être de nature différente.

Requêter suivant un type

2 méthodes sont possibles pour spécifier le type de l’objet à requêter:

  • Spécifier directement le type en tant qu’argument du décorateur utilisé, par exemple pour requêter une directive dont le type est TypedDirective:
    @ViewChild(TypedDirective) requestedDirective: TypedDirective;

    Dans cet exemple, on a utilisé @ViewChild() toutefois les autres décorateurs utilisent la même syntaxe.

  • Dans le cas où on effectue une requête avec le nom d’une variable référence, on peut préciser le type attendu de l’objet en l’indiquant en utilisant l’option read.

    Par exemple, pour requêter une directive avec une variable référence 'directiveRef' et dont le type est TypedDirective:

    @ViewChild('directiveRef', { read: TypedDirective }) requestedDirective: TypedDirective;

Requêter suivant une variable référence

Si l’objet est identifié dans la vue en utilisant une variable référence, on peut requêter en utilisant le nom de cette variable.

Par exemple, pour requêter une directive avec une variable référence 'directiveRef':

@ViewChild('directiveRef') requestedDirective: TypedDirective;

Indiquer si l’objet fait partie du contenu statique de la vue

Le contenu de la vue est séparé en 2 parties:

  • Un contenu statique: ce contenu permet de mettre à jour le DOM seulement à l’initialisation de la vue. Ce contenu est initialisé juste avant le déclenchement des callbacks ngOnInit() et/ou ngDoCheck() du cycle de vie du composant.
  • Un contenu dynamique: cette partie de la vue est mise à jour à chaque détection de changement. Ce contenu est initialisé et mis à jour à des périodes différentes suivant l’objet requêté:
    • Si l’objet se trouve directement dans la vue du composant: il est requêté avec @ViewChild() ou @ViewChildren(). Le contenu dynamique de cet objet est initialisé et mis à jour juste avant le déclenchement des callbacks ngAfterViewInit() et/ou ngAfterViewChecked().
    • Si l’objet se trouve dans du contenu projeté: il est requêté avec @ContentChild() ou @ContentChildren(). Le contenu dynamique de cet objet est initialisé et mis à jour avant le déclenchement des callbacks ngAfterContentInit() et/ou ngAfterContentChecked().

Un objet peut être requêté dans le contenu statique ou dynamique de la vue suivant la valeur de l’option static:

  • { static: false }: valeur par défaut, elle permet de requêter le contenu statique et dynamique d’une vue. Le résultat de cette requête est disponible dans la propriété au déclenchement des callbacks:
    • ngAfterContentInit() et/ou ngAfterContentChecked() pour du contenu projeté.
    • ngAfterViewInit() et/ou ngAfterViewChecked() pour une requête directement sur la vue.
  • { static: true }: permet de requêter seulement le contenu statique d’une vue. Le résultat de cette requête est disponible dans la propriété au déclenchement des callbacks ngOnInit() et/ou ngDoCheck().

    Par exemple, pour requêter un élément HTML p nommé 'content' dans le contenu statique de la vue:

    @ViewChild('content', { static: true }) contentRef: ElementRef<HTMLParagraphElement>;
    

QueryList

Si on utilise @ViewChildren() ou @ContentChildren() pour requêter une liste d’objets, on peut utiliser la liste QueryList pour stocker la liste des objets.

Par exemple, pour requêter des directives de type TypedDirective directement sur la vue d’un composant:

@ViewChildren(TypedDirective) requestedDirectives: QueryList<TypedDirective>;

Option descendants

L’option { descendants: true } utilisable avec @ContentChildren() permet d’indiquer si la requête doit porter sur tous les descendants d’un élément dans la hiérarchie HTML. Par défaut, la requête est effectuée seulement sur les enfants directs.

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

Le scope des variables en Javascript

Le scope (ou la portée) d’un objet est la portion de code dans laquelle une variable peut exister et maintenir une valeur qui lui aura été préalablement affectée. Le scope des objets est loin d’être un sujet trivial en Javascript car suivant la façon dont on les déclare beaucoup de règles ou de comportements du langage ne sont pas forcément intuitifs et surtout sont opposés à ceux d’autres langages plus standards comme le C++, C#, Java etc…

@martinols3n

Le but de cet article est d’essayer d’indiquer tous les cas atypiques de comportements de la gestion des scopes des objets en Javascript pouvant mener à des erreurs d’implémentations.
Dans un premier temps, on va indiquer les règles générales liées au scope suivant l’utilisation de var, let ou const. Ensuite, on montrera quelle syntaxe permet de modifier les règles liées à la gestion du scope par le moteur Javascript. Dans un 3e temps, on développera les différents comportements du mot-clé this. Enfin, on indiquera quelles sont les règles liées à l’hoisting.

Pour déclarer des variables en Javascript de façon à éviter de déclarer dans le scope global, on utilise les mots-clé var ou let (ou const dans le cas d’une variable dont la valeur est constante). Suivant le mot-clé utilisé, le comportement du moteur Javascript sera très différent. Avec let et const, les comportements sont assez standards pour rapport à d’autres langages. En revanche, avec var, les comportements sont spécifiques et peuvent surprendre en particulier si on est habitué à d’autres langages.
On pourrait se demander quelle est la justification qui nécessite de s’intéresser à var, il suffirait d’utiliser uniquement let. La raison est que let est apparu avec ES6 (ES2015) et que beaucoup d’applications fonctionnent avec un code compatible ES5 utilisant var.

Variables globales

Un des plus gros inconvénient de Javascript est de permettre de déclarer des variables globales. Le scope de ces variables étant celui de l’application, elles garderont leur valeur dans toute l’application. Ainsi une même variable peut être valuée à des endroits différents du code et des conflits peuvent apparaître entre ces valuations dans le cas où elles sont incompatibles ce qui peut mener à des erreurs. D’une façon générale, il faut éviter d’utiliser des variables globales.

Les variables globales se déclarent de cette façon:

<nom variable> = <valeur>;

ou

window.<nom variable> = <valeur>;

Par exemple:

variable = 'OK';

ou

window.variable = 'OK';

Lexical scope vs dynamic scope

Quand Kyle Simpson présente le scope des objets en Javascript dans You don’t know JS, il explique le comportement de Javascript en passant par le lexical scope.
Le lexical scope ou static scope s’oppose au dynamic scope. Il s’agit d’un ensemble de règles utilisé par le moteur d’exécution d’un langage pour identifier une variable nommée et pour en déterminer la valeur. Le lexical scope est le comportement utilisé par la plupart des langages y compris C++, C# ou Java. Javascript utilise aussi cet ensemble de règles.

Dans la gestion du scope des variables, tous ces langages ont des comportements similaires:

  • Les scopes s’imbriquent: un scope peut se trouver à l’intérieur d’un autre scope. Les variables définies dans le scope parent sont accessibles dans le scope enfant.
  • Le scope le plus général est le scope global: dans le cas de Javascript, une variable globale est accessible partout.
  • La plupart du temps, un scope ne peut pas appartenir à plus d’un seul scope parent.

En Javascript, les règles qu’imposent le lexical scope sont les suivantes:

  • Règle 1: lorsque le moteur d’exécution exécute la déclaration d’une variable, le scope de la variable sera celui dans lequel se trouve la déclaration.
  • Règle 2: lorsque le moteur d’exécution cherche la valeur d’une variable, il recherche sa déclaration dans le scope courant. Si aucune déclaration n’est trouvée, le moteur cherche dans les scopes parent. Si aucune déclaration n’est trouvée alors la variable appartient au scope global.
  • Règle 3: si une variable est déclarée dans un scope parent et qu’une variable est déclarée avec le même nom dans un scope enfant, la variable dans le scope parent sera occultée par celle du scope enfant. Ce comportement correspond au shadowing.

Pour se rendre compte de la différence entre le lexical scope et le dynamic scope, on peut prendre l’exemple suivant:

var x = 1;

function a() {
  console.log('x in a(): ',x); // x => 1
  x = 2;
}

function b() {
  var x = 3; // x => 3
  a();
  console.log('x in b(): ',x); // x => 3
}

b();
console.log(x); // x => 2

Suivant le scope utilisé, le comportement est le suivant:

Lexical scope

Dynamic scope

Le dynamic scope utilise des piles durant l’exécution pour garder les valeurs des variables de façon dynamique.
Javascript a le comportement correspondant au lexical scope, il n’utilise donc pas le dynamic scope..
  1. Dans le corps principal, var x = 1 permet d’affecter la valeur 1 à la variable x (qu’on peut appeler x1).
  2. La méthode b() est appelée:
    1. Dans le corps de la méthode b(), var x = 3 permet d’affecter 3 à une nouvelle variable nommée x (qu’on peut appeler x2). La première variable x (i.e. x1) déclarée dans le corps principal est occultée par shadowing.
    2. On appelle la méthode a(), dans le corps de la méthode b():
      1. On affiche la valeur de x, pour savoir à quoi correspond cette variable:
        • Dans le scope de la méthode a(), y’a-t-il une déclaration d’une variable x dans le scope correspondant au corps de la méthode a() ? ⇒ non donc on cherche dans le scope parent de la méthode.
        • Dans le corps principal, y’a-t-il une déclaration d’une variable nommée x ? ⇒ oui avec var x = 1 (il s’agit de x1).
        • On peut en déduire la valeur de x qui est 1.
      2. On affecte une nouvelle valeur à la variable x. Par le même principe que précédemment, la variable x (i.e. x1) correspond à la variable du corps principal. La nouvelle valeur de cette variable est donc 2.
    3. Dans le corps de b(), on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable: y’a-t-il une déclaration d’une variable x dans le scope correspondant au corps de b() ? ⇒ oui à cause de var x = 3. La valeur de cette variable est donc 3 (il s’agit de x2).
  3. Dans le corps principal, on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable x: y’a-t-il une déclaration d’une variable x dans le scope du corps principal ? ⇒ oui à cause de var x = 1 (il s’agit de x1). Toutefois la valeur de cette variable a été modifiée lors de l’appel à la méthode a(). La valeur de x est 2.
  1. Dans le corps principal, var x = 1 permet d’ajouter à la pile la valeur 1 pour la variable x (qu’on peut appeler x1).
  2. La méthode b() est appelée:
    1. Dans le corps de la méthode b(), var x = 3 ajoute à la pile la valeur 3 pour une nouvelle variable x (qu’on peut appeler x2).
    2. On appelle la méthode a(), dans le corps de la méthode b():
      • On affiche la valeur de x. En regardant au sommet de la pile, on cherche une variable nommée x (il s’agit de x2) donc la valeur affichée est 3.
      • On affecte une nouvelle valeur à la variable x ⇒ en cherchant au sommet de la pile on trouve une variable nommée x (i.e. x2) donc la valeur est 3. On modifie cette valeur pour affecter 2.
    3. Dans le corps de b(), on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable, on cherche au sommet de la pile une variable nommée x ⇒ la variable trouvée est x2, la valeur affichée est donc 2. Quand on sort de la méthode b(), la variable nommée x au sommet de la pile est supprimée (x2 est donc supprimée de la pile).
  3. Dans le corps principal, on affiche la valeur d’une variable nommée x. Pour savoir à quoi correspond cette variable x: on regarde au sommet de la pile, la variable nommée x correspond à x1. La valeur de cette variable n’a jamais été modifiée, sa valeur est 1.

Les 2 comportements sont donc différents et impliquent des valeurs différentes suivants les scopes dans lesquels les affectations et les récupérations de valeurs sont faites.

var, let et const

Les mots-clé var, let et const permettent de déclarer une variable en limitant son scope: suivant le mot-clé utilisé, le scope de la variable sera très différent:

  • var permet de déclarer une variable dans le scope global ou dans le scope d’une fonction.
  • const et let permettent de déclarer une variable dans le scope d’un bloc de code.

Déclarer une variable avec ces mots-clé est possible avec la syntaxe suivante:

var <nom variable>;
let <nom variable>;

Avec const, une initialisation est obligatoire.

Pour initialiser une variable en même temps que sa déclaration, la syntaxe est:

var <nom variable> = <valeur d'initialisation>
let <nom variable> = <valeur d'initialisation>
const <nom variable> = <valeur d'initialisation>

Scope avec let et const

La gestion du scope est similaire entre let et const. La différence entre ces mots-clé est que:

  • let autorise des affectations d’une nouvelle valeur à une variable.
  • const interdit de nouvelles affectations.

Si on exécute:

const a = 3;
a = 5; // SyntaxError

On obtient une erreur de type 'SyntaxError: redeclaration of const a'.

let et const sont apparus à partir de ES6 et ont pour but d’avoir un comportement similaire à la plupart des autres langages: ils limitent le scope d’une variable à un bloc de code. Un bloc de code étant la portion de code délimitée par { ... }, par exemple:

  • Une fonction:
    function add(a, b) {
      // ...
    }
    
  • Une clause if...else, for etc:
    for (let i = 0; i < 10; i++) {
      // ...
    }
    
  • Le contenu des blocs dans un try...catch:
    try {
      // ...
    }
    catch (err) {
      // err est limité au bloc catch
    }
    
  • Une bloc simple:
    {
      // ...
    }
    
  • Une bloc nommé:
    namedBlock: {
      // ...
    }
    
  • Un objet:
    var obj = {
      // ...
    };
    

Ainsi avec let ou const, le scope d’une variable est limité au bloc de code dans lequel elle est définie et dans les blocs enfant.

Le comportement est similaire à la plupart des autres langages, une variable déclarée dans un bloc de code ne sera pas accessible en dehors de ce bloc et de ses blocs enfant (sauf en cas de shadowing).

Par exemple:

const a = 'OK';
if (a === 'OK') {
  let b = 5;
  console.log('Inside if: a=',a,' ;b=',b); // 'Inside if: a=OK ;b=5'
}

console.log('Outside if: a=',a); // le résultat est 'Outside if: a=OK'
console.log('Outside if: b=',b): // Reference Error

Dans le code plus haut, on peut voir 2 blocs de code: celui en dehors de la clause if et celui en dehors. Ainsi:

  • La variable a a été définie dans le bloc en dehors de la clause if, elle est donc disponible dans les 2 blocs.
  • La variable b a été définie dans le bloc du if, elle n’est accessible que dans ce bloc.

Scope avec var

Contrairement à let et const, le scope des objets avec var correspond aux fonctions. Les blocs de code ne limitent pas les scopes quand on déclare une variable avec var.

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

var a = 'OK';
if (a === 'OK') {
  var b = 5;
  console.log('Inside if: a=',a,' ;b=',b);
}

console.log('Outside if: a=',a,' ;b=',b);

Le scope est le même pour les variables a et b même si b est déclaré dans le corps de la clause if. A l’intérieur ou à l’extérieur de la clause if, le résultat est le même:

Inside if: a=OK ;b=5
Outside if: a=OK ;b=5
Fonction, fonction anonyme, arrow function et IIFE

La notion de scope n’est pas modifiée suivant le type de fonction. Dans le cas de var, le scope des variables correspond aux fonctions.

Si on considère le code suivant:

var x = 3;
function namedFunction() {
  var x = 2;
  console.log(x); // 2
}

namedFunction();

console.log(x); // 3

La règle du lexical scope s’applique dans le cadre de cet exemple.

  • Fonction anonyme (i.e. function expression): le comportement est le même pour une fonction anonyme:
    var x = 3;
    var anonymousFunction = function() {
      var x = 2;
      console.log(x); // 2
    }
    
    anonymousFunction();
    
    console.log(x); // 3
    
  • IIFE (i.e. Immediatly Invoked Function Expression) qui sont exécutées au même moment que leur déclaration:
    var x = 3;
    (function() {
      var x = 2;
      console.log(x); // 2
    })();
    
    console.log(x); // 3
    
  • Arrow function: à partir de ES6:
    var x = 3;
    var arrowFunction = () => {
      var x = 2;
      console.log(x); // 2
    })();
    
    arrowFunction();
    console.log(x); // 3
    

Comment modifier le comportement du lexical scope ?

Il est possible de modifier le comportement du lexical scope en utilisant eval() ou le constructeur Function().

eval()

eval() permet d’évaluer une expression sous forme de chaîne de caractères.

Par exemple:

let result = eval('1 + 3');
console.log(result); // 4

Ainsi eval() exécute l’expression sous forme de chaîne de caractères: '1 + 3'.

Le scope d’une variable utilisée dans une expression évaluée par eval() dépend du scope dans lequel l’expression est évaluée et non en fonction du scope dans lequel la chaîne évaluée est construite.

Par exemple, si on exécute:

function a(expr) {
  eval(expr);
  console.log('x=',x); // 4
}

var x = 3;
let expr = 'var x = 4';
a(expr);

console.log('x=',x); // 3

Le scope utilisé pour évaluer x avec eval() est celui de la fonction a(). Même si l’expression expr a été construite à l’extérieure de a(), c’est le scope dans lequel eval() est exécuté qui sera utilisé.
Dans cet exemple, quand expr est évaluée dans le corps de la méthode a(), var x = 4 occulte la première déclaration de x pour définir une 2e variable nommée x dont la valeur est 4. À l’extérieur de a(), la variable x correspond à la variable déclarée avec var x = 3.

Comportement de eval() avec le mode strict

Si on utilise le mode strict, l’expression évaluée dans eval() possède un scope spécifique qui ne déborde pas du scope dans lequel eval() est exécuté.

Par exemple si on exécute le code suivant:

function a(expr) {
  "use strict"; 
  eval(expr); // Inside eval: 4
  console.log('x=',x); // 3
}

var x = 3;
let expr = "var x = 4; console.log('Inside eval: ',x)";
a(expr);

console.log('x=',x); // 3

Toutes les évaluation de x en dehors de eval() possède la valeur 3 car la variable utilisée est celle déclarée par var x = 3. Toutefois avec le mode strict, le scope de l’expression évaluée par eval() est limité, ainsi x possède la valeur 4 seulement dans l’expression évaluée.

Comportement de eval() avec const et let

Si on utilise const ou let pour déclarer une variable dans une expression évaluée par eval(), le scope est spécifique à l’expression évaluée, il ne déborde pas dans le scope dans lequel eval() est exécuté.

Par exemple, si on exécute le code suivant:

function a(expr) {
  eval(expr); // Inside eval: 4
  console.log('x=',x); // 3
}

let x = 3;
let expr = "let x = 4; console.log('Inside eval: ',x)";
a(expr);

console.log('x=',x); // 3

Toutes les évaluation de x en dehors de eval() possède la valeur 3 car la variable utilisée est celle déclarée par var x = 3. Le scope de l’expression évaluée par eval() est limité, x possède la valeur 4 seulement dans l’expression évaluée.

new Function()

La constructeur Function() permet de déclarer un objet de type Function. Utiliser ce constructeur permet d’évaluer une expression de la même façon qu’avec eval(). La syntaxe à utiliser est:

var objectFunction = new Function([arg1, arg2, ..., argN], expression);

Dans cet appel, expression contient la chaîne de caractères à évaluer.

On peut exécuter la fonction de cette façon:

objectFunction(arg1, arg1, ..., argN, '....');

Du point de vue de scope, cet objet ne se comporte ni comme une fonction normale, ni comme eval(): le scope parent d’un objet de type Function est le scope global et non le scope de la fonction parente.

Par exemple dans le cadre général, si une fonction est définie dans une autre fonction dite parente, le scope parent de la fonction est le scope de la fonction parente. C’est la règle du lexical scope.

Ainsi, si on exécute le code suivant:

function parent() {
  function a() {
    x = 2;
    console.log('x=',x); // x=2
  }

  var x = 3;
  a();

  console.log('x=',x,'window.x=',window.x); // x=2 window.x=undefined
}

parent();

La règle du lexical scope s’applique pour déterminer qu’elle est la déclaration d’une variable:

  • Dans le corps de la fonction a(): x désigne la variable déclarée avec var x = 3 donc partout x désigne la même variable.
  • Dans le corps de parent(): window.x est indéfinie puisqu’on a jamais affecté de valeur à la variable globale x.

Si on considère un code semblable utilisant un objet de type Function:

function parent() {
  var a = new Function("x = 2; console.log('x=',x);");
  var x = 3;

  a(); // x= 2

  console.log('x=',x,'window.x=',window.x); // x=3 window.x=2
}

parent();

Le scope parent de l’objet Function n’est pas la fonction parent() mais le scope global, ainsi:

  • Quand l’expression est évaluée x = 2 fera référence à x dans le scope parent c’est-à-dire le scope global. C’est la raison pour laquelle window.x renvoie la valeur 2.
  • L’expression évaluée dans le constructeur de Function() modifie x en tant que variable globale donc la variable déclarée par var x = 3 n’est pas modifiée et console.log('x=',x) dans le corps de parent() renvoie 3.

De même, le scope parent de eval() correpond, par défaut, au scope dans lequel eval() est exécuté. Ce qui est contraire au comportement de l’objet Function puisque son scope parent est le scope global.

this

this est un mot-clé très courant dans beaucoup de langage pour désigner l’instance courante d’une classe.

En Javascript, c’est aussi le sens de ce mot-clé dans le cadre des classes introduites à partir de ES6.

Par exemple:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  logPerson() {
    console.log(this.firstName, this.lastName);
  }
}

var person = new Person('Uncle', 'Bob');
person.logPerson(); // Uncle Bob

Dans cet exemple, this renvoie bien à l’instance courante de la classe Person.

En Javascript, le plus souvent this est utilisé dans un cadre différent des classes. Au lieu de représenter l’instance d’une classe, il désigne un contexte dans lequel le code est exécuté. Suivant les cas d’usage de this, sa valeur peut être plus difficile à prévoir que dans le cas d’une classe.

Parmi les cas les plus courants d’utilisation de this, il y a celui du corps d’une fonction (cette fonction n’appartient pas à une classe ou à un objet). Dans le cas d’une fonction:

  • En mode non strict: this désigne le contexte global.
  • En mode strict: this est indéfini sauf si on indique explicitement sa valeur.

Par exemple:

function a() {
  return this;
}

console.log(a() === window); // true

Dans ce cas, this désigne le contexte global.

Dans la cas du mode strict, this est indéfini car sa valeur n’a pas été indiquée explicitement:

function a() {
  "use strict";
  return this;
}

console.log(a()); // undefined

Dans le cas d’un objet, this dans une fonction désigne implicitement cet objet:

var obj = {
  x: 5,
  a: function() {
    return this;
  }
};

console.log(obj.a().x === 5); // true

Dans ce cas, this désigne l’objet obj.

Arrow function

D’une façon générale, les arrow functions ont les mêmes comportements que les fonctions normales c’est-à-dire que this désigne le contexte global ou l’objet englobant.

Par exemple, si on considère l’exemple suivant:

var arrowFunction = () => this; // Référence d'une arrow function

console.log(arrowFunction() === window); // true

Dans le cas de l’exemple précédent, on exécute dans le contexte global donc this dans l’arrow function désigne le contexte global.

Dans le cas d’une arrow function déclarée dans un objet, il faut distinguer l’appel à une fonction et le cas d’une référence vers une fonction.

Par exemple, si on place cette fonction dans un objet:

var obj = {
  x: 5,
  a: function() {
    var arrowFunction = () => this;
    return arrowFunction; // On retourne la référence de la fonction
  }
};

var fn = obj.a(); // On récupère la référence de l'arrow function dans le contexte de l'objet obj.
console.log(fn().x === 5); // True
console.log(obj.a().x === 5); // False
console.log(obj.a()().x === 5); // True

Ainsi:

  • console.log(fn().x === 5) retourne true car fn est une référence vers l’arrow function dans le contexte de l’objet obj. Quand on exécuté fn(), on exécute l’arrow function et this désigne l’objet obj. Par suite obj.x contient la valeur 5.
  • console.log(obj.a().x === 5) retourne false car obj.a() contient la référence de l’arrow function, il faut exécuter obj.a()() pour exécuter réellement l’arrow function.

Comment affecter une valeur à this ?

On peut affecter une valeur à this avec les fonctions call(), apply() ou bind().

call() et apply()

call() et apply() permettent de contrôler la valeur de this en indiquant quel est le contexte d’exécution d’une fonction.

La syntaxe de ces méthodes est:

  • call():
    <fonction>.call(thisVar, [arg1, arg2,..., argN]);
    

    Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [arg1, arg2,..., ArgN], les arguments de la fonction à exécuter.
    En mode non strict, si thisVar est nul, alors this contient le contexte global.

  • apply():
    <fonction>.apply(thisVar, [tableau d'arguments]);
    

    Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [tableau d'arguments], les arguments de la fonction à exécuter sous forme d’un tableau.
    En mode non strict, si thisVar est nul, alors this contient le contexte global.

Si on considère l’exemple suivant:

function a(z, w) {
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

console.log(a.call(null, 3, 4)); // 10
console.log(a.apply(null, [3, 4])); // 10

Les 2 appels précédents permettent d’exécuter a() dans le contexte global puisque la valeur indiquée de this est nulle. Dans le contexte global, x et y contiennent respectivement les valeurs 1 et 2.

Si on exécute:

console.log(a.call(obj, 3, 4)); // 18
console.log(a.apply(obj, [3, 4])); // 18

this contient obj donc a() est exécuté dans le contexte de obj. x et y contiennent respectivement les valeurs 5 et 6.

Si on modifie le code pour activer le mode strict:

function a(z, w) {
  "use strict";
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

console.log(a.call(null, 3, 4)); // TypeError: this is null
console.log(a.apply(null, [3, 4])); // TypeError: this is null

Si le mode strict est activé et que this contient null, sa valeur n’est pas remplacée par le contexte global. a() ne peut pas effectuer l’addition car this est nul.

bind()

A partir de ES5, bind() permet aussi de préciser une valeur de this en générant une référence vers une nouvelle fonction. Cette nouvelle fonction sera liée au contexte indiqué lors de l’exécution de bind().
La syntaxe de bind() est:

let <référence fonction> = <fonction>.bind(thisVar, [arg1, arg2,..., argN]);

Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [arg1, arg2,..., ArgN], les arguments de la fonction à exécuter.

Avec bind(), le cas d’usage est un peu différent de call() et apply(). Si on reprend l’exemple précédent:

function a(z, w) {
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

var boundA = a.bind(null, 3, 4); // On définit une nouvelle référence sans indiquer de valeur pour this
console.log(boundA()); // 10

Si on ne précise pas de valeur pour this, c’est le contexte global qui est utilisé. Dans cet exemple, dans le corps de a(), this fait référence au contexte global, x et y ont pour valeur, respectivement, 1 et 2.

Si on indique une autre valeur pour this:

var boundA = a.bind(obj, 3, 4);
console.log(boundA()); // 18

Dans cet appel, this fait référence à l’objet obj donc x et y ont pour valeur, respectivement 5 et 6.

Enfin dans le cas du mode strict, si on ne précise pas de valeur pour this, sa valeur restera nulle. Si on reprend l’exemple précédent:

function a(z, w) {
  "use strict";
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

var boundA = a.bind(null, 3, 4);
console.log(boundA()); // TypeError: this is null

Avec le mode strict, this est nul dans le corps de la fonction a() d’où l’erreur.

setTimeout()

Avec setTimeout(), la valeur par défaut de this est le contexte global. Ce comportement peut prêter à confusion quand on utilise this dans une fonction exécutée par setTimeout().

Par exemple:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x);
  }
};

var x = 3;

obj.a(); // 5

Dans ce dernier appel, this fait référence à l’objet obj puisque a() se trouve dans cet objet.

En revanche si on exécute le code suivant, this fait référence au contexte global:

setTimeout(obj.a, 100); //3

C’est la 3 qui s’affiche car c’est la variable x déclarée dans le contexte global qui est utilisée.

La solution peut consister à utiliser une arrow function (disponible à partir de ES6):

setTimeout(() => obj.a(), 100); // 5

Cet appel permet de forcer l’appel dans le contexte de l’objet obj.

On peut aussi utiliser bind() (à partir de ES5) pour indiquer explicitement la valeur de this:

var boundA = obj.a.bind(obj);
setTimeout(boundA, 100); // 5

Enfin, on peut utiliser une variable locale et encapsuler l’appel à setTimeout dans la fonction déclarée dans l’objet obj:

var obj = {
  x: 5,
  a: function() {
    var self = this;
    setTimeout(function() {
      console.log(self.x);
    }, 100);
  }
};

var x = 3;

obj.a(); // 5

Closure

Dans le cadre des closures (i.e. fermetures), le comportement de this peut prêter à confusion car Javascript perd le scope de this quand il est utilisé dans une fonction qui se trouve dans une autre fonction.

Qu’est ce qu’une closure ?

Avant de rentrer dans les détails de this dans le cadre d’une closure, on peut rappeler la définition d’une closure (les closures ne sont pas spécifiques à Javascript):

D’après MDN web docs:

“Une fermeture correspond à la combinaison entre une fonction et l’environnement lexical au sein duquel elle a été déclarée. En d’autres termes, une fermeture donne accès à la portée d’une fonction externe à partir d’une fonction interne. En Javascript, une fermeture est créée chaque fois qu’une fonction est créée.

L’exemple le plus courant d’une closure est celui intervenant dans le cadre d’une boucle:

for (var i = 1; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

A l’exécution, on s’attendrait à voir s’afficher 1, 2, 3, etc… toutefois on voit 5 fois s’afficher 6. Ce comportement s’explique par le fait que la fonction anonyme se trouvant dans setTimeout() utilise le scope de la fonction externe, or dans le scope externe i varie de 1 à 6. Étant donné que setTimeout() retarde l’exécution de la fonction anonyme pendant 100 ms, les valeurs affichées correspondent à la dernier valeur de i.

Pour empêcher ce comportement, il faudrait que la fonction anonyme utilise une variable dont le scope ne dépend pas de la fonction externe. Par exemple, si on utilise une IIFE exécutée à chaque boucle et si on utilise une variable locale à l’IIFE, on pourra utiliser une variable dont la valeur est spécifique à chaque boucle:

for (var i = 1; i < 5; i++) {
  // IIFE exécutée à chaque boucle
  (function() {
    var j = i; // Variable locale
    setTimeout(function() {
      console.log(j); // On utilise la variable locale
    }, 100);
  })();
}

A l’exécution, on affiche les différentes valeurs: 1, 2, 3, etc…

Conséquences avec this

Comme on l’a indiqué plus haut:

En Javascript, une fermeture est créée chaque fois qu’une fonction est créée“.

Ainsi dans le cadre de l’exemple suivant, la closure (i.e. fermeture) entraîne le comportement du lexical scope: pour connaître la valeur d’une variable, le moteur vérifie le scope courant puis les scopes parent jusqu’à ce qu’il trouve la déclaration.

Par exemple:

function a() {
  var x = 5;
  var nestedA = function() {
    console.log(x); // 5
  }
  
  nestedA();
}

a();

La valeur affichée sera 5 dans le corps de nestedA() car cette fonction a accès au scope de la fonction externe c’est-à-dire celui de a(). x étant déclarée dans le corps de a() alors la valeur affichée sera 5.

On pourrait s’attendre à ce que le comportement de this soit identique, par exemple:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    var nestedA = function() {
      console.log(this.x); // undefined
    };
    nestedA();
  }
};

obj.a();

On pourrait s’attendre à ce que this.x dans nestedA() contienne la valeur 5. Ce n’est pas le cas car le scope de this est perdu quand une fonction se trouve dans une autre fonction. Dans nestedA(), this ne correspond pas à l’objet obj (comme c’est la cas dans le corps de a()) mais au scope global. Ainsi, this.x dans nestedA() est indéfinie.

Le comportement est le même si on utilise une IIFE:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    (function() {
      console.log(this.x); // undefined
    })();
  }
};

obj.a();

Par contre avec les arrow functions implémentées à partir de ES6, le comportement est plus standard:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    var nestedA = () => {
      console.log(this.x); // 5
    };
    nestedA();
  }
};

obj.a();

Dans le cadre des arrow functions, this correspond bien à l’objet obj.

Hoisting avec var

L’utilisation de var pour déclarer des variables entraîne un comportement qui peut prêter à confusion: l’hoisting (i.e. hisser). Le principe de ce comportement est le suivant:

  1. Le moteur Javascript effectue 2 passes sur le code avant de l’exécuter:
    • Une 1ère passe pour référence toutes les déclarations de variables, de fonctions et les définitions des fonctions.
    • Une 2e passe permet d’affecter à ces objets leur valeur d’initialisation (s’il y en a une).
  2. Ces 2 passes entraînent que l’ordre de traitement des lignes de code par le moteur Javascript n’est pas toute à fait le même que celui du script:
    • Les déclarations sont traitées avant les affectations (même si dans le script l’affectation d’une variable est placée avant la déclaration).
    • Les déplacements des lignes traitées sont effectués à l’intérieur d’un même scope et se limite au scope d’une fonction.
    • Les déclarations de fonction sont déplacées avant les autres objets.

Par exemple, si on exécute le code suivant:

function a() {
  x = 5;
  var x;
  console.log(x); // 5
  console.log(window.x); // undefined
}

a();

On pourrait penser qu’étant donné l’ordre des lignes dans a(), x = 5 correspondrait à l’affectation de 5 à la variable globale x. Mais le comportement de hoisting entraîne la modification de l’ordre de traitement des lignes par le moteur Javascript. Dans la réalité, les lignes sont exécutées de cette façon:

function a() {
  var x;
  x = 5;
  console.log(x); // 5
  console.log(window.x); // undefined
}

a();

A l’intérieur du scope de la fonction a(), var x est traité avant x = 5.

Pas d’hoisting avec let ou const

Si on exécute:

function a() {
  x = 5; // ReferenceError: can't access lexical declaration 'x' before initialisation
  let x;
  console.log(x);
  console.log(window.x);
}

a();

Une erreur explicite indique qu’on peut pas inverser l’ordre des lignes, la déclaration doit se trouver avant l’initialisation.

Hoisting valable pour les fonctions anonymes et les arrow functions
Le même comportement d’hoisting est valable pour les fonctions anonymes (i.e. function expressions), par exemple:

a = function() {
  return 'from function expression';
}

var a;

console.log(a()); // from function expression
console.log(window.a()); // window.a is not a function

Même comportement que précédemment, la déclaration de la fonction a() est déplacée par hoisting.

Le comportement est le même pour les arrow functions:

a = () => {
  return 'from arrow function';
}

var a;

console.log(a()); // from arrow function
console.log(window.a()); // window.a is not a function

Les déclarations de fonctions sont déplacées avant les autres objets
Les déclarations des fonctions sont traitées prioritairement par rapport aux déclarations de variables.
Si on exécute le code suivant:

console.log(a()); // from a()

a = function() {
  return 'from function expression';
}

var a;

function a() {
  return 'from a()';
}

L’hoisting déplace le code de cette façon:

function a() {
  return 'from a()';
}

console.log(a()); // from a()

a = function() {
  return 'from function expression';
}

En résumé…

D’une façon générale, il faut éviter de déclarer des variables dans le scope global en javascript. Pour que la portée d’une variable soit limitée, on utilise les mots-clé var, let ou const:

  • var est compatible avec toutes les versions de Javascript toutefois il entraîne certains comportements qui ne sont pas standards.
  • let est apparu à partir de ES6. La portée d’une variable déclarée avec ce mot-clé est standard par rapport aux autres langages.
  • const est similaire à let à la différence qu’il n’autorise pas de nouvelles affectations à une variable.

Le scope d’une variable se limite à un bloc de code pour let et const. Avec var, le scope correspond à une fonction, un bloc de code ne limite pas la portée.

Lexical scope
Le moteur Javascript utilise le lexical scope pour déterminer la déclaration d’une variable. Suivant les différents mots-clé utilisés la notion de scope ne sera pas la même toutefois la recherche de la déclaration d’une variable se fait de la même façon:

  • Lorsque le moteur d’exécution exécute la déclaration d’une variable, le scope de la variable sera celui dans lequel se trouve la déclaration.
  • Lorsque le moteur d’exécution cherche la valeur d’une variable, il recherche sa déclaration dans le scope courant. Si aucune déclaration n’est trouvée, le moteur cherche dans les scopes parent. Si aucune déclaration n’est trouvée alors la variable appartient au scope global.
  • Si une variable est déclarée dans un scope parent et qu’une variable est déclarée avec le même nom dans un scope enfant, la variable dans le scope parent sera occultée par celle du scope enfant. Ce comportement correspond au shadowing.

eval() et new Function()
On peut modifier le comportement du lexical scope avec eval() qui permet d’évaluer une expression sous forme de chaîne de caractères:

  • Le scope de l’expression évaluée par eval() est celui dans lequel eval() est exécuté. L’expression peut être construite dans un scope différent, toutefois c’est au moment de l’exécution que le scope de l’expression sera confondu avec le scope dans lequel eval() est exécuté.
  • En mode strict, le scope de l’expression évaluée par eval() est limité à eval(), il n’est pas confondu avec le scope dans lequel eval() est exécuté.

Le constructeur new Function() permet aussi d’évaluer une expression sous forme de chaîne de caractères. La différence avec eval() est que le scope parent d’un objet de type Function est le scope global et non le scope dans lequel new Function() est exécuté.

this
La valeur de this dépend du contexte dans lequel il est utilisé:

  • Dans une classe, this est l’instance de cette classe.
  • Dans un objet, this est l’instance de cet objet.
  • Dans une fonction n’appartenant pas à un objet:
    • En mode non strict, this est le contexte global
    • En mode strict, this est indéfini.

On peut affecter explicitement une valeur à this quand on exécute une fonction en utilisant:

  • call(): <fonction>.call(thisVar, [arg1, arg2,..., argN]);
  • apply(): <fonction>.apply(thisVar, [tableau d'arguments]);
  • bind() qui permet de créer une nouvelle référence vers une fonction: let <référence fonction> = <fonction>.bind(thisVar, [arg1, arg2,..., argN]);

Si on exécute une fonction avec setTimeout(), par défaut, this fait référence au contexte global.

Hoisting

  • Si on déclare une variable avec var, le comportement d’hoisting du moteur Javascript peut entraîner un déplacement des lignes de code d’un script pour prendre en compte la déclaration d’une variable avant son initialisation. Même si dans le script initial, l’initialisation se trouve avant la déclaration, le moteur prendra en compte d’abord la déclaration.
  • Il n’y a pas d’hoisting si on utilise let ou const.
  • L’hoisting est valable aussi pour les fonctions anonymes ou les arrow functions.
  • Les déclarations de fonctions sont déplacées avant les autres objets.
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Personnaliser la détection de changements dans une application Angular

@raychelsnr

Le but de cet article est d’expliquer quelques caractéristiques de la détection automatique de changements dans une application Angular. Ensuite, on indiquera comment on peut personnaliser cette détection pour améliorer les performances ou pour la solliciter de façon explicite.
Un autre article permet de rentrer dans les détails du fonctionnement de cette détection de changements: Fonctionnement de la détection de changement.

Une application Angular peut être impactée par des évènements provenant du browser comme:

  • Les évènements Javascript comme les clicks, keydown, submit, input etc… (Référence des événements),
  • Les évènements XHR provenant de l’objet XmlHttpRequest,
  • Les évènements provenant de Timers comme setTimeout() ou setInterval().

Certains de ces évènements peuvent entraîner des modifications sur des éléments de l’application et nécessiter une mise à jour des vues de certains composant. La détection automatique des changements provoqués par ces évènements permet de mettre à jour les éléments dans le DOM quand cela est nécessaire.

Ainsi, les changements pouvant impacter la vue d’un composant peuvent provenir de:

  • La mise à jour d’un binding dans un composant,
  • La mise à jour d’une expression dans une interpolation d’un template.

D’autres changements peuvent impacter des bindings se trouvant dans la classe du composant comme:

  • Les requêtes de vue avec @ViewChild() ou @ViewChildren(),
  • Les requêtes sur du contenu projeté avec @ContentChild() ou @ContentChildren().
  • Des bindings avec un élément natif hôte concernant des propriétés @HostBinding() ou des évènements avec @HostListener().

Lorsqu’au moins une des sources impacte un élément d’une vue, la détection de changements est exécutée en comparant les références des anciens bindings et les nouveaux de façon à détecter un changement. Dans le cas où un changement est détecté, les éléments natifs correspondant dans le DOM sont modifiés (pour plus de détails, voir le fonctionnement de la détection de changements).

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

Template
(example.component.html)
<p>Example composant</p>
<p>Click count: {{clickCount}}</p>
Classe du composant
(example.component.ts)
import { Component, HostListener } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html',
    styleUrls: ['./example.component.css']
})
export class ExampleComponent {
    clickCount = 0;

    @HostListener('click') onHostClicked(triggeredEvent): void {
        this.clickCount++;
    }
}
Style CSS
(example.component.css)
:host {
    width: 300px;
    height: 150px;
    background-color: lightblue;
    display: block;
    padding: 10px;
}

Dans cet exemple, on utilise @HostListener() pour s’abonner à l’évènement click de l’élément natif de la vue du composant de façon à effectuer un traitement pour chaque click sur cet élément.

A l’exécution, la vue de ce composant se présente de cette façon:

Exemple de détection de changements avec click

Si on clique sur la zone en bleu, le compteur s’incrémente. Pour que la propriété clickCount soit incrémentée, l’évènement est propagé de cette façon dans Angular:

  1. Un click sur un élément natif déclenche un évènement click.
  2. Cet évènement se propage dans Zone.js.
  3. Zone.js déclenche une callback correspondant à du code Angular qui lancera la méthode tick().
  4. La méthode tick() permet de déclencher la détection de changements dans l’application en commençant par le composant Root.
  5. La détection de changements est déclenchée dans le composant Example ce qui va entraîner la mise à jour du binding initié par @HostListener(). Cette mise à jour met à jour la propriété clickCount.
  6. En parallèle, le template est mis à jour et l’expression d’interpolation {{clickCount}} est évaluée ce qui modifie la valeur (car la propriété clickCount a changé de valeur).
  7. Une comparaison entre la nouvelle valeur de l’expression d’interpolation et l’ancienne permet d’indiquer qu’un changement a été détecté.
  8. Le changement permet d’effectuer la mise à jour de l’objet natif dans le DOM.

Détection des changements et cycle de vie des composants

La détection des changements est liée à l’exécution des callbacks du cycle de vie des composants (i.e. lifecycle hooks). Ainsi, la vérification des changements et la mise à jour éventuelle des éléments du DOM sont effectuées dans un certain ordre. Modifier un binding de façon trop tardive par rapport à la détection de changements dans le cycle de vie d’un composant peut ne pas entraîner de modifications dans la vue.

Le cycle de vie d’un composant est, dans l’ordre de déclenchement:

  • A l’initialisation du composant:
    1. ngOnChanges(): cette callback est exécutée si le composant contient des propriétés en entrée (notamment avec le décorateur @Input()). Si cette callback est implémentée sans paramètre, elle sera déclenchée autant de fois qu’il y a de propriétés en entrée du composant.

      Si la callback est implémentée avec un argument de type SimpleChanges:

      void ngOnChanges(changes: SimpleChanges): void {}
      

      Elle sera déclenchée une seule fois.

    2. ngOnInit(): déclenchée après l’exécution du constructeur. Elle permet d’initialiser le composant avec le 1er affichage des données de la vue ayant un binding avec des propriétés de la classe du composant. Cette callback est déclenchée une seule fois à l’initialisation du composant même si ngOnChanges() n’est pas déclenchée.
    3. ngDoCheck() permet d’indiquer des changements si Angular ne les a pas détecté.
    4. ngAfterContentInit() est déclenchée à l’initialisation après la projection de contenu. Elle est déclenchée même s’il n’y a pas de contenu à projeter.
    5. ngAfterContentChecked(): déclenchée après la détection de changement dans le contenu projeté. Cette callback est déclenchée même s’il n’y a pas de projection de contenu.
    6. ngAfterViewInit(): déclenchée après l’initialisation de la vue du composant et après l’initialisation de la vue des composants enfant.
    7. ngAfterViewChecked() est déclenchée après détection d’un changement dans la vue du composant et dans la vue des composants enfant.
    8. ngOnDestroy() est déclenchée avant la destruction du composant.
  • A chaque détection de changements:
    1. ngOnChanges() si les paramètres en entrée du composant sont modifiés.
    2. ngDoCheck()
    3. ngAfterContentChecked() est déclenchée même s’il n’y a pas de contenu projeté.
    4. ngAfterViewChecked().

Lors de l’exécution de la détection de changements, la mise à jour des éléments est effectuée de la façon suivante par rapport aux callbacks du cycle de vie:

  1. Exécution des callbacks ngOnChanges(), ngOnInit() et ngDoCheck(). La callback ngOnInit() est exécutée seulement à l’initialisation du composant.
  2. Exécution de la fonction contentQueries: cette fonction permet de mettre à jour les requêtes effectuées sur le contenu projeté de la vue avec @ContentChild() ou @ContentChildren().
  3. Exécution des callbacks ngAfterContentInit() et ngAfterContentChecked(): l’exécution de ces callbacks vient clôturer la mise à jour des requêtes effectuées sur le contenu projeté. La callback ngAfterContentInit() est exécutée seulement à l’initialisation du composant.
  4. Exécution de la fonction hostBindings: cette fonction permet d’affecter les bindings provenant de l’élement HTML hôte avec @HostBinding().
  5. Exécution de la fonction template: elle servira à mettre à jour le DOM avec tous les éléments dynamiques de la vue.
  6. Exécution de la fonction viewQuery: cette étape permet de mettre à jour les requêtes effectuées sur la vue du composant avec @ViewChild() et @ViewChildren().
  7. Exécution des callbacks ngAfterViewInit() et ngAfterContentChecked(), la callback ngAfterViewInit() est exécutée seulement à l’initialisation du composant.

Erreur “ExpressionChangedAfterItHasBeenCheckedError”

Dans le cas où une modification est effectuée trop tardivement, une erreur du type ExpressionChangedAfterItHasBeenCheckedError peut se produire en mode développement.
Pour davantage de détails, voir cdiese.fr/angular-change-detection.

Au cours de son exécution, l’algorithme de détection de changements ne s’applique pas qu’à un composant, il parcourt aussi toutes les directives enfant de ce composant. Un composant parent peut être lié aux directives enfants de multiples façons comme par exemple:

  • Un binding entre une propriété et un paramètre d’entrée du composant enfant,
  • Une requête sur un contenu projeté dans le composant enfant avec @ContentChild(),
  • Une requête sur le composant natif hôte du composant enfant avec @HostListener().
  • Etc…

Toutes ces fontionnalités entre un composant parent et un composant enfant imposent à l’algorithme de détection de prendre en compte ce lien et surtout un ordre dans la mise à jour des objets dans le DOM.

Au niveau d’un composant, du point de vue de la détection de changements on pourrait résumer le lien avec son parent avec ce schéma (pour davantages de détails, voir le fonctionnement de la détection de changements):

Ce schéma permet de montrer que certains éléments d’un composant sont mis à jour de façon concomitante avec des traitements du composant parent.

Ordre d’exécution de la détection de changements dans l’arbre des composants

Etant donné les liens entre un composant parent et ses enfants, à l’échelle de plusieurs composants, l’ordre d’exécution des traitements de la détection de changements dans l’arbre des composants n’est pas forcément trivial.

Si on reprend le schéma plus haut permettant de schematiser l’exécution de l’algotihme de détection de changements entre un composant parent et un composant enfant, on peut le séparer en 2 parties: avant et après la boucle récursive de parcours des composants enfant:

Ces 2 parties amènent 2 comportements différents qui entraînent un parcours différents de l’arbre des composants:

  • Avant la boucle récursive: les traitements et exécution des callbacks du cycle de vie sont exécutés dans l’arbre des composants parent vers les composants enfant:

    Ainsi les callbacks ngOnChanges(), ngOnInit(), ngDoCheck(), ngAfterContentInit(), ngAfterContentChecked() et la fonction template permettant de mettre à jour le DOM sont exécutées dans cette partie. Dans le cadre de l’exemple, elles seront déclenchées dans cet ordre: d’abord les composants parent et ensuite les composants enfant.

  • Après la boucle récursive: les traitements et exécution des callbacks sont exécutés des composants enfant vers les composants parents en allant d’abord vers le composant le plus profond dans l’arbre:

    Les callbacks ngAfterViewInit() et ngAfterViewChecked() sont exécutées dans cette partie. Dans le cadre de l’exemple, elles seront déclenchées dans cet ordre: d’abord les composants enfant et ensuite les composants parent.

Contourner la détection de changements

Dès qu’un évènement notifie Zone.js, il déclenche la détection de changements dans Angular. Si on souhaite exécuter du code sans que la détection de changements ne soit sollicitée, on peut utiliser l’objet NgZone.

En injectant un objet de type NgZone dans le constructeur d’un composant, on obtient un service permettant d’effectuer des traitements sur la zone parente de la vue du composant:

import { Component, NgZone } from '@angular/core';

@Component({
    // ...
})
export class ExampleComponent {
    constructor(private zone: NgZone) {}
}

Le service NgZone permet d’exécuter des lambdas avec les fonctions:

  • runOutsideAngular() pour exécuter du code en dehors de la détection de changements Angular.
  • run() exécute du code dans une zone Angular. La détection de changements sera exécutée.
  • runTask() permet d’exécuter du code dans une zone Angular de façon asynchrone. La détection de changements sera exécutée.

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

Template
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, NgZone, OnInit } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private zone: NgZone) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }
}

Au moyen de setInterval(), on peut mettre à jour la propriété randomNumber toutes les secondes avec un nombre aléatoire. Une interpolation dans le template du composant permet d’afficher la propriété randomNumber dans la vue.

A l’exécution, on peut voir que le nombre est mis à jour toutes les secondes:

Exemple de détection de changements avec setInterval()

La propagation de l’évènement est effectuée de cette façon:

  1. La callback de la méthode setInterval() est déclenchée toutes les secondes.
  2. L’exécution de la callback permet de mettre à jour la propriété randomNumber.
  3. Cet évènement se propage dans Zone.js.
  4. La suite de la détection de changements s’exécute le long de l’arbre des composants.

Si on modifie la méthode ngOnInit() dans la classe du composant de cette façon:

ngOnInit(): void {
    this.zone.runOutsideAngular(
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    );
}

Après cette modification, la mise à jour de la vue n’est plus effectuée. La propriété est bien mise à jour par setInterval() toutefois la détection de changements n’est pas déclenchée:

Mettre à jour la vue directement

Quand on utilise NgZone.runOutsideAngular(), il est possible de mettre à jour la vue sans utiliser la détection de changement en intervenant directement dans l’objet natif de la vue.

Par exemple, si on reprend le composant Example introduit plus haut, on peut utiliser @ViewChild() avec une variable référence dans le template pour effectuer une requête sur la vue de façon à pouvoir accéder à l’objet natif dans lequel on veux mettre à jour l’affichage. L’implémentation deviendrait:

Template
<p>Example composant</p>
<p #random></p>
Classe du composant
import { Component, NgZone, OnInit, ViewChild, ElementRef } 
  from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    @ViewChild('random', { static: true }) 
        random: ElementRef<HTMLParagraphElement>

    constructor(private zone: NgZone) {}

    ngOnInit(): void {
        this.zone.runOutsideAngular(
            setInterval(() => {
                this.randomNumber = Math.random();
                this.random.nativeElement.textContent = 
                    `Random number: ${this.randomNumber}`;
            }, 1000);
        );
    }
}

Ainsi:

  • Ce code permet d’effectuer une requête sur la vue du composant avec @ViewChild() pour récupérer un objet identifié avec la variable référence #random dans le template. Cet objet permettra d’accéder à l’objet natif correspondant.
  • On utilise l’option { static: true } dans @ViewChild() de façon à ce que la requête soit exécutée lors de la construction des éléments statiques de la vue.
  • Avec la propriété nativeElement, on peut accéder à l’objet natif correspond à l’élément p de façon à modifier son contenu.

A l’exécution, on peut voir que le nombre est mise à jour chaque seconde dans la vue du composant.

Déclencher la détection de changements dans toute l’application

A l’opposé, on peut volontairement lancer l’exécution de la détection de changements de toute l’application en injectant l’objet ApplicationRef et en appelant ApplicationRef.tick().

Par exemple, si on réutilise l’exemple précédent de ce composant:

Template
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, NgZone, OnInit } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private zone: NgZone) {}

    private updateRandomNumber(): void {
        this.randomNumber = Math.random();
    }

    ngOnInit(): void {
        this.zone.runOutsideAngular(
            setInterval(() => {
                this.updateRandomNumber();
            }, 1000);
        );
    }
}

Cet exemple ne permet pas de mettre à jour la vue avec la propriété randomNumber car NgZone.runOutsideAngular() ne déclenche pas la détection de changements.

Pour forcer la détection de changements à chaque exécution de la lambda dans setInternal(), on peut rajouter ApplicationRef.tick(). On modifie la méthode ngOnInit() dans ce sens:

import { Component, NgZone, OnInit, ApplicationRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private zone: NgZone, private ApplicationRef: ApplicationRef) {}

    private updateRandomNumber(): void {
        this.randomNumber = Math.random();	

        this.applicationRef.tick();
    }

    ngOnInit(): void {
        this.zone.runOutsideAngular(
            setInterval(() => {
                this.updateRandomNumber();
            }, 1000);
        );
    }
}

Dans ce code, this.applicationRef.tick() permet de lancer l’exécution de la détection de changements à chaque exécution de la lambda dans setInterval(). A l’exécution, on peut voir que la vue est correctement mise à jour: la valeur du nombre change toutes les secondes.

Personnaliser la détection de changements

2 méthodes permettent de mettre à jour une vue en cas de changements:

  • Détecter les changements de façon automatique: l’algorithme de détection effectue les comparaisons nécessaires pour savoir quand un changement nécessite une mise à jour de propriétés dans le DOM. L’algorithme détecte le changement de valeur sans savoir d’où provient ce changement.
  • Mettre à jour la vue à la demande: en cas de changements, on peut solliciter explicitement la détection de changements pour que la vue soit mise à jour à la demande. L’algorithme de détection de changements et la mise à jour des éléments natifs du DOM sont indissociables. On ne peut pas exécuter l’un sans l’autre. En revanche, il est possible de désactiver la détection automatique pour privilégier une exécution à la demande pour maitriser la mise à jour d’une vue ou pour améliorer les performances d’exécution.

Dans cette partie, on va expliciter des méthodes pour désactiver la détection automatique de changements ou pour lancer explicitement son exécution.

ChangeDetectionStrategy

Modifier le paramètre changeDetection d’un composant (dans le décorateur @Component()) permet de modifier le comportement de la détection:

  • ChangeDetectionStrategy.Default: déclenchement automatique de la détection de changements, c’est le comportement par défaut. La détection est exécutée pour le composant et ses enfants de façon automatique.
  • ChangeDetectionStrategy.OnPush: désactive la détection automatique des changements pour le composant et pour ses enfants. Il faut déclencher la détection de la façon explicite avec ChangeDetectorRef ou ApplicationRef.

Modifier le paramètre changeDetection permet de désactiver la détection pour toutes les instances du composant et pour toute sa durée de vie. Cette implémentation est moins flexible qu’en utilisant ChangeDetectorRef (même si on exécute ChangeDetectorRef.reattach(), la détection automatique restera désactivée).

Par exemple, si on modifie le paramètre changeDetection dans le cas du composant Example, l’implémentation devient:

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExampleComponent implements OnInit {
    // ...
}

ChangeDetectorRef

En injectant ChangeDetectorRef dans un composant, on peut personnaliser la détection de changements pour:

  • Désactiver son exécution automatique avec ChangeDetectorRef.detach(),
  • Ré-activer la détection automatique avec ChangeDetectorRef.reattach(),
  • Forcer l’exécution de la détection avec ChangeDetectorRef.detectChanges(),
  • Vérifier si des changements sont détectés avec ChangeDetectorRef.checkNoChanges() ou
  • Marquer la vue du composant pour que la détection de changements soit effectuée avec ChangeDetectorRef.markForCheck().

On peut injecter un objet de type ChangeDetectorRef en utilisant le moteur d’injection de dépendances Angular, par exemple:

Template
<p>Example composant</p>
Classe du composant
import { Component, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent {
    constructor(private changeDetectorRef: ChangeDetectorRef) {}
}

L’instance injectée de l’objet de type ChangeDetectorRef est spécifique à la vue du composant.

Désactiver/Activer la détection de changements

La détection de changements peut être désactivée ou activée par programmation de façon à optimiser les performances d’une vue. Lorsque la détection est désactivée pour un composant donné, la détection ne sera plus exécutée de façon automatique sur la vue du composant et sur les vues des composant enfant:

Pour exécuter la détection de changements à la demande, il faudra explicitement exécuter ChangeDetectorRef.detectChanges() ou ChangeDetectorRef.markForCheck() sur le composant pour lequel la vue doit être mise à jour. En cas d’exécution explicite de la détection pour un composant donné, la détection sera effectuée sur le composant et sur toutes ses directives enfants.

Par exemple, si on considère un composant pour lequel on place un Timer qui permet de mettre à jour périodiquement la valeur d’une propriété de type number et qu’on affiche cette propriété sur la vue en utilisant une interpolation:

Template
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private changeDetectorRef: ChangeDetectorRef) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }
}

Si on exécute ce code, on pourra voir que l’affichage est mise à jour toutes les secondes.

On ajoute 2 boutons pour désactiver et activer la détection de changements:

Template
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
<p><button (click)='disableChangeDetection()'>Disable change detection</button></p>
<p><button (click)='enableChangeDetection()'>Enable change detection</button></p>
Classe du composant
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private changeDetectorRef: ChangeDetectorRef) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }

    disableChangeDetection(): void {
        this.changeDetectorRef.detach();
    }

    enableChangeDetection(): void {
        this.changeDetectorRef.reattach();
    }
}

A l’exécution, on peut voir que le nombre ne sera plus mis à jour dans la vue si on clique sur “Disable change detection”. La mise à jour est réativée si on clique sur “Enable change detection “.

Ainsi, quand on a exécuté ChangeDetectorRef.detach() dans le composant Example, la détection de changements est désactivée pour ce composant et pour les composants enfant:

Vérifier si des changements sont détectés

On peut se demander l’utilité de cette méthode en sachant qu’elle déclenche une erreur dans le cas où un changement est détecté. La raison est que cette détection a pour but d’éviter des erreurs d’implémentation en vérifiant que des changements ne sont pas effectués trop tardivement dans le cycle de vie d’un composant (cf. ExpressionChangedAfterItHasBeenCheckedError).

La vérification peut être effectuée en exécutant:

ChangeDetectorRef.checkNoChanges();

En conclusion

Pour résumer, on peut personnaliser la détection de changements en:

  • Contournant l’exécution de la détection: en injectant l’objet NgZone dans le composant. Avec NgZone.runOutsideAngular(), on peut exécuter du code qui n’aboutira pas à la détection automatique de changements.
  • Forçant la mise à jour d’un élément d’une vue en modifiant directement l’objet natif, par exemple:
    • En effectuant une requête sur la vue avec @ViewChild() pour obtenir l’objet natif:
      @ViewChild('elementName', { static: true }) element: ElementRef<HTMLParagraphElement>
      
    • En affectant une valeur avec la propriété nativeElement:
      this.element.nativeElement.textContent = ... 
      
  • Forçant l’exécution de la détection de changements en injectant l’objet ApplicationRef et exécutant ApplicationRef.tick().
  • Désactivant définitivement la détection dans un composant avec le paramètre changeDetection dans le décorateur @Component():
    @Component({
        selector: ...,
        templateUrl: ...,
        changeDetection: ChangeDetectionStrategy.OnPush
    })
    
  • En injectant l’objet ChangeDetectorRef dans le constructeur pour:
    • Désactiver l’exécution automatique de la détection en exécutant ChangeDetectorRef.detach(),
    • Ré-activer la détection automatique en exécutant ChangeDetectorRef.reattach(),
    • Forcer l’exécution de la détection avec ChangeDetectorRef.detectChanges(),
    • Vérifier si des changements sont détectés en exécutant ChangeDetectorRef.checkNoChanges() ou
    • Marquer la vue du composant pour que la détection de changements soit effectuée en exécutant ChangeDetectorRef.markForCheck().
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page