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:

API minimale (.NET 6)

Crédit

Dans le but d’apporter une réponse technique au besoin de pouvoir créer des applications web, Microsoft a développé la technologie ASP.NET quasi depuis les débuts de .NET. Quelques années plus tard, est arrivé ASP.NET MVC permettant de construire des pages web en utilisant le modèle Modèle-Vue-Controleur (MVC) de façon à permettre d’organiser le code lié à la GUI dans la vue et le code plus fonctionnel dans le controller. Lorsque .NET Core est apparu en 2016, ASP.NET MVC a été remplacé par ASP.NET Core. Avec l’arrêt du développement du framework .NET et le renommage de .NET Core en .NET en 2022, l’appelation ASP.NET Core a été abandonnée pour revenir à ASP.NET. Fonctionnellement ASP.NET regroupe des cas d’utilisations assez différents liés aux applications web: ASP.NET Web Forms, ASP.NET Web Pages, ASP.NET Web API etc… Même si la technologie sous-jacente est la même, chaque cas d’utilisation est adressé avec ces différents modèles de programmation.

Parmi ces modèles, ASP.NET Web API comme son nom l’indique, a pour but de créer des API Web. Il utilise la base ASP.NET MVC pour ne garder que les controllers qui répondent aux requêtes.

Les applications ASP.NET peuvent être construites en utilisant le design pattern Builder qui va permettre de rajouter et de configurer des fonctionnalités suivant son cas d’utilisation de l’application web.
Dans le cas d’ASP.NET, la classe IApplicationBuilder permet de rajouter des fonctionnalités à l’application et de les configurer.

Avec ASP.NET 6, quelques améliorations et changements ont été faits pour simplifier le code nécessaire pour créer une API. C’est dans ce cadre que sont apparues les API minimales.

Le but de cet article est de passer en revue les fonctionnalités les plus importantes des API minimales. L’objectif n’est pas de paraphraser la documentation officielle mais d’avoir rapidement une idée des caractéristiques et fonctionnalités des API minimales.

On peut créer une API minimale en exécutant avec le CLI .NET:

dotnet new web -o <nom du projet>

On obtient une application dont la quantité de code est très réduite:

var builder = WebApplication.CreateBuilder(args);     # Instanciation de WebApplicationBuilder
var app = builder.Build();                            # Instanciation de l'application web

app.MapGet("/", () => "Hello World!");                # Définition d'une réponse à la route GET à l'adresse "/"

app.Run();                                            # Exécution de l'API

Ce code permet d’implémenter en peu de code une API capable de répondre à une requête GET à l’adresse "/". Peu de lignes sont nécessaires pour implémenter l’API, il n’y a pas de lignes using pour indiquer les namespaces utilisés à cause de la fonctionnalité des namespaces implicites (C# 10).

Minimal API vs MVC

A partir de .NET 6 et ASP.NET 6, un effort de simplification a été fait pour ne pas être obligé d’utiliser MVC pour construire des applications web et des API. Que ce soit une application web ou une API, l’approche modulaire en utilisant le design pattern Builder (avec WebApplicationBuilder) permet de rendre une application ASP.NET facilement modifiable et rend aisé l’ajout de nouvelles fonctionnalités. L’intérêt étant d’avoir une application simple si on le désire, qui pourra facilement être perfectionnée par la suite suivant les besoins.

Par exemple, avec le CLI .NET, plusieurs possibilités pour créer une application ASP.NET:

  • dotnet new web pour créer une application web simple sous la forme d’une API minimale comme l’exemple précédent
  • dotnet new webapi pour créer une API avec des controllers.
  • dotnet new webapp ou dotnet new razor pour créer une application web avec des pages Razor.
  • dotnet new mvc pour créer une application web MVC (i.e. Model-View-Controller)
  • dotnet new angular ou dotnet new react pour créer une application Single Page (i.e. SPA), respectivement en Angular ou React.

Tous ces types d’applications ont un point commun, elles utilisent, toutes, la classe WebApplicationBuilder qui utilise le design pattern Builder pour ajouter des fonctionnalités à l’application avec:

WebApplicationbuilder builder = WebApplication.CreateBuilder(args);

Construire l’application avec:

WebApplication app = builder.Build();

Et exécuter l’application avec:

app.Run();

L’ajout de fonctionnalités plus spécifiques au type d’application se fait avec l’objet WebApplicationBuilder ou WebApplication avec l’exécution de fonctions comme:

  • builder.services.AddControllers() pour gérer les controllers dans le cadre d’une API web
  • builder.Services.AddRazorPages() pour rajouter la gestion des pages Razor.

On peut avoir une liste plus exhaustive des configurations possibles sur la page: learn.microsoft.com/fr-fr/dotnet/api/microsoft.aspnetcore.builder.iapplicationbuilder.

Fonctionnalités de l’API minimale

Comme on l’a vu précédemment, l’implémentation des API minimales est réduite de façon à minimiser la quantité de code nécessaire. Suivant son besoin, il faudra se poser la question de savoir si on souhaite implémenter une API minimale ou une application web.

Différences entre une API minimale et une application Web

Dans la documentation, une opposition est faite entre les API minimales et les API utilisant des controllers. De la même façon, on peut croire qu’il y a des grosses différences entre les API minimales, les applications Web utilisant Razor ou le modèle MVC. En réalité, toutes ces types d’application utilisent la même base de composants:

  • Microsoft.NETCore.App correspondant aux assemblies de .NET.
  • Microsoft.AspNetCore.App correspondant aux assemblies ASP.NET.

Le choix du type d’application se fait suivant les middlewares ou la configuration qui est faite par la suite. Il est très bien possible de combiner dans la même application tous les différents types d’applications. On peut très bien partir d’une API minimale qui ne répond qu’à un seul end-point et rajouter la gestion des controllers, puis la gestion des pages Razor etc…

Ainsi la version actuelle d’ASP.NET (version 7) a permis de concilier tous les différents types d’applications en implémentant des comportements différents au moment du routage d’une requête. La configuration de ce routage se fait avec des méthodes d’extensions qui ajoutent des fonctionnalités à l’application, par exemple:

  • MapRazorPages(this IEndpointRouteBuilder endpoints) dans Microsoft.AspNetCore.Mvc.RazorPages rajoute la gestion des pages Razor,
  • AddRazorPages(this IServiceCollection services) dans Microsoft.AspNetCore.Mvc ajoute les services utilisés par les pages Razor.
  • MapControllerRoute(this IEndpointRouteBuilder endpoints, ...) dans Microsoft.AspNetCore.Mvc.Core rajoute la gestion des controllers,
  • AddControllersWithViews(this IServiceCollection services) dans Microsoft.AspNetCore.Mvc pour ajouter les services utilisés par les vues dans le cadre du modèle MVC (Model-View-Controller).

Routing

Un des points clés des API minimales mais aussi des autres types d’applications est le routing. C’est un des composants le plus important qui permet de diriger les requêtes vers l’élément technique qui sera chargé de son exécution: cet élément technique peut être une expression lambda, une fonction, un controller, un middleware technique ou une page statique.

Paramètres dans la route

On peut paramétriser la route en indiquant des arguments, par exemple si on ajoute un paramètre:

app.MapGet("/order/{id}", (string id) => $"Returning order with id: {id}");

Par exemple, pour requêter cette route, on peut utiliser l’URL:

<URL de l'API>/order/FEW3Z

On peut aussi utiliser plusieurs paramètres:

app.MapGet("/client/{lastName}/{firstName}", (string lastName, string firstName) => $"Returning client named: {firstName} {lastName});

Contraintes sur les routes

On peut indiquer des contraintes sur les paramètres d’une route pour limiter les types possibles des paramètres ou définir des réponses différentes suivant l’application de ces contraintes.

D’une façon générale, la contrainte peut être définie avec la syntaxe:

{<nom paramètre>:<contrainte>}

La contrainte peut être sur:

  • Le type du paramètre, par exemple pour indiquer qu’un paramètre doit être un entier, par exemple {orderId:int}.
    D’autres types sont possibles:

    • bool: booléen,
    • datetime: date
    • float: nombre flottant
    • alpha: chaîne de caractères ne contenant que les caractères alphabétiques (de A à Z non sensible à la casse).
  • Taille d’une chaîne de caractères:
    • minlength(<taille minimum de la chaîne>): par exemple {firstName:minlength(2)}
    • maxlength(<taille maximum de la chaîne>): par exemple {firstName:maxlength(128)}
  • Expression régulière: regex(<expression régulière>)
  • Indiquer que le paramètre est indispensable avec l’indication required: {firstName:required}.

Une liste exhaustive des types est présentée sur: learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints.

MapGet

Dans le cadre des API minimales, les fonctions les plus immédiates pour implémenter des end-points sont:

  • EndpointRouteBuilderExtensions.MapGet() pour répondre à une requête GET,
  • EndpointRouteBuilderExtensions.MapPost() pour répondre à une requête POST,
  • EndpointRouteBuilderExtensions.MapPut() pour répondre à une requête PUT,
  • EndpointRouteBuilderExtensions.MapDelete() pour répondre à une requête DELETE,
  • EndpointRouteBuilderExtensions.MapPatch() pour répondre à une requête PATCH,
  • EndpointRouteBuilderExtensions.MapMethods() pour répondre à plusieurs types de requêtes
  • etc…

Ces méthodes permettent d’implémenter facilement une réponse à une requête, par exemple EndpointRouteBuilderExtensions.MapGet() permet de répondre à une requête GET. On indique le chemin de la route et le code à exécuter lorsque la route est sollicitée. Ce code peut être indiqué avec un delegate:

app.MapGet("/", delegate () { return "This is a GET response"; });

Par suite, une expression lambda:

app.MapGet("/", () => "This is a GET response");

Avec une expression lambda asynchrone:

app.MapGet("/", async () => { await Task.Run<string>(() => "This is a GET response"); });

Les syntaxes sont similaires pour MapPost(), MapPut() et MapDelete().

Avec la méthode MapMethods() , on peut indiquer les méthodes auxquelles le end-point doit répondre sous forme d’une liste de chaines de caractères, par exemple:

app.MapMethods("/", new List<string> { "GET", "PATCH" }, () => "This is a GET response");

Codes statut HTTP

On peut renvoyer des codes de statut HTTP en réponse avec la classe Microsoft.AspNetCore.Http.Results, par exemple:

app.MapGet("/", () => {
  return Results.Ok("This is a GET response");     // Code 200
});

D’autres codes de réponse sont possibles comme:

  • 400 (Bad request): Results.BadRequest()
  • 401 (utilisateur non authentifié): Results.NotAuthorized()
  • 404 (ressource non trouvée): Results.NotFound()
  • 403 (accès refusé): Results.Forbid()
  • 201 (Created): Results.Created()
  • etc…

La classe Results permet aussi de renvoyer directement un code avec:

Results.StatusCode(<code sous forme d'entier>);

D’autres types de retour sont aussi possibles:

  • Chaîne de caractères: Results.Text(<string à renvoyer>)
  • JSON: Results.Json(new { FirstName = "Douglas", LastName = "Crockford" })
  • Flux: Results.Stream(...)
  • Une redirection d’URL: Results.Redirect("/newURL")
  • Un fichier (dans le cas d’un téléchargement): Results.File(<chemin du fichier>)

Injection de dépendances

L’injection de dépendances est aussi supportée pour les API minimales. Comme pour les applications ASP.NET, un moteur d’injection de dépendances est nativement fourni. Pour configurer des objets à injecter, il faut utiliser le membre WebApplicationBuilder.Services de type IServiceCollection qui dispose de quelques méthodes pour effectuer cette configuration suivant la durée de vie voulue des objets:

  • Transient (i.e. éphémère): les objets enregistrés de cette façon sont instanciés à chaque fois qu’ils sont injectés.
  • Scoped: la même instance de l’objet sera utilisée dans le cadre d’une même requête HTTP. Ainsi une nouvelle instance est créée pour chaque requête à l’API.
  • Singleton: les objets de ce type sont créés une seule fois et la même instance est utilisée pendant toute la durée de vie.

Il existe plusieurs façons de configurer un objet à injecter. Ces différentes méthodes correspondent à des surcharges différentes des méthodes utilisées pour configurer ces objets:

  • On indique seulement le type de l’objet lors de la configuration. L’objet sera identifié par ce type lorsqu’il doit être injecté.
  • On indique un type par lequel l’objet sera identifié et le type réel de l’objet à injecter. Le type réel de l’objet doit dériver du type utilisé pour l’identifier. Lors de l’injection, l’objet sera identifié par le type utilisé pour l’identification de l’objet.
  • On indique une interface par laquelle l’objet sera identifié et le type réel de l’object à injecter. Le type réel de l’objet doit satisfaire l’interface utilisée pour l’identifier. Lors de l’injection, l’objet sera identifié par l’interface.
  • Enfin il est possible d’utiliser des factories pour instancier l’objet. On indique l’interface avec laquelle l’objet sera identifié. Lors de l’injection, l’objet sera identifiée par l’interface et la factory sera appelée pour instancier l’objet.

Pour chaque durée de vie, la méthode pour configurer l’objet dans le moteur d’injection de dépendances est:

  • Transient: IServiceCollection.AddTransient()
  • Scoped: IServiceCollection.AddScoped()
  • Singleton: IServiceCollection.AddSingleton()

Par exemple, si on considère la classe et interface suivantes:

public interface IServiceToInject
{
  string InnerMember { get; }
}

public class ServiceToInject: IServiceToInject
{
  public string InnerMember => "Inner value";
}

On peut enregistrer le service de cette façon:

builder.Services.AddTransient<IServiceToInject, ServiceToInject>();

Lors de la configuration d’une route, on peut utiliser le service par injection, par exemple:

app.MapGet("/order/{id}", (string id, IServiceToInject instance) => $"This ID is: {id} and Inner member value: {instance.InnerMember}");

Si on requête l’API à l’adresse https://localhost:7120/order/ALDSE3XD

Le retour sera:

This ID is: ALDSE3XD and Inner member value: Inner value

Middlewares

Dans une application ASP.NET, les middlewares correspondent à des portions de code qui peuvent être exécutées lorsqu’une requête HTTP est reçue par une application ASP.NET Core. Ces portions de code sont exécutées successivement. Lorsqu’un middleware écrit une réponse correspondant à la requête, les middlewares suivants ne sont plus exécutés. Ainsi lorsqu’une requête HTTP parvient à l’API web, les portions de code correspondant aux middlewares vont être exécutées successivement jusqu’à ce qu’un des middlewares écrive la réponse. L’appel successif des différents middlewares s’appelle un pipeline de middlewares. Les middlewares sont ordonnés dans le pipeline et ils sont exécutés dans le même ordre.

Comme les API minimales sont des applications ASP.NET, de nombreux middlewares sont directement disponibles. Pour les ajouter au pipeline de middlewares, il faut généralement utiliser une méthode d’extension avec IApplicationBuilder.

CORS

Par exemple, pour configurer l’activation des requêtes multi-origines (i.e. Cross-Origin Resource Sharing ou CORS), on peut utiliser la méthode d’extension:
Microsoft.AspNetCore.Builder.WebApplication.UserCors().

Pour résumer, par sécurité les browsers empêchent un même script d’effectuer des requêtes HTTP vers des origines différentes. Si une requête est effectuée vers une origine différente, par défaut, la requête sera bloquée par le browser. Dans le cas où des ressources nécessitent des requêtes dans une origine différente, il faut activer des requêtes multi-origines (CORS) de façon à relâcher un élément de sécurité du browser. Cette activation se fait par le serveur répondant à la requête en indiquant les origines vers lesquelles le browser doit autoriser des requêtes multi-origines. Ces indications se font en ajoutant dans le header de la réponse le champ:
Access-Control-Allow-Origin avec la valeur correspondant à l’origine vers laquelle autoriser les requêtes.
Pour une explication plus complète, voir Cross-Origin Resource Sharing (CORS).

Dans le cadre d’une API, c’est donc cette API qui doit effectuer l’ajout du champ Access-Control-Allow-Origin. Le middleware CORS permet d’effectuer ce traitement. Ainsi si la requête reçue par l’API contient dans le header le champ: Origin avec une adresse à autoriser ou la wildcard "*" alors la réponse contiendra un champ Access-Control-Allow-Origin avec l’adresse autorisée si la configuration le permet.

Ainsi, sans activation du CORS, avec l’implémentation suivante:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/order/{id}", (string id) => $"Order ID is: {id}");
app.Run();

Si on effectue une requête GET https://localhost:7120/order/45345 sans champ particulier dans le header, on obtient la réponse suivante:

Order ID is: 45345

Si on rajoute le champ Origin dans le header de la requête:

Origin http://otherorigin.com

Pas de changement dans la réponse de l’API. A ce stade il n’y a pas d’activation du CORS.

Si on modifie l’implémentation de l’API:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
  options.AddDefaultPolicy(
    policy =>
    {
      policy.WithOrigins("http://otherorigin.com");
    });
});

var app = builder.Build();
app.UseCors();
app.MapGet("/order/{id}", (string id) => $"Order ID is: {id}");

app.Run();

Si on effectue une requête sans champ Origin, la réponse de l’API ne comporte pas de champ particulier.
Si on rajoute le champ Origin dans la requête:

Origin http://otherorigin.com

La réponse de l’API comporte 2 champs supplémentaires:

Access-Control-Allow-Origin http://otherorigin.com
Vary Origin

A ce stade le CORS est activé pour http://otherorigin.com.

Dans le cas où on veut activer le CORS pour toutes les URL, on peut configurer l’API en utilisant une wildcard "*":

builder.Services.AddCors(options =>
{
  options.AddDefaultPolicy(
    policy =>
    {
      policy.WithOrigins("*");
    });
});

Dans ce cas, quelque soit l’URL indiquée dans le header de la requête:

Origin http://xyz.com/code

La réponse de l'API comportera le champ:

Access-Control-Allow-Origin *

Autres middlewares

D'autres middlewares sont disponibles:

Pour avoir une liste plus exhaustive des middlewares disponibles voir learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis#aspnet-core-middleware.

Le code de cet article se trouve sur: github.com/msoft/minimal-api-example.

Références

Namespaces implicites (C# 10)

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

A partir de C# 10 et .NET 6, 2 fonctionnalités permettent de limiter la répétition des using <namespace> en tête des fichiers C#:

  • l’opérateur global et
  • la propriété ImplicitUsings.

Ces 2 fonctionnalités visent à déclarer les namespaces une fois par projet de façon à ne pas avoir à répéter les déclarations dans tous les fichiers .cs.

Mot-clé global

A partir de C# 10, le mot-clé global a désormais 2 significations:

  • Utilisé en tant que mot-clé de cette façon global:: il est un alias pour désigner le namespace global par opposition à un namespace déclaré par le développeur dans son code. Le namespace global contient des types qui ne sont pas déclarés dans un namespace nommé. global:: permet de différencier 2 namespaces qui auraient le même nom et dont un des 2 ferait partie de code déclaré en dehors du code du développeur.

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

    namespace CS10_Example.System
    {
      public class Object
      {  }
    }
    

    On peut utiliser cette classe de cette façon:

    namespace CS10_Example
    {
      internal class GlobalKeywordExample
      {
        public void ExecuteMe()
        {
          var exampleInstance = new System.Object();
        }
      }
    }
    

    System.Object désigne la classe CS10_Example.System.Object. Si on veut instancier l’objet Object provenant du framework, on ne peut pas le faire sans passer par le mot-clé global:

    var exampleInstance = new System.Object();
    var objectInstance = new global::System.Object();
    

    Dans cet exemple global::System.Object désigne bien l’objet object du framework.

  • A partir de C# 10, global est aussi utilisé en tant que modificateur de portée lorsqu’on déclare des namespaces. Lorsque global précède une déclaration using <namespace>, il permet d’étendre la portée de la déclaration à la portée du projet.
    Ainsi si on déclare l’utilisation d’un namespace dans un fichier, en faisant précéder la déclaration avec global, la déclaration aura pour portée le projet:

    global using CS10_Example.System;
    

    On peut aussi déclarer de façon globale des alias avec une syntaxe du type:

    global using Env = System.Environment;
    

    L’intérêt est d’éviter de multiplier les déclarations des mêmes namespaces dans plusieurs fichiers .cs du projet. Une seule déclaration suffit.

Propriété ImplicitUsings

ImplicitUsings est une propriété du .csproj qui est apparu à partir de .NET 6 qui permet d’ajouter des déclarations globales de namespace dans un .csproj en fonction du type de projet créé. Si la propriété est activée, alors à la compilation, des déclarations du type suivant seront rajoutées:

global using global::<namespace du framework>;

Ces déclarations ont pour but d’éviter la multiplication des using <namespace> dans chaque fichier d’un projet.
Dans la pratique suivant le type du projet, les déclarations sont rajoutées dans un fichier se trouvant dans:

<répertoire du projet>\obj\<configuration>\<target framework>\<nom du projet>.GlobalUsings.g.cs

Par exemple si le projet s’appelle Cs10Example, que la configuration de compilation soit Debug et que le framework cible net6.0 alors le fichier sera:

Cs10Example\obj\Debug\net6.0\Cs10Example.GlobalUsings.g.cs

Le contenu de ce fichier dépend du type de projet indiqué dans le .csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
  ...
</Project>

Ainsi pour une application Console générée avec:

dotnet new console -o <nom du projet>

L’entête du .csproj sera:

<Project Sdk="Microsoft.NET.Sdk">
  ...
</Project>

Et les déclarations des namespaces seront:

global using global::System
global using global::System.Collections.Generic
global using global::System.IO
global using global::System.Linq
global using global::System.Net.Http
global using global::System.Threading
global using global::System.Threading.Tasks 

Pour une application Web générée avec:

dotnet new web -o <nom du projet>

L’entête du .csproj sera:

<Project Sdk="Microsoft.NET.SdkWeb”>
  ...
</Project>

En plus des déclarations précédentes, les namespaces implicites seront:

global using global::System.Net.Http.Json
global using global::Microsoft.AspNetCore.Builder
global using global::Microsoft.AspNetCore.Hosting
global using global::Microsoft.AspNetCore.Http
global using global::Microsoft.AspNetCore.Routing
global using global::Microsoft.Extensions.Configuration
global using global::Microsoft.Extensions.DependencyInjection
global using global::Microsoft.Extensions.Hosting
global using global::Microsoft.Extensions.Logging 

On peut trouver une liste plus exhaustive des namespaces implicites sur learn.microsoft.com/en-us/dotnet/core/project-sdk/overview#implicit-using-directives.

Valeur par défaut

Pour tous les nouveaux projets ciblant .NET 6 et supérieur, la propriété ImplicitUsings est présente dans le .csproj avec la valeur enable:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <!-- ... -->
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- ... -->
  </PropertyGroup>
</Project>

Si la propriété ImplicitUsings n’est pas présente dans le fichier .csproj, la fonctionnalité est désactivée par défaut.

Désactiver ImplicitUsings

Pour désactiver ImplicitUsings, on peut soit supprimer la propriété du fichier .csproj (la valeur par défaut est désactivée), soit explicitement la désactiver avec la valeur disable:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <!-- ... -->
    <ImplicitUsings>disable</ImplicitUsings>
    <!-- ... -->
  </PropertyGroup>
</Project>

Ajouter ou supprimer une déclaration using globale dans le .csproj

Il est possible d’ajouter ou de supprimer une déclaration using globale directement dans le fichier .csproj.
Pour effectuer une déclaration using globale dans le .csproj pour supprimer un namespace particulier, on peut utiliser la syntaxe:

<Project Sdk="Microsoft.NET.Sdk.Web">
  </PropertyGroup>
    <!-- ... -->
    <ItemGroup>
      <Using Remove="System.Threading.Tasks" />
    </ItemGroup>
</Project>

Par opposition, pour effectuer une déclaration globale permettant de rajouter un namespace particulier:

<Project Sdk="Microsoft.NET.Sdk.Web">
  </PropertyGroup>
    <!-- ... -->
    <ItemGroup>
      <Using Include="System.Net.Http" />
    </ItemGroup>
</Project>

Gestionnaire d’interpolation de chaînes de caractères (C# 10)

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

Cette fonctionnalité permet d’apporter une solution pour faciliter et personnaliser l’interpolation de chaînes de caractères.

Avant C# 10.0, le traitement appliqué lors de l’interpolation de chaînes de caractères ne pouvait pas être modifié ni personnalisé. C# 10.0 introduit l’attribut InterpolatedStringHandler dans le but d’implémenter un traitement personnalisé lorsqu’une interpolation de chaînes de caractères est effectuée.

Interpolation de chaîne de caractères

Pour rappel l’interpolation de chaîne de caractères consiste à faciliter la construction d’une chaîne de caractères en permettant d’interpoler des expressions qui seront évaluées au moment de la construction de la chaîne.

Par exemple:

int wordCount = 5;
string interpolationExample = $"Ceci est un exemple contenant {2 * wordCount} mots provenant de la variable '{nameof(wordCount)}'";

interpolationExample contient la chaîne "Ceci est un exemple contenant 10 mots provenant de la variable 'wordCount'" qui provient de l’interpolation de 2 expressions:

  • 2 * wordCount renvoyant 10 et
  • nameof(wordCount) renvoyant "wordCount".

Options d’alignement et de formatage

L’interpolation peut être affinée en utilisant d’autres options de construction des expressions interpolées. La forme générale des expressions est:

{<expression interpolée>[,<option d'alignement>][:<option de formatage>]}

Ainsi:

  • L’option d’alignement permet d’indiquer une constante correspondant au nombre minimum de caractères de l’expression.
  • Si la chaîne est trop courte et que la constante est positive: la chaîne sera alignée à droite et la longueur sera complétée par des espaces.
  • Si la chaîne est trop courte et que la constante est négative: la chaîne sera alignée à gauche et la longueur sera complétée par des espaces.

L’option de formatage suivant le type du résultat de l’expression permet d’apporter une indication sur le formatage à appliquer (voir Format string component pour des exemples d’options de formatage).

Par exemple, si on exécute:

string example1 = $"'{1,6}'";    // chaine de caractères trop courte, alignement à droite
Console.WriteLine(example1);

On obtient:

'     1'

Avec:

string example2 = $"'{1,-6}'";   // chaine de caractères trop courte, alignement à gauche
Console.WriteLine(example2);

On obtient:

'1     '

Avec:

string example3 = $"'{1234567,6}'"; // Chaîne assez longue, pas d'ajout de caractères
Console.WriteLine(example3);

Le résultat es:

'1234567'

Concernant l’option de formatage, l’option dépend du type de la valeur résultant de l’évaluation de l’expression. Si on considère un nombre décimal pour lequel on ne garde que 3 chiffres significatifs, on appliquera l’option de formatage '0.000' (voir les options de formatage des nombres):

string example4 = $"{45235.776522:0.000}";
Console.WriteLine(example4);
45235.776

Echappement des caractères spéciaux

Pour échapper les caractères { et }, il faut utiliser respectivement {{ et }}, par exemple:

int number = 5742;
string example5 = $"Le nombre {number} est affiché avec {{number}}.";
Console.WriteLine(example5);

Le nombre 5742 est affiché avec {number}.

Pour éviter que le caractère : ne soit évalué dans une expression comme une option de formatage, il faut entourer l’expression ternaire utilisant : avec ( et ). Par exemple:

int limit = 7;
string exemple6 = $"La limite est: {limit > 5 ? "haute" : "basse"}";  // ERREUR
string exemple6 = $"La limite est: {(limit > 5 ? "haute" : "basse")}";  // OK

Verbatim string

Dans une chaîne de caractères simple, on peut échapper les caractères spéciaux en utilisant @, par exemple pour le chemin d’un fichier:

string exemple7 = @"C:\MyFolder\InnerFolder\File.txt";

Il est possible d’associer une verbatim string avec des interpolations en utilisant @$ ou $@, par exemple:

string innerFolderName = "TheInnerFolder";
string exemple8 = $@"C:\MyFolder\{innerFolderName}\File.txt";
string exemple9 = @$"C:\MyFolder\{innerFolderName}\File.txt";

Interpolation de chaînes constantes

C# 10.0

Antérieurement à C# 10, l’interpolation de chaînes de caractères n’était pas possible pour les chaînes constantes toutefois il était possible d’effectuer des concaténations de chaînes constantes:

public const string constIntegerAsString = "5";
public const string stringInterpolationExample = $"Number {constIntegerAsString} is five !!";  // ERREUR avant C# 10 
public const string constantStringExample = "Number " + constIntegerAsString + " is five !!";  // OK

La concaténation est possible car le compilateur construit une chaine de caractères unique à la compilation ce que n’était pas le cas de l’interpolation avant C# 10. Si on regarde le code MSIL correspondant à cette méthode:

public void ExecuteMe()
{
    Console.WriteLine(constantStringExample);
}

On peut voir que la concaténation correspond à une chaîne constante:

.method public hidebysig instance void  ExecuteMe() cil managed
{
  // Code size       11 (0xb)
  .maxstack  8
  IL_0000:  ldstr      "Number 5 is five !!"
  IL_0005:  call       void [System.Console]System.Console::WriteLine(string)
  IL_000a:  ret
}

Avec C# 10, le compilateur a été amélioré pour permettre les interpolations de chaînes constantes:

public const string constIntegerAsString = "5";
public const string stringInterpolationExample = $"Number {constIntegerAsString} is five !!";    // OK à partir de C# 10

Si on regarde le code MSIL de la méthode suivante, on peut se rendre compte que le code est identique à celui plus haut:

public void ExecuteMe()
{
    Console.WriteLine(stringInterpolationExample);
}

De même que pour la concaténation, dans le cas de l’interpolation, le compilateur effectue la construction de la chaîne directement lors de la compilation.

L’interpolation pour une chaîne de caractères constante n’est possible que si l’interpolation est effectuée avec d’autres chaînes constantes. Il n’est pas possible d’effectuer une interpolation avec des variables dont le type nécessite une conversion.

Les exemples suivants génèrent une erreur de compilation:

public const int constInteger = 5;
public const string stringInterpolationExample = $"Number {constInteger} is five !!";   // ERREUR

L’interpolation "{constInteger}" nécessite une conversion d’un entier vers une chaîne de caractères qui doit être effectuée à l’exécution en prenant en compte les paramètres régionaux. Cette conversion ne peut être effectuée à la compilation.

Gestionnaire d’interpolation de chaînes de caractères

C# 10.0

C# 10.0 permet d’implémenter des classes qui peuvent effectuer des traitements personnalisés à la suite d’interpolation de chaînes de caractères. L’intérêt est d’être flexible sur le traitement effectué en utilisant les avantages de la syntaxe de l’interpolation de chaînes. Le traitement peut consister à construire une chaîne de caractères en utilisant les expressions à évaluer comme c’est le cas pour une interpolation de chaîne normale mais il n’est pas obligatoire de construire une chaîne de caractères.

L’implémentation de la classe correspondant à un gestionnaire de chaînes de caractères doit répondre à certaines conditions:

  • Il faut utiliser l’attribut InterpolatedStringHandlerAttribute
  • Le constructeur doit accepter au moins 2 arguments entier:
    • literalLength permettant d’indiquer la longueur de la chaine de caractères.
    • formattedCount indiquant le nombre d’éléments pour lesquels il faudra effectuer un traitement de formatage.
  • Une méthode publique void AppendLiteral(string s) pour ajouter une chaîne où aucun traitement n’est nécessaire.
  • Une méthode publique void AppendFormatted<T>(T t) acceptant un objet pour lequel un formatage est nécessaire.

Attribut InterpolatedStringHandler

A titre d’exemple, on considère les 2 objets suivants:

public class TwoDimensionPoint
{
    public TwoDimensionPoint(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get; set; }
    public int Y { get; set; }
}

public class ThreeDimensionPoint
{
    public ThreeDimensionPoint(int x, int y, int z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public int X { get; set; }
    public int Y { get; set; }
    public int Z { get; set; }
}

Si on souhaite générer une chaîne de caractères contenant les valeurs des membres de ces 2 classes, une première approche est de surcharger les fonctions ToString():

public class TwoDimensionPoint
{
    // ...
    public override string ToString()
    {
        return $"{X}; {Y}";
    }
}

public class ThreeDimensionPoint
{
    // ...

    public override string ToString()
    {
        return $"{X}; {Y}; {Z}";
    }
}

Avec cette implémentation, on doit surcharger ToString() pour les 2 objets. Si on a de nombreux objets pour lesquels on doit surcharger ToString(), cela peut rendre l’implémentation assez fastidieuse. Une autre approche pourrait être de rassembler le traitement de conversion en chaîne de caractères dans un seul objet.

Avec C# 10, avec un gestionnaire de chaînes de caractères il est possible d’implémenter une classe qui va gérer les interpolations de chaînes de caractères pour des types particuliers et ainsi permettre de rassembler les conversions en chaînes de caractères dans une seule classe. Ainsi si on considère la classe suivante dont les caractéristiques correspondantes aux conditions indiquées précédemment:

[InterpolatedStringHandler]
public class PointStringFormatter
{
    StringBuilder builder;

    public PointStringFormatter(int literalLength, int formattedCount)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    public void AppendLiteral(string s)
    {
        builder.Append(s);
    }

    public void AppendFormatted<T>(T t)
        where T: class 
    {
        if (t is null)
            AppendLiteral("null");
        if (t is ThreeDimensionPoint threeDimensionPoint)
            AppendLiteral($"{threeDimensionPoint.X}; {threeDimensionPoint.Y}; {threeDimensionPoint.Z}");
        else if (t is TwoDimensionPoint twoDimensionPoint)
            AppendLiteral($"{twoDimensionPoint.X}; {twoDimensionPoint.Y}");
        else if (t is string)
            AppendLiteral(t as string);
        else
            throw new InvalidOperationException($"{nameof(T)} is unknown");
    }

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

    internal string GetFormattedText() => builder.ToString();
}

Dans la méthode AppendFormatted(), on peut voir que la conversion des 2 types ThreeDimensionPoint et TwoDimensionPoint est gérée. Ainsi, dans le cas où il est nécessaire d’avoir une implémentation particulière pour un grand nombre de classes, il peut être plus aisé de rassembler les logiques de conversion dans une même méthode.

On peut améliorer l’implémentation de la méthode AppendFormatted() en utilisant le pattern matching plutôt que des if...then...else:

public void AppendFormatted<T>(T t)
    where T: class 
{
    string pointAsString = t switch
    {
        ThreeDimensionPoint threeDimensionPoint => $"{threeDimensionPoint.X}; {threeDimensionPoint.Y}; {threeDimensionPoint.Z}",
        TwoDimensionPoint twoDimensionPoint => $"{twoDimensionPoint.X}; {twoDimensionPoint.Y}",
        null => "null",
        string formattedString => formattedString,
        _ => throw new InvalidOperationException($"{nameof(T)} is unknown")
    };

    AppendLiteral(pointAsString);
}

Si on définit la méthode suivante:

public void ShowPoint(PointStringFormatter point)
{
    Console.WriteLine(point.GetFormattedText());
}

On peut utiliser directement la classe PointStringFormatter en exécutant:

var point1 = new ThreeDimensionPoint(4, 7, 9);
var point2 = new TwoDimensionPoint(4, 7);

ShowPoint($"{nameof(point1)}: {point1}");
ShowPoint($"{nameof(point2)}: {point2}");

Le résultat est:

literal length: 2, formattedCount: 2
point1: 4; 7; 9
literal length: 2, formattedCount: 2
point2: 4; 7

Ainsi comme on peut le constater, l’instanciation de l’objet PointStringFormatter est effectuée à partir d’une chaîne de caractères à interpoler. De façon plus explicite, on pourrait écrire:

var point1 = new ThreeDimensionPoint(4, 7, 9);
PointStringFormatter pointFormatter = $"{nameof(point1)}: {point1}";
Console.WriteLine(pointFormatter);  // Execution de PointStringFormatter.ToString()

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

La même instance de PointStringFormatter peut servir pour les 2 types d’objets:

var point1 = new ThreeDimensionPoint(4, 7, 9);
var point2 = new TwoDimensionPoint(4, 7);

PointStringFormatter pointFormatter = $"{nameof(point1)}: {point1} / {nameof(point2)}: {point2}";
Console.WriteLine(pointFormatter);

Le résultat est similaire à l’exemple précédent:

literal length: 7, formattedCount: 4
point1: 4; 7; 9 / point2: 4; 7

On peut observer que l’instanciation d’un gestionnaire d’interpolation de chaînes de caractères peut se faire implicitement à partir d’une chaîne de caractères à interpoler. Ainsi, on peut facilement implémenter des comportements spécifiques pour:

  • Effectuer des conversions de classes en chaînes de caractères,
  • Faciliter des logs particuliers suivant des types d’objets,
  • Centraliser dans une même classe les conversions en chaînes de types d’objets différents.
Gestionnaire par défaut: DefaultInterpolatedStringHandler

Si on regarde le code MSIL correspondant au code suivant:

int number = 5742;
string example5 = $"Le nombre {number} est affiché avec {{number}}.";
Console.WriteLine(example5);

On peut voir:

// Code size       61 (0x3d)
.maxstack  3
.locals init (int32 V_0,
         valuetype [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler V_1)
IL_0000:  ldc.i4     0x166e
IL_0005:  stloc.0
IL_0006:  ldloca.s   V_1
IL_0008:  ldc.i4.s   37
IL_000a:  ldc.i4.1
IL_000b:  call       instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32,
                                                                                                                           int32)
IL_0010:  ldloca.s   V_1
IL_0012:  ldstr      "Le nombre "
IL_0017:  call       instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_001c:  ldloca.s   V_1
IL_001e:  ldloc.0
IL_001f:  call       instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
IL_0024:  ldloca.s   V_1
IL_0026:  ldstr      bytearray (20 00 65 00 73 00 74 00 20 00 61 00 66 00 66 00   //  .e.s.t. .a.f.f.
                                69 00 63 00 68 00 E9 00 20 00 61 00 76 00 65 00   // i.c.h... .a.v.e.
                                63 00 20 00 7B 00 6E 00 75 00 6D 00 62 00 65 00   // c. .{.n.u.m.b.e.
                                72 00 7D 00 2E 00 )                               // r.}...
IL_002b:  call       instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0030:  ldloca.s   V_1
IL_0032:  call       instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
IL_0037:  call       void [System.Console]System.Console::WriteLine(string)
IL_003c:  ret

On peut y voir que le gestionnaire DefaultInterpolatedStringHandler est utilisé pour l’interpolation des chaînes de caractères. C’est le gestionnaire par défaut pour traiter les chaînes de caractères interpolées.

ref struct
Un intérêt d’utiliser un gestionnaire de chaînes de caractères interpolées est de prévoir une implémentation optimisée pour minimiser l’utilisation des ressources en particulier quand le gestionnaire est instancié fréquemment. Ainsi dans le cas où on implémente le gestionnaire sous la forme d’une classe, chaque instanciation va créer un objet dans le tas managé. En cas d’utilisation fréquente, le garbage collector pourrait être sollicité de façon répétée pour traiter les instances du gestionnaire à disposer.
Une optimisation permettant d’éviter des sollicitations du garbage collector serait d’utiliser une structure. En effet les structures étant, la plupart du temps, instanciées sur la pile, elles évitent une utilisation du garage collector en cas d’instanciation répétée.

Pour aller plus loin, on peut utiliser une ref struct qui va garantir que le structure ne peut être utilisée que sur la pile. En effet, apparu en C# 7.2, ref peut être utilisé quand on déclare un objet struct pour indiquer qu’une instance de la structure ne peut se trouver que dans la pile et ne pourra pas correspondre à une allocation dans le tas managé.

Dans le cadre de notre exemple, on peut modifier l’implémentation en utilisant une ref struct plutôt qu’une classe:

public ref struct PointStringFormatter
{
    ...
}

Attribut InterpolatedStringHandlerArgument

Dans le cas précédent, l’instanciation du gestionnaire de chaîne de caractères à interpoler a été effectuée juste avec un argument qui est la chaîne à interpoler. On peut ajouter des arguments lors de l’instanciation du gestionnaire en utilisant l’attribut InterpolatedStringHandlerArgumentAttribute.

Pour rappel, on peut instancier un gestionnaire:

  • Directement à partir d’une chaîne de caractères interpolées:
    PointStringFormatter pointFormatter = $"{nameof(point1)}: {point1} / {nameof(point2)}: {point2}";
    
  • Dans l’argument d’une méthode: par exemple si on définit une méthode avec un gestionnaire de cette façon:
    public void ShowPoint(PointStringFormatter point)
    {
        ...
    }
    

On peut instancier le gestionnaire en appelant la méthode avec une chaîne de caractères interpolées:

ShowPoint($"{nameof(point1)}: {point1} / {nameof(point2)}: {point2}");

C’est en utilisant cette 2e méthode, qu’il est possible d’utiliser l’attribut InterpolatedStringHandlerArgument pour instancier le gestionnaire avec des arguments supplémentaires. Ainsi on considère une méthode avec plusieurs arguments en plus du gestionnaire, on utilise le attribut InterpolatedStringHandlerArgument pour indiquer quels sont les arguments à utiliser pour instancier le gestionnaire.

Par exemple, si on considère le gestionnaire PointStringFormatterV2 avec un constructeur avec 2 arguments supplémentaires par rapport à PointStringFormatter:

public ref struct PointStringFormatterV2
{
    public PointStringFormatterV2(int literalLength, int formattedCount, string formattingPrefix, string formattingSuffix)
    {
        // ...
    }

    // ...
}

On définit la méthode suivante en ajoutant les arguments formattingPrefix et formattingSuffix. Puis on indique dans l’attribut InterpolatedStringHandlerArgument les arguments à utiliser pour instancier le gestionnaire:


public void ShowPoint(string formattingPrefix, string formattingSuffix, 
    [InterpolatedStringHandlerArgument("formattingPrefix", "formattingSuffix")] PointStringFormatterV2 point)
{
    // ...
}

Ainsi si on appelle ShowPoint() de cette façon:

ShowPoint("(", ")", $"{nameof(point1)}: {point1}");

Le constructeur du gestionnaire PointStringFormatterV2 sera instancié en utilisant les arguments formattingPrefix et formattingSuffix ayant respectivement les valeurs "(" et ")".

Dans le cadre de cet exemple, l’implémentation de PointStringFormatterV2 est:

[InterpolatedStringHandler]
public class PointStringFormatterV2
{
    private StringBuilder builder;
    private string formattingPrefix;
    private string formattingSuffix;

    public PointStringFormatterV2(int literalLength, int formattedCount, string formattingPrefix, string formattingSuffix)
    {
        builder = new StringBuilder(literalLength);
        this.formattingPrefix = formattingPrefix;
        this.formattingSuffix = formattingSuffix;
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    public void AppendLiteral(string s)
    {
        builder.Append(s);
    }

    public void AppendFormatted<T>(T t)
    {
        string pointAsString = t switch
        {
            ThreeDimensionPoint threeDimensionPoint => $"{threeDimensionPoint.X}; {threeDimensionPoint.Y}; {threeDimensionPoint.Z}",
            TwoDimensionPoint twoDimensionPoint => $"{twoDimensionPoint.X}; {twoDimensionPoint.Y}",
            null => "null",
            string formattedString => formattedString,
            _ => throw new InvalidOperationException($"{nameof(T)} is unknown")
        };
        AppendLiteral(this.formattingPrefix);
        AppendLiteral(pointAsString);
        AppendLiteral(this.formattingSuffix);
    }

    internal string GetFormattedText() => builder.ToString();
}

L’implémentation de ShowPoint() est:

public void ShowPoint(string formattingPrefix, string formattingSuffix, 
    [InterpolatedStringHandlerArgument("formattingPrefix", "formattingSuffix")] PointStringFormatterV2 point)
{
    Console.WriteLine(point.GetFormattedText());
}

Si on appelle ShowPoint() de cette façon:

var point1 = new ThreeDimensionPoint(4, 7, 9);
var point2 = new TwoDimensionPoint(4, 7);
ShowPoint("(", ")", $"{nameof(point1)}: {point1}");
ShowPoint("(", ")", $"{nameof(point2)}: {point2}");

On obtient:

literal length: 2, formattedCount: 2
(point1): (4; 7; 9)
literal length: 2, formattedCount: 2
(point2): (4; 7)

Ainsi on peut voir que l’attribut InterpolatedStringHandlerArgument a permis d’instancier PointStringFormatterV2 avec 2 arguments supplémentaires.

Amélioration des informations de diagnostic sur une méthode (C# 10)

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

Il existe des attributs permettant d’indiquer des informations sur l’appelant d’une méthode. Avant C# 10, il existait 3 attributs:

C# 10 permet de rajouter l’attribut CallerArgumentExpressionAttribute pour indiquer sous forme d’une chaine de caractères l’expression à l’origine de la valeur d’un paramètre de la fonction courante.

Attributs de diagnostic avant C# 10

Tous ces attributs peuvent être utilisés pour apporter des informations de diagnostic sur la façon dont une méthode est appelée. Ces informations peuvent, par exemple, être logguées.

Par exemple si on considère le code suivant:

public class Example
{
  public void CallingMethod()
  {
    string calleeSecondArgument = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor...";
    int firstInteger = 9;
    int secondInteger = 610;

    this.Callee(firstInteger + secondInteger, calleeSecondArgument, true);
  }

  private void Callee(int firstArgument, string secondArgument, bool thirdArgument)
  {
    Console.WriteLine(firstArgument);
    Console.WriteLine(secondArgument);
    Console.WriteLine(thirdArgument);
  }
}

Sans surprise la valeur des 3 arguments est affichée dans la console:

619
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor...
True

Dans Callee(), on peut facilement afficher la valeur de chaque argument. Dans le but d’avoir des informations supplémentaires au moment de l’exécution, on peut utiliser les attributs indiqués précédemment sous la forme d’arguments supplémentaires de la méthode:

using System.Runtime.CompilerServices;

private void Callee(int firstArgument, string secondArgument, bool thirdArgument,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  Console.WriteLine(firstArgument);
  Console.WriteLine(secondArgument);
  Console.WriteLine(thirdArgument);

  Console.WriteLine(memberName);
  Console.WriteLine(sourceFilePath);
  Console.WriteLine(sourceLineNumber);
}

Quand on rajoute les arguments avec les attributs CallerMemberName, CallerFilePath et CallerLineNumber:

  • Il est obligatoire d’ajouter une valeur par défaut si une erreur de compilation est générée,
  • L’appel de la méthode Callee() n’est pas modifié:
    this.Callee(firstInteger + secondInteger, calleeSecondArgument, true);
    

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

619
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor...
True
CallingMethod
C:\MyStuff\Dev\Example\ExampleCS10\CallerExpresssionArgumentFeature.cs
19

Comme on peut le voir, les arguments avec les attributs de diagnostic ne comportent plus les valeurs par défaut mais sont interprétés par le compilateur.

Précisions sur la valeur de CallerMemberName

La valeur renvoyée par l’argument CallerMemberName change suivant la nature de la méthode appelante:

  • Dans le cas d’une méthode ou propriété: la valeur sera le nom de la méthode ou de la propriété comme on a pu le voir précédemment.
  • Dans le cas d’un constructeur: la valeur sera ".ctor", par exemple:
    internal class Example
    {
      public Example()
      {
        this.Callee();
      }
    
      private void Callee([CallerMemberName] string memberName = "")
      {
        Console.WriteLine(memberName);
      }
    }
    

    Le résultat est:

    .ctor
    
  • Dans le cas d’un constructeur statique: ".cctor", par exemple:
    internal class Example
    {
      static Example()
      {
        var example = new Example();
        example.Callee();
      }
    
      private void Callee([CallerMemberName] string memberName = "")
      {
        Console.WriteLine(memberName);
      }
    }
    

    Le résultat est:

    .cctor
    
  • Pour une surcharge d’opérateur: la valeur sera du type "op_<nom de l'opérateur>", par exemple:
    internal class Example
    {
      public static Example operator +(Example a) => a.Callee();
    
      private Example Callee([CallerMemberName] string memberName = "")
      {
        Console.WriteLine(memberName);
        return this;
      }
    }
    

    On peut appeler la surcharge de l’opérateur en exécutant:

    var example = new Example();
    Console.WriteLine(+classToExecute);
    

    On obtient:

    op_UnaryPlus
    
  • Dans le corps du finalizer: la valeur sera "Finalize".

    Le plus compliqué est d’avoir un exemple permettant d’exécuter le finalizer:

    internal class CallerMemberNameFeature: IDisposable
    {
      // Finalizer
      ~CallerMemberNameFeature()
      {
        this.Callee();
        Dispose(false);
      }
    
      public void Dispose()
      {
        GC.SuppressFinalize(this);
      }
    
      private void Callee([CallerMemberName] string memberName = "")
      {
        Console.WriteLine(memberName);
      }
    }
    

    Pour exécuter:

    static void Main(string[] args)
    {
      var example = new CallerMemberNameFeature();
      MyMethod(1);
      example.Dispose();
      GC.Collect();
      GC.WaitForPendingFinalizers();
    }
    
    private static void MyMethod(int i)
    {
      new CallerMemberNameFeature();
    }
    

    A l’exécution, on obtient:

    Finalize
    

CallerArgumentExpression

C# 10

L’attribut CallerArgumentExpressionAttribute apparu en C# 10 permet de renvoyer l’expression à l’origine de la valeur du paramètre de fonction pour lequel l’attribut est utilisé.

Si on considère l’exemple suivant:

internal class CallerExpressionArgumentFeature
{
  public void CallingMethod()
  {
    string calleeSecondArgument = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor...";
    int firstInteger = 9;
    int secondInteger = 610;
     
    this.Callee(firstInteger + secondInteger, 
    string.Format($"{0} {1} {2}", calleeSecondArgument, firstInteger, secondInteger));
  }

  private void Callee(int argument1, string argument2, 
    [CallerArgumentExpression("argument1")] string argumentExpression = "")
  {
    Console.WriteLine(argumentExpression);
  }
}

Dans cet exemple, le paramètre argumentExpression de la méthode Callee() comporte un attribut CallerArgumentExpression("argument1") indiquant que le paramètre doit contenir l’expression à l’origine de la valeur de l’argument argument1.

L’argument comportant l’attribut CallerArgumentExpression doit obligatoirement comporter une valeur par défaut.

En appelant la méthode CallingMethod():

var example = new CallerExpressionArgumentFeature();
example.CallingMethod();

On obtient:

firstInteger + secondInteger

Cette expression se trouve dans l’appel de la méthode Callee() dans CallingMethod():

this.Callee(firstInteger + secondInteger, 
  string.Format($"{0} {1} {2}", calleeSecondArgument, firstInteger, secondInteger));

De même si on modifie l’implémentation de Callee() de cette façon:

private void Callee(int argument1, string argument2, 
  [CallerArgumentExpression("argument1")] string argument1Expression = "",
  [CallerArgumentExpression("argument2")] string argument2Expression = "")
{
  Console.WriteLine(argument1Expression);
  Console.WriteLine(argument2Expression);
}

On peut obtenir l’expression à l’origine de la valeur de l’argument argument2 de Callee(). A l’exécution, on obtient:

firstInteger + secondInteger
string.Format($"{0} {1} {2}", calleeSecondArgument, firstInteger, secondInteger)

Obtenir l’expression à l’origine des valeurs des arguments peut être intéressant à logguer, par exemple, dans le cas où la valeur n’est pas celle attendue.

Amélioration des structures (C# 10, C# 11)

Le but de cet article est d’indiquer les améliorations faites sur les objets structures (i.e. struct) en C# 10 et C# 11.

Dans un 1er temps, on rappelle les caractéristiques des structs. Ensuite, on indique quelles ont été les améliorations apportées aux structs par C# 10 et C# 11.

Rappels concernant les objets de type valeur

D’une façon générale, les types d’objet en C# peuvent être séparés en 2 familles:

  • Les objets de type référence: les variables d’objets de type référence contiennent des références vers les objets en mémoire. Ces variables contenant les références sont objets de type valeur. Ainsi lorsqu’on effectue une affectation d’une variable d’un objet de type référence vers une autre variable, la référence est dupliquée et copiée dans la nouvelle variable toutefois l’objet référencé n’est pas dupliqué.
    Parmi les objets de type référence, on peut trouver les classes, les interfaces, les tableaux, le type delegate et le type dynamic. Les objets de type référence dérivent de System.Object.
  • Les objets de type valeur: les variables d’objets de type valeur correspondent à la représentation de la valeur réelle de l’objet. L’affectation d’une variable d’un objet de type valeur vers une autre variable effectue une copie de la représentation de la valeur de l’objet.
    Les objets de type valeur sont les structs et les enums. Parmi les structs, on peut citer les tuples, booléens, les types intégrals (sbyte, byte, short, ushort, int, uint, long, ulong et char), les types à virgule flottante (float ou double) et decimal. Les objets de type valeur dérivent de System.ValueType qui dérive de System.Object.

Caractéristiques des objets de type valeur

Les caractéristiques essentielles des objets de type valeur sont qu’en tant que représentation de la valeur d’un objet, ils ne sont jamais nuls et les affectations de variables effectuent des copies des objets par valeur. Ainsi tous ces objets possèdent un constructeur par défaut qui va effectuer une initialisation à zéro des différentes données membres que constituent l’objet de type valeur. Si ce constructeur n’est pas explicitement implémenté, il est rajouté par le compilateur.

L’initialisation à zéro consiste à affecter:

  • 0 aux membres de type intégral, 0.0f aux float, 0.0d aux double, 0m aux decimal,
  • false aux objets bool,
  • null aux objets de type référence,
  • Initialiser à zéro les membres de type valeur.

Caractéristiques des objets struct

En plus des caractéristiques des objets de type valeur, une struct ne peut pas être statique, ne peut pas hériter d’une autre struct et ne peut pas être abstraite. Une struct peut satisfaire une interface et peut avoir des membres statiques.

Ainsi, si on considère:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;
}

internal class ClassExample { }

Alors:

var structExample = new StructExample();
Console.WriteLine(structExample.IntegerMember);  // 0
Console.WriteLine(structExample.StringMember);   // null
Console.WriteLine(structExample.ClassMember);    // null

Cette implémentation ne génère pas d’erreurs toutefois des warnings indiquent que les membres ne sont jamais initialisés:

warning CS0649: Field 'StructExample.StringMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.ClassMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.IntegerMember' is never assigned to, and will always have its default value 0

Les warnings disparaissent si on initialise les membres:

var structExample = new StructExample { IntegerMember = 0, StringMember = string.Empty, ClassMember = new ClassExample() };

Concernant les autres caractéristiques des structs:

internal abstract struct StructExample {}  // ⚠ Erreur de compilation ⚠

internal static struct StructExample {}  // ⚠ Erreur de compilation ⚠

internal struct OtherStruct {} 
internal struct StructExample: OtherStruct {}  // ⚠ Erreur de compilation ⚠

Mais:

internal interface IExample {} 
internal struct StructExample: IExample {}  // OK

Les structures ne peuvent pas contenir de destructeurs:

internal struct StructExample
{
  ~StructExample() {} // ⚠ Erreur de compilation ⚠
}

Code MSIL

Du coté du code MSIL, les différences ne sont pas très grandes entre les classes et les structs. Si on considère les 2 objets suivants dont les implémentations sont volontairement très proches:

internal struct StructExample
{
  public int IntegerMember;

  public StructExample(int integerMember)
  {
    this.IntegerMember = integerMember;
  }
}

Et:

internal class ClassExample
{
  public int IntegerMember;

  public ClassExample(int integerMember)
  {
    this.IntegerMember = integerMember;  
  }
}

Si on regarde le code MSIL correspondant à ces 2 objets:

.class private sequential ansi sealed beforefieldinit CS10_Tests.StructExample
    extends [System.Runtime]System.ValueType
{
  .field public int32 IntegerMember

  .method public hidebysig specialname rtspecialname 
      instance void  .ctor(int32 integerMember) cil managed
  {
    // Code size     8 (0x8)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  ldarg.1
    IL_0002:  stfld    int32 CS10_Tests.StructExample::IntegerMember
    IL_0007:  ret
  } 
} 

Et:

.class private auto ansi beforefieldinit CS10_Tests.ClassExample
    extends [System.Runtime]System.Object
{
  .field public int32 IntegerMember 

  .method public hidebysig specialname rtspecialname 
      instance void  .ctor(int32 integerMember) cil managed
  {
    // Code size     14 (0xe)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()
    IL_0006:  ldarg.0
    IL_0007:  ldarg.1
    IL_0008:  stfld    int32 CS10_Tests.ClassExample::IntegerMember
    IL_000d:  ret
  } 
} 

On remarque que le type sous-jacent des objets est le même: .class. La plus grande différence réside dans l’héritage à:

  • System.ValueType dans le cas de la struct:
    .class private sequential ansi sealed beforefieldinit CS10_Tests.StructExample
      extends [System.Runtime]System.ValueType
    
  • System.Object dans le cas de la classe:
    .class private auto ansi beforefieldinit CS10_Tests.ClassExample
      extends [System.Runtime]System.Object
    

Ensuite:

  • sealed dans le cas de la struct qui interdit l’héritage.
  • auto dans le cas de la classe qui permet au compilateur de réordonner les membres de l’objet pour réduire les “espaces morts” entre membres occupant un espace différent en mémoire.
  • sequential dans le cas de la struct pour indiquer que les membres de l’objet sont disposés séquentiellement dans l’ordre de définition.

Dans le constructeur, un appel est effectué dans le cas de la classe au constructeur de System.Object:

IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()

Si on utilise ces 2 objets de cette façon:

var structExample = new StructExample(10);
Console.WriteLine(structExample.IntegerMember);

var classExample = new ClassExample(10);
Console.WriteLine(classExample.IntegerMember);

Le code MSIL correspondant est:

// Code size     35 (0x23)
.maxstack  8
IL_0000:  ldc.i4.s   10
IL_0002:  newobj   instance void CS10_Tests.StructExample::.ctor(int32)
IL_0007:  ldfld    int32 CS10_Tests.StructExample::IntegerMember
IL_000c:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0011:  ldc.i4.s   10
IL_0013:  newobj   instance void CS10_Tests.ClassExample::.ctor(int32)
IL_0018:  ldfld    int32 CS10_Tests.ClassExample::IntegerMember
IL_001d:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0022:  ret

On peut remarquer que pour l’instanciation de la struct ou de la classe, le même opérateur newobj est utilisé. Newobj est, en effet, utilisé dans le cas d’un objet de type référence ou d’un objet de type valeur.
On peut donc constater que le code MSIL généré est très semblable entre une classe et une struct.

Si on modifie l’implémentation de cette façon:

StructExample structExample;  // Une struct n'est pas null donc cette construction est possible
structExample.IntegerMember = 10;
Console.WriteLine(structExample.IntegerMember);

ClassExample classExample = new ClassExample(10);
Console.WriteLine(classExample.IntegerMember);

Le code MSIL est sensiblement différent et reflète la construction de la struct spécifique aux objets de type valeur. Cette construction n’est pas possible dans le cas de la classe:

// Code size     38 (0x26)
.maxstack  2
.locals init (valuetype CS10_Tests.StructExample V_0)
IL_0000:  ldloca.s   V_0
IL_0002:  ldc.i4.s   10
IL_0004:  stfld    int32 CS10_Tests.StructExample::IntegerMember
IL_0009:  ldloc.0
IL_000a:  ldfld    int32 CS10_Tests.StructExample::IntegerMember
IL_000f:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0014:  ldc.i4.s   10
IL_0016:  newobj   instance void CS10_Tests.ClassExample::.ctor(int32)
IL_001b:  ldfld    int32 CS10_Tests.ClassExample::IntegerMember
IL_0020:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0025:  ret

Dans le cas de la struct, newobj n’est pas utilisé mais une variable locale est déclarée et directement initialisée:

.locals init (valuetype CS10_Tests.StructExample V_0)

Comme l’objet est de type valeur, il n’est pas nul. La variable locale correspondant à l’objet est sur la pile avec:

IL_0000:  ldloca.s   V_0

Dans le cas de la classe, le constructeur est appelé et l’opérateur newobj renvoie la référence vers la pile.

Avant C# 10.0

De façon à ce que les membres d’une structure soient initialisés à zéro à l’initialisation, certaines restrictions étaient appliquées aux structures. En cas d’absence de constructeur, le compilateur ne faisait que rajouter un constructeur permettant d’initialiser à zéro les membres de la structure. Toutes les autres formes d’implémentation du constructeur où tous les membres ne sont pas initialisés, menaient à une erreur de compilation:

  • Il n’était pas possible d’implémenter un constructeur sans paramètre:
    internal struct StructExample
    {
      public int IntegerMember;
      public string StringMember;
      public ClassExample ClassMember;
    
      public StructExample() {}  // ⚠ Erreur avant C# 10.0 ⚠
    }
    
  • Les initialisations de membres au même niveau que leur déclaration n’étaient pas possible:
    internal struct StructExample
    {
      public int IntegerMember = 0;  // ⚠ Erreur avant C# 10.0 ⚠
      public string StringMember = string.Empty;
      public ClassExample ClassMember = null;
    }
    
  • Le constructeur doit initialiser toutes les données membres:
    internal struct StructExample
    {
      public int IntegerMember;
      public string StringMember;
      public ClassExample ClassMember;
    
      // Il n'est pas nécessaire que le constructeur 
      // contienne des paramètres pour tous les membres
      public StructExample(int integerMember)
      {
        IntegerMember = integerMember;
        // StringMember = string.Empty;       // ⚠ Erreur, membre non initialisé ⚠
        // ClassMember = new ClassExample();  // ⚠ Erreur, membre non initialisé ⚠
      }
    }
    

readonly struct

C# 7.2

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

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

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

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

Pour davantage de détails, voir cdiese.fr/csharp7-ref-struct/.

ref struct

C# 7.2

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

ref struct StackOnlyStruct  
{ ... }  

readonly ref struct

C# 7.2

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

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

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

readonly ref struct ImmutableStackOnlyStruct  
{ ... }  

ref struct et readonly ref struct disposable

C# 8.0

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

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

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

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

using (new DisposableStruct())
{
}

Amélioration des structs en C# 10 et C# 11

De façon à rendre les structs moins contraignantes à utiliser et de les rapprocher des fonctionnalités de classes, quelques améliorations ont été apportées:

  • A partir de C# 10:
    • Il est désormais possible de déclarer un constructeur sans paramètre.
    • L'initialisation d'un membre ou d'une propriété est possible directement au niveau de se déclaration.
    • On peut utiliser l'opérateur with avec des structs.
  • A partir de C# 11, il n'est plus nécessaire que le constructeur initialise tous les membres.

Constructeur sans paramètre

C# 10

A partir de C# 10, il n'est plus obligatoire d'utiliser un constructeur avec au moins un paramètre. On peut désormais implémenter un constructeur sans paramètre toutefois il est obligatoire d'initialiser tous les membres en C# 10 (cette obligation n'est plus valable avec C# 11). Si les membres ne sont pas initialisés explicitement, des erreurs de compilation sont générées:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
    // ⚠ ERREUR en C# 10 ⚠
  }
}

Erreurs de compilation si les membres ne sont pas explicitement initialisés:

error CS0171: Field 'StructExample.IntegerMember' must be fully assigned before control is returned to the caller. 
error CS0171: Field 'StructExample.StringMember' must be fully assigned before control is returned to the caller. 
error CS0171: Field 'StructExample.ClassMember' must be fully assigned before control is returned to the caller. 

Si on initialise tous les membres:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
     IntegerMember = 0;
     StringMember = string.Empty;
     ClassMember = new ClassExample();    
  }
}

L'obligation d'initialiser explicitement les membres disparait avec C# 11.

Initialisation des membres ou propriétés directement lors de leur déclaration

C# 10

Désormais il est possible d'initialiser des membres et des propriétés d'une struct lors de leur déclaration. Lorsqu'au moins un membre est initialisé lors de sa déclaration, un constructeur explicite est requis sinon une erreur de compilation est générée (cette obligation ne s'applique pas pour une propriété):

internal struct StructExample
{
   public int IntegerMember = 0; // ⚠ ERREUR ⚠: au moins un constructeur est requis
}

Cette implémentation entraîne une erreur à la compilation:

error CS8983: A 'struct' with field initializers must include an explicitly declared constructor.

L'implémentation d'un constructeur sans paramètre suffit:

internal struct StructExample
{
  // Membres
  private ClassExample classExample = new ClassExample();

  public int IntegerMember = 0;
  public string StringMember = string.Empty;


  // Constructeur sans paramètre
  public StructExample() {}

  // Propriété
   public ClassExample ClassMember => this.classExample;
}

Utilisation de with avec des structs

C# 10

Si on considère la struct:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
}

On peut utiliser l'opérateur with (introduit en C# 9) pour construire une autre instance d'une struct en se basant sur la 1ère instance:

var firstExample = new StructExample { IntegerMember = 10, StringMember = "First" };
var secondExample = firstExample with { StringMember = "Second" };

Console.WriteLine(secondExample.IntegerMember);  // 10
Console.WriteLine(secondExample.StringMember);   // Second

L'objet généré par l'opérateur with (cf. secondExample) possède des membres avec les mêmes valeurs que l'objet à gauche de l'opérateur (cf. firstExample) à l'exception des membres dont on modifie explicitement la valeur (comme StringMember):

10 
Second

L'initialisation explicite des membres n'est plus obligatoire

C# 11

A partir de C# 11, il n'est plus nécessaire d'initialiser tous les membres dans le constructeur. Dans le cas où les membres ne sont pas initialisés explicitement, ils sont initialisés à zéro (comme dans le cas où il n'y a pas de constructeur):

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample() { }
}

Cette implémentation ne générera pas d'erreurs à la compilation toutefois des warnings seront générés car les membres ne sont pas initialisés:

warning CS0649: Field 'StructExample.StringMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.ClassMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.IntegerMember' is never assigned to, and will always have its default value 0 

Les membres sont initialisés à zéro (comme dans le cas d'une absence de constructeur):

var structExample = new StructExample();
Console.WriteLine(structExample.IntegerMember);   // 0 
Console.WriteLine(structExample.StringMember);    // null
Console.WriteLine(structExample.ClassMember);     // null

En initialisant les membres, les warnings disparaissent:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
    this.ClassMember = new ClassExample();
    this.StringMember = string.Empty;
    this.IntegerMember = 0;
  }
}