Async/await en bref…

Cet article fait partie d’une série d’articles sur async/await.

Les mot-clés async/await sont apparus avec C# 5.0 et la version 4.5 du framework .NET. Sous l’apparente simplicité des mot-clés se cache une implémentation complexe et beaucoup de mécanismes implicites qu’il est préférable d’avoir en tête car leurs implications peuvent être significatives en terme de performance.

Fonctionnement général

async et await ne sont pas des mot-clés qui permettent la création de thread ou de task à proprement parlé mais ils permettent d’indiquer au compilateur:

  • les méthodes pour lesquelles l’exécution sera asynchrone en utilisant async,
  • les endroits dans le code où on va attendre la fin de l’exécution d’une tâche en utilisant await.

L’utilisation d’async/await entraîne l’exécution d’une partie du code de façon asynchrone. Comme on a pu l’indiquer précédemment, effectuer des traitements de façon asynchrone n’est pas tout à fait la même chose que d’exécuter du code en parallèle:

  • L’asynchronisme consiste à exécuter du code de façon non bloquante et éventuellement d’attendre le résultat de l’exécution de code. Exécuter de façon asynchrone peut entraîner l’exécution d’une partie du code en parallèle mais ce n’est pas indispensable. Le but recherché avec l’exécution asynchrone est l’aspect non bloquant.
  • Exécuter du code en parallèle implique de tirer partie des ressources matérielles pour effectuer plus de traitements pour une période de temps donnée en les exécutant en parallèle (sur plusieurs machines, plusieurs processeurs, plusieurs threads etc…).

Async

Async s’utilise dans la signature d’une fonction pour indiquer qu’elle contient du code qui peut être exécuté de façon asynchrone. Il s’applique sur des méthodes qui renvoient un objet de type Task<TResult>, Task, ValueTask ou ValueTask<TResult> (depuis C# 7.0). Utiliser async implique dans la majorité des cas d’utiliser le mot-clé await (mais ce n’est pas indispensable) pour attendre la fin de l’exécution.

Si le corps d’une méthode avec async ne contient pas le mot clé await, il y aura un message d’avertissement du compilateur car cela signifie que le code sera exécuté de façon synchrone.

Par exemple, le code suivant est exécuté de façon synchrone i.e. le thread principal est bloqué jusqu’à la fin du “Sleep” même avec la présence du mot-clé async:

public string WaitSynchronously()
{
  Thread.Sleep(10000);
  return "Finished";
}

En revanche le code suivant sera exécuté de façon asynchrone i.e. le thread principal ne sera pas bloqué:

public async Task<string> WaitAsynchronouslyAsync()
{
  await Task.Delay(10000);

  return "Finished";
}

Tout ce qui se trouve après l’instruction await dans la fonction async sera considéré comme une continuation. Ainsi le code ne sera pas bloquant et ce qui se trouve après le await dans la fonction sera exécuté quand la tâche sera terminée.

Si on utilise une boucle:

Task GetWebPageAsync(string uri) 
{ 
  ... 
}

async void Test() 
{ 
  for (int i = 0; i < 5; i++) 
  { 
    string html = await GetWebPageAsync("..."); 
    Console.WriteLine(html): 
  } 
}

L’exécution des boucles se fera sans blocages. Ce qui se trouve après la ligne avec le await sera exécuté comme une continuation quand le GetWebPageAsync() aura terminé son exécution.

Pour arriver à exécuter ce traitement, le compilateur transforme le code en une machine à états dont le but est de capturer le contexte d’exécution à chaque état c’est-à-dire à chaque fois qu’une instruction est lancée avec un await. Ainsi les valeurs des variables à ce moment sont celles au moment du lancement de l’instruction await.

Await

Ce mot-clé permet d’indiquer l’emplacement du code où il faut attendre la fin de l’exécution pour continuer. Ainsi, lorsque l’exécution arrive au mot-clé await, elle est stoppée pour attendre la fin de l’exécution du code qui suit. Ce code étant exécuté en parallèle de façon à ne pas être bloquant. Ainsi la partie située après la ligne await est considérée comme une continuation et sera exécutée lorsque la ligne await aura terminé son exécution.

Par exemple, si on reprend l’exemple précédent:

public async Task<string> WaitAsynchronouslyAsync()  
{
  // Exécution synchrone
  Console.WriteLine("Before await...");

  // L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
  await Task.Delay(10000);  

  // Continuation exécutée quand Task.Delay(10000) est terminé
  Console.WriteLine("Before await...");

  return "Finished";  
}

Si on exécute ce code:

string result = WaitAsynchronouslyAsync().Result;
Console.WriteLine(result);  

On obtient:

Before await...     ⇐ on attends 10 sec 
Continuation...     ⇐ la continuation est lancée après attente des 10 sec
Finished

On modifie légèrement le code pour que l’intérêt de await soit plus compréhensible:

public static async Task<string> WaitAsynchronouslyAsyncModified()
{
  // Exécution synchrone
  Console.WriteLine("Before await...");

  // L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
  Task<int> delayTask = Task.Run<int>(() =>
  {
    int taskThreadId = Environment.CurrentManagedThreadId;
    Task.Delay(10000);
    return taskThreadId;
  });
  
  Console.WriteLine("Code exécuté par le thread principal (thread ID: {0})...", Environment.CurrentManagedThreadId);

  int taskThreadId = await delayTask;

  // Continuation exécutée quand Task.Delay(10000) est terminé
  Console.WriteLine("Continuation...");
  Console.WriteLine("Thread ID du code exécuté en parallèle: {0}", taskThreadId);

  return "Finished";
}

On lance l’exécution:

string result = WaitAsynchronouslyAsyncModified().Result;
Console.WriteLine(result);

On obtient:

Before await...                                         ⇐ L'exécution en parallèle est lancée mais on n'attend pas à ce stade
Code exécuté par le thread principal (thread ID: 1)...  ⇐ Comme le lancement de la Task est non bloquant, ce code est exécuté 
                                                        ⇐ tout de suite par le thread principal
                                                        ⇐ On attends 10 sec au niveau du await
Continuation...                                         ⇐ la continuation est lancée après attente des 10 sec
Thread ID du code exécuté en parallèle: 9               ⇐ On affiche l'ID du thread utilisé pour l'exécution du code en parallèle
Finished

Dans cet exemple, on peut voir qu’une partie du code est exécutée en parallèle c’est-à-dire dans un thread différent du thread principal (mais ce n’est pas obligatoire):

Task<int> delayTask = Task.Run<int>(() =>
{  ...  });

Le thread principal continue de s’exécuter. Lorsque l’exécution atteint le await, elle est stoppée pour attendre la fin de l’exécution du code en parallèle.

Exemple WPF

Le grand intérêt d’async/await n’est pas d’implémenter ce modèle complètement mais de l’utiliser avec les bibliothèques du framework .NET qui fournissent des méthodes et fonctions compatibles. Par exemple, si on considère un exemple WPF consistant à utiliser 3 boutons:

  • Un bouton “Start/Stop counter” permettant de lancer et stopper un compteur exécuté par le thread principal.
  • Un bouton “Launch sync process” qui va lancer un traitement bloquant pendant 20 sec.
  • Un bouton “Launch async process” pour lancer un traitement asynchrone non bloquant pendant 20 sec.

Le code de l’application est disponible sur: github.com/msoft/asyncAwaitExamples.

Le traitement consiste à attendre pendant 20 sec et à renvoyer un nombre aléatoire. L’implémentation synchrone est:

public class UselessProcessSync
{
  private readonly int processExecutionTimeMs;
  private readonly Random randomGenerator;

  public UselessProcessSync(int processExecutionTimeMs)
  {
    this.processExecutionTimeMs = processExecutionTimeMs;   
    randomGenerator = new Random();
  }

  public int ProceedAndWait()
  {
    Thread.Sleep(processExecutionTimeMs);
    return randomGenerator.Next();
  }
}

Le traitement asynchrone est le même mais utilise async/await:

public class UselessProcess
{
  private readonly UselessProcessSync uselessProcessSync;

  public UselessProcess(int processExecutionTimeMs)
  {
    uselessProcessSync = new UselessProcessSync(processExecutionTimeMs);
  }

  public async ValueTask<int> Proceed()
  {
    return await Task<int>.Run(() => uselessProcessSync.ProceedAndWait());
  }
}

Le traitement synchrone est lancé sans async/await:

private void LaunchSyncProcess(object sender, RoutedEventArgs e)
{
  // ...
  int result = synchronousBackEndProcess.ProceedAndWait();
  this.Dispatcher.Invoke(() =>
  {
    RandomValue.Content = result;
    // ...
  });
}

Le traitement asynchrone est lancé avec async/await:

private async void LunchAsyncProcess(object sender, RoutedEventArgs e)
{
  // ...
  int result = await asynchronousBackEndProcess.Proceed();
  this.Dispatcher.Invoke(() =>
  {
    RandomValue.Content = result;
    // ...
  });
}

Le lancement des méthodes LaunchSyncProcess() et LaunchAsyncProcess() se fait de la même façon car WPF supporte async/await et prend en compte cette implémentation avec LaunchAsyncProcess():

<Button Name="SyncProcessLaunchButton" Content="Launch sync process" Click="LaunchSyncProcess"/>
<Button Name="AsyncProcessLaunchButton" Content="Launch async process" Click="LunchAsyncProcess"/>

A l’exécution, on peut se rendre compte que le traitement synchrone gèle l’application, le compteur s’arrête pendant le traitement. L’exécution du traitement synchrone étant effectuée par le thread principal, aucun autre traitement n’est possible. A la différence, le traitement asynchrone ne gèle pas l’interface car le thread principal est disponible. Il n’exécute pas le traitement asynchrone.

Modèle awaitable

Les mots clés async/await permettent de simplifier par des éléments de syntaxe l’implémentation du modèle awaitable. Ce modèle permet d’implémenter une action à exécuter de façon asynchrone et une continuation à appeler quand l’action est exécutée. On peut ensuite récupérer le résultat de l’opération asynchrone. Le modèle awaitable est proche du modèle basé sur des tasks (i.e. Task-based Asynchronous Pattern) vu précédemment.

La condition pour utiliser async/await est que l’objet qui suit await possède une fonction GetAwaiter():

TaskAwaiter GetAwaiter();

Appeler la fonction GetAwaiter() permet de retourner un objet de type TaskAwaiter contenant des propriétés et fonctions permettant d’implémenter le modèle awaitable:

  • IsCompleted() pour savoir si l’opération est exécutée
  • GetResult() pour récupérer le résultat
  • OnCompleted() cette méthode sera exécutée en tant que continuation.

L’objet awaitable doit aussi satisfaire la classe System.Runtime.CompilerServices.INotifyCompletion.

Les objets Task et ValueTask (disponibles à partir de C# 7.0) contiennent une fonction GetAwaiter(). L’utilisation des objets Task et ValueTask n’est pas indispensable après await, il suffit d’utiliser un objet fournissant la méthode GetAwaiter().

L’évaluation d’une expression await peut se résumer avec le diagramme de séquence suivant:

Pour résumer, await doit être suivi d’un objet awaitable ou d’une action fournissant un objet awaitable. Par exemple, les objets Task et ValueTask sont des objets awaitables. Les objets awaitable permettent de fournir un autre objet qui pourra être utilisé pour attendre la fin de l’exécution de la tâche asynchrone avec awaitable.GetAwaiter(). L’objet awaiter permet de fournir des propriétés et des méthodes pour vérifier que la tâche asynchrone a terminé son exécution.

Lors de l’instanciation de l’objet awaitable et plus généralement lors de l’exécution de la tâche asynchrone, des informations liées au contexte transitent entre le corps de la méthode async et l’objet awaitable. Ces informations correspondent au contexte d’exécution. Le contexte d’exécution est un ensemble de données d’état du thread dans lequel le code est exécuté. Ces données d’état peuvent être capturées et restaurées dans un autre thread. L’objet ExecutionContext permet de gérer les données de contexte du thread courant.

En plus du contexte d’exécution, il existe une autre notion appelée SynchronizationContext. Cette notion correspond à l’environnement dans lequel le code est exécuté. L’objet SynchronizationContext va fournir une abstraction permettant d’interagir avec cet environnement. Plus concrètement, on peut récupérer l’instance d’un objet correspondant au contexte de synchronisation (avec SynchronizationContext.Current). L’instance permet d’interagir avec le contexte en y lançant l’exécution de code de façon synchrone (avec Send()) ou asynchrone (avec Post()). Ces méthodes peuvent s’appliquer pour des technologies différentes comme WPF ou en Windows Forms etc…

Avec aync/await, lors d’un appel à await, un objet awaiter est instancié. Il sera utilisé pour attendre la fin de l’exécution grâce à un autre objet awaitable. Lorsque l’exécution dans la méthode async est suspendue, le contexte d’exécution est capturé. La continuation correspondant au code exécuté lorsque l’exécution asynchrone est terminée, utilisera aussi ce contexte d’exécution. Lors de l’utilisation d’async, les objets awaitables sont créés par l’intermédiaire d’objets System.Runtime.CompilerServices.AsyncTaskMethodBuilder qui assurent que le contexte d’exécution transitera de l’appelant vers le delegate.

Exemple d’implémentation d’un objet awaitable

Comme indiqué plus haut, on peut implémenter un objet awaitable personnalisé plutôt que d’utiliser les classes Task ou ValueTask. Pour illustrer les différentes étapes du diagramme plus haut, on implémente un objet awaitable qui satisfait INotifyCompletion et contient des propriétés et fonctions:

  • IsCompleted()
  • GetResult()
  • OnCompleted()

Cette classe ne fait qu’attendre un certain temps avant de renvoyer un entier aléatoire:

internal class CustomAwaitable: INotifyCompletion
{
    private Task<int> waitingTask;
    private int result;

    public CustomAwaitable(TimeSpan waitingTime)
    {
        result = (new Random()).Next();
        if (waitingTime == TimeSpan.Zero)
            this.waitingTask = Task.FromResult<int>(result);
        else
            this.waitingTask = Task.Run(() =>
            {
              // Attente avant de renvoyer un résultat
              Task.Delay(waitingTime).Wait();
              return result;
            });
    }

    public bool IsCompleted 
    { 
        get 
        {
            bool isCompleted = this.waitingTask.IsCompleted;
            Log.LogConsole($"Calling \"IsCompleted\": {isCompleted}");
            return isCompleted;
        }
    }

    public void OnCompleted(Action continuation)
    {
        Log.LogConsole("Calling \"OnCompleted()\"");
        continuation();
    }

    public int GetResult() // Can also be void
    {
        Log.LogConsole("Calling \"GetResult()\"");
        return this.waitingTask.Result;
    }

}

L’objet se trouvant après await doit implémenter une fonction GetAwaiter() qui doit instancier l’objet awaitable. Une possibilité est de créer une méthode d’extension sur TimeSpan correspondant au temps d’attente:

internal static class CustomAwaitableBuilder
{
  public static CustomAwaitable GetAwaiter(this TimeSpan timeSpan)
  {
    Log.LogConsole("GetAwaiter()");
    return new CustomAwaitable(timeSpan);
  }
}

On peut lancer l’exécution en utilisant await suivi d’un objet TimeSpan qui grâce à la méthode d’extension implémente la fonction GetAwaiter():

Log.LogConsole("Starting...");
//TimeSpan timeSpan = TimeSpan.FromSeconds(0);  // Temps d'attente nul
TimeSpan timeSpan = TimeSpan.FromSeconds(10);

int result = await timeSpan;

Log.LogConsole($"Ending with result: {result}");

A l’exécution, on obtient:

14:13:36.278: Starting...
14:13:36.318: GetAwaiter()
14:13:36.319: Calling "IsCompleted": False
14:13:36.320: Calling "OnCompleted()"
14:13:36.320: Calling "GetResult()"
14:13:46.329: Ending with result: 706177357

Avec un temps d’attente nul:

14:14:47.755: Starting...
14:14:47.774: GetAwaiter()
14:14:47.775: Calling "IsCompleted": True
14:14:47.775: Calling "GetResult()"
14:14:47.775: Ending with result: 1427412265

Le code de cet exemple est disponible sur: github.com/msoft/asyncAwaitExamples/blob/master/ExampleWithTestLauncher/CustomAwaitable.cs.

Références

Modèles de programmation asynchrone (async/await)

Cet article fait partie d’une série d’articles sur async/await.

Quelque soit le type d’application, il peut être nécessaire de vouloir exécuter des traitements de façon asynchrone, en particulier pour permettre l’exécution de traitements longs sans bloquer l’interface graphique; ou de pouvoir lancer plusieurs traitements simultanément. La programmation asynchrone implique de pouvoir lancer un ou plusieurs traitements sans bloquer le thread appelant et de pouvoir récupérer les résultats éventuels quand les traitements sont terminés.

En .NET, une implémentation de ce type de programmation a abouti à l’utilisation des mot-clés async/await. Le but de cet article est d’expliquer l’utilisation de async/await et ce qu’implique l’utilisation de ces mot-clés. Dans un premier temps, on va expliquer les différentes approches en .NET pour implémenter des traitements asynchrones. Ensuite, on va expliquer le fonctionnement d’ async/await et enfin, indiquer la façon dont ce pattern est implémenté.

Programmer une exécution de façon asynchrone impose quelques mécanismes qui ne sont pas triviaux:

  • Il faut lancer l’exécution éventuellement dans un thread séparé pour que l’exécution dans le thread appelant puisse se poursuivre.
  • Récupérer le résultat de l’exécution effectuée dans un thread séparé du thread appelant.
  • Le cas échéant attendre la fin d’un traitement en cours d’exécution dans un thread séparé.

Avant d’aborder async/await, plusieurs approches ont été implémentées en .NET pour permettre une programmation asynchrone:

Modèle de programmation asynchrone (Asynchronous Programming Model)

Ce modèle est basé sur l’utilisation de 2 méthodes pour lancer le traitement asynchrone:

  • BeginXXX pour commencer à effectuer le traitement,
  • EndXXX pour éventuellement attendre la fin du traitement asynchrone et récupérer le résultat.

Dans sa version la plus simple, ce modèle permet d’implémenter un algorithme qui lance le traitement asynchrone, permet d’effectuer un autre traitement et attend le résultat de façon bloquante. Un exemple de ce modèle est la lecture d’un fichier avec FileStream.BeginRead() et FileStream.EndRead():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024,
  FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

// On peut effectuer un autre traitement 
// ...

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Un modèle plus complexe comprend une fonctionnalité de “polling” pour vérifier périodiquement si le traitement est terminé. Ce modèle permet d’exécuter un autre traitement tant que le traitement asynchrone n’est pas terminé.

Par exemple si la fonction effectuant un traitement synchrone s’appelle MakeProcess(string arg) avec un argument en entrée alors les fonctions permettant de lancer le traitement asynchrone seront:

public IAsyncResult BeginMakeProcess(string arg, AsyncCallback? callback, object? state);
public int EndMakeProcess(IAsyncResult asyncResult);

La fonction BeginMakeProcess() a pour argument:

  • arg qui est l’argument fonctionnel de la fonction originale
  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

  • state permet de transmettre la référence d’un objet qui sera transmis dans le résultat de la fonction BeginMakeProcess().

La fonction EndMakeProcess() prend pour argument l’objet de type IAsyncResult renvoyé par BeginMakeProcess().

Avec FileStream, pour vérifier le statut on peut, par exemple, utiliser IAsyncResult.IsCompleted():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
    FileAccess.Read, FileShare.Read, 1024,
    FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

while (!result.IsCompleted)
{
  // On peut effectuer un autre traitement 
  // ...

  // On attend avant une nouvelle tentative
  Thread.Sleep(100);
}

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Ce modèle était valable à un époque où il n’y avait pas les Tasks (Framework .NET 1.0), il est maintenant obsolète.

Modèle asynchrone basé sur des événements (Event-based asynchronous pattern)

A partir du Framework .NET 2.0, est apparu un modèle basé sur des évènements avec des callbacks qui sont exécutées lorsque le traitement est terminé. Ce modèle de programmation introduit la notion de continuation comme dans le cas des Tasks. L’exécution de la continuation est lancée lorsque le tâche asynchrone est terminée. L’implémentation de la callback est fournie au moment du lancement de la tâche asynchrone sous la forme d’une méthode. L’exécution de cette implémentation doit se faire dans un contexte d’exécution précis. Ce contexte va permettre d’organiser l’exécution de la callback.

L’objet SynchronizationContext est apparu aussi avec le framework .NET 2.0. Suivant la technologie utilisée, SynchronizationContext se décline différemment, par exemple: WindowsFormsSynchronizationContext pour les Windows Forms; DispatcherSynchronizationContext pour WPF.

Par exemple, pour lancer la lecture d’un fichier de façon asynchrone, on peut utiliser la surcharge de FileStream:

IAsyncResult BeginRead(Byte[] array, Int32 offset, Int32 numBytes, AsyncCallback userCallback, Object stateObject)

Comme pour le modèle Asynchronous Programming Model:

  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

En déclinant la lecture d’un fichier avec la modèle Event-based asynchronous pattern, on obtient:

private static Byte[] buffer = new Byte[100];

public static void ReadAsynchronouslyWithEap() 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("Main thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024, FileOptions.Asynchronous);

  // Lecture asynchrone d'une quantité de données dans le fichier
  // On passe le paramètre fs (FileStream) à la callback (méthode ReadIsDone)
  fs.BeginRead(buffer, 0, buffer.Length, WhenReadIsCompleted, fs);

  // On peut effectuer un autre traitement 
  // ...

  // On stoppe le thread principal pour éviter de sortir de la méthode
  Console.ReadLine();
}

private static void WhenReadIsCompleted(IAsyncResult result) 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("ReadIsDone thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  // On récupère l'objet FileStream (correspondant à l'état) fournit en argument
  FileStream fs = (FileStream) result.AsyncState;

  // On récupère le résultat
  Int32 bytesRead = fs.EndRead(result);

  // Fermeture du fichier
  fs.Close();
}

L’intérêt de ce modèle par rapport à Asynchronous Programming Model est la possibilité d’utiliser le contexte de synchronisation SynchronizationContext plus spécifique au contexte dans lequel l’exécution asynchrone est effectuée.
On peut obtenir une instance de SynchronizationContext en utilisant la propriété statique SynchronizationContext.Current. L’ajout de tâches à exécuter dans le scheduler peut se faire avec la méthode SynchronizationContext.Post(), par exemple:

static void ExecuteAction(Action<string> actionToBeExecuted)
{
  SynchronizationContext? sc = SynchronizationContext.Current;
  ThreadPool.QueueUserWorkItem(_ =>
  {
    string message = "Message exemple";
    if (sc is not null)
    {
	    // Exécution par le "scheduler"
      sc.Post(_ => actionToBeExecuted(message), null);
    }
    else
    {
      // Exécution directe
      update(message);
    }
  });
}

Modèle asynchrone basé sur les Tasks (Task-based asynchronous pattern)

Les tasks sont apparues avec le framework .NET 4.0, permettant ainsi d’ajouter une abstraction au dessus des threads. Le gros intérêt des tasks est qu’elles facilitent considérablement l’implémentation d’algorithmes asynchrones. Un objet de type Task contient, en particulier, plusieurs propriétés:

Ces propriétés sont utiles pour savoir si la Task a terminé son exécution, gérer les erreurs, implémenter un mécanisme de continuation (correspondant à une autre Task qui s’exécutera quand la Task précédente sera terminée).

Contexte d’exécution

Le contexte d’exécution permet de capturer des données qui pourront être restituées d’un thread à l’autre. Ainsi dans le cadre de l’exécution d’une continuation, le contexte sera transmis d’une Task à l’autre. De même quand on appelle des méthodes asynchrones, ce contexte est transmis implicitement. Des méthodes comme Task.Run() et ThreadPool.QueueUserWorkItem() transmettent le contexte d’exécution automatiquement. Le contexte est capturé à partir du thread appelant et il est stocké dans l’instance de Task. Si le TaskScheduler exécute un delegate, il le fait avec ExecutionContext.Run() en utilisant le contexte stocké.

Par exemple si on crée une continuation en utilisant Task.ContinueWith() le contexte d’exécution est transmis d’une Task à l’autre. Avec TaskAwaiter.GetAwaiter().UnsafeOnComplete() le contexte n’est pas transmis.

Si on rapproche le modèle Task-based asynchronous pattern aux modèles précédents, la continuation s’apparente à la callback exécutée quand le code asynchrone a terminé son exécution. De plus tout le code implémenté explicitement dans le cadre des autres modèles pour la gestion d’erreurs se trouve dans l’objet Task.

Dans le cadre de l’exécution d’une série de traitements asynchrones, on peut imaginer une série de continuations ce qui amène à l’implémentation d’ async/await.

Dans l’article suivant, on rentrera davantage dans les détails d’async/await.

Aide mémoire syntaxe Markdown

Cet article rassemble les éléments de syntaxe les plus utiles pour écrire un document en markdown.

Code HTML

Pour tous les éléments de syntaxe, il est possible d’utiliser directement du code HTML dans un texte en markdown.

Styles du texte

Markdown HTML Résultat
Gras **Texte en gras**
<b>Texte en gras</b>
Texte en gras
Italique _Texte en italique_
<i>Texte en italique</i>
Texte en italique
Gras et en italique **_Texte gras en italique_**
<b><i>Texte gras en italique</i></b>
Texte gras en italique
Barré ~~Texte barré~~
<s>Texte barré</s>
Texte barré
Souligné Pas de syntaxe
<u>Texte souligné</u>
Texte souligné
Exposant Pas de syntaxe
Texte<sup>exposant</sup>
Texteexposant
Indice Pas de syntaxe
Texte<sub>indice</sub>
Texteindice
Emojis Liste des émojis: Emoji cheat-sheet
Par exemple:
:slightly_smiling_face:

En markdown, on peut aussi directement utiliser le code HTML du caractère avec la syntaxe:

Liste des émojis en HTML: Emoji smileys
Par exemple:
🙂

Titres

Différents types de titres

Markdown HTML Résultat
Gros titre # Gros titre
ou
Gros titre
=
<h1>Gros titre</h1>
Gros titre
Titre moyen ## Titre moyen
ou
Titre moyen
-
<h2>Gros titre</h2>
Titre moyen
Petit titre ### Petit titre
<h3>Petit titre</h3>
Petit titre

Lien vers les titres

Il est possible d’intégrer des liens vers les titres comme on peut le faire en HTML. Il faut utiliser la syntaxe:

  • Pour identifier le titre: # <texte du titre> {#<identifiant du titre>}
  • Pour renvoyer au titre avec un identifiant: [<titre>](#<identifiant du titre>)

Par exemple:

Markdown HTML Résultat
Pour définir le titre avec un identifiant # Titre 1 avec ID {#identifiant-titre-1}
<h1 id="identifiant-titre-1">Titre 1 avec ID</h1>
Titre 1 avec ID
Pour faire référence à un titre avec identifiant [Titre 1 avec ID](#identifiant-titre-1)
<a href="#identifiant-titre-1">Titre 1 avec ID</a>
Titre 1 avec ID

Liens

Pour afficher des liens hypertexte, il faut utiliser la syntaxe:

  • Lien classique, par exemple Page wikipedia sur Markdown:
    Markdown [<Texte du lien>](<lien http://...>)

    Par exemple:

    [Page wikipedia sur Markdown](https://en.wikipedia.org/wiki/Markdown)
    
    HTML
    <a href="https://en.wikipedia.org/wiki/Markdown">Page wikipedia sur Markdown</a>
    
  • Afficher directement un lien, par exemple https://en.wikipedia.org:
    Markdown Il faut entourer le lien avec <...>, par exemple:

    <https://en.wikipedia.org>
    HTML
    <a href="https://en.wikipedia.org">https://en.wikipedia.org</a>
  • Afficher une adresse mail, par exemple webmaster@example.com:
    Markdown Comme pour les liens, il faut entourer le lien avec <...>, par exemple:

    <webmaster@example.com>
    HTML
    <a href="mailto:webmaster@example.com">webmaster@example.com</a>

Tableaux

Sans alignement du contenu

Sans précision, l’alignement est par défaut sur la gauche.
La syntaxe est:

| Titre 1 | Titre 2 | Titre 3 |
| --------------- | --------------- | ----- |
| Ligne 1 | Contenu | Autre contenu |
| Ligne 2 | Contenu | Autre contenu |
| Ligne 3 | Contenu | Autre contenu |

Pour obtenir:

Titre 1 Titre 2 Titre 3
Ligne 1 Contenu Autre contenu
Ligne 2 Contenu Autre contenu
Ligne 3 Contenu Autre contenu

Indiquer un alignement du contenu

Pour préciser l’alignement, il faut indiquer juste en dessous du titre:

  • Aligner sur la gauche: | :--- |
  • Aligner au centre: | :---: |
  • Aligner sur la droite: | ---: |

Ainsi avec la syntaxe:

| Titre 1 | Titre 2 | Titre 3 |
| :-------------- | :--------------: | --------------: |
| Aligné à gauche | Aligné au centre | Aligné à droite |

Pour obtenir:

Titre 1 Titre 2 Titre 3
Aligné à gauche Aligné au centre Aligné à droite

Bloc de texte

Citation

On rajoute le caractère > avant le texte:

> Texte ligne 1
> Texte ligne 2
> Texte ligne 3

Pour obtenir:

Texte ligne 1
Texte ligne 2
Texte ligne 3

Bloc de code

On entoure le texte avec ```:

```
Bloc de code ligne 1
ligne 2
ligne 3
```

Pour obtenir:

Bloc de code ligne 1
ligne 2
ligne 3

Elément de code dans le texte

Pour obtenir un élément de code sans bloc séparé de cette façon code inline, il faut entourer le texte avec le caractère `:

`code inline`

Paragraphe d’alerte

Cette syntaxe ne fonctionne pas avec tous les éditeurs de markdown. Elle fonctionne avec GitHub.

Markdown Résultat
> [!NOTE]
> Texte de la note.

ⓘ Note


Texte de la note.

> [!TIP]
> Texte du conseil.

💡 Tip


Texte du conseil.

> [!IMPORTANT]
> Texte de la note importante.

⚠ Important


Texte de la note importante.

> [!WARNING]
> Texte warning.

⚠ Warning


Texte warning.

> [!CAUTION]
> Texte attention.

⚠ Caution


Texte attention.

Listes

Listes non ordonnées

Pour afficher des listes non ordonnées, il faut utiliser l’un des caractères: *, - ou +:

Markdown HTML Résultat
* Element 1
* Element 2
* Element 3
<ul>
  <li>Element 1</li>
  <li>Element 2</li>
  <li>Element 3</li>
</ul>
  • Element 1
  • Element 2
  • Element 3
- Element 1
- Element 2
- Element 3
+ Element 1
+ Element 2
+ Element 3

Listes ordonnées

On peut juste rajouter 1. avant chaque ligne ou numéroter directement 1., 2., 3., etc:

Markdown HTML Résultat
1. Element 1
1. Element 2
1. Element 3
<ol>
  <li>Element 1</li>
  <li>Element 2</li>
  <li>Element 3</li>
</ol>
  1. Element 1
  2. Element 2
  3. Element 3
1. Element 1
2. Element 2
3. Element 3

Listes imbriquées

On peut imbriquer des listes en indentant les paragraphes. Les indentations peuvent être faites avec des espaces (il faut au moins 3 espaces) ou le caractère [Tab]:

1. Elément 1
   * Element 1 - Sub 1
   * Element 1 - Sub 2
1. Element 2
   1. Element 2 - Sub 1
   1. Element 2 - Sub 2
1. Element 3
   * Element 3 - Sub 1
   * Element 3 - Sub 2

Pour obtenir:

  1. Elément 1
    • Element 1 – Sub 1
    • Element 1 – Sub 2
  2. Element 2
    1. Element 2 – Sub 1
    2. Element 2 – Sub 2
  3. Element 3
    • Element 3 – Sub 1
    • Element 3 – Sub 2

Liste de tâches

On peut simplement afficher une liste de tâches à cocher avec la syntaxe (il faut au moins 2 lignes):

- [ ] <texte correspondant à la tâche>

Quelques remarques:

  • On peut utiliser les autres caractères * ou + à la place de -, par exemple:
    + [ ] Element 1
    + [ ] Element 2
    
  • Pour afficher un élément coché directement, on peut utiliser le caractère x:
    - [x] Element directement coché
    
  • Si le texte correspondant à une ligne commence par une parenthèse, il faut l’échapper avec \:
    - [ ] \(ligne commençant par une parenthèse)
    

Par exemple:

- [ ] Element 1
- [x] Element 2
- [ ] \(Element 3)

Pour afficher:
□ Element 1
☑ Element 2
□ (Element 3)

Images

Pour afficher une image:
Logo wikipedia
La syntaxe est:

  • Afficher une image avec texte alternatif (sans lien):
    Markdown
    ![<texte alternatif>](<chemin absolu ou relatif>)

    Par exemple:

    ![Logo wikipedia](https://fr.wikipedia.org/static/images/icons/wikipedia.png)
    HTML
    <img alt="<texte alternatif>" 
      src="<chemin absolu ou relatif de l'image>"/>

    Par exemple:

    <img alt="Logo wikipedia" src="https://fr.wikipedia.org/static/images/icons/wikipedia.png" />
  • Afficher une image sans texte alternatif (sans lien):
    Markdown
    ![](<chemin absolu ou relatif>)

    Par exemple:

    ![](https://fr.wikipedia.org/static/images/icons/wikipedia.png)
    HTML
    <img src="<chemin absolu ou relatif de l'image>"/>

    Par exemple:

    <img src="https://fr.wikipedia.org/static/images/icons/wikipedia.png" />
  • Afficher une image avec lien:
    Il faut mettre le lien de l’image à la place du texte du lien hypertexte:

    Markdown
    [![](<chemin absolu ou relatif de l'image>)](<lien hypertexte>)

    Par exemple:

    [![](https://fr.wikipedia.org/static/images/icons/wikipedia.png)](https://en.wikipedia.org/)
    HTML
    <a href="<lien hypertexte>"><img src="<chemin absolu ou relatif de l'image>"/></a>

    Par exemple:

    <a href="https://en.wikipedia.org/"><img src="https://fr.wikipedia.org/static/images/icons/wikipedia.png"/></a>

Ligne horizontale

La ligne horizontale est affichée sur toute la largeur du paragraphe:


La syntaxe est:

Markdown HTML
Il faut au moins 3 caractères:
***
<hr />
---
___ (caractère underscore)

Caractères à échapper

Les caractères de la liste suivante sont interprétés donc si on veut afficher ces caractères sans qu’ils soient interprétés, il faut les échapper en les précédant avec le caractère \ (avec \<caractère à échapper>).
Ces caractères sont:
\ (pour échapper: \\), `, *, _ (caractère underscore), { }, [ ], < >, ( ), #, +, -, ., ! et |.

Editeurs en ligne

On peut trouver quelques éditeurs en ligne de markdown:

On peut trouver une liste plus exhaustive sur Awesome Markdown Editors & (Pre)viewers.

Eviter les éditeurs en ligne pour du texte sensible

En éditant ou en copiant collant du texte en ligne, il faut garder en tête que ce texte est partagé sur un serveur distant donc ces solutions sont à éviter pour du texte sensible.

Aide-mémoire pattern matching C#

Cet article est un aide-mémoire des motifs les plus courants de pattern matching suivant les versions de C# pour aider à se rappeler de la syntaxe:

Motif Version C# Remarques et exemples
Null pattern C# 7.0 Test pour vérifier si une variable est nulle

Vehicle vehicle = new Car();  
if (vehicle is null)  
  Console.WriteLine($"{nameof(vehicle)} is null.");  
else  
  Console.WriteLine($"{nameof(vehicle)} is not null.");
Constant pattern C# 7.0 Comparaison entre une variable et une constante

object carAsObj = new Car();  
if (carAsObj is "45")  
  Console.WriteLine($"{nameof(carAsObj)} is 45.");  
else  
  Console.WriteLine($"{nameof(carAsObj)} is not 45.");
Type pattern < C# 7.0 Test par rapport à un type:
Si on considère les types:

class Vehicle
{
    public string Name;
}

class MotoBike : Vehicle
{
    public int Power => 100;
}

class Car : Vehicle
{
    public int PassengerCount { get; set; }
}

Avant C# 7.0, on pouvait effectuer les tests suivants:

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."); 
C# 7.0 Test par rapport à un type et cast (utilisation implicite de as).

Avec le motif type, à partir de C# 7.0, on peut écrire:

if (vehicle is Car car)  
  Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} 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.");
Construction switch...case C# 7.0
object carAsObj = new Car();  
switch (carAsObj)  
{  
  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;  
} 
Ajouter des conditions avec when et switch...case C# 7.0
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;  
}
var pattern C# 7.0 Ce motif donne l’impression d’appliquer une condition avec l’opérateur is toutefois ce n’est pas le cas, la clause est toujours vraie. L’intérêt est d’effectuer une affectation du résultat de l’expression dans une variable qui pourra être utilisée pour d’autres conditions:
<expression> is var <nom de la variable> est toujours vraie, le résultat de <expression> est affecté dans la variable <nom de la variable>.

Par exemple:

List<Vehicle> vehicles = new List<Vehicle>{ new Car() }; 
// Toujours vrai, le résultat est dans bigVehicle
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"); 
}

Utilisation avec switch...case:

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 Syntaxe permettant de faciliter l’affectation d’une variable suivant plusieurs conditions:

<nouvelle variable à assigner> = <variable existante> switch
{
  <condition 1> => <expression 1>,
  <condition 2> => <expression 2>,
  // ...
};

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"), // discard pattern (cas par défaut)
};
Type pattern dans une expression switch C# 8 Pour tester par rapport à un type dans une expression switch.

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}",
  // [...]
};
C# 9 On peut supprimer la variable si on ne l’utilise pas par la suite.

Vehicle vehicle = new Car{ Name = "Car1"  };
string text = vehicle switch
{
  Car => "The vehicle is a car",
  MotoBike => "The vehicle is a motobike",
  // [...]
};
null pattern C# 9 A utiliser avec une expression switch pour tester si une variable est nulle.

Vehicle vehicle = new Car{ Name = "Car1"  };
string text = vehicle switch
{
  // [...]
  null => "No vehicle", // null pattern
  // [...]
};

La valeur "No vehicle" est affectée à text si vehicle est null.

Discard pattern C# 9 C’est le cas par défaut dans une expression switch.

Ce cas s’applique si aucune autre condition n’est satisfaite (cas par défaut).

string text = vehicle switch
{
  // [...]
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};
Ajouter des conditions avec when dans une expression switch C# 9
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"), // cas par défaut
};
var pattern avec une expression switch C# 9 Ce motif s’applique quelque soit le type de variable (la condition est toujours vraie).

L’intérêt est d’effectuer une affectation de l’expression dans une variable qui pourra éventuellement être utilisée dans le reste de l’expression.

string text = vehicle switch
{
  // [...]
  var unknownType => $"The vehicle type {unknownType} is not handled", // var pattern
  // _ => throw new InvalidOperationException("Vehicle is unknown"), // Ce code est inatteignable 
};

Le motif var est toujours vrai donc il est inutile de l’utiliser avec le motif discard.

Tuple pattern C# 9 Pour tester des conditions dans le cas de tuple et d’une expression switch.

(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 
};
Positional pattern avec un tuple C# 9 On utilise le caractère _ si on ne veut pas que la condition s’applique à un élément d’un tuple.

(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.
Relational pattern C# 9 On peut utiliser directement les opérateurs >, <, >= et <= pour tester une condition.

string text = vehicle.GetWheelCount() switch
{
  >= 4 => "The vehicle is a car",
  <= 2 => "The vehicle is a motobike",
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};

Ce motif peut être utilisé en dehors d’une expression switch.

if (vehicle is Car { PassengerCount: <= 4 } car)
{
  // ...
}

L’opérateur == n’est pas utilisable, pour appliquer une condition d’égalité, il suffit d’omettre l’opérateur:

string text = vehicle.GetWheelCount() switch
{
  4 => "The vehicle is a car",
  2 => "The vehicle is a motobike",
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};
Logical pattern C# 9 Permet d’utiliser les opérateurs and, or et not pour tester des conditions.

string text = vehicle.GetWheelCount() switch
{
  >= 4 and <= 6 => "The vehicle is a car",
  <= 2 and > 0 => "The vehicle is a motobike",
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};

string text = vehicle switch
{
  null => "not instanciated",
  not null => "instanciated", // Opérateur not
};

Ce motif peut être utilisé en dehors d’une expression switch.

if (vehicle is Car { PassengerCount: >= 4 and <= 6 and not 5 } car)
{
  // ...
}
Property pattern C# 10 Permet d’appliquer des conditions sur les propriétés des objets sans avoir une syntaxe trop lourde.

Si on considère:

public class Car
{
  public int PassengerCount;
  public Engine Engine;
}

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

On peut écrire:

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

Avec une clause if:

if (vehicle is Car { Engine.EngineType: "four stroke" } car)
{
  // ...
}
List pattern C# 11 Permet d’énoncer des conditions applicables sur les éléments d’une structure:

  • Enumérable (i.e. countable) et indexable ou
  • Enumérable (i.e. countable) et dont on peut extraire un sous-groupe (i.e. sliceable).

La syntaxe générale de ce motif utilise l’opérateur is:

<structure> is <conditions>

Il est possible de combiner plusieurs conditions avec les opérateurs and, or ou not:

<structure> is <condition 1> and <condition 2>

Une condition peut être énoncée en utilisant les syntaxes:

  • [2, 4, 6, 8, 10] pour indiquer que la structure doit contenir 5 entiers précis dans cet ordre.
  • Discard pattern: en utilisant le caractère _ pour indiquer n’importe quel élément.
    Par exemple:
    [2, 4, _, 8, _] permet d’indiquer que la structure doit contenir 3 entiers précis à la 1ère, 2e et 4e place. Il n’y a pas de condition sur les éléments à la 3e et 5e place.
  • Range pattern: permet d’indiquer un interval en utilisant les caractères ...
    Par exemple:

    • [.. , 10] permet d’indiquer que la structure doit se terminer par 10.
    • [.. , 8, 10] permet d’indiquer que la structure doit se terminer par un sous-groupe contenant 8 et 10.
    • [2, .. ] permet d’indiquer que la structure doit commencer par 2.
    • [2, 4, .. ] permet d’indiquer que la structure doit commencer par un sous-groupe contenant 2 et 4.
    • [2, .. , 10] permet d’indiquer que la structure doit commencer par 2 et se terminer par 10.
  • Relational pattern: permet d’indiquer des conditions en utilisant des opérateurs de comparaison <, <=, => ou > (== ne peut pas être utilisé, pour ajouter une condition d’égalité il suffit d’omettre l’opérateur).
    Par exemple:
    [2, 4, >= 6, <= 8, 10] permet d’indiquer que le 3e élément doit être supérieur ou égale à 6 et que le 4e élément doit inférieur ou égal à 8.
  • var pattern: ce motif n’applique pas de conditions mais permet d’effectuer des assignations en une seule ligne.
    Par exemple:

    var integers = {2, 4, 6, 8, 10};
    bool result = integers is [var item1, var item2, .., var itemN];
    

    Cette ligne permet d’assigner le 1er élément à item1, le 2e élément à item2 et le dernier élément à itemN. Les autres éléments sont ignorés.

Il est possible de combiner plusieurs motifs, par exemple le discard pattern et le range pattern:

bool result = integers is [_, 4, .., 10];

Pas de conditions sur le 1er élément et des conditions d’égalité sur le 2e et dernier élément.

On ne peut pas utiliser 2 fois le range pattern:

bool result = integers is [2, .., 4, .., 10]; // NE COMPILE PAS

On peut combiner le var pattern avec d’autres motifs:

bool result = integers is [var item1, .., var itemN];

Permet d’assigner le 1er et le dernier élément de la structure.

L’encodage base64

L’encodage base64 est très répandu et utilisé dans des cas d’applications très différents. Très souvent, il est pris pour un codage cryptographique alors que ce n’est pas le cas. Le but de cet article est d’expliquer l’intérêt et le procédé utilisé pour effectuer cet encodage.

Quel est l’intérêt du codage base64 ?

Le codage base64 utilise 64 caractères ASCII pour encoder des données binaires. Ainsi le 1er intérêt de cet encodage est de pouvoir encoder n’importe quelle donnée binaire sous la forme d’une chaîne de caractères utilisant des caractères interprétables par tous les systèmes (car étant en ASCII). Volontairement les 64 caractères ASCII utilisés sont restreints à des lettres, des chiffres et aux caractères spéciaux '+', '/', '='. Le but de cette restriction est d’éviter le plus possible d’utiliser des caractères qui pourraient mener à des interprétations différentes d’un système à l’autre.

Ainsi, on retrouve le codage base64 dans des cas où on souhaite transmettre ou stocker des données binaires en utilisant des systèmes n’autorisant que des caractères textuels:

  • Historiquement le protocole d’échange d’emails SMTP (i.e. Simple Mail Transfer Protocol) utilisait un encodage MIME Base64 faisant partie du protocole PEM (i.e. Privacy-enhanced Electronic Mail) décrit dans la publication RFC 989 en 1987. A l’origine ce protocole n’autorise que 64 caractères plus le caractère '='.
    Ainsi pour transmettre des pièces jointes non textuelles sous la forme binaire (comme des images), on utilisait l’encodage base64 pour transformer les données non textuelles en chaîne de caractères qu’on rajoutait au corps du mail.
    Cette restriction de SMTP n’est plus valable maintenant, des extensions ont été rajoutées (cf. RFC 6152) pour supporter 8BITMIME autorisant, entre autres, des données binaires directement.
  • L’encodage base64 est utilisé pour transmettre des données binaires ou non directement lisibles dans une URL lors d’une requête HTTP GET.
  • Des données binaires peuvent être stockées dans un fichier XML en utilisant l’encodage base64.
  • Base64 est souvent utilisé pour stocker dans des fichiers texte, des mots de passe dont on ne désire pas qu’ils soient lisibles directement. Bien que ce ne soit pas une méthode d’encodage cryptographique, elle permet de facilement rendre un mot de passe plus difficilement lisible.
Précisions concernant ASCII

ASCII (pour American Standard Code for Information Interchange) est une norme de codage de caractères datant des années 60. Elle comporte 128 caractères numérotés de 0 à 127. Ces caractères sont limités aux lettres sans accents majuscules et minuscules, aux chiffres et à des caractères spéciaux usuels, par exemple: rapidtables.com/code/text/ascii-table.html.

Cette norme est largement utilisée car tous les systèmes sont capables d’interpréter du texte utilisant cette norme. Toutefois la plus grosse restriction est que cette norme ne prend en compte que les caractères de la langue anglaise, il n’est pas possible de l’utiliser pour d’autres langues utilisant des caractères accentués, d’autres caractères spéciaux et plus largement pour des langues n’utilisant pas d’alphabet classique. Généralement tous les systèmes sont capables de lire des caractères ASCII toutefois le plus souvent, les encodages sont faits en utilisant au moins UTF-16, UTF-8 et plus largement Unicode (cf. Unicode en 5 min). Décoder des caractères Unicode permet de décoder des caractères ASCII, les numérotations des caractères entre ASCII et les encodages Unicode étant presque les mêmes:

  • En UTF-8: la longueur de l’encodage d’un caractère est variable. Elle commence à 8 bits (comme l’ASCII) pour aller jusqu’à 32 bits. La compatibilité avec l’ASCII est donc complète: un système ne décodant que de l’ASCII pourra décoder de l’UTF-8 (si tous les caractères font partie de l’ASCII).
  • En UTF-16: la longueur de l’encodage d’un caractère est de 16 bits. La compatibilité n’est donc pas totale avec l’ASCII, décoder des caractères UTF-16 permettra de reconnaître les caractères ASCII toutefois il y a un espace correspondant à un mot de 8 bits entre chaque caractère.

Par exemple, pour encoder la chaîne de caractères "AZERTY" en utilisant la norme ASCII:

Caractère 'A' 'Z' 'E' 'R' 'T' 'Y'
Hexadécimal 41 5A 45 52 54 59
Binaire 01000001 01011010 01000101 01010010 01010100 01011001

ASCII est une norme numérotant 128 caractères. Ainsi pour numéroter de 0 à 127, il faut 7 bits (27 = 128) toutefois on considère 8 bits car ASCII a été rapidement étendu pour inclure d’autres caractères liés à des spécificités régionales (cf. Code Pages).

Principe de l’encodage base64

Le but de l’encodage base64 est de transformer des données binaires en caractères textuels. 65 caractères sont utilisés:

  • 62 caractères correspondant à l’alphabet en majuscules, minuscules et les chiffres.
  • Les caractères '+' et '/'
  • Le caractère '=' qui sert à effectuer du padding. Ce caractère ne fait pas partie de l’encodage à proprement parlé.

Pour numéroter 64 caractères, il suffit de 6 bits (car 26 = 64). Les 6 bits utilisés sont appelés sextet. Ainsi pour encoder, il suffit de séparer la série de bits en lots de 6 bits et de faire la correspondance entre chaque sextet avec un caractère textuel:

Index décimal Sextet Caractère Index décimal Sextet Caractère Index décimal Sextet Caractère Index décimal Sextet Caractère
0 000000 'A' 1 000001 'B' 2 000010 'C' 3 000011 'D'
4 000100 'E 5 000101 'F' 6 000110 'G' 7 000111 'H'
8 001000 'I' 9 001001 'J' 10 001010 'K' 11 001011 'L'
12 001100 'M' 13 001101 'N' 14 001110 'O' 15 001111 'P'
16 010000 'Q' 17 010001 'R' 18 010010 'S' 19 010011 'T'
20 010100 'U' 21 010101 'V' 22 010110 'W' 23 010111 'X'
24 011000 'Y' 25 011001 'Z' 26 011010 'a' 27 011011 'b'
28 011100 'c' 29 011101 'd' 30 011110 'e' 31 011111 'f'
32 100000 'g' 33 100001 'h' 34 100010 'i' 35 100011 'j'
36 100100 'k' 37 100101 'l' 38 100110 'm' 39 100111 'n'
40 101000 'o' 41 101001 'p' 42 101010 'q' 43 101011 'r'
44 101100 's' 45 101101 't' 46 101110 'u' 47 101111 'v'
48 110000 'w' 49 110001 'x' 50 110010 'y' 51 110011 'z'
52 110100 '0' 53 110101 '1' 54 110110 '2' 55 110111 '3'
56 111000 '4' 57 111001 '5' 58 111010 '6' 59 111011 '7'
60 111100 '8' 61 111101 '9' 62 111110 '+' 63 111111 '/'
Les encodages ASCII et base64 ne sont pas similaires

Les caractères utilisés font partie de la norme ASCII mais la correspondance de code utilisé entre base64 et les caractères ASCII n’est pas la même:

  • Les caractères de base64 sont encodés sur 6 bits car 26 = 64.
  • Les caractères d’ASCII sont encodés sur 8 bits (dans le cas de 256 caractères) car 28 = 256.

Par exemple, 'Q' est:

  • ASCII: 01010001 (81 en décimal)
  • Base64: 010000 (17 en décimal)

Si on veut encoder le mot "PYTHON" avec le codage base64, il faut:

  1. Transformer au préalable des caractères en binaire avec les équivalents ASCII, chaque caractère correspond à 8 bits.
  2. Convertir les lots de 8 bits en lots de 6 bits pour former des sextets.
  3. Trouver la correspondance entre chaque sextet et un caractère suivant le codage base64.

Ainsi:

1. Caractère 'P' 'Y' 'T' 'H' 'O' 'N'
Equivalent décimal ASCII 80 89 84 72 79 78
Caractère ⇒ Binaire 01010000 01011001

01010100

01001000

01001111

01001110
2. Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100

111101

001110
3. Sextet ⇒ Index décimal 20 5 37 20 18 4 61 14
Index ⇒ Caractère base64 'U' 'F' 'l' 'U' 'S' 'E' '9' 'O'

Le résultat est une chaîne de caractères "UFlUSE9O".

Dans cet exemple, étant donné qu'on a encodé une chaîne de caractères, il était nécessaire d'effectuer une transformation préalable des caractères ASCII en binaire. L'encodage base64 à proprement parlé transforme les données binaires et caractères textuels.

Padding

L'exemple de l'encodage de la chaîne "PYTHON" est parfait puisque le nombre de bits correspondant est 48 qui est multiple de 8 et 6 (48 = 6 * 8 + 0). Dans un cadre plus général, le nombre de bits correspondant à une chaîne à encoder n'est pas forcément multiple de 6 et 8.

Exemple de padding avec 2 bits

Par exemple, si on considère la chaine "PYTHONES", en effectuant l'encodage:

Caractère 'P' 'Y' 'T' 'H' 'O' 'N' 'E' 'S'
Equivalent décimal ASCII 80 89 84 72 79 78 69 83
Caractère ⇒ Binaire 01010000

01011001

01010100

01001000

01001111

01001110

01000101

01010011
Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100

111101 001110 010001 010101 0011??
Sextet ⇒ Index décimal 20 5 37 20 18 4 61 14 17 21 ???
Index ⇒ Caractère base64 'U' 'F' 'l' 'U' 'S' 'E' '9' 'O' 'R' 'V' ???

La chaîne "PYTHONES" correspond à 64 bits qui n'est pas multiple de 6 (64 = 6 * 10 + 4). On ne peut pas former le dernier sextet car il manque 2 bits. On complète alors arbitrairement avec des 0 pour obtenir un sextet complet:

Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100

111101 001110 010001 010101 001100
Sextet ⇒ Index décimal 20 5 37 20 18 4 61 14 17 21 12
Index ⇒ Caractère base64 'U' 'F' 'l' 'U' 'S' 'E' '9' 'O' 'R' 'V' 'M'

Le sextet complet permet de rajouter un caractère qui dans notre cas est le 'M'.

Ajout du caractère '='

Lors du décodage, il suffirait dans la formule suivante d'identifier j pour savoir si des 0 de padding ont été rajoutés:

NbreBits = 8 * i + j

Dans notre cas NbreBits = 66 = 8 * 8 + 2 donc il y a 2 bits de padding.
Même s'il est possible d'effectuer cette déduction, il a été décidé de rajouter le caractère '=' pour indiquer qu'un padding a été effectué:

  • On rajoute une fois '=' si on a complété par 2 bits à 0 pour obtenir un sextet ou
  • On rajouter 2 fois '==' si on a complété par 4 bits à 0.

Pour cet exemple, la chaîne correctement encodée est donc:

"UFlUSE9ORVM="

Exemple de padding avec 4 bits

Si on prend l'exemple de la chaîne "PYTHONE", en effectuant l'encodage, on obtient:

Caractère 'P' 'Y' 'T' 'H' 'O' 'N' 'E'
Equivalent décimal ASCII 80 89 84 72 79 78 69
Caractère ⇒ Binaire 01010000 01011001 01010100 01001000 01001111 01001110 01000101
Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100 111101 001110 010001 01????
Sextet ⇒ Index décimal 20 5 37 20 18 4 61 14 17 ???
Index ⇒ Caractère base64 'U' 'F' 'l' 'U' 'S' 'E' '9' 'O' 'R' ???

La chaîne "PYTHONE" correspond à 56 bits non multiple de 6 (56 = 6 * 9 + 2). Comme l'exemple précédent, il manque 4 bits pour avoir un sextet complet. En complétant avec des bits à 0, on obtient:

Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100 111101 001110 010001 010000
Sextet ⇒ Index décimal 20 5 37 20 18 4 61 14 17 16
Index ⇒ Caractère base64 'U' 'F' 'l' 'U' 'S' 'E' '9' 'O' 'R' 'Q'

Le sextet complet permet de rajouter le caractère 'Q' qui dans notre cas est le 'M'. Comme on a rajouté 4 bits à 0, on complète la chaine en rajoutant les caractères '=='. La chaîne correctement encodée est:

'UFlUSE9ORQ=='

Comment encoder/decoder en base64 ?

On peut utiliser les sites suivants (toutefois attention à ne pas utiliser dans le cas de données sensibles):

Powershell

Voici des scripts pour encoder et décoder en base64:

  • Pour encoder en base64:
    $sourceText = $Args[0]
    Write-Host "Encoding: $sourceText"
    # ATTENTION encodage UTF-16LE
    $bytes = [System.Text.Encoding]::Unicode.GetBytes($sourceText)
    $encodedText =[Convert]::ToBase64String($bytes)
    Write-Host $encodedText
    
  • Pour décoder:
    $encodedText = $Args[0]
    Write-Host "Encoding: $encodedText"
    # ATTENTION encodage UTF-16LE
    $decodedText = [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($encodedText))
    Write-Host $decodedText
    
Attention au choix de l'encodage entre ASCII, UTF-8 et UTF-16

Les scripts précédents sont exécutables sur Windows où les chaines correspondent à un encodage UTF-16 "Little Endian" (pour plus de détails voir Unicode en 5 min). Sur internet et en particulier avec les sites base64encode.org et base64decode.org lorsqu'on écrit, par défaut c'est de l'UTF-8. Enfin dans les exemples présentés précédemment on a effectué un encodage en ASCII.

Donc il faut bien avoir en tête l'encodage utilisé pour la chaîne d'origine, ainsi:

  • Sur les sites base64encode.org et base64decode.org, pour avoir des résultats similaires aux scripts powershell, il faut sélectionner l'encodage UTF-16LE (pour "Little Endian").
  • Avec les scripts pour avoir une équivalence avec les exemples présentés précédemment, il faut effectuer les conversions à partir de l'ASCII en utilisant:
    • Encodage (de caractères ASCII):
      $bytes = [System.Text.Encoding]::ASCII.GetBytes($sourceText)
      
    • Décodage (de caractères ASCII):
      $decodedText = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($encodedText))
      
  • Si les chaînes sont en UTF-8:
    • Encodage (de caractères UTF-8):
      $bytes = [System.Text.Encoding]::UTF8.GetBytes($sourceText)
      
    • Décodage (de caractères UTF-8):
      $decodedText = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encodedText))
      
  • Enfin, UTF-8 est compatible avec l'ASCII c'est-à-dire des caractères ASCII seront encodés de la même façon en ASCII et en UTF-8 mais l'inverse n'est pas forcément vrai puisque UTF-8 comprend beaucoup plus de caractères que l'ASCII. L'encodage en UTF-16LE n'est pas complêtement compatible avec l'ASCII.

A la ligne de commande

Sur Linux et MacOS (sur ces systèmes l'encodage utilisé est UTF-8):

  • Encodage:
    echo -n '<chaîne à encoder>' | base64
    
  • Décodage:
    echo -n '<chaîne à decoder>' | base64 --decode
    

Avec OpenSSL

Avec la bibliothèque cryptographique OpenSSL, voici les commandes pour encoder et décoder en base64:

  • Pour encoder directement du texte à la ligne de commandes:
    echo -n '<chaîne à encoder>' | openssl base64
    
  • Pour décoder directement du texte à la ligne de commandes:
    echo -n '<chaîne à decoder>' | openssl base64 -d 
    
  • Pour encoder un fichier:
    openssl base64 -in <chemin du fichier à encoder> -out <chemin du fichier en sortie>
    
  • Pour décoder un fichier:
    openssl base64 -d -in <chemin du fichier à décoder> -out <chemin du fichier en sortie>
    
Références

Les “Assembly Load Contexts” en 5 min

Les contextes de chargement d’assemblies (i.e. assembly load contexts) correspondent à une proposition différente et plus aboutie que les domaines d’application (i.e. application domain) pour permettre d’assurer le même type de fonctionnalités:

  • Permettre de charger des versions différentes d’une même assembly dans un même processus,
  • Permettre de charger puis de décharger de façon modulaire des assemblies

L’avantage des contextes de chargement d’assemblies est qu’il n’y a pas de frontières entre les contextes comme pour les domaines d’application: des assemblies peuvent être partagées d’un contexte à l’autre. Pour assurer la possibilité de charger une même assembly avec une version différente, les contextes de chargement se concentrent sur l’aspect chargement de l’assembly plutôt que d’établir une frontière qui va perdurer pendant toute la durée de présence des 2 versions d’assemblies dans le processus.

Le but de cet article est de passer en revue les caractéristiques des assembly load contexts en les illustrant avec des exemples et de les comparer aux application domains.

Dans un 1er temps, on va expliquer le fonctionnement général des contextes de chargement d’assemblies, ensuite on va rentrer davantage dans les détails sur la façon dont les assemblies sont chargées dans le cas de plusieurs contextes. Enfin, on explicitera quelques fonctionnalités un peu plus avancées.

Fonctionnement des contextes de chargement

Il existe un contexte de chargement par défaut toujours présent lors de l’exécution d’un processus. Par la suite, on peut créer d’autres contextes pour des besoins particuliers de chargement d’assemblies. Dans un premier temps, on ne considère que le contexte de chargement par défaut.

Contexte de chargement par défaut

A l’issue de la compilation d’une application .NET, au moins 2 fichiers sont générés:

  • Une assembly contenant le “main” de l’application avec le code fonctionnel et
  • Un fichier exécutable qui est l’app host (i.e. application host) spécifique au système d’exploitation et à un runtime donné.

Au démarrage de l’application .NET, l’app host va charger quelques assemblies du framework comme par exemple:

  • hostfxr.dll: cette DLL va sélectionner le bon runtime permettant d’exécuter l’application .NET. Ce runtime dépend du runtime ciblé au moment de la compilation, du système d’exploitation et du runtime réellement installé.
  • hostpolicy.dll: regroupe toutes les stratégies pour charger le runtime, appliquer la configuration, résoudre les dépendances de l’application et appeler le runtime pour exécuter l’application.
  • coreclr.dll: c’est le CLR qui va exécuter le code .NET. Le comportement est ensuite similaire au framework .NET historique: le code .NET sous la forme de code IL est compilé au besoin par l’intermédiaire du compilateur JIT. Ce code est ensuite exécuté.

Lorsque le code fonctionnel se trouvant dans la fonction “main” est exécuté, suivant les types à exécuter, il peut être nécessaire de charger d’autres assemblies plus spécifiques au code à exécuter. Ces assemblies doivent être chargées avec leurs dépendances managées ou natives. Avant de charger ces assemblies, une recherche des fichiers est effectuée pour les localiser. Cette recherche est appelée assembly probing, elle consiste à parcourir plusieurs chemins précis pour trouver où se situe l’assembly et ses éventuelles dépendances.

Par défaut, les emplacements suivants sont parcourus:

  • TRUSTED_PLATFORM_ASSEMBLIES: liste des chemins des assemblies du framework (managées et natives).
  • PLATFORM_RESOURCE_ROOTS: liste des chemins des répertoires qui seront parcourus pour chercher les assemblies satellites (assemblies de ressources).
  • NATIVE_DLL_SEARCH_DIRECTORIES: liste des chemins des répertoires qui seront parcourus pour chercher les DLL natives.
  • APP_PATHS et APP_NI_PATHS: chemin de l’application

Lorsque l’assembly est trouvée, elle est chargée avec ces dépendances. Il ne faut pas considérer que l’assembly est chargée dans le contexte par défaut mais grâce au contexte de chargement par défaut. La notion d’application domain avec des frontières n’existe plus, le contexte de chargement permet de fournir une logique pour trouver et charger une assembly.

Fonctionnement avec des contextes de chargement supplémentaires

Si on crée des contextes de chargement d’assemblies supplémentaires, la recherche et le chargement des assemblies peuvent être modifiés toutefois il n’y a pas d’isolation avec le contexte de chargement par défaut c’est la logique de recherche des assemblies qui est différente.

Ainsi si on souhaite accéder et exécuter un type particulier:

  • Si le type a été chargé avec le contexte de chargement par défaut alors on y accède et on peut l’exécuter directement.
  • Si le type a été chargé avec un contexte supplémentaire, son accès et son exécution ne seront possibles qu’en précisant le contexte supplémentaire explicitement lors de l’accès au type. Dans le cas contraire une exception sera lancée.
    Si le chargement du type nécessite le chargement d’autres types dans d’autres assemblies alors:

    • La recherche des dépendances se fera d’abord en utilisant le contexte de chargement supplémentaire.
    • Si le contexte de chargement supplémentaire ne permet pas de trouver la dépendance alors la recherche se fera en utilisant le contexte de chargement par défaut.

Exemples d’utilisation des contextes de chargement

Au moyen de quelques exemples, on va essayer d’indiquer quelles sont les fonctionnalités principales des contextes de chargement des assemblies.

L’exemple utilisé est similaire à celui utilisé pour les application domains:

  • Une assembly nommée DotNetCommonInterfaces contenant une interface avec la signature de la fonction à exécuter:
    public interface ISimpleClass
    {
      void HelloWorldExample();
    }
    
  • Une assembly nommée DotNetSimplePlugIn contenant le code à exécuter qui doit être chargé dans un autre contexte de chargement d’assemblies. Dans cette assembly se trouve la classe SimpleClass qui satisfait ISimpleClass:
    public class SimpleClass: ISimpleClass
    {
      public void HelloWorldExample()
      {
        Console.WriteLine($"{nameof(SimpleClass.HelloWorldExample)} executed");
      }
    }
    
  • Un exécutable DotNetExamples contenant le code de test pour manipuler les contextes de chargement des assemblies. Cet exécutable a une référence vers l’assembly DotNetCommonInterfaces. Dans DotNetExamples, il n’a pas de référence explicite vers DotNetSimplePlugIn.

Chargement d’une assembly dans un contexte de chargement

Comme indiqué plus haut, les contextes de chargement d’assemblies permettent d’apporter une solution pour personnaliser la façon dont les assemblies sont chargées en utilisant une logique différente de celle par défaut. En effet, par défaut, le mécanisme de recherche d’une assembly et de ses dépendances est celui d’assembly probing. L’assembly probing s’applique dans le cas du contexte de chargement d’assemblies par défaut AssemblyLoadContext.Default. L’intérêt de pouvoir ajouter d’autres contextes de chargement d’assemblies est de disposer de plusieurs logiques de chargement d’assemblies dans un même processus et de pouvoir les personnaliser.

Le code suivant permet de charger une assembly dans un contexte de chargement d’assemblies qui n’est pas le contexte par défaut:

const string assemblyLoadContextName = "PlugInLoadContext";
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);  // ATTENTION: il faut utiliser le chemin absolu

La fonction GetAssemblyPath() permet de construire le chemin absolu de l’assembly à charger (le chemin relatif n’est pas suffisant):

private string GetAssemblyPath(string assemblyName)
{
  string currentAssemblyLocation = Assembly.GetExecutingAssembly().Location;
  string assemblyNameWithoutExtension = Path.GetFileNameWithoutExtension(assemblyName);
  string relativePath = Path.Combine(Path.GetDirectoryName(currentAssemblyLocation), 
    $@"..\..\..\..\{assemblyNameWithoutExtension}\bin\Debug\net7.0", assemblyName);
  return Path.GetFullPath(relativePath);
}

La méthode suivante permet de lister les assemblies dans un contexte de chargement donné:

private void DisplayAssembliesInLoadContext(AssemblyLoadContext loadContext)
{
  Console.WriteLine("----------------------------------");
     Console.WriteLine($"Assemblies loaded in: {loadContext.Name}:");
  foreach (Assembly assembly in loadContext.Assemblies)
  {
    Console.WriteLine($"{assembly.FullName}");
  }

  Console.WriteLine("----------------------------------");
}

Si on vérifie les assemblies dans le contexte de chargement par défaut et dans le contexte supplémentaire créé en exécutant:

DisplayAssembliesInLoadContext(plugInLoadContext);
DisplayAssembliesInLoadContext(AssemblyLoadContext.Default);

On obtient:

----------------------------------
Assemblies loaded in: PlugInLoadContext:
DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
----------------------------------
Assemblies loaded in: Default:
System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
DotNetExamples, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
System.Runtime, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
Microsoft.Extensions.DotNetDeltaApplier, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
System.IO.Pipes, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Linq, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Collections, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Console, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Threading, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Runtime.InteropServices, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Threading.Overlapped, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Security.AccessControl, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Security.Principal.Windows, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Security.Claims, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Runtime.Loader, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
System.Collections.Concurrent, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
DotNetCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
System.Text.Encoding.Extensions, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
----------------------------------

On peut voir que l’assembly DotNetSimplePlugIn est chargée seulement dans le contexte supplémentaire. Toutes les assemblies du framework sont chargées seulement dans le contexte par défaut.

Exécuter du code dans un contexte de chargement supplémentaire avec la reflection

Appeler du code dans le contexte de chargement d’assemblies supplémentaire ne nécessite pas d’utiliser du marshalling ou que les objets soient sérialisables. On peut directement utiliser la reflection pour instancier et utiliser le type contenant le code à appeler.

Par exemple, si on souhaite instancier un type se trouvant dans une assembly chargée dans un contexte de chargement particulier, on peut exécuter:

// il faut indiquer explicitement le contexte ayant permis de charger l'assembly
Assembly a = plugInLoadContext.LoadFromAssemblyPath(dependencyAssemblyPath); 
const string simpleClassTypeName = "AssemblyLoadContextExamples.DotNetSimplePlugIn.SimpleClass";
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType); 
plugin.HelloWorldExample();

Dans cet exemple, on effectue un cast vers le type ISimpleClass pour faciliter l’appel de la méthode HelloWorldExample(). Ce cast n’est pas indispensable, on peut utiliser la reflection différemment pour appeler le constructeur et pour exécuter la méthode:

Type pluginType = a.GetType(simpleClassTypeName);
// Appel du constructeur pour instancier la classe SimpleClass
ConstructorInfo plugInConstructor = pluginType.GetConstructor(Array.Empty<Type>());
object plugInAsObject = plugInConstructor.Invoke(Array.Empty<object>());
// Exécution de la méthode HelloWorldExample()
MethodInfo helloWorldMethodInfo = pluginType.GetMethod("HelloWorldExample", BindingFlags.Instance | BindingFlags.Public);
helloWorldMethodInfo.Invoke(plugInAsObject, Array.Empty<object>());

Si on affiche les assemblies dans le contexte de chargement par défaut et dans le contexte créé après avoir exécuté la méthode SimpleClass.HelloWorldExample(), on obtient le même résultat que plus haut, l’assembly DotNetSimplePlugIn n’est pas dans le contexte par défaut.

On peut noter que si on exécute les autres surcharges de la méthode Activator.CreateInstance(), en précisant le nom de l’assembly DotNetSimplePlugIn.dll et de la classe SimpleClass:

var pluginObjectHandle = Activator.CreateInstance(a.FullName, pluginType.FullName);
dynamic pluginUntyped = pluginObjectHandle.Unwrap();

On obtient une erreur car la recherche ne se fait que dans le contexte de chargement par défaut:

System.IO.FileNotFoundException: 'Could not load file or assembly 'DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.'

Il faut que l’assembly soit chargée dans le contexte par défaut pour que cette surcharge fonctionne:

Assembly b = AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyAssemblyPath);
var pluginObjectHandle = Activator.CreateInstance(b.FullName, pluginType.FullName);
dynamic pluginUntyped = pluginObjectHandle.Unwrap();
plugin.HelloWorldExample();

Chargement des dépendances

Une assembly peut avoir des dépendances spécifiques. Si du code dans une assembly nécessite le chargement de dépendances managées ou non managées, le CLR va chercher à charger les assemblies ou DLL correspondantes. Le comportement sera légèrement différent suivant le contexte de chargement utilisé pour charger l’assembly d’origine:

  • Si l’assembly d’origine a été chargée avec le contexte de chargement par défaut, alors la dépendance sera localisée en utilisant l’algorithme de “default probing” évoqué précédemment. La dépendance sera ensuite chargée en utilisant le contexte de chargement par défaut ce qui implique qu’elle sera accessible à tous les autres contextes de chargement en tant qu’assembly partagée.
  • Si l’assembly d’origine a été chargée avec un contexte supplémentaire (différent du contexte par défaut), alors:
    • Dans un 1er temps, le CLR cherchera à charger la dépendance en utilisant le contexte de chargement supplémentaire (le comportement de ce contexte peut éventuellement être personnalisé).
    • Si le contexte de chargement supplémentaire n’a pas permis de trouver la dépendance alors le CLR effectuera la recherche de la dépendance en utilisant le contexte de chargement par défaut.

Pour illustrer ce comportement, on reprend l’exemple précédent en ajoutant une dépendance à l’assembly DotNetSimplePlugIn sous la forme d’une assembly nommée DotNetCommonDependency:

On modifie la classe SimpleClass en rajoutant la méthode suivante:

using AssemblyLoadContextExamples.DotNetCommonInterfaces;
using AssemblyLoadContextExamples.DotNetCommonDependency;

namespace AssemblyLoadContextExamples.DotNetSimplePlugIn;

public class SimpleClass: ISimpleClass
{
  // ...

  public void FunctionToExecute()
  {
    var commonDependency = new CommonDependency();
    Console.WriteLine($"{nameof(SimpleClass.FunctionToExecute)} executed. From CommonDependency: {commonDependency.InnerStringValue}");
  }
}

La classe CommonDependency se trouve dans une autre assembly nommée DotNetCommonDependency:

namespace AssemblyLoadContextExamples.DotNetCommonDependency;

public class CommonDependency
{
  public string InnerStringValue => $"From {nameof(CommonDependency)}";
}

L’assembly DotNetSimplePlugIn référence directement l’assembly DotNetCommonDependency.
On exécute le code suivant permettant de charger l’assembly DotNetSimplePlugIn avec un contexte de chargement supplémentaire et d’exécuter la méthode SimpleClass.FunctionToExecute():

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);

// Instanciation de la classe SimpleClass
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType);

// On affiche les assemblies chargées grâce au contexte de chargement supplémentaire
DisplayAssembliesInLoadContext(plugInLoadContext);

plugin.FunctionToExecute();  // ⚠ ERREUR ⚠

A l’exécution du code précédent, on obtient une erreur lors de la l’exécution de la ligne plugin.FunctionToExecute() car il n’est pas possible de charger l’assembly DotNetCommonDependency:

  1. Le contexte de chargement supplémentaire n’a pas d’indications pour charger DotNetCommonDependency (même si l’assembly DotNetCommonDependency se trouve dans le répertoire de sortie de l’assembly DotNetSimplePlugIn),
  2. Le contexte de chargement par défaut n’a pas non plus d’indication sur l’emplacement de DotNetCommonDependency.

L’erreur est:

System.IO.FileNotFoundException: 'Could not load file or assembly 'DotNetCommonDependency, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.'

A l’exécution, on peut voir que le contexte de chargement supplémentaire ne peut pas charger l’assembly DotNetCommonDependency:

----------------------------------
Assemblies loaded in: PlugInLoadContext:
DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Si on modifie le code exécuté pour permettre au contexte de chargement supplémentaire de charger l’assembly DotNetCommonDependency:

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);
string dependencyAssemblyPath = GetAssemblyPath("DotNetCommonDependency.dll");
plugInLoadContext.LoadFromAssemblyPath(dependencyAssemblyPath);

// Instanciation de la classe SimpleClass
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType);

// On affiche les assemblies chargées grâce au contexte de chargement supplémentaire
DisplayAssembliesInLoadContext(plugInLoadContext);

plugin.FunctionToExecute();

L’exécution ne produit pas d’erreur, la dépendance est correctement chargée grâce au contexte de chargement supplémentaire:

----------------------------------
Assemblies loaded in: PlugInLoadContext:
DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
DotNetCommonDependency, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
FunctionToExecuteInVersion1 executed. From CommonDependency: From CommonDependency

On modifie de nouveau le code pour ne pas charger l’assembly DotNetCommonDependency avec le contexte de chargement supplémentaire mais avec le contexte par défaut:

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);
string dependencyAssemblyPath = GetAssemblyPath("DotNetCommonDependency.dll");
AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyAssemblyPath);

// Instanciation de la classe SimpleClass
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType);

// On affiche les assemblies chargées grâce au contexte de chargement supplémentaire
DisplayAssembliesInLoadContext(plugInLoadContext);

plugin.FunctionToExecute();

L’exécution ne produit pas d’erreur. Conformement au mécanisme de chargement expliqué plus haut, dans un 1er temps le CLR cherche à charger la dépendance DotNetCommonDependency avec le contexte de chargement supplémentaire ce qui échoue. Dans un 2e temps, le chargement de la dépendance se fait avec le contexte de chargement par défaut, ce qui réussit. Durant l’exécution, on peut voir que le contexte de chargement supplémentaire ne permet pas de charger l’assembly DotNetCommonDependency:

----------------------------------
Assemblies loaded in: PlugInLoadContext:
DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
FunctionToExecuteInVersion1 executed. From CommonDependency: From CommonDependency

Assemblies partagées entre plusieurs contexte de chargement

Lors du chargement d’une assembly, quelque soit le cas de figure, le CLR va tenter d’utiliser le contexte de chargement par défaut pour localiser puis charger l’assembly soit directement, soit après avoir essayé d’utiliser un contexte de chargement supplémentaire (voir plus haut).
Ainsi si on charge une assembly avec le contexte de chargement par défaut (comme dans le cas de l’exemple précédent), il est possible de partager cette assembly entre tous les contextes de chargement.

Exemple de chargement d’un même type dans des contextes de chargement différents

Le but de cet exemple est de montrer comment il est possible de charger 2 versions d’une même assembly dans un même processus en utilisant les contextes de chargement. En pratique il suffit de charger les 2 versions dans 2 contextes de chargement différents. Dans cet exemple, on crée 2 contextes de chargement différents toutefois, on peut se contenter d’en créer un seul:

  • Une 1ère version est chargée dans le contexte de chargement par défaut et
  • Une 2e version dans un contexte supplémentaire.

Dans cet exemple, on considère 2 versions de l’assembly DotNetSimplePlugIn.dll avec les implémentations suivantes:

  • Version 1 (dans le répertoire \..\Version1\DotNetSimplePlugIn.dll):
    public class SimpleClass: ISimpleClass
    {
      public void FunctionToExecuteInVersion1()
      {
        var commonDependency = new CommonDependency();
        Console.WriteLine($"{nameof(SimpleClass.FunctionToExecuteInVersion1)} executed. From CommonDependency: {commonDependency.InnerStringValue}");
      }
    }
    
  • Version 2 (dans le répertoire \..\Version2\DotNetSimplePlugIn.dll):
    public class SimpleClass: ISimpleClass
    {
      public void FunctionToExecuteInVersion2()
      {
        var commonDependency = new CommonDependency();
        Console.WriteLine($"{nameof(SimpleClass.FunctionToExecuteInVersion2)} executed. From CommonDependency: {commonDependency.InnerStringValue}");
      }
    }
    

Comme on peut le voir, ces 2 versions dépendent d’une assembly commune nommée DotNetCommonDependency. Cette assembly sera chargée dans le contexte de chargement par défaut pour qu’elle soit partagée.

Dans un 1er temps, on va essayer de charger les 2 versions dans le même contexte de chargement:

// Création d'un seul contexte de chargement
var loadContextForV1 = new AssemblyLoadContext("loadContextV1Name");
//var loadContextForV2 = new AssemblyLoadContext("loadContextV2Name");

// On charge les 2 versions dans un même contexte de chargement
Assembly assemblyV1 = loadContextForV1.LoadFromAssemblyPath(
  Path.GetFullPath("..\\..\\..\\..\\TEST\\Version1\\DotNetSimplePlugIn.dll"));
Assembly assemblyV2 = loadContextForV1.LoadFromAssemblyPath(
  Path.GetFullPath("..\\..\\..\\..\\TEST\\Version2\\DotNetSimplePlugIn.dll")); // ⚠ ERREUR ⚠

// On charge la dépendance DotNetCommonDependency
string dependencyAssemblyPath = GetAssemblyPath("DotNetCommonDependency.dll");
Assembly commonDependency = AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyAssemblyPath);

// Instanciation des 2 versions de SimpleClass
Type typeV1 = assemblyV1.GetType(simpleClassTypeName);
Type typeV2 = assemblyV2.GetType(simpleClassTypeName);
dynamic simpleClassFromV1 = Activator.CreateInstance(typeV1);
dynamic simpleClassFromV2 = Activator.CreateInstance(typeV2);

// On exécute les méthodes correspondant aux bonnes versions
simpleClassFromV1.FunctionToExecuteInVersion1();
simpleClassFromV2.FunctionToExecuteInVersion2();

Sans surprise, quand on essaie de charger une 2e version de la même assembly dans le même contexte de chargement on obtient une erreur:

System.IO.FileLoadException: 'Assembly with same name is already loaded'

On place alors la 2e version dans un 2e contexte de chargement:


// Création de 2 contextes de chargement
var loadContextForV1 = new AssemblyLoadContext("loadContextV1Name");
var loadContextForV2 = new AssemblyLoadContext("loadContextV2Name");

// On charge les 2 versions dans un même contexte de chargement
Assembly assemblyV1 = loadContextForV1.LoadFromAssemblyPath(
  Path.GetFullPath("..\\..\\..\\..\\TEST\\Version1\\DotNetSimplePlugIn.dll"));
Assembly assemblyV2 = loadContextForV2.LoadFromAssemblyPath(
  Path.GetFullPath("..\\..\\..\\..\\TEST\\Version2\\DotNetSimplePlugIn.dll"));

// On charge la dépendance DotNetCommonDependency
string dependencyAssemblyPath = GetAssemblyPath("DotNetCommonDependency.dll");
Assembly commonDependency = AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyAssemblyPath);

// Instanciation des 2 versions de SimpleClass
Type typeV1 = assemblyV1.GetType(simpleClassTypeName);
Type typeV2 = assemblyV2.GetType(simpleClassTypeName);
dynamic simpleClassFromV1 = Activator.CreateInstance(typeV1);
dynamic simpleClassFromV2 = Activator.CreateInstance(typeV2);

// On exécute les méthodes correspondant aux bonnes versions
simpleClassFromV1.FunctionToExecuteInVersion1();
simpleClassFromV2.FunctionToExecuteInVersion2();

Pas d’erreur, l’exécution aboutit normalement:

FunctionToExecuteInVersion1 executed. From CommonDependency: From CommonDependency
FunctionToExecuteInVersion2 executed. From CommonDependency: From CommonDependency

Le point important dans cet exemple est que les 2 types sont bien distincts même s’il s’agit de 2 versions d’une même classe.

AssemblyLoadContext.EnterContextualReflection()

Si on ne précise pas le contexte de chargement à utiliser pour exécuter certaines fonctions statiques, c’est le contexte par défaut qui est utilisé. Ainsi si on a chargé des assemblies dans un contexte de chargement particulier et qu’on souhaite effectuer des opérations sur ces assemblies, on risque d’avoir une erreur car ces opérations pourront être exécutées dans le contexte par défaut.

Les fonctions pour lesquelles il peut être nécessaire de préciser le contexte sont:

Dans un des exemples présentés précédemment, nous avons expérimenté ce problème. Dans cet exemple, 2 assemblies sont en jeu:

  • DotNetExamples contenant le “main” qui permet de créer un contexte de chargement supplémentaire et de lancer le code de l’exemple,
  • DotNetSimplePlugIn qui est le plug-in à charger dans un contexte de chargement différent,

Pour rappel, le code exécuté est le suivant:

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);

// Instanciation de la classe SimpleClass
Type pluginType = a.GetType(simpleClassTypeName);
var pluginObjectHandle = Activator.CreateInstance(a.FullName, simpleClassTypeName);  // ⚠ ERREUR ⚠
dynamic pluginUntyped = pluginObjectHandle.Unwrap();

// Exécution de la fonction dans le plug-in
pluginUntyped.HelloWorldExample();

L’erreur est:

System.IO.FileNotFoundException: 'Could not load file or assembly 'DotNetSimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.'

L’erreur se produit à l’exécution de la fonction statique Activator.CreateInstance() car le contexte dans lequel cette fonction est exécutée est celui du contexte de chargement par défaut. Etant donné que l’assembly DotNetSimplePlugIn n’a pas été chargée dans le contexte par défaut, elle ne peut être instanciée avec le contexte par défaut.

Pour remédier à ce problème, une solution est de préciser un contexte de chargement à appliquer avec la fonction AssemblyLoadContext.EnterContextualReflection(). Cette fonction permet à l’intérieur d’une clause using de préciser le contexte qui devrait être utilisé:

using (<instance contexte de chargement>.EnterContextualReflection())
{
  // Le code sera exécuté dans un contexte de chargement spécifique
} 

Une autre surcharge de cette fonction permet d’appliquer un contexte utilisé pour charger une assembly donnée:

using (AssemblyLoadContext.EnterContextualReflection(<assembly>))
{
  // Le code sera exécuté dans un contexte de chargement spécifique
} 

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

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName);

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);

// On précise le contexte à utiliser
using (plugInLoadContext.EnterContextualReflection())
{
  // Instanciation de la classe SimpleClass
  Type pluginType = a.GetType(simpleClassTypeName);
  var pluginObjectHandle = Activator.CreateInstance(a.FullName, simpleClassTypeName);  // OK
  dynamic pluginUntyped = pluginObjectHandle.Unwrap();

  // Exécution de la fonction dans le plug-in
  pluginUntyped.HelloWorldExample();
}

Il n’y a pas d’erreur, la fonction statique Activator.CreateInstance() est exécutée dans le contexte de chargement plugInLoadContext. Ainsi la classe SimpleClass est correctement instanciée.

Déchargement d’un contexte de chargement

Il est possible de décharger un contexte de chargement ainsi que toutes les assemblies qu’il contient lorsque c’est possible. En réalité, le déchargement ne se fait que si le garbage collector ne constate pas de références qui justifieraient de maintenir chargé le contexte de chargement.
L’appel à la fonction AssemblyLoadContext.Unload() permet d’indiquer qu’on souhaite décharger le contexte de chargement. L’appel ne garantit pas le déchargement effectif du contexte. En effet, il ne sera pas effectué de façon synchrone lors de l’appel mais après exécution du garbage collector et dans le cas où il n’y a pas de dépendances nécessitant que les assemblies soient maintenues dans le contexte.
Si un appel à Unload() n’est pas effectué explicitement pour un contexte de chargement d’assemblies donné, il ne sera jamais déchargé. Enfin, pour décharger un contexte, il faut qu’il soit instancié avec l’option Collectible = true:

var newAppContext = new AssemblyLoadContext(dependencyAssemblyName, true); 

Dans l’exemple suivant, pour éviter de maintenir une référence qui empêchera le déchargement, on entoure la référence du contexte de chargement avec une WeakReference:

// Création contexte de chargement supplémentaire
var plugInLoadContext = new AssemblyLoadContext(assemblyLoadContextName, true); // Pour permettre le déchargement Collectible = true

// Ajout d'une WeakReference 
WeakReference plugInLoadContextWeakRef = new WeakReference(plugInLoadContext);

string plugInAssemblyPath = GetDependencyAssemblyPath(plugInAssemblyName);
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType);
plugin.HelloWorldExample();

// Déchargement du contexte 
plugInLoadContext.Unload();

// On retourne la WeakReference pour vérifier si le contexte a bien été déchargé
return plugInLoadContextWeakRef;

Après l’exécution de la méthode AssembleLoadContext.Unload(), on sollicite l’exécution du garbage collector puis on vérifie que le contexte a bien été déchargé:

var example = new ApplicationLoadContextExamples();
WeakReference plugInLoadContextWeakRef = example.LoadPlugInExecuteAndUnLoad();

for (int i = 0; plugInLoadContextWeakRef.IsAlive && (i < 10); i++)
{
  GC.Collect();
  GC.WaitForPendingFinalizers();
}

Contexte de chargement personnalisé

Il est possible d’implémenter un contexte de chargement de façon à personnaliser des comportements lors du chargement d’assemblies. Cette personnalisation se fait en créant une classe dérivant de System.Runtime.Load.AssemblyLoadContext. Appliquer un comportement particulier peut se faire en surchargeant les fonctions:

  • protected override Assembly? Load(AssemblyName assemblyName) qui permet d’indiquer une assembly en fonction de son nom.
  • protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) permettant d’indiquer l’adresse de base d’une DLL non managée chargée (i.e. HMODULE). Il est possible d’obtenir cette adresse à partir du chemin de la DLL en utilisant la fonction: IntPtr LoadUnmanagedDllFromPath(string unmanagedDllPath).

Des comportements particuliers peuvent être implémentés en s’abonnant aux évènements:

  • public event Func<AssemblyLoadContext, AssemblyName, Assembly?>? Resolving déclenché quand aucune assembly correspondant au nom fourni n’a pu être trouvée. Cet évènement donne une possibilité de fournir l’assembly quand les autres procédés ont échoué.
  • public event Func<Assembly, string, IntPtr>? ResolvingUnmanagedDll qui est déclenché quand aucune DLL non managée n’a pu être fournie pour un nom donné. Dans les arguments de cet évènement, on peut trouver l’assembly pour laquelle le chargement de la DLL est nécessaire et son nom. Cet évènement est le dernier recours quand une DLL n’a pas été trouvée.

Par exemple, si on reprend l’exemple précédent où l’assembly DotNetSimplePlugIn.dll possède une dépendance vers l’assembly DotNetCommonDependency.dll. On charge l’assembly DotNetSimplePlugIn grâce à un contexte de chargement personnalisé de façon à préciser le chemin de la dépendance DotNetCommonDependency.dll.

Ainsi, dans un premier temps, on crée un contexte de chargement personnalisé:

internal class PlugInLoadContext: AssemblyLoadContext
{
  private readonly string plugInPath;
  private const string dependencyAssemblyName = "DotNetCommonDependency";

  public PlugInLoadContext(string plugInPath)
  {
    this.plugInPath = plugInPath;
  }
  
  protected override Assembly? Load(AssemblyName assemblyName)
  {
    if (assemblyName.Name.Equals(dependencyAssemblyName))
    {
      string plugInAssemblyPath = GetAssemblyPath(dependencyAssemblyName);
      return LoadFromAssemblyPath(plugInAssemblyPath);
    }
    else
      return base.Load(assemblyName);
  }
  
  private string GetAssemblyPath(string assemblyName)
  {
    string currentAssemblyLocation = Assembly.GetExecutingAssembly().Location;
    string assemblyNameWithoutExtension = Path.GetFileNameWithoutExtension(assemblyName);
    string relativePath = Path.Combine(Path.GetDirectoryName(currentAssemblyLocation), 
      $@"..\..\..\..\{assemblyNameWithoutExtension}\bin\Debug\net7.0", assemblyName);
    return Path.GetFullPath(relativePath);
  }
}

Ensuite on charge l’assembly DotNetSimplePlugIn avec le contexte de chargement personnalisé:

// Création d'un contexte de chargement personnalisé
var plugInLoadContext = new PlugInLoadContext("...");

// Chargement de l'assembly DotNetSimplePlugIn avec le contexte supplémentaire
string plugInAssemblyPath = GetDependencyAssemblyPath("DotNetSimplePlugIn.dll");
Assembly a = plugInLoadContext.LoadFromAssemblyPath(plugInAssemblyPath);

// Instanciation de la classe SimpleClass
Type pluginType = a.GetType(simpleClassTypeName);
ISimpleClass plugin = (ISimpleClass)Activator.CreateInstance(pluginType);

// On affiche les assemblies chargées grâce au contexte de chargement supplémentaire
DisplayAssembliesInLoadContext(plugInLoadContext);

plugin.FunctionToExecute();

La dépendance est correctement chargée car le contexte de chargement personnalisé permet d’indiquer le chemin de l’assembly.

System.Runtime.Loader.AssemblyDependencyResolver

L’objet System.Runtime.Loader.AssemblyDependencyResolver permet de trouver le chemin complet d’une dépendance d’une assembly à partir de son nom. AssemblyDependencyResolver peut donc s’avérer utile dans le cas où on souhaite implémenter un contexte de chargement personnalisé.

Par exemple, si on considère l’assembly DotNetSimplePlugIn.dll qui possède une dépendance directe vers DotNetCommonDependency.dll (à la compilation l’assembly DotNetCommonDependency.dll sera donc rajoutée dans le répertoire de sortie du projet DotNetSimplePlugIn), on peut retrouver le chemin de l’assembly DotNetCommonDependency.dll à partir du nom de la référence DotNetCommonDependency de cette façon:

string plugInAssemblyPath = GetAssemblyPath(plugInAssemblyName);
var resolver = new AssemblyDependencyResolver(plugInAssemblyPath);
var dependencyAssemblyName = new AssemblyName("DotNetCommonDependency");
string? dependencyPath = resolver.ResolveAssemblyToPath(dependencyAssemblyName);

En conclusion…

Comme on a pu le voir, les contextes de chargement d’assemblies permettent d’assurer tous les cas d’utilisation assurés anciennement par les application domains (utilisables seulement avec le framework .NET): le chargement modulaire d’assemblies dans un processus, le déchargement d’assemblies ou le chargement de versions différentes d’une même assembly.

Outre l’aspect isofonctionnel, les contextes de chargement sont plus faciles à mettre en œuvre car ils ne nécessitent pas de marshalling ou de sérialisation des objets. En effet, il n’y a pas de frontières strictes entre les contextes de chargement, il est ainsi plus facile et moins gourmant en performance d’accéder à des objets d’un contexte à l’autre et des dépendances peuvent être facilement partagées entre plusieurs contextes.

Plutôt que d’avoir une isolation forte, le principe des contextes de chargement est de permettre des logiques de chargement d’assemblies différentes d’un contexte à l’autre. Les assemblies et leurs dépendances peuvent être chargées différemment et les possibilités de personnalisation sont bien plus grandes que les domaines d’application.

Références

Les “Application Domains” en 5 min

Le code d’une application .NET est déployé sous la forme de code IL (i.e. Intermediate Language) dans des unités déployables appelées assemblies. Ces assemblies sont des fichiers avec une extension .exe pour un exécutable ou .dll pour une bibliothèque de classes. L’intérêt de pouvoir organiser le code dans des assemblies différentes est, par exemple, de partager du code semblable entre plusieurs applications ou de rendre une application modulable en permettant de charger du code sous la forme de plug-in.

Durant l’exécution d’une application, par défaut, les assemblies sont chargées en mémoire en mode “lazy-loading” c’est-à-dire qu’elles ne sont chargées que si le code qui s’y trouve est appelé.

Dans le cadre du framework .NET, les assemblies sont chargées en mémoire dans une couche d’isolation appelée application domain. Cette couche peut être unique pour tout le processus ou suivant les besoins il peut en exister plusieurs. L’implémentation plus récente de .NET (anciennement appelée .NET Core) n’utilise pas les application domains mais une notion améliorée équivalente appelée assembly load context.

Le but de cet article est de passer en revue les caractéristiques des application domains. Dans un prochain article, on explicitera les fonctionnalités principales des assembly load contexts.

Dans un 1er temps, on va passer en revue les caractéristiques principales des application domains puis dans un 2e temps, on va illustrer quelques cas d’utilisation avec des exemples.

Caractéristiques principales

Les application domains sont des couches d’isolation dans un processus qui permettent:

  • D’isoler le code: il est possible de charger des assemblies dans des application domains différents. L’isolation des application domains permet de charger, le cas échéant, de même assemblies dans des versions différentes.
  • D’implémenter des plug-ins: les assemblies correspondant au plug-in peuvent être chargées dans un application domain différent de l’application domain principal. Par la suite, ces assemblies ne peuvent pas être déchargées mais l’application domain dans lequel elles se trouvent peut, en revanche, être déchargés.
  • D’apporter une isolation en terme de sécurité: des portions de code dans des assemblies particulières peuvent être exécutées avec un niveau de sécurité différent.

Espace mémoire isolé

Les application domains permettent d’isoler des espaces contigués de mémoire virtuelle et d’y placer du code et des ressources auxquels on pourra accéder et les référencer sans, toutefois, partager ces espaces. Par exemple, d’un application domain à l’autre:

  • Il n’est pas possible de référencer directement un contenu dans un application domain différent,
  • Les données ne peuvent pas être passées d’un application domain à un autre directement, elles sont copiées par valeur en utilisant la sérialisation. Dans le cadre des objets de type référence qui sont accessibles avec des références (les références sont des objets de type valeur), les références pourraient être copiées par valeur d’un application domain à l’autre. Toutefois pour que les méthodes de l’objet soit réellement “visible” dans un autre application domain, il faut utiliser le mécanisme de marshalling: un objet proxy sert d’intermédiaire entre l’application domain où l’objet a été créé et l’application domain où l’objet est utilisé.

Enfin, les application domains partagent le même tas managé toutefois ils sont assez isolés pour qu’un application domain ne puisse interférer directement les objets d’un autre application domain.

Partage des threads

Dans le système d’exploitation, les processus sont isolés en terme de mémoire et en terme de thread.
Ainsi:

  • Pour exécuter du code de façon isolée, par exemple dans un autre processus, il faudrait créer ce processus et le détruire à la fin de son utilisation;
  • Le partage de données entre ces processus ne se fait pas directement, il faudrait prévoir des mécanismes de type Named Pipes, Memory mapped file ou le système de fichiers.
  • Enfin le lancement de code dans un processus séparé puis la récupération du résultat le cas échéant nécessite des mécanismes de synchronisation et d’appels comme le remoting, WCF, des communications réseaux ou RPC (Remote Procédure Call).

Tous ces mécanismes sont couteux en ressource, en temps d’exécution et en complexité d’implémentation. Les application domains proposent une solution pour s’affranchir de la difficulté de devoir faire des appels à du code dans des processus différents en partageant les mêmes threads. Le gros intérêt est d’éviter de devoir implémenter des mécanismes de synchronisation lorsqu’on appelle du code dans un application domain différent.

Marshalling et sérialisation

Le passage d’objets entre des application domains se fait par des copies par valeur, les objets sont sérialisés dans leur application domain d’origine puis désérialisés dans l’application domain où ils seront utilisés. Même si entre des application domains, les mêmes threads sont partagés, la frontière est assez importante pour qu’un application domain ne puisse pas accéder et exécuter du code se trouvant dans un autre application domain. Les appels se font par un mécanisme appelé marshalling qui consiste à créer un objet proxy dans l’application domain dans lequel on veut exécuter une méthode, l’appel à travers la frontière des application domains consistera à appeler une méthode dans l’objet proxy.

Le marshalling utilise la sérialisation lors du passage des objets.

Manipulation des application domains

La plupart des manipulations concernant les application domains se font par l’intermédiaire de la classe statique System.AppDomain:

  • Accéder à l’application domain principal: AppDomain.CurrentDomain.
  • Créer un nouvel application domain: AppDomain.CreateDomain()
  • Instancier une classe dans un application domain particulier: <instance AppDomain>.CreateInstanceAndUnwrap(<nom assembly>, <nom complet de la classe à instancier>).
    On peut voir dans la fonction CreationInstanceAndUnwrap() qu’on manipule les noms des objets sous la forme d’une chaîne de caractères. Par exemple pour l’argument correspondant au nom du type de la classe à instancier, on n’utilise pas un objet Type car le code qui exécute la fonction CreateInstanceAndUnwrap() se trouve dans l’application domain courant. Ainsi si on manipule un objet Type, cela signifie que l’assembly contenant ce type est chargé dans l’application domain courant ce qui n’est pas le but recherché.
  • Lister les assemblies chargées dans un application domain: <AppDomain>.GetAssemblies().
  • Décharger un application domain (et toutes les assemblies qu’il contient): AppDomain.Unload(<AppDomain à décharger>)

Exemples d’utilisation des application domains

Les cas d’utilisation les plus fréquents où on souhaite manipuler les application domains sont pour:

  • Plug-in: avoir du code qu’il est possible de charger sous la forme d’un plug-in. Le plug-in est ainsi modulaire, on peut le charger et le décharger à sa guise suivant les besoins.
  • Charger plusieurs versions d’une même assembly: normalement une seule version d’une assembly est chargée en mémoire toutefois pour satisfaire des dépendances indirectes, il peut être nécessaire de charger des versions différentes d’une même assembly.
Seulement disponible pour le framework .NET

Les application domains ne sont disponibles que si on cible le framework .NET (<= 4.8). Cette fonctionnalité n’est pas disponible avec .NET (>= 5.0).

Au travers de quelques exemples, on va montrer comment on peut manipuler les application domains. Ces exemples comportent 3 assemblies:

  • FxDotNetExamples.exe: c’est le “main” qui permet de lancer l’exécution de l’exemple.
  • SimplePlugIn.dll: c’est l’assembly qui sera chargée dans un application domain séparé. Cette assembly contient la classe SimpleClassMarshalByRef.
  • FxDotNetCommonInterfaces.dll: cette assembly est référencée dans FxDotNetExamples.exe et SimplePlugIn.dll. Elle contient l’interface ISimpleClass.

Charger et décharger du code sous la forme d’un plug-in avec une interface

On se propose de:

  • Charger du code se trouvant dans une assembly nommée SimplePlugIn dans un application domain différent,
  • Exécuter du code dans SimplePlugIn puis
  • Décharger cet application domain.

Tout au long des étapes, on affiche les assemblies chargées dans l’application domain courant en exécutant:

static void PrintLoadedAssemblies()
{
  Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
  foreach (Assembly assembly in assemblies)
    Console.WriteLine(assembly.FullName);
}

On crée une assembly nommée FxDotCommonInterfaces contenant une interface définissant la méthode à exécuter dans un autre application domain:

public interface ISimpleClass
{
  void HelloWorldExample();
}

FxDotCommonInterfaces est ajoutée en référence du programme principal et de SimplePlugIn.

Le classe concrète à exécuter qui satisfait ISimpleClass est:

public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  public void HelloWorldExample()
  {
    Console.WriteLine($"{nameof(SimpleClassMarshalByRef.HelloWorldExample)} executed");
  }
} 
System.MarshalByRefObject

Cet objet permet de mettre en œuvre le marshalling en . NET (voir plus haut). En dérivant de MarshalByRefObject, il sera possible d’appeler des fonctions dans la classe SimpleClassMarshalByRef par l’intermédiaire d’un objet proxy. SimpleClassMarshalByRef et son proxy satisfont la même interface. SimpleClassMarshalByRef reste dans l’application domain supplémentaire, le proxy est utilisé dans l’application domain courant. Quand la fonction HelloWorldExample() est appelée, l’appel se fait dans l’objet proxy et cet appel est répercuté par référence sur l’objet réel dans l’application domain supplémentaire.

Comme on peut le voir dans le code de cet objet mscorlib/system/marshalbyrefobject.cs#L46, les appels se font par référence. Ainsi s’il n’y a pas d’arguments lors de l’appel de fonction, l’appel d’une méthode par l’intermédiaire du proxy n’est pas significativement plus couteux. Malheureusement des appels de fonction se font rarement sans arguments impliquant des mécanismes de sérialisation couteux en performance.

Le code permettant d’exécuter les différentes étapes est:

Console.WriteLine("Before loading FxDotNetDependency");
PrintLoadedAssemblies();
Console.ReadLine();

// 1. Création de l'app domain différent
Console.WriteLine("Creating app domain");
AppDomain ad = AppDomain.CreateDomain(appDomainName);
PrintLoadedAssemblies();

// 2. Instanciation d'une classe dans cet app domain
var o = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);

Console.WriteLine("Before executing SimpleClass");
PrintLoadedAssemblies();
Console.ReadLine();

// 3. Appel d'une fonction dans cette classe 
this.CallFunctionWithInterface(o);

Console.WriteLine("Before app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

// 4. Déchargement de l'app domain différent
AppDomain.Unload(ad);

Console.WriteLine("After app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

Le code de CallFunctionWithInterface() est:

private void CallFunctionWithInterface(object instance)
{
  var simpleClass = instance as ISimpleClass;
  simpleClass.HelloWorldExample();
}

A l’exécution, on peut voir que l’assembly SimplePlugIn n’est jamais chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------  

L’interface permet de manipuler le type SimpleClassMarshalByRef sans avoir à charger explicitement son type (dans le méthode CallFunctionWithInterface()).

Exemple sans marshalling

Sans marshalling il n’est pas possible d’exécuter une méthode dans la classe SimpleClassMarshalByRef dans l’application domain supplémentaire sans charger le type SimpleClassMarshalByRef dans l’application domain courant.

Par exemple si on modifie la classe SimpleClassMarshalByRef pour qu’elle ne dérive pas de MarshalByRefObject:

[Serializable]
public class SimpleClassMarshalByRef : ISimpleClass
{
  // ...
}

En exécutant le même code permettant d’exécuter la méthode SimpleClass.HelloWorldExample(), on peut voir que l’assembly SimplePlugIn est chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Charger et décharger du code sous la forme d’un plug-in avec la reflection

On se propose d’exécuter le même code que précédemment à la différence qu’on n’utilise pas une interface mais on essaie d’exécuter la méthode SimpleClassMarshalByRef.HelloWorldExample() avec la reflection. On remplace l’appel à CallFunctionWithInterface() par:

private void CallFunctionWithReflection(object instance)
{
  Type type = instance.GetType();
  MethodInfo methodInfo = type.GetMethod("HelloWorldExample");
  methodInfo.Invoke(instance, Array.Empty<object>());
}

Si on exécute ce code (très similaire au code précédemment à l’exception de l’appel à CallFunctionWithReflection()):

Console.WriteLine("Before loading FxDotNetDependency");
PrintLoadedAssemblies();
Console.ReadLine();

// 1. Création de l'app domain différent
Console.WriteLine("Creating app domain");
AppDomain ad = AppDomain.CreateDomain(appDomainName);
PrintLoadedAssemblies();

// 2. Instanciation d'une classe dans cet app domain
var o = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);

Console.WriteLine("Before executing SimpleClass");
PrintLoadedAssemblies();
Console.ReadLine();

// 3. Appel d'une fonction avec la reflection
this.CallFunctionWithReflection(o);
    
Console.WriteLine("Before app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

// 4. Déchargement de l'app domain différent
AppDomain.Unload(ad);

Console.WriteLine("After app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

On peut voir que le comportement est différent de précédemment, l’assembly contenant la classe SimpleClassMarshalByRef est bien chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

La reflection ne permet pas d’isoler la manipulation du code à l’application domain supplémentaire. La mise en œuvre de la reflection entraîne le chargement du type SimpleClassMarshalByRef et donc le chargement de l’assembly SimplePlugIn dans l’application domain courant. Ceci s’explique par le fait que la reflection doit charger le type SimpleClassMarshalByRef pour l’instancier, il n’y a pas d’utilisation du marshalling.

Passage d’argument par valeur

Dans cet exemple, on se propose d’effectuer un passage d’un argument sérialisable par valeur. Volontairement le type de l’objet passé en argument ne permet d’effectuer du marshalling (i.e. il ne dérive pas de MarshalByRefObject) toutefois il est sérialisable.

On rajoute une fonction dans l’interface ISimpleClass permettant le passage d’une liste générique en argument et la modification de cette liste:

public interface ISimpleClass
{
  void HelloWorldExample();
     void ChangeArgumentPassedByValue(int newValue, List<int> intValues);
}

On rajoute l’implémentation correspondante dans la classe SimpleClassMarshalByRef:

[Serializable]
public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  // ...

  public void ChangeArgumentPassedByValue(int newValue, List<int> intValues)
  {
    Console.WriteLine($"Hash code: {intValues.GetHashCode()}");
    intValues.Add(newValue);
  }
}

La liste générique est un objet sérialisable:

[Serializable]
[DebuggerTypeProxy(typeof(Mscorlib_CollectionDebugView<>))]
[DebuggerDisplay("Count = {Count}")]
[__DynamicallyInvokable]
public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>
{
  // ...
}

Si on exécute le code suivant:

AppDomain ad = AppDomain.CreateDomain(appDomainName);
var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
var simpleClass = instance as ISimpleClass;      

var integerList = new List<int> { 1, 2, 3 };
Console.WriteLine($"Hash code: {integerList.GetHashCode()}");
simpleClass.ChangeArgumentPassedByValue(4, integerList);
Console.WriteLine(string.Join(", ", integerList.Select(l => l.ToString())));

PrintLoadedAssemblies();
Console.ReadLine();

On obtient le résultat:

Hash code: 19575591
Hash code: 47096010
1, 2, 3
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
System.Core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
----------------------------------

On peut voir que le hash code n’est pas le même car lors du passage en argument, il s’est produit une copie par valeur de la liste. Même si on rajoute un élément dans la liste dans la fonction ChangeArgumentPassedByValue(), on peut voir que la liste d’origine n’est pas modifiée car il ne s’agit du même objet.

Passage d’argument par référence

Il est possible de passer un objet en argument d’un application domain à un autre à condition qu’il soit possible d’effectuer du marshalling. Par exemple, si on considère l’objet suivant autorisant le marshalling:

public class MarshalByRefList<T> : MarshalByRefObject
{
  private List<T> values;

  public MarshalByRefList(params T[] values)
  {
    this.values = new List<T>(values);
  }
  
  public void AddValue(T value)
  {
    this.values.Add(value);
  }
  
  public void DisplayValues()
  {
    foreach (T value in this.values)
      Console.WriteLine(value);
  }

  public T this[int key]
  {
    get => this.values[key];
    set => this.values[key] = value;
  }
}

En reprenant l’exemple précédent permettant:

  • De passer un objet en argument d’une méthode d’un application domain à l’autre et
  • De modifier dans un application domain pour vérifier si les modifications sont visibles dans l’autre application domain.

On rajoute la méthode ChangeArgumentPassedByRef<T>() dans l’interface ISimpleClass et la classe SimpleClassMarshalByRef:

public interface ISimpleClass
{
  // ...

  void ChangeArgumentPassedByRef<T>(T newValue, MarshalByRefList<T> intValues);
}

public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  // ...

  public void ChangeArgumentPassedByRef<T>(T newValue, MarshalByRefList<T> values)
  {
    Console.WriteLine($"Hash code: {values.GetHashCode()}");
    values.AddValue(newValue);
  }
}

Si on exécute le code suivant:

AppDomain ad = AppDomain.CreateDomain(appDomainName);
var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
var simpleClass = instance as ISimpleClass;

var marshalByRefList = new MarshalByRefList<int>(1, 2, 3);
Console.WriteLine($"Hash code: {marshalByRefList.GetHashCode()}");
marshalByRefList.DisplayValues();
simpleClass.ChangeArgumentPassedByRef(4, marshalByRefList);
marshalByRefList.DisplayValues();

PrintLoadedAssemblies();
Console.ReadLine();

On obtient le résultat suivant:

Hash code: 19575591
1
2
3
Hash code: 19575591
1
2
3
4
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

La structure modifiée est la même entre les 2 application domains. Le marshalling permet de faire passer l’objet par référence, il n’y a pas de copie par valeur.

Serialization vs marshalling

Cet exemple permet d’illustrer la différence entre la sérialisation et le marshalling. Il consiste à observer la différence de comportement lorsqu’on passe une liste d’objets d’un application domain à un autre suivant si le marshalling est appliqué ou non à ces objets.

On considère l’objet sérialisable suivant sur lequel on peut appliquer le marshalling (grâce à MarshalByRefObject):

[Serializable]
public class CustomMarshalByRefObject: MarshalByRefObject
{
  public int InnerValue { get; set; }

  public override string ToString()
  {
    return this.InnerValue.ToString();
  }
}

On modifie le classe SimpleClassMarshalByRef et l’interface ISimpleClass pour permettre le passage d’une liste de type CustomMarshalByRefObject d’un application domain à un autre:

public interface ISimpleClass
{
  // ...
  void PassArgumentByRef(List<CustomMarshalByRefObject> values);
  
}
[Serializable]
public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  private List<CustomMarshalByRefObject> marshalByRefObjectList;

  public void DisplayInnerValues()
  {
    if (marshalByRefObjectList != null)
    {
      foreach (var value in this.marshalByRefObjectList)
      {
        Console.WriteLine(value);
      }
    }
  }

  // ...

  public void PassArgumentByRef(List<CustomMarshalByRefObject> values)
  {
    this.marshalByRefObjectList = values;
  }
}

Le code suivant permet de passer d’un application domain à un autre une liste de CustomMarshalByRefObject. On modifie ensuite le contenu d’un objet de la liste dans l’application domain principal et on vérifie si cette modification s’est répercutée dans l’application domain supplémentaire:

public void ChangeMarshalByRefObjectWithList()
{
  AppDomain ad = AppDomain.CreateDomain(appDomainName);
  var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
  var simpleClass = instance as ISimpleClass;

  var thirdObject = new CustomMarshalByRefObject { InnerValue = 3 };
  var marshalByRefList = new List<CustomMarshalByRefObject>{
    new CustomMarshalByRefObject { InnerValue = 1 },
    new CustomMarshalByRefObject { InnerValue = 2 },
    thirdObject };
  simpleClass.PassArgumentByRef(marshalByRefList);
  simpleClass.DisplayInnerValues();
  thirdObject.InnerValue = 5;
  simpleClass.DisplayInnerValues();

  PrintLoadedAssemblies();
  Console.ReadLine();
}

En exécutant ce code, on obtient:

1
2
3
1
2
5
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

On constate que la valeur modifiée dans l’application domain principal a bien été répercutée dans l’application domain principal. Plusieurs éléments expliquent ce comportement:

  • Une liste générique est sérialisable mais il n’est pas possible d’y appliquer du marshalling. Ainsi lors du passage en argument dans la fonction SimpleClassMarshalByRef.PassArgumentByRef(), la liste est dupliquée dans l’application domain supplémentaire.
  • Etant donné que la liste ne contient que des références vers des instances d’objets de type CustomMarshalByRefObject, les objets CustomMarshalByRefObject vers lesquelles pointent les références ne sont pas dupliquées.
  • Enfin, le marshalling est appliquée sur les instances d’objets CustomMarshalByRefObject, ainsi lorsqu’on modifie le contenu de ces objets dans un application domain, cette modification est répercutée via le proxy dans la seule instance de cet objet. La modification est alors visible à partir des 2 application domains.

Si on modifie la classe CustomMarshalByRefObject pour que le marshalling ne puisse plus s’appliquer:

[Serializable]
public class CustomMarshalByRefObject //: MarshalByRefObject
{
  public int InnerValue { get; set; }

  public override string ToString()
  {
    return this.InnerValue.ToString();
  }
}

En exécutant le même code que précédemment, on obtient:

1
2
3
1
2
3
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

On peut voir que la modification dans l’application domain principal ne s’est pas répercutée dans l’application domain supplémentaire. Cela s’explique par le fait qu’en supprimant le marshalling, le passage en argument de la liste d’objets CustomMarshalByRefObject a conduit à la duplication de ces objets en utilisant la sérialisation. 2 instances de chaque objet existent dans les 2 application domains. Quand on modifie une instance de CustomMarshalByRefObject, la modification n’est pas répercutée dans l’autre instance.

En conclusion…

Les application domains sont une solution pour permettre d’isoler des portions de code dans un même processus dans le but de rendre ces portions modulables. Cette fonctionnalité permet, par exemple:

  • De mettre en place des mécanismes de plug-in: on peut ainsi charger des assemblies dans des application domains différents et décharger ces application domains par la suite.
  • De charger des versions différentes d’une même assembly.

La mise en œuvre des application domains n’est, toutefois, pas tout à fait directe car elle implique que les objets transitants d’un application domain à l’autre soient sérialisables. D’autre part, pour appeler du code d’un application domain à l’autre, il faut utiliser des mécanismes de marshalling. Le marshalling et surtout la sérialisation entraînent un coût en performance non négligeable par rapport à des appels dans un même application domain.

Les application domains ne sont utilisables que dans le cadre du framework .NET car une fonctionnalité plus performante a été implémentée à partir de .NET 5: les assembly load contexts. Les assembly load contexts permettent une isolation moins franche entre des portions de code en permettant de maîtriser le chargement des assemblies. Malgré des contraintes moins fortes que pour les application domains, les assembly load contexts permettent d’implémenter tous les cas d’utilisation adressés par les application domains sans dégrader les performances.

Les fonctionnalités C# 10.0

@jaymantri

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

Précisions sur les versions de C#

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

Chronologie des releases

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

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

Lien entre la version C# et le compilateur

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

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

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

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

csc -help

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

csc -langversion:? 

Limiter la version C# à compiler

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

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

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

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

Fonctionnalités C# 10

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

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

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

namespace <nom du namespace>;

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

namespace CS10Syntax;

internal class FileScopedNamespaceDemo
{

}

Est équivalente à:

namespace CS10Syntax
{
  internal class FileScopedNamespaceDemo
  {

  }
} 

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

namespace CS10Syntax1;

internal class FirstClass
{

}

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

internal class SecondClass
{

}

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

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

using System;

De même:

using System;

internal class SecondClass
{

}

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

L’emplacement correct est:

using System;

namespace CS10Syntax1;

internal class SecondClass
{

}

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

using System;

namespace CS10Syntax1;

Motif property étendu (pattern matching)

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

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

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

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

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

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

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

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

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

Si on utilise la syntaxe correspond au motif property:

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

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

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

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

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

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

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

Amélioration des expressions lambda

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

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

Déduction du type de delegate

Delegate

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

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

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

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

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

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

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

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

Expression lambda

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

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

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

Cette notation est un raccourci pour:

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

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

int result = delWithLambda(3, 5);

Les expressions lambda sont apparues avec C# 3.0.

Action<T> et Func<T, TResult>

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

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

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

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

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

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

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

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

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

Expression

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

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

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

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

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

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

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

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

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

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

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

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

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

Alors on peut aussi écrire:

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

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

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

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

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

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

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

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

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

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

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

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

object et System.Delegate

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

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

ou

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

Appliquer des attributs aux expressions lambda

C# 10.0

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

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

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

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

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

  public string InnerProperty { get; set; }
}

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

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

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

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

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

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

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

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

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

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

On obtient le résultat:

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

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

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

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

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

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

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

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

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

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

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

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

Une autre syntaxe possible:

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

System.ValueTuple

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

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

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

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

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

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

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

ou:

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

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

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

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

Déconstruction

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

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

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

var (valueAsInt, valueAsString, valueAsFloat) = tuple; 

Si on utilise des variables existantes:

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

Affectation + déclaration dans la même ligne

C# 10.0

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

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

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

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

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

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

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

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

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

Autres fonctionnalités

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