Les fonctionnalités C# 9.0


Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 9.0. Dans un premier temps, on explicitera le contexte de C# 9.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 .NET se font pour .NET Core seulement. Le framework .NET est toujours supporté toutefois les nouvelles fonctionnalités ne sont pas implémentées pour cet environnement.

Etant donné que l’environnement correspondant au framework .NET n’évoluera plus, l’environnement .NET Core a été renommé .NET. Ainsi .NET 5.0 correspond à la nouvelle version uniformisée de .NET.

Comme les environnements framework .NET et .NET Core ne subsistent plus en parallèle, l’approche .NET Standard n’a plus d’intérêt. .NET Standard s’arrête donc à .NET 5.0.

Chronologie des releases

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

Date Version C# Version Visual Studio Compilateur Version Framework .NET Version .NET Core
Septembre 2019 C# 8.0 VS2019 (16.3) Roslyn 3.2(1) .NET 4.8(2)(3)
(NET Standard 1.0⇒2.0)
.NET Core 3.0
(NET Standard 1.0⇒2.1)
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) .NET 6.0
  • (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’existent plus. 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 Core.

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

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

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

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

  • .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 versions C# que le compilateur va traiter.

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

Fonctionnalités C# 9.0

Les fonctionnalités les plus basiques de C# 9.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:

Accesseur init

Avec C# 9, l’accesseur init est ajouté à la syntaxe C#. Cet accesseur permet de limiter l’affectation d’une propriété d’un objet dans le corps du constructeur, dans un initializer ou avec le mot-clé with (valable pour les records).

Pour une version antérieure à C# 9, les accesseurs possibles d’une propriété sont:

  • get: accesseur en lecture pour accéder à la valeur d’une propriété d’un objet
  • set: accesseur en écriture pour affecter une valeur à une propriété

Accesseurs get et set

L’absence de l’accesseur set permet de réserver l’affectation d’une propriété au constructeur, par exemple si on considère la classe suivante:

public class Car
{
  public string Brand { get; set; }
  public string Model { get; set; }

  public Car()
  {
    this.Brand = "Ford"; // Affectation possible dans le constructeur
  }
}

Il est possible d’affecter une valeur aux propriétés à tout niveau:

var car = new Car();
car.Brand = "Ford";
car.Model = "Mustang";

En l’absence d’accesseur set, l’affectation n’est possible que dans le constructeur:

public class Car
{
  public string Brand { get; }
  public string Model { get; set; }

  public Car()
  {
    this.Brand = "Ford"; // OK
  }
}

// ...

var car = new Car();
car.Brand = "Renault"; // ⚠ ERREUR ⚠ car non accessible en écriture
car.Model = "Mustang"; // OK

En l’absence d’accesseur en écriture, l’affectation n’est pas possible dans un initializer:

var car = new Car { Brand = "Ford", Model = "Mustang" }; // ⚠ ERREUR ⚠ il n’est pas possible d’affecter Brand

Les accesseurs sont valables pour les classes, les records et les structures.

Syntaxe non condensée
Généralement les accesseurs sont écrits sous une forme condensée:

public class Car
{
  public string Brand { get; set; }
}

L’équivalent plus verbeux de cette syntaxe condensée est:

public class Car
{
  private string brand;

  public string Brand 
  {
    get { return this.brand; }
    set { this.brand = value; }
  }
}
C# 9.0

Nouvel accesseur init

C# 9 introduit l’accesseur init qui autorise l’affectation dans le constructeur et dans un initializer, par exemple:

public class Car
{
  public string Brand { get; init; }
  public string Model { get; init; }

  public Car(string brand, string model)
  {
    this.Brand = brand; // OK
    this.Model = model; // OK
  }

  public Car() {} // Constructeur par défaut pour permettre l’utilisation d’un initializer
}

Les propriétés peuvent être affectées en utilisant un initializer:

var car = new Car { Brand = "Ford", Model = "Mustang" }; // OK

En revanche les affections en dehors du constructeur et de l’initializer ne sont pas possibles:

car.Brand = "Renault"; // ⚠ ERREUR ⚠

Affectation possible à partir d’un autre accesseur init

Si une propriété comporte un accesseur init, il est possible d’affecter cette propriété dans le corps de l’accesseur d’une autre propriété. Par exemple, si on considère la classe suivante avec un accesseur utilisant une syntaxe non condensée:

public class Car
{
  private string brand;

  public string Brand 
  { 
    get { return this.brand; } 
    init { this.brand = value; }
  }
  public string Model { get; init; }
}

Il est possible d’affecter la propriété Model à partir de l’accesseur init de la propriété Brand:

public class Car
{
  private string brand;

  public string Brand 
  { 
    get { return this.brand; } 
    init { 
      this.brand = value; 
      this.Model = "Unknown"; // OK
    }
  }
  public string Model { get; init; }
} 

L’affectation est possible dans l’accesseur init d’une propriété d’une classe dérivée, par exemple:

public class Vehicle
{
  public string Brand { get; set; }
}

public class Car : Vehicle
{
  private string model;

  public string Model 
  {
    get { return this.model; }
    init { 
      this.model = value;
      this.Brand = "Unknown"; // OK
     } 
  }
}

Affectation possible dans le constructeur d’un type dérivé
Dans le cas des classes et des records (les structures n’autorisent pas l’héritage), il est possible d’effectuer des affectations dans le constructeur des objets dérivés.

Par exemple:

public class Vehicle
{
  public string Brand { get; init; }
}

public class Car : Vehicle
{
  public Car(string brand)
  {
    this.Brand = brand; // OK
  }
}

readonly
Comme pour set, l’accesseur init permet d’affecter des membres avec un opérateur readonly. Le mot-clé readonly peut être utilisé pour indiquer qu’un membre d’une classe ou d’une structure ne peut être initialisé que par un initializer ou par le constructeur.

Par exemple:

public class Car
{
  private readonly string brand;

  public string Brand {
    get { return this.brand; }
    init {
      this.brand = value; // OK
     }
  }
}

L’affectation d’un membre readonly n’est possible que dans la classe dans laquelle le membre est défini. Les classes dérivées ne peuvent pas affecter un membre readonly:

public class Vehicle
{
  protected readonly string brand;
}

public class Car: Vehicle
{
  public string Brand {
    get { return this.brand; }
    init {
      this.brand = value;  // ⚠ ERREUR ⚠: brand ne peut être affecté que dans Vehicle
    }
  }
}

Utilisation de with
A partir de C# 9, il est possible d’utiliser des objets de type record. Une méthode pour instancier ces objets est d’utiliser with. with permet de créer un nouvel objet record à partir d’un objet existant, par exemple si on considère le record suivant:

public record Car
{
  public string Brand { get; init; }
  public string Model { get; init; }
}

Si on utilise with pour créer un nouveau record:

var initialCar = new Car { Brand = "Renault", Model = "Clio" };
var littleCar = initialCar with { Model = "Twingo" };

Console.WriteLine(initialCar.Brand); // Renault
Console.WriteLine(initialCar.Model); // Twingo

Opérateurs de portée
Comme pour get et set, on peut ajouter un opérateur de portée à init pour modifier sa portée, par exemple:

public class Vehicle
{
  public string Brand { get; private init; }  // Pour limiter à la classe seulement
  public string Model { get; protected init; } // Pour limiter aux classes dérivées
}

Ainsi si on considère une classe dérivant de Vehicle

public class Car: Vehicle
{
  public Car(string brand, string model)
  {
    this.Brand = brand; // ⚠ ERREUR ⚠ à cause de private init
    this.Model = model; // OK
  }
}

new()

On peut omettre de préciser le type lors de l’instanciation d’un objet avec l’opérateur new quand le type est connu:

  • Avant C# 9:
    ExampleClass instance = new ExampleClass(arg1, arg2, arg3);
    
  • A partir de C# 9:
    ExampleClass instance = new(arg1, arg2, arg3); // Le type peut être omis après new
    

Pour utiliser new(), il faut que le compilateur puisse déterminer le type, l’utilisation de var n’est donc pas possible:

var instance = new(); // ⚠ ERREUR ⚠

Dans le cas où il n’y a pas d’arguments dans le constructeur, on utilise la forme new():

ExampleClass instance = new(); // OK

Le type peut être trouvé par le compilateur lors de la création:

  • D’une liste:
    List<ExampleClass> list = new() { new() }; 
    
  • D’un dictionnaire:
    Dictionary<string, string> dictionary = new()
    {
      { "key", "value" }
    };
    
  • D’un enum:
    Si on considère l’enum suivant:

    public. enum EnumExample
    {
      value1,
      value2,
      value3,
    }
    

Les formes suivantes sont équivalentes:

EnumExample enum1 = new EnumExample();
EnumExample enum2 = default; // A partir de C# 7.1
EnumExample enum3 = new();

Dans tous les cas, la valeur des enum est value1.

Utiliser new() est possible en retour d’une fonction:

Car CreateNewCar()
{
  return new();
}

On ne peut pas utiliser new() pour un tableau:

ExampleClass[] array = new ExampleClass[] {}; // OK
ExampleClass[] array = new() {}; // ⚠ ERREUR ⚠

Fonctions anonymes statiques

C# 9 permet de créer des fonctions anonymes statiques de façon à ne pas utiliser le contexte d’exécution.

Comme leur nom l’indique, les fonctions anonymes sont des fonctions dont la définition ne possède pas de nom en opposition aux fonctions classiques. Pour les utiliser, on définit des delegates qui sont des références vers la fonction. La définition d’un delegate correspond à une signature de fonction précise:

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

Le delegate permettra d’indiquer le type de la référence vers une fonction. Cette référence peut être créée à partir d’une fonction anonyme:

ArithmeticOperation multiply = delegate(int a, int b)
{
  return a * b;
};

A partir de C# 3, sont apparues les fonctions lambda qui permettaient de définir facilement des fonctions anonymes sans avoir à définir au préalable des delegates:

Func<int, int, int> multiply = (int  a, int b) => {
  return a * b;
};

Func<int, int, int> est un delegate dont la définition se trouve dans le framework:

public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);

Pour exécuter la fonction anonyme, il suffit d’utiliser la référence:

int result = multiply(2, 3);

La fonction anonyme étant définie à l’intérieur d’une autre fonction, elle peut être une closure c’est-à-dire qu’elle peut capturer des variables provenant du contexte de cette autre fonction, par exemple:

public void ExecuteMe()
{
  int localVar = 0;
  // Fonction lambda sans argument
  Action printLocalVar = () => 
  {
    Console.WriteLine(localVar); // La variable localVar est capturée
  };

  printLocalVar(); // 0
  
  localVar++;

  printLocalVar(); // 1
}
C# 9.0

A partir de C# 9, il est possible de définir des fonctions anonymes statiques. Ces fonctions ne capturent pas le contexte extérieur. Pour définir une fonction anonyme statique, il faut utiliser le mot classique static:

ArithmeticOperation multiply = static delegate(int a, int b)
{
  return a * b;
};

Si on utilise une fonction lambda, de la même façon on peut utiliser le mot clé static pour rendre la fonction statique:

Func<int, int, int> multiply = static (int  a, int b) => {
  return a * b;
};

Si la fonction anonyme est statique, il n’est pas possible d’utiliser des variables dans une closure, les variables sont obligatoirement des arguments ou des variables définies localement dans le corps de la fonction anonyme. En reprenant l’exemple précédent:

public void ExecuteMe()
{
  int localVar = 0;
  // Fonction lambda sans argument
  Action<int> printLocalVar = static (arg) => 
  {
    Console.WriteLine(arg); 
  };

  printLocalVar(localVar); // 0
  
  localVar++;

  printLocalVar(localVar); // 1

}

Déclaration de premier niveau

Cette fonctionnalité permet de simplifier le code de la fonction Main() d’applications en permettant d’omettre la déclaration d’un namespace, d’une classe et d’une méthode Main().

Par exemple, si on crée une nouvelle application console avec:

dotnet new console

Dans le fichier Program.cs, au lieu d’écrire un Main de cette façon:

using System;

namespace SimpleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      int result = 0;
      int n1 = 0;
      int n2 = 1;

      for (int i = 0; i < 15; i++)
      {
        result = n1 + n2;
        n2 = n1;
        n1 = result;
        Console.WriteLine(result);
      }
    }
  }
}

On peut omettre le namespace, la classe, la méthode Main() et les déclarations using dans le fichier Program.cs:

int result = 0;
int n1 = 0;
int n2 = 1;
for (int i = 0; i < 15; i++)
{
    result = n1 + n2;
    n2 = n1;
    n1 = result;
    Console.WriteLine(result);
}

A la compilation, le contenu du fichier Program.cs sera considéré comme étant le Main. Toutefois il est possible dans le même fichier, de déclarer d’autres méthodes, classes ou namespaces. Une erreur de compilation sera générée si un autre fichier contient un Main():

namespace Example
{
  public class EntryPoint
  {
    static void Main(string[] args)
    {
      // ...
    }
  }
}

Cette fonctionnalité n’impose pas d’avoir qu’un seul fichier, on peut créer d’autres classes dans d’autres fichiers. En revanche si un autre fichier contient des déclarations sans indications de méthode, de classe ou de namespace, une erreur sera générée:

error CS8802: Only one compilation unit can have top-level statements. 

Expression conditionnelle vers un type cible

Une expression conditionnelle correspond à une expression ternaire du type:

<condition> ? <expression 1 si condition vraie> : <expression 2 si condition fausse>

Pour que cette expression soit valide, il faut qu’elle soit intégrée à une déclaration du type:

var result = <expression conditionnelle>;

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

public class A 
{
  public int InnerProperty;
}

On peut utiliser une expression conditionnelle de cette façon:

var rnd = new Random();
var a1 = new A { InnerProperty = 1 };
var a2 = new A { InnerProperty = 2 };
var result = rnd.Next() % 2 == 0 ? a1 : a2;

Suivant la valeur de la condition de l’expression conditionnelle, la 1ère ou la 2e expression est évaluée pour connaître le type du résultat. Pour que l’expression conditionnelle puisse produire un résultat prévisible, il faut que les types des résultats des expressions 1 et 2 aient des éléments communs:

  1. Les types peuvent être les mêmes: c’est le cas de l’exemple précédent. Le type du résultat est A dans cet exemple.
  2. Les types peuvent avoir un même ancêtre dans l’arbre d’héritage:
    Par exemple, si on considère les types suivants:

    public class B: A {}
    public class C: A {}
    

    On peut utiliser une expression conditionnelle de cette façon:

    var b = new B { InnerProperty = 1 };
    var c = new C { InnerProperty = 2 };
    A result = rnd.Next() % 2 == 0 ? b : c;
    

    Dans cet exemple, le type commun entre B et C est A.

  3. Une conversion implicite peut exister pour passer du type de l’expression 1 vers le type de l’expression 2 ou vice versa.
    Par exemple, si on considère les types suivants:

    public class A 
    {
      public static implicit operator A(B b) => new A();
    }
    
    public class B {}
    

    Une conversion implicite existe pour convertir des objets de type B en objets de type A.

    On peut construire ainsi une expression conditionnelle de cette façon:

    var a = new A();
    var b = new B();
    var result = rnd.Next() % 2 == 0 ? a : b;
    

    Le type du résultat sera A.

Dans le cas d’une conversion explicite:

public class A 
{
	public static explicit operator A(B b) => new A();
}

public class B {}

L’utilisation directe de l’expression conditionnelle n’est pas possible, il faut effectuer un cast explicite:

var a = new A();
var b = new B();
var result = rnd.Next() % 2 == 0 ? a : b; // ⚠ ERREUR ⚠: Type of conditional expression cannot be determined because there is no implicit conversion between ...
var result = rnd.Next() % 2 == 0 ? a : (A)b; // OK
C# 9.0

Description de la fonctionnalité

Avant C# 9.0, il fallait qu’une des 3 conditions décrites précédemment soient satisfaites pour que l’expression conditionnelle soit syntaxiquement correcte. A partir de C# 9.0, une autre condition a été rajoutée: il faut que des conversions implicites existent pour transformer le type de l’expression 1 et le type de l’expression 2 dans un type commun.

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

public class A {}
public class B {}

public class C 
{
  public static implicit operator C(A a) => new C();
  public static implicit operator C(B b) => new C();
}

Des conversions implicites permettent de convertir A en C et B en C.
On peut ainsi construire une expression conditionnelle de cette façon:

var a = new A();
var b = new B();
C result = rnd.Next() % 2 == 0 ? a : b;

Cette nouvelle façon d’exécuter les expressions conditionnelles est appelée “conversion des expressions conditionnelles”.

Hiérarchie des conversions

Dans le cas de l’existence de plusieurs conversions possibles, il existe une concurrence dans les conversions et une hiérarchie est appliquée pour qu’une conversion soit appliquée plutôt qu’une autre.

Cette hiérarchie a été complexifiée par l’ajout de la conversion des expressions conditionnelles apparue en C# 9.0.
Si on considère une expression conditionnelle de cette façon:

var result = <condition> ? <expression 1> : <expression 2>;

Des conversions sont appliquées aux expressions pour obtenir le type du résultat. L’application de ces conversions se fait suivant un ordre de priorité. Ainsi une conversion est meilleure qu’une autre si:

  1. Une conversion implicite permet d’obtenir exactement le type attendu. Pour connaître toutes les conditions permettant d’indiquer qu’une expression permet d’obtenir exactement le type attendu voir Exactly matching Expression.
  2. Une expression non conditionnelle est considérée comme meilleure qu’une condition conditionnelle. En effet il est possible d’imbriquer des conditions conditionnelles:
    var result = <condition 1> ? <condition 2> ? <expression 1a> : <expression 1b> : <expression 2>;
    

    Si l’expression 2 n’est pas une expression conditionnelle, elle est considérée meilleure que: <condition 2> ? <expression 1a> : <expression 1b>

  3. Au delà de la hiérarchie des conversions d’expressions, il y a une hiérarchie dans la conversion de type. Ainsi si les expressions 1 et 2 sont toutes les deux des expressions conditionnelles ou si toutes les deux elles ne sont pas des expressions conditionnelles, une hiérarchie suivant le type cible est appliquée. Le critère le plus important est l’existence d’une conversion implicite d’un type à l’autre.
    Pour connaître la liste exhaustive des critères utilisés pour appliquer la hiérarchie des types cibles, voir Better conversion target.

Cast

Pour éviter les breaking changes, quand un cast est appliqué à une expression conditionnelle, par exemple:

T result = (T)(<condition> ? <expression 1> : <expression 2>);

Toutes les autres formes de conversion possibles mise à part la conversion de l’expression conditionnelle sont testées pour arriver au type T. La conversion de l’expression conditionnelle est utilisée en dernier ressort quand toutes les autres formes de conversions n’ont pas permis d’aboutir au type T.

Attributs sur les fonctions locales

Une évolution a été apportée pour permettre d’utiliser des attributs sur les fonctions locales (la fonctionnalité de fonction locale a été rajoutée en C# 7).

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

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

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

Si on considère l’attribut suivant:

[AttributeUsage(AttributeTargets.Method)]
public class CustomAttribute : Attribute
{}

Cet attribut est limité aux méthodes à cause de AttributeTargets.Method.
On peut placer cet attribut sur la fonction locale isPositive():

[Custom]
bool isPositive(int number)
{
  // ...
}

Paramètres ignorés dans les fonctions lambda et fonctions anonymes

Cette fonctionnalité permet de définir des expressions lambda et des fonctions anonymes en permettant d’ignorer des paramètres lorsqu’ils ne sont pas utilisés.

Pour ignorer un paramètre, il faut utiliser le caractère _ (underscore):

  • Pour les fonctions lambda, 2 syntaxes sont possibles en ignorant le nom de certains paramètres ou en ignorant le type et le nom de tous les paramètres:
    • Ignorer le nom de paramètres:
      Func<int, int, int, int> lambda = (int arg1, int _, int _) => { ... };
      
    • Ignorer le type et le nom de tous les paramètres:
      Func<int, int, int, int> lambda = (_, _, _) => { ... };
      

      Le type et le nom doivent être ignorés pour tous les paramètres, il n’est pas possible d’ignorer le type et le nom seulement pour certains paramètres:

      Func<int, int, int, int> lambda = (int arg1, _, _) => { ... }; // ⚠ ERREUR ⚠
      
  • Pour les fonctions anonymes, seulement les noms de paramètres peuvent être ignorés:
    delegate(int arg1, int _, int _) { ... }
    

    Il n’est pas possible d’ignorer le type et le nom de paramètre:

    delegate(int _, int _, int _) { ... } // OK
    delegate(_, _, _) { ... } // ⚠ ERREUR ⚠
    

Fonction lambda

Cette fonctionnalité s’utilise si une signature est imposée pour une fonction lambda ou une fonction anonyme mais que le corps de la fonction n’utilise pas tous les paramètres. Par exemple, si on considère la fonction suivante:

public int ExecuteLambda(int arg1, int arg2, int arg3, Func<int, int, int, int> lambda)
{
  return lambda(arg1, arg2, arg3);
}

On peut exécuter cette fonction en utilisant des fonctions lambda de cette façon:

Func<int, int, int, int> addIntegers = (int arg1, int arg2, int arg3) => {
  return arg1 + arg2 + arg3;
};

int result = ExecuteLambda(2, 4, 3, addIntegers);

Ou plus directement:

int result = ExecuteLambda(2, 4, 3, 
  (int arg1, int arg2, int arg3) => arg1 + arg2 + arg3);

Dans le cas où on veut utiliser ExecuteLambda() mais qu’on souhaite ignorer des arguments, la signature du paramètre lambda est imposée:

Func<int, int, int, int> identity = (int arg1, int _, int _) => {
  return arg1;
};

int result = ExecuteLambda(2, 0, 0, identity);

Avec une syntaxe plus directe:

int result = ExecuteLambda(2, 0, 0, (int arg1, int _, int _) => arg1);

On ne peut pas ignorer le type de certains arguments:

int result = ExecuteLambda(2, 0, 0, (int arg1, _,  _) => arg1); // ⚠ ERREUR ⚠

On peut ignorer le type et le nom de tous les arguments:

int result = ExecuteLambda(0, 0, 0, (_, _, _) => 0); // OK

Si _ est utilisé pour un seul caractère, il n’est pas ignoré
Dans le cas où on utilise le caractère _ pour un seul paramètre, il n’est pas ignoré. Le nom du paramètre est _. Par exemple, si on utilise _ pour un seul caractère:

Func<int, int, int, int> addIntegers = (int arg1, int _, int arg3) => {
	return arg1 + _ + arg3; // OK le paramètre n’est pas ignoré 
};

Fonction anonyme

Comme pour les fonctions lambda, certains paramètres peuvent être ignorés si la signature d’un delegate est imposée.

Si on considère le delegate suivant:

public delegate int CustomOperation(int a, int b, int c);

Et la fonction suivante utilisant le delegate

public int ExecuteDelegate(int arg1, int arg2, int arg3, CustomOperation operation)
{
  return operation(arg1, arg2, arg3);
}

On peut exécuter cette fonction en déclarant la fonction suivante au préalable:

public int AddIntegers(int arg1, int arg2, int arg3)
{
  return arg1 + arg2 + arg3;
}

L’appel s’effectue de cette façon:

int result = ExecuteDelegate(2, 4, 3, AddIntegers);

Plus directement, on peut utiliser une fonction anonyme:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int arg2, int arg3)
{
  return arg1 + arg2 + arg3;
});

Il est possible d’ignorer des arguments avec une fonction anonyme:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int _, int _)
{
  return arg1;
});

Il n’est pas possible d’utiliser une syntaxe ignorant les types des arguments:

int result = ExecuteDelegate(2, 4, 3, delegate(_, _, _)   // ⚠ ERREUR ⚠ il faut préciser le type des arguments
{
  return 0;
});

Si on utilise _ pour un seul paramètre, il n’est pas ignoré:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int arg2, int _)
{
  return arg1 + arg2 + _; // OK le paramètre n’est pas ignoré
});

Support de la méthode d’extension GetEnumerator() pour les boucles foreach

A partir de C# 9.0, il suffit qu’une méthode d’extension GetEnumerator() existe pour un objet donné pour qu’il soit possible d’utiliser foreach sur cet objet.

Si on souhaite effectuer une énumération sur un objet EnumerableObject, la signature de la méthode GetEnumerator() doit être:

public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject)

ou

public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject)

avec CustomEnumerator comportant les membres suivants:

  • object Current { get; }: cette propriété doit renvoyer l’objet courant dans l’objet à énumérer.
  • bool MoveNext(): cette méthode permet de passer à l’élément suivant.
  • void Reset(): permet de repositionner l’objet courant sur le 1er objet à énumérer.

Avant C# 9.0, pour pouvoir utiliser foreach sur un objet, il faut que cet objet respecte au moins une des conditions suivantes:

  • Cet objet doit satisfaire l’interface System.Collections.IEnumerable:
    Par exemple, si on considère l’objet EnumerableObject, un exemple d’implémentation pourrait être:

    public class EnumerableObject : IEnumerable
    {
      public readonly List<int> internalEnumerable;
    
      public EnumerableObject(params int[] items)
      {
        this.internalEnumerable = new List<int>(items);
      }
    
      public IEnumerator GetEnumerator()
      {
        return ((IEnumerable)this.internalEnumerable).GetEnumerator();
      }
    }
    
  • Cet objet doit satisfaire l’interface System.Collections.Generic.IEnumerable<T>:
    Par exemple, une implémentation d’un objet satisfaisant cette interface pourrait être:

    using System.Collections;
    using System.Collections.Generic;
    
    // ...
    public class EnumerableObject<T> : IEnumerable<T>
    {
      private readonly List<T> internalEnumerable;
    
      public EnumerableObject(params T[] items)
      {
        this.internalEnumerable = new List<T>(items);
      }
    
      public IEnumerator GetEnumerator()
      {
        return this.internalEnumerable.GetEnumerator();
      }
    
      IEnumerator<T> IEnumerable<T>.GetEnumerator()
      {
        return this.internalEnumerable.GetEnumerator();
      }
    }
    
  • L’objet doit comporter au moins une fonction publique dont la signature est:
    • IEnumerable GetEnumerator():
      Par exemple, une implémentation pourrait être:

      public class EnumerableObject 
      {
        public readonly List<int> internalEnumerable;
      
        public EnumerableObject(params int[] items)
        {
          this.internalEnumerable = new List<int>(items);
        }
      
        public IEnumerator GetEnumerator()
        {
          return this.internalEnumerable.GetEnumerator();
        }
      }
      
    • IEnumerable<T> GetEnumerator():
      Par exemple:

      public class EnumerableObject<T>
      {
        private readonly List<T> internalEnumerable;
      
        public EnumerableObject(params T[] items)
        {
          this.internalEnumerable = new List<T>(items);
        }
      
        public IEnumerator<T> GetEnumerator()
        {
          return this.internalEnumerable.GetEnumerator();
        }
      }
      
    • CustomEnumerator GetEnumerator():
      Par exemple, une implémentation pourrait être:

      public class EnumerableObject
      {
        public readonly List<int> internalEnumerable;
        public readonly CustomEnumerator enumerator;
      
        public EnumerableObject(params int[] items)
        {
          this.internalEnumerable = new List<int>(items);
          this.enumerator = new CustomEnumerator(this.internalEnumerable.GetEnumerator());
        }
      
        public CustomEnumerator GetEnumerator()
        {
          return this.enumerator;
        }
      }
      

      CustomEnumerator doit comporter les membres Current, MoveNext() et Reset(), par exemple:

      public class CustomEnumerator
      {
        private readonly IEnumerator enumerator;
      
        public CustomEnumerator(IEnumerator enumerator)
        {
          this.enumerator = enumerator;
        }
      
        public object Current => this.enumerator.Current;
      
        public bool MoveNext()
        {
          return this.enumerator.MoveNext();
        }
            
        public void Reset()
        {
          this.enumerator.Reset();
        }
      }
      
C# 9.0

Depuis C# 9.0, pour énumérer un objet avec foreach, il suffit qu’il existe au moins une méthode d’extension avec la signature suivante:

  • public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject):
    Par exemple:

    public static class EnumeratorHelper
    {
      public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject)
      {
          return enumerableObject.internalEnumerable.GetEnumerator();
      }
    }
    
  • public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject):
    Par exemple:

    public static class EnumeratorHelper
    {
      public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject)
      {
          return new CustomEnumerator(enumerableObject.internalEnumerable);
      }
    }
    

L’implémentation de CustomEnumerator est similaire à celle plus haut.

Autres fonctionnalités

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

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

Attribut SkipLocalsInit (C# 9.0)

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

Cette fonctionnalité est une optimisation dont le but est d’éviter au compilateur d’émettre une instruction MSIL pour initialiser des variables locales.

Par défaut, une instruction MSIL permet d’initialiser à zéro les variables locales et les données allouées avec stackalloc lors de leur déclaration. Pour certains algorithmes et dans le but d’optimiser l’exécution du code, il est désormais possible de supprimer l’instruction permettant cette initialisation à zéro.

.local init

Quand des variables locales sont déclarées dans une fonction, les instructions MSIL .locals init sont émises:

  • .locals: permet de déclarer une variable locale accessible avec un nom symbolique.
  • init permet d’initialiser systématiquement ces variables à zéro.

Ces instructions sont suivis d’un tableau déclarant ces variables avec leur type et un leur nom symbolique:

.locals init (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N) 

Lorsque init n’est pas émise:

.locals (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N)

Par exemple si considère le code suivant:

public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

Le code MSIL correspondant est (en mode release):

.method public hidebysig instance void  Example() cil managed
{
  // Code size       15 (0xf)
  .maxstack  2
  // Instruction permettant l'initialisation 
  //  à zéro des variables locales
  .locals init (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

Dans ce code, on peut voir 2 variables V_0 et V_1 alors que 3 variables a, b et c sont déclarées dans le code C#. Il s’agit d’une optimisation du compilateur dans le cadre du mode release.
Le nom des variables est 0 et 1, les instructions y font référence par la suite comme par exemple:

  • stloc.0 (pour STore in LOCal 0): pour affecter le 1er niveau de la pile à la variable 0.
  • stloc.1 (pour STore in LOCal 1): pour affecter le 1er niveau de la pile à la variable 1.
  • ldloc.0 (pour LoaD LOCal 0): pour ajouter dans le pile la valeur de la variable 0.
  • ldloc.1 (pour LoaD LOCal 1): pour ajouter dans le pile la valeur de la variable 1.

ldc.i4.0 (pour LoaD Constant 0 in 4-byte Integer) ne fait pas référence à la variable 0, cette instruction ajoute dans la pile la constante 0 sous forme d’un entier 32 bits (sur 4 octets).

Conséquences de l’utilisation de SkipLocalsInitAttribute

Code MSIL

A partir de C# 9.0, on peut utliser l’attribut SkipLocalsInitAttribute au dessus d’une méthode, d’une classe, d’une structure, d’une interface, d’un constructeur ou d’une propriété pour indiquer que les variables locales se trouvant dans ces objets ne seront pas initialisées à zéro. Ainsi si on place l’attribut:

  • Sur une méthode, toutes les variables locales de la méthode ne seront pas initialisées à zéro.
  • Sur une classe, toutes les variables locales se trouvant dans les méthodes de la classe ne seront pas initialisées à zéro.
  • Sur une propriété, toutes les variables locales se trouvant dans l’implémentation du get ou set de la propriété ne seront pas initialisées à zéro. On peut s’en rendre compte si on implémente la propriété en implémentant le get et set, par exemple:
    public class Example
    {
      public int PropExample
      {
        get
        {
         // Implémentation getter
        }
        set
        {
          // Implémentation setter
        }
      }
    }
    
  • etc…

Par exemple, si on utilise l’attribut SkipLocalsInitAttribute sur la méthode de l’exemple plus haut:

[SkipLocalsInit]
public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

On obtient le code MSIL:

.method public hidebysig instance void  Example() cil managed
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.SkipLocalsInitAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       15 (0xf)
  .maxstack  2
  // L'instruction init est absente
  .locals (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

On peut voir que init a été supprimé et que les variables locales sont déclarées directement avec:

.locals (int32 V_0, int32 V_1)

Conséquence dans l’exécution

L’utilisation de l’attribut SkipLocalsInitAttribute ne doit se faire que dans des conditions particulières où le gain en performance est significatif. La conséquence la plus importante d’utiliser cet attribut est que l’initialisation à zéro n’est plus vérifiée ce qui peut entraîner des comportements inattendus si on ne prend pas soin de n’utiliser que des variables initialisées.

La documentation indique que le gain en performance est particulièrement significatif avec stackalloc. Pour rappel stackalloc permet d’allouer un tableau sur la pile et de retourner un pointeur vers ce tableau. A partir de C# 7.2, stackalloc permet de renvoyer un objet de type Span<T> ou ReadOnlySpan<T> qui sera un point d’accès performant vers le tableau sans effectuer d’allocations et sans utiliser de pointeur. L’absence de pointeur permet de se passer d’exécuter le code dans un contexte unsafe.
Pour davantage de détails sur stackalloc, voir stackalloc en C# 7.2.

Si on considère les implémentations suivantes:

public void Example
{
  Span<int> s = stackalloc int[50];
  foreach (int item in s)
    Console.WriteLine(item);
}

A l’exécution, pas de surprise, on obtient une suite de 0:

0
0
0
0
0
...

Si on place [SkipLocalsInit] au dessus de la méthode, l’exécution devient:

217
-1986532568
217
0
0
...

Les éléments du tableau n’étant plus initialisés à zéro, il peut contenir d’autres valeurs.

Initialiser des variables locales permet de garantir que l’exécution du code est vérifiable et qu’elle ne va pas effectuer des opérations dangereuses. A l’opposé des opérations de manipulation de pointeurs conduit à produire du code non vérifiable puisque le compilateur ne peut pas garantir que le code généré ne va pas effectuer d’opérations non autorisées pouvant, par exemple, corrompre la mémoire. Lorsqu’une variable n’est pas initialisée, le compilateur génère une erreur pour forcer son initialisation. Le fait d’utiliser [SkipLocalsInit] peut produit du code dont les variables peuvent contenir des données arbitraires en particulier pour des variables allouées sur la pile.

Comparaison des performances

Comme on l’a déjà indiqué, l’utilisation de l’attribut [SkipLocalsInit] est réservée aux cas où il y a un gain en performance. Ainsi l’absence d’initialisation peut présenter un intérêt si l’algorithme effectue de nombreuses déclarations de variables locales et si ces déclarations sont significatives par rapport aux restes des instructions.

Par exemple, on va considérer 2 algorithmes:

  • Le 1er algorithme effectue d’abord l’allocation d’un bloc mémoire sur la pile en utilisant stackalloc. Ensuite un traitement est effectué sur des éléments du bloc mémoire en utilisant une boucle for. L’intérêt de cet algorithme est que l’allocation n’est pas significative par rapport à la boucle.
    Le code de cet algorithme est:

    public static int Use_StackAlloc_Outside_For_Loop()
    {
      Span<int> s = stackalloc int[2048];
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        result += s[i];
      }
    
      return result; 
    }
    
  • Le 2e algorithme effectue les allocations de blocs mémoire à l’intérieur d’une boucle for. Le but de cet algorithme est de trouver un exemple pour lequel toutes les allocations représentent un coût en performance plus important.
    Le code est:

    public static int Use_StackAlloc_In_For_Loop()
    {
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        Span<int> s = stackalloc int[2048];
        result += s[0];
      }
    
      return result; 
    }
    

On exécute ces 2 algorithmes avec et sans l’attribut [SkipLocalsInit]:

[Benchmark]
public int Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
public int Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

Les résultats sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1916 (1909/November2019Update/19H2)
Intel Xeon CPU E5-2690 v3 2.6GHz, 2 CPU, 4 Logical and 4 physical cores
.NET SDK=5.0.302
  [Host]     : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                                 Method |     Mean |     Error |    StdDev |
|--------------------------------------------------------|---------:|----------:|----------:|
| Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit | 1.919 us | 0.0347 us | 0.0325 us |
|    Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit | 1.926 us | 0.0279 us | 0.0261 us |
|      Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit | 3.994 us | 0.0770 us | 0.0683 us |
|         Use_StackAlloc_In_For_Loop_With_SkipLocalsInit | 3.963 us | 0.0768 us | 0.0754 us |

On peut remarquer que les 2 exemples avec l’allocation à l’extérieur de la boucle for (Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()) ont un temps d’exécution très similaire. L’utilisation de [SkipLocalsInit] n’apporte rien en temps d’exécution, les résultats montrent même que le temps est plus long avec l’attribut. Ces résultats peuvent s’expliquer de la façon suivante:

  • Etant donné que l’allocation ne se fait qu’une seule fois, elle est peu significative par rapport à l’exécution de la boucle for. L’absence d’initialisation à zéro est une opération si peu couteuse par rapport au reste de l’algorithme qu’on n’en voit pas les conséquences sur le temps de traitement.
  • Le temps de traitement avec l’attribut est plus long. Ceci peut s’expliquer par le fait qu’avec l’attribut, l’absence d’initialisation à zéro implique que la structure contient des valeurs non nulles. La somme de ces valeurs est plus couteuses que la somme de valeur nulle dans le cas de l’absence de l’attribut d’où le temps d’exécution plus long avec l’attribut.

Dans le cas où les allocations se font dans la boucle for (Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()), elles sont beaucoup plus nombreuses et donc plus significatives par rapport au reste des instructions. On peut ainsi voir le gain de temps de calcul, l’utilisation de l’attribut permet réduire le temps de traitement par rapport à son absence. En revanche on peut remarque que le gain est très faible (<1%).

Pour aller plus loin…

Pour éviter les erreurs d’implémentation et les comportement inattendus, le compilateur indique lorsqu’une variable n’est pas initialisée, par exemple:

int a;
Console.WriteLine(a);  // ⚠ ERREUR ⚠ Use of unassigned local variable 'a'

Dans le cas d’une liste, quelque soit l’utilisation de [SkipLocalsInit], il n’y a pas de conséquences car à l’instanciation d’un objet System.Collections.List<T> il n’y a aucun objet dans la liste. Quand on ajoute un élément, la longueur de la liste est portée à 1 toutefois 4 emplacements sont créés et la capacité est 4. A l’ajout du 5e élément, la taille réelle de la liste est doublée et portée à 8 toutefois la longueur accessible est 5. Ainsi étant donné qu’il est nécessaire d’ajouter des éléments, les emplacements accessibles de la liste sont de fait, initialisées.

Dans le cas d’un tableau, l’utilisation de [SkipLocalsInit] n’a pas de conséquences: tous les emplacements du tableau sont initialisés à zéro. Si on exécute le code suivant:

[SkipLocalsInit]
public void Example()
{
  int[] array = new int[5];
  for (int i = 0; i < array.Length; i++)
    Console.WriteLine(array[i]);
}

Le résultat est:

0
0
0
0
0

Pour d’autres types d’objet, il peut y avoir un impact si on utilise [SkipLocalsInit] comme on a pu le voir précédemment avec stackalloc. Les objets Span<T> ou ReadOnlySpan<T> obtenus peuvent contenir des valeurs inattendues.

D’autres cas de figure peuvent mener les objets à contenir des valeurs inattendues avec [SkipLocalsInit] comme la manipulation de pointeur, des appels Platform/Invoke ou

Manipulation de pointeur

Si on manipule des pointeurs dans un contexte unsafe, le compilateur n’indique pas si une variable n’est pas initialisée. Par exemple si on écrit:

[SkipLocalsInit]
public unsafe void UsingPointer()
{
  int i;  // Pas d’initialisation
  int* ptr = &i; 
  Console.WriteLine(*ptr);
}

Ce code ne provoque pas d’erreur à la compilation. La valeur affichée est différente à chaque exécution. Si on supprime l’attribut [SkipLocalsInit], le résultat est toujours 0 malgré l’absence d’initialisation explicite.

Appels Platform/Invoke

Les appels Platform/Invoke permettent des appels à du code natif en passant en argument des objets ou des pointeurs. La manipulation de ces objets par le code natif échappe à la vérification du compilateur ce qui peut mener à l’utilisation d’objets dont la valeur peut être inattendue.

Par exemple si on considère le code natif suivant exposé de façon à permettre un appel Platform/Invoke (pour plus de détails sur ce type d’appel, voir Platform invoke en 5 min):

  • .cpp:
    void SetValueFromNativeCode(int* valueToSet)
    {
      *valueToSet = 5;
    }
    
  • .h:
    extern "C" __declspec(dllexport) void SetValueFromNativeCode(int* valueToSet);
    
  • Code C#:
    [SkipLocalsInit]
    public void CallNativeCode()
    {
      int a;
      SetValueFromNativeCode(out a);  // La valeur est affectée dans le code natif
      Console.WriteLine(a); // Le résultat est 5
    }
    
    [DllImport("CalledNativeDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
    public extern static void SetValueFromNativeCode(out int valueToSet);
    

Si on modifie la fonction SetValueFromNativeCode() pour ne pas affecter de valeur:

void SetValueFromNativeCode(int* valueToSet)
{
  //*valueToSet = 5;
}

Sachant que a n’est pas initialisé dans le code C# aussi bien explicitement qu’implicitement à cause de l’attribut [SkipLocalsInit], sa valeur est non prévisible.

Utilisation de structure

Si on considère la structure suivante:

public struct CustomStruct
{
	public int x;
	public int y;
}

Si on effectue des allocations sur la pile de cette structure en utilisant stackalloc, les propriétés de la structure sont à zéro même sans initialisation explicite:

public void UseCustomStruct()
{	
	Span<CustomStruct> customStructs = stackalloc CustomStruct[5]; 
	for (int i = 0; i < customStructs.Length; i++)
	{
		customStructs[i].x = 5;
		Console.WriteLine($"({customStructs[i].x};{customStructs[i].y})");
	}
} 

Le résultat est:

(5;0)
(5;0)
(5;0)
(5;0)
(5;0)

Si on rajoute [SkipLocalsInit] sur la méthode, on obtient:

(5;0)
(5;49803632)
(5;2045470872)
(5;49803844)
(5;49803824)

La propriété y n’étant pas initialisée explicitement, sa valeur est non prévisible.

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

Covariance pour le retour de fonction (C# 9.0)

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

Avant de rentrer dans le détail de la fonctionnalité “covariant return”, on va expliquer ce que signifie le terme “covariant” (i.e. covariance). Dans un 2e temps, on expliquera quelques subtilités de la fonctionnalité en vérifiant les conséquences du point de vue du code MSIL.

Variance

La variance consiste à donner la possibilité de considérer les signatures des fonctions de façon moins stricte suivant les critères de dérivation des types des arguments. Ainsi des déclarations d’affectation d’un argument de fonction et de retours de fonction peuvent être considérées syntaxiquement correcte alors que le type des objets dans la signature de la fonction n’est pas rigoureusement respectés. On considère 2 types de variance:

  • Covariance qui permet d’assigner un delegate qui retourne un objet dont le type est moins précis dans l’arbre de dérivation par rapport au type de la signature originale, par exemple:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Func<Car> getNewCar = () => new Car();
    Func<Vehicle> getNewVehicle = getNewCar;
    

    Implicitement, il y a une conversion de type de Func<Car> vers Func<Vehicle>. Cette conversion est possible grâce à la signature Func<T> qui autorise ce type de conversion à cause du mot-clé out:

    public delegate TResult Func<out TResult>();
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<out TId> 
    {
      TId Id { get; }
    }
    
    public class Car<TId>: IVehicle<TId>
    {
        public TId Id { get; }
    }
    
    //...
    IVehicle<string> carWithStringId = new Car<string>();
    IVehicle<object> carWithObjectId = carWithStringId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠ Only interface and delegate type parameters can be specified as variant.
    public class Vehicle<out T>  
    {
      ...
    }
    

    D’autre part, le type string dérive de object donc object est plus général que string. Le mot-clé out dans la déclaration IVehicle<out T> indique que le type T est destiné à être retourné et non à être utilisé comme argument. Comme les affectations suivantes sont compatibles alors la covariance est possible:

    string varAsString = "example";
    object varAsObject = varAsString; // Conversion implicite
    
  • Contravariance consistant à accepter des types moins précis dans l’arbre de dérivation concernant le type des arguments d’un delegate.
    La contravariance est utilisée dans le cadre du type des arguments indiqués dans un generic d’un delegate ou d’une interface:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Action<Vehicle> useVehicle = v => Console.WriteLine(v.GetType());
    Action<Car> useCar = useVehicle;
    

    Implicitement, il y a une conversion de type de Action<Vehicle> vers Action<Car>. Cette conversion est possible grâce à la signature Action<T> qui autorise ce type de conversion à cause du mot-clé in:

    public delegate void Action<in T>(T object);
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<in TId> 
    {
      void SetVehicleId(TId vehicleId);
    }
    
    public class Car<TId>: IVehicle<TId>
    {
      public void SetVehicleId(TId vehicleId)
      {
        // ... 
      }
    }
    
    //...
    IVehicle<object> vehicleWithObjectId = new Car<object>();
    IVehicle<string> vehicleWithStringId = vehicleWithObjectId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠: Only interface and delegate type parameters can be specified as variant.
    public class Car<in T>
    {
      ...
    }
    

    Comme précédemment, le type string dérive de object donc object est plus général que string. Le mot-clé in dans la déclaration IVehicle<in T> indique que le type T est destiné à être utilisé comme argument. Si on considère une méthode dont la signature est:

    void SetVehicleId(object id) {}
    

    On peut écrire:

    string id = "id";
    SetVehicleId(id); // Conversion implicite du type de l'argument
    

Covariance pour le retour de fonction

C# 9.0

Dans le cadre de C# 9.0, la covariance est étendue à la surcharge des fonctions virtuelles en permettant de retourner un type plus précis dans l’arbre de dérivation que le type original de la signature. Par exemple, si on considère les objets suivants:

public class Vehicle {}
public class Car: Vehicle {}

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() => new Car();
}

La fonction surchargée CarFactory.CreateNewVehicle() retourne le type Car qui est plus précis que le type Vehicle de la signature originale de la fonction virtuelle VehicleFactory.CreateNewVehicle(). Cette fonctionnalité s’appelle “covariant return” en référence à la covariance plus haut.

Cette fonctionnalité est aussi valable pour les propriétés en lecture seule:

public class VehicleWrapper
{
  public VehicleWrapper()
  {
    this.Vehicle = new Vehicle();
  }

  public virtual Vehicle Vehicle { get; }
} 

public class CarWrapper : VehicleWrapper
{
  public CarWrapper()
  {
    this.Vehicle = new Car();
  }

  public override Car Vehicle { get; }
}

Si la propriété comporte un setter, la signature de la surcharge doit comporter le type original exacte:

public class VehicleWrapper
{
  // ...  
  
  public virtual Vehicle Vehicle { get; set; }
} 

public class CarWrapper : VehicleWrapper
{
  // ...

  // ⚠ ERREUR ⚠: covariant return type of property can only be used if the overriding property is read-only. 
  public override Car Vehicle { get; set; } 
}

Cette limitation s’explique car l’affectation de la propriété de l’extérieur est ambigue car on ne sait pas le type attendu: Car ou Vehicle ?
L’utilisation du getter de la propriété ne pose pas de problème d’ambiguïté:

var vehicleWrapper = new VehicleWrapper();
Vehicle vehicle = vehicleWrapper.Vehicle; // Pas de cast nécessaire

var carWrapper = new CarWrapper();
Car car = carWrapper.Vehicle; // Pas de cast nécessaire

Conséquences de la covariance dans le code MSIL

On pourrait se demander si l’utilisation de la covariance dans le retour d’une fonction un cast implicit. On considère le code suivant:

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Car car = carFactory.CreateNewVehicle(); 

Les implémentations de VehicleFactory et CarFactory sont précisées plus haut.

Le MSIL correspondant est:

  • Pour VehicleFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Vehicle::.ctor()
      IL_0005:  ret
    }
    
  • Pour CarFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

On peut voir que le code MSIL correspondant aux lignes plus haut ne comporte pas de cast:

IL_0000:  newobj     instance void FunctionPointerTests.Covariant.VehicleFactory::.ctor()
IL_0005:  callvirt   instance class FunctionPointerTests.Covariant.Vehicle FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle()
IL_000a:  pop
IL_000b:  newobj     instance void FunctionPointerTests.Covariant.CarFactory::.ctor()
IL_0010:  callvirt   instance class FunctionPointerTests.Covariant.Car FunctionPointerTests.Covariant.CarFactory::CreateNewVehicle()
IL_0015:  pop
IL_0016:  ret

Le code MSIL est le reflet du code C# et il n’y a pas de cast implicite. Dans le cas de la covariance dans le retour d’une fonction, c’est directement la méthode CarFactory.CreateNewVehicle() qui est appelée.

Si on considère le même code sans utilisation de la fonctionnalité de covariance dans le retour de la fonction:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Vehicle CreateNewVehicle() => new Car();
}

// ...

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Vehicle car = carFactory.CreateNewVehicle(); 

Seul le code MSIL de CarFactory.CreateNewVehicle() diffère:

  • Sans utilisation de la covariance:
    .method public hidebysig virtual instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    
  • Si on utilise la covarianvce:
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

Les différences concernent:

  • La présence de newslot dans la signature de la fonction
  • La présence de l’attribut System.Runtime.CompilerServices.PreserveBaseOverridesAttribute

newslot

La signature de la méthode CarFactory.CreateNewVehicle() comporte newslot quand on utilise la fonctionnalité de covariance. newslot permet d’indiquer une entrée spécifique dans le tableau des fonctions virtuelles vtable.
Le tableau des fonctions virtuelles est une solution technique pour exécuter la bonne implémentation d’une fonction dans le cas de surcharge. En effet, quand une fonction est surchargée dans une classe il existe 2 versions de la fonction:

  • Une version de base de la fonction se trouvant dans la classe mère
  • Une version surchargée (cf. overriding) de la fonction se trouvant dans la classe fille

Le polymorphisme impose que si on considère une classe suivant son type le plus général à savoir celui de la classe mère, il n’est pas possible, à la compilation, de prévoir quelle implémentation concrète d’une fonction sera exécutée. L’implémentation exécutée devra être celle correspondant au type réel de la classe connu à l’exécution. Ainsi pour pointer vers la bonne implémentation et choisir cette bonne implémentation à l’exécution, une solution technique consiste à utiliser un tableau de pointeurs de fonction pour chaque type pointant vers les différentes implémentations des fonctions. A l’exécution, suivant le type réel de la classe, le runtime appelle une fonction en utilisant le bon pointeur de fonction. En C#, ce tableau s’appelle virtual method table ou vtable (cf. wikipedia.org/wiki/Virtual_method_table).

Dans le cas de la fonctionnalité covariance pour le retour d’une fonction, la présence du mot clé newslot indique que la fonction fait l’objet d’une entrée distincte dans la vtable. Cela signifie qu’il y a bien une distinction entre l’implémentation de la fonction:

  • Dans le cas de la covariance pour le retour d’une fonction: la fonction dans la classe fille est considérée comme distincte de la fonction dans la classe mère. Même si le code C# comporte les mot clés virtual pour la méthode de la classe mère et override pour la méthode de la classe fille, la présence du mot clé newslot dans le code MSIL indique qu’il s’agit de méthodes différentes qui n’ont pas de lien.
  • En l’absence de covariance: il n’y a pas d’utilisation du mot clé newslot. La fonction de la classe fille est une surcharge de la fonction de la classe mère. Il n’y a pas forcément une entrée distincte dans la vtable.

PreserveBaseOverridesAttribute

L’attribut PreserveBaseOverridesAttribute a été introduit avec la framework .NET 5. Il permet de garantir qu’un appel à la fonction utilise l’implémentation de la classe fille même si la signature utilisée n’est celle de la classe fille.

Par exemple si on utilise l’implémentation suivante:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() 
  {
    Console.WriteLine("Vehicle");
    return new Vehicle();
  }
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() 
  {
    Console.WriteLine("Car");
    return new Car();
  }
}

A l’exécution des lignes suivantes:

var carFactory = new CarFactory();
Car car1 = carFactory.CreateNewVehicle(); // Même signature que la classe fille (CarFactory)
Vehicle car2 = carFactory.CreateNewVehicle(); // Même signature que la classe mère (VehicleFactory)

On obtient:

Car
Car

Cela signifie que dans les 2 cas quelque soit la signature utilisée c’est l’implémentation de la classe file qui est exécutée.

Pour conclure…

L’héritage est un concept puissant, un de ces intérêts est de pouvoir bénéficier du polymorphisme. La conséquence est qu’en cas d’héritage on peut choisir de surcharger des méthodes ou d’utiliser l’implémentation plus générale de la classe mère. Ce mécanisme permet d’éviter la duplication de code et de rendre plus abstrait des comportements. Un des plus gros inconvénients de l’héritage est que les méthodes surchargées doivent partager la même signature que les méthodes virtuelles. Ainsi même si une classe spécialise un comportement, les méthodes surchargées qu’elle comporte devront avoir la même signature générale que les méthodes de la classe de base. Ce gros inconvénient force à devoir effectuer des casts pour pouvoir utiliser des types plus spécialisés.

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

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 
} 

public class CarFactory: VehicleFactory
{
  public override Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 	

  public Car CreateNewCarFrom(Car template) { ... } 	
} 

VehicleFactory comporte une fonction CreateNewVehicleFrom() dont le but est de créer une nouvelle instance de Vehicle. On peut surcharger cette fonction dans CarFactory de façon à créer une nouvelle instance de Car. CarFactory.CreateNewVehicleFrom() utilise la même signature que VehicleFactory.CreateNewVehicleFrom() or:

  • Le type de retour est imposé: on peut vouloir renvoyer une instance de Car plutôt qu’une instance de Vehicle.
  • Le type et le nombre des arguments sont imposés: on peut souhaiter utiliser un type particulier ou un nombre particulier d’arguments différents de ceux de la fonction de la classe de base.

Ainsi on peut être amené à spécialiser la signature d’une méthode surchargée même si le comportement est le même que la classe de base: dans notre cas, le comportement consiste à créer un nouveau véhicule.

Une solution rapide consiste à effectuer des casts pour utiliser un objet Car à partir d’un argument de type Vehicle. D’autres solutions peuvent être d’utiliser des patterns plus complexes comme Visiteur (voir Eviter d’effectuer des “casts” avec Bridge et Visiteur).

La fonctionnalité “covariance return” permet d’apporter une nouvelle solution à ce problème même si elle ne concerne que le retour de fonction.

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

Native ints (C# 9.0)

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

Cette fonctionnalité consiste à permettre d’utiliser les types “native int” et “native unsigned int” dans du code C#. Avant cette fonctionnalité, ces types n’existaient que dans le code MSIL, ils étaient générés quand on utilisait les types System.IntPtr et System.UIntPtr.
L’inconvénient est que les types IntPtr et UIntPtr ne sont pas trop très flexibles et ne permettent pas d’effectuer des opérations arithmétiques.

En préambule, on va expliquer l’intérêt des types IntPtr et UIntPtr; ensuite on indiquera l’intérêt des nouveaux types nint et nuint et la différence avec IntPtr et UIntPtr.

IntPtr et UIntPtr

Historiquement avant C# 9, IntPtr était le type privilégié pour contenir une adresse mémoire sans être dans un contexte unsafe. Dans cette partie, on va détailler l’utilisation de ce type.

Adressage de la mémoire

La mémoire est composée de blocs d’octets (i.e. bytes). Chaque octet est lui-même composé de 8 bits. Ensuite chaque octet est identifié par une adresse unique qui correspond au décalage par rapport au début de la mémoire. Le CPU identifie le bloc avec une adresse dont la taille est fixe, cette taille est appelée mot (i.e. word). Actuellement le plus souvent, la taille des mots est:

  • 4 octets pour les processeurs ou systèmes 32 bits.
  • 8 octets pour les processeurs ou systèmes 64 bits.

Pour un système 32 bits, en théorie il est possible d’adresser au maximum 232-1 = 4294967295 blocs (car la 1ère adresse commence à 0). Pour un système 64 bits, on peut adresser 264-1 blocs. 232-1 correspond à un peu près à 4 x 230 octets = 4 GiB (i.e. gibibyte) = 4 x 1073741824 octets.

Dans la pratique, dans un système Windows 32 bits, un processus ne peut pas adresser plus de 3 GiB. Dans un système Windows 64 bits, un processus 32 bits ne peut pas adresser plus de 4GiB.

Dans un système d’exploitation, il faut distinguer les adresses physiques de la mémoire utilisées par le CPU et les adresses virtuelles utilisées par les processus. Le lien entre les adresses physiques et les adresses virtuelles est effectué par le système d’exploitation.

Utilisation de IntPtr et UIntPtr

Comme on l’a vu précédemment, l’adresse d’un bloc mémoire est identifiée par un mot de 4 ou 8 octets suivant l’architecture du système sur lequel est exécuté un processus. Dans le cadre de Windows, il est possible d’exécuter des processus dont l’architecture d’exécution ne correspond pas forcément à l’architecture du système:

Architecture du système Architecture d’exécution du processus
32 bits 64 bits
32 bits OK Impossible
64 bits Possible avec WoW64(*) OK

*: Windows 32-bit on Windows 64-bit.

Ainsi au delà de l’architecture du système, suivant l’architecture d’exécution d’un processus l’adressage de la mémoire pourrait se faire avec des mots dont la taille est différente. Le type System.IntPtr permet de proposer une solution dans le code pour stocker une adresse mémoire en permettant de s’adapter à l’architecture d’exécution du processus:

  • Dans un processus 32 bits sizeof(IntPtr) est 4
  • Dans un processus 64 bits sizeof(IntPtr) est 8

Dans la pratique, System.IntPtr est un entier dont la taille est la même qu’un pointeur pour une architecture d’exécution donnée. L’intérêt de ce type est de pouvoir stocker des adresses mémoire comme des pointeurs sans forcément être dans un contexte unsafe et sans se préoccuper de la taille du pointeur qui peut varier suivant l’architecture d’exécution.

Le nom du type IntPtr peut prêter à confusion à cause du terme Ptr pour “pointer”. Il laisse penser qu’une variable de type IntPtr est un pointeur. Ce n’est pas le cas, un IntPtr correspond seulement à un entier dans lequel peut être stocké une adresse. Il n’y a pas de garantie ni de vérification que l’adresse correspond effectivement à un objet en mémoire ou à un emplacement utilisable par le processus. Ce type d’objet est similaire au type void* en C++. L’intérêt d’utiliser IntPtr est d’avoir un entier dont la taille varie suivant l’architecture d’exécution du processus.

Le plus souvent IntPtr sert dans le cadre d’appels à du code natif pour stocker des pointeurs non typés.

Par exemple si on considère une fonction native dont la signature est:

void* NativeFunctionExample(void* ptr);

Cette fonction utilise et retourne des pointeurs non typés. On peut effectuer un appel à cette fonction à partir de code C# en utilisant Platform/Invoke avec IntPtr:

[DllImport(...)]
public extern static IntPtr NativeFunctionExample(IntPtr ptr);

Ainsi les pointeurs pourront être stockés dans des variables de type IntPtr, par exemple:

int valueExample = 7;
int* valuePtr = &valueExample;
IntPtr valueIntPtr = new IntPtr(valuePtr);

Console.WriteLine(valueIntPtr.ToString("X")); // Afficher l'adresse du pointeur

IntPtr result = NativeFunctionExample(valueIntPtr);

Autre exemple:

int[] arrayValues = { 1, 2, 3, 4 };
unsafe
{
  // fixed Permet d'extraire le pointeur et empêche au 
  // Garbage Collector de déplacer l'objet dans un scope donné
  fixed (int* arrayPtr = arrayValues)
  {
    IntPtr arrayIntPtr = new IntPtr(arrayPtr);
    Console.WriteLine(arrayIntPtr.ToString("X")); // Affichage du pointeur
  }
}

Intervalle de valeur

Sachant que la taille de IntPtr s’adapte en fonction de l’architecture d’exécution du processus, sur un processus 32 bits, IntPtr est un entier sur 4 octets Int32. Ainsi l’intervalle de valeur de IntPtr est celui de System.Int32 c’est-à-dire:

  • Int32.MinValue = -2147483648
  • Int32.MaxValue = 2147483647

Ainsi une exception System.OverflowException survient si on initialise un objet IntPtr avec une valeur supérieure à Int32.MaxValue dans un processus 32 bits:

IntPtr value1 = new IntPtr((long)Int32.MaxValue); // OK 
IntPtr value2 = new IntPtr((long)Int32.MaxValue + 1L); // ⚠ ERREUR ⚠

On peut se poser la question de l’intérêt d’utiliser des valeurs négatives pour représenter des adresses mémoires. De ce point de vue System.UIntPtr serait plus adapté.

Code MSIL

Les objets IntPtr et UIntPtr sont convertis respectivement en native int et native uint dans le code MSIL. Les types MSIL native int et native uint ne sont pas directement accessibles dans le code C#. L’arithmétique mathématique et les conversions applicables aux objets Int32 sont aussi applicables aux native ints.

Avant C# 9, seuls IntPtr et UIntPtr permettent de générer des objets native int et native uint. Bien que les opérations d’arithmétiques classiques peuvent s’appliquer aux types MSIL native int et native uint, il n’est bas possible d’effectuer ces opérations avec du code C#. IntPtr n’autorise que les additions ou soustraction avec:

Par exemple quelques opérations qui sont possibles si on manipule IntPtr:

IntPtr intPtrValue1 = new IntPtr(5);
// Utilisation de IntPtr.Add()
IntPtr result = IntPtr.Add(4); // OK

// Addition impossible
IntPtr offset = new IntPtr(4);
IntPtr addResult = intPtrValue1 + offset; // ⚠ ERREUR ⚠

// Cast possible
int castValue = (int)intPtrValue1; // OK

// Boxing possible
object boxedValue = intPtrValue1; // OK

D’un point du code MSIL, on peut voir que la type MSIL utilisé est native int Si on compile le code suivant:

IntPtr initialValue = new IntPtr(5);
IntPtr withOffset = IntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL (compile en mode Release) est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
  IL_0006:  ldc.i4.4
  // Le type MSIL utilisé est native int
  IL_0007:  call       native int [System.Runtime]System.IntPtr::Add(native int,int32)
  // Le boxing est effectué implicitement pour utiliser object.ToString()
  IL_000c:  box        [System.Runtime]System.IntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

UIntPtr

System.UIntPtr est l’équivalent non signé de System.IntPtr. De la même façon il s’agit d’un type d’entier dont l’intervalle de valeurs varie suivant l’architecture d’exécution du processus:

UIntPtr IntPtr
Processus 32 bits Minimum 0 -232/2 = -2147483648
Maximum 232-1 = 4294967296 232/2-1 = 2147483647
Processus 64 bits Minimum 0 -264/2 = -9,22 x 1018
Maximum 264-1 = 18,45 x 1018 264/2-1 = 9,22 x 1018

Etant donné que les adresses mémoires sont forcément positives, on pourrait se demander pourquoi ne pas utiliser UIntPtr plutôt que IntPtr pour stocker des adresses mémoire. En effet dans un processus 64 bits, l’intervalle de valeurs de IntPtr dépasse largement le nombre d’adresses possibles pour un processus. En revanche pour un processus 32 bits et si on ne considère que des valeurs supérieures à 0, l’intervalle de valeur de IntPtr (de 0 à 232/2-1) ne permet pas de traiter toutes les adresses mémoire possibles. En effet, un processus 32 bits peut adresser au maximum:

  • 2 GB sans ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly (voir Plateforme cible en .NET).
  • 3 GB sur un système 32 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.
  • 4 GB sur un système 64 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.

Par exemple si on exécute le code suivant dans un processus 32 bits, il se produit une exception System.OverflowException:

// 0x7FFFFFFF est la valeur hexadecimale de Int32.MaxValue
IntPtr val1 = new IntPtr(0x7FFFFFFF); // OK
// 0x80000000 est la valeur hexadecimale de Int32.MaxValue + 1
IntPtr val2 = new IntPtr(0x80000000); // Exception dans un processus 32 bits.

Dans un processus 32 bits, l’intervalle de valeur de UIntPtr comprend Int32.MaxValue + 1 donc le code précédent ne produit pas d’erreur:

UIntPtr val3 = new UIntPtr(0x80000000); // OK dans un processus 32 bits.

L’autre différence significative entre IntPtr et UIntPtr est que UIntPtr n’est pas conforme au CLS (i.e. Common Language Specification) (voir CLS-compliant). Cette caractéristique de non-conformité est partagée par tous les entiers non signés à part byte.

D’un point du vue du code MSIL, si on reprend le code précédent dans le cas de UIntPtr:

UIntPtr initialValue = new UIntPtr(5);
UIntPtr withOffset = UIntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.UIntPtr::.ctor(uint32)
  IL_0006:  ldc.i4.4
  // Le type MSIL correspondant à UIntrPtr est native uint
  IL_0007:  call       native uint [System.Runtime]System.UIntPtr::Add(native uint,
                                                                       int32)
  IL_000c:  box        [System.Runtime]System.UIntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

nint et nuint

Comme on l’a évoqué précédemment IntPtr et UIntPtr sont limités pour effectuer des opérations arithmétiques ou des comparaisons. Les nouveaux types nint et nuint disponibles à partir de C# 9.0 proposent les mêmes fonctionnalités que IntPtr et UIntPtr mais ils permettent d’effectuer des opérations arithmétiques et des comparaisons. Après compilation, les types MSIL utilisés sont les mêmes que pour IntPtr et UIntPtr:

  • nint permet de générer le type native int (comme pour IntPtr) pour stocker un entier sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits et
  • nuint permet de générer le type native uint (comme pour UIntPtr) pour stocker un entier non signé sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits.

Dans le code C#, les types équivalents sont interchangeables, par exemple si on écrit le code suivant:

IntPtr initialValue = new IntPtr(5);
nint nintValue = initialValue;
IntPtr otherValue = nintValue;

Console.WriteLine(otherValue); 

Le code MSIL généré n’effectue pas de conversion entre IntPtr et nint, c’est le même type MSIL qui est utilisé. Pour cet exemple, le code MSIL généré en mode release n’effectue pas d’affectations mis à part l’initialisation car elles sont inutiles:

.maxstack  8
IL_0000:  ldc.i4.5
IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
IL_0006:  box        [System.Runtime]System.IntPtr
IL_000b:  call       void [System.Console]System.Console::WriteLine(object)
IL_0010:  ret

Opérations arithmétiques

nint et nuint permettent d’effectuer des opérations arithmétiques en plus de l’addition et de la soustraction:

nint val1 = 4;
nint val2 = 5;

nint addResult = val1 + val2;
nint subResult = val1 - val2;
nint divResult = val1 / val2;
nint mulResult = val1 * val2; 

Des conversions implicites peuvent être effectuées:

nint result = val1 + 5;
float floatResult = val1 + 5f;
long doubleResult = val1 + 5L;

Comparaisons

nint et nuint autorisent les comparaisons:

Console.WriteLine(val1 > val2);   // False
Console.WriteLine(val1 >= val2);  // False
Console.WriteLine(val1 == val2);  // False
Console.WriteLine(val1 == val3);  // True
Console.WriteLine(val1.Equals(val3));   // True

typeof() et GetType()

typeof() et GetType() retournent IntPtr au lieu de nint:

Console.WriteLine(typeof(result)); // System.IntPtr
Console.WriteLine(val1.GetType());  // System.IntPtr

De même pour UIntPtr et nuint, typeof() et GetType() retournent UIntPtr au lieu de nuint:

nuint nuintValue = 5;
Console.WriteLine(typeof(nuintValue)); // System.UIntPtr
Console.WriteLine(nuintValue.GetType());  // System.UIntPtr

Utilisation de nint et nuint pour des index de tableau

Les types nint et nuint peuvent être utilisés pour les index d’un tableau:

int[] array = new int[] { 2, 3, 4, 5 };
nint index = 2;
nuint unsignedIndex = 3;
Console.WriteLine(table[index]);   // OK le résultat est 4
Console.WriteLine(table[unsignedIndex]);  // OK le résultat est 5

Il n’est pas possible d’utiliser nint ou nuint en tant qu’index dans le cadre de List<>:

List<int> list = new List<int>{ 2, 3, 4, 5 };
Console.WriteLine(list[index]);   // ⚠ ERREUR ⚠
Console.WriteLine(list[unsignedIndex]);  // ⚠ ERREUR ⚠

NativeIntegerAttribute

Quelque soit le type d’objet utilisé dans le code C#, le même type d’objet est utilisé dans le code MSIL:

  • native int dans le cas où on utilise IntPtr ou nint
  • native uint dans le cas où on utilise UIntPtr ou nuint

La différence est que le compilateur ajoute l’attribut System.Runtime.CompilerServices.NativeIntegerAttribute sur les objets utilisant nint ou nuint. Si ces objets utilisent IntPtr ou UIntPtr, cet attribut n’est pas ajouté. NativeIntegerAttribute est une classe utilisée seulement par le compilateur, elle ne peut être utilisée dans du code C#.

Par exemple si considère l’objet suivant:

public class NintExample
{
  public IntPtr A;
  public UIntPtr B;
  public nint C;
  public nuint D; 
}

Le code MSIL correspondant aux membres est:

.field public native int A

.field public native uint B

.field public native int C
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

.field public native uint D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

L’initialisation de l’attribut NativeIntegerAttribute se fait avec un tableau de booléens. Chaque booléen du tableau permet d’indiquer quelle partie du type référence utilise nint ou nuint

  • true indique que le type native int utilisé provient de nint ou nuint; ou
  • false pour indiquer que le type native int utilisé provient de IntPtr ou UIntPtr.

Dans le code MSIL, l’attribut est initialisé suivant les spécifications d’initialisation des custom attributes (i.e. attributs personnalisés) (cf. ECMA):
La syntaxe de cette initialisation est définie par le diagramme général suivant:

source: ECMA-335

Ainsi les éléments sont indiqués sous forme d’octet en hexadecimal en little-endian (i.e. octet de poids faible en premier):

  • Prolog est un entier sur 2 octets permetant d’indiquer qu’il s’agit d’un custom attribute, sa valeur est toujours 01 00.
  • FixedArg indique les paramètres fixes du constructeur sous forme de tableau. Cet élément respecte une syntaxe particulière, dans le cas d’un tableau de booléen, un entier sur 4 octets indique la taille du tableau. Chaque octet suivant contient un booléen: 01 pour true et 00 pour false.
  • NumNamed est un entier sur 2 octets indiquant le nombre de propriétés nommés. Dans notre cas, il n’y en a pas de propriété nommé donc la valeur est 00 00.
  • NamedArg indique les propriétés nommées suivant une syntaxe particulière. Dans notre cas, il n’y a pas de propriété nommé donc NamedArg ne contient pas de valeurs.

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

private (IntPtr, nint) D;

Le code MSIL correspond est:

.field private valuetype [System.Runtime]System.ValueTuple`2<native int,native int> D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 02 00 00 00 00 01 00 00 ) 

La valeur d’initialisation est sur 10 octets:

01 00 02 00 00 00 00 01 00 00

Le tuple contient 2 objets native ints de type IntPtr et nint. 2 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute, ainsi:

  • Le Prolog: 01 00
  • FixedArg: 02 00 00 00 00 01 avec:
    • 02 00 00 00 permettant d’indiquer le nombre d’arguments qui est 2.
    • 00 01 qui sont 2 booléens false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.

De la même façon, si on considère le tuple:

private (IntPtr, IntPtr, nint) E;

Le code MSIL correspondant est:

.field private valuetype [System.Runtime]System.ValueTuple`3<native int,native int,native int> E
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 03 00 00 00 00 00 01 00 00 ) 

3 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute

  • Le Prolog: 01 00
  • FixedArg: 03 00 00 00 00 00 01 avec:
    • 03 00 00 00 permettant d’indiquer le nombre d’arguments qui est 3.
    • 00 00 01 qui sont 3 booléens false false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les pointeurs de fonction (C# 9.0)

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

Le but de la fonctionnalité des pointeurs de fonction en C# est de proposer une syntaxe pour facilement manipuler ce type de pointeurs. La manipulation de pointeurs est possible en C# toutefois avant C# 9, manipuler des pointeurs de fonction n’était pas direct, cela nécessitait de passer par l’émission directe d’instructions en MSIL ce qui complique l’écriture de code et éventuellement le débugage.

Plus techniquement, l’intérêt principal de cette manipulation de pointeurs de fonction directement à travers le code est de permettre d’accéder aux instructions IL (i.e. Intermediate Language) ldftn et calli. Ces instructions servent respectivement, à pousser dans la pile un pointeur de fonction non managé et à appeler cette méthode.

Le but de cet article est de rentrer dans les détails de cette nouvelle fonctionnalité pour en comprendre le fonctionnement et l’intérêt. Dans un 1er temps, quelques explications seront apportées sur des sujets autour de la fonctionnalité comme:

  • Les instructions CIL/MSIL,
  • La manipulation de pointeurs en C# et en C++,
  • Les fonctions intrinsèques du compilateur.

Dans un 2e temps, on apportera plus de précisions sur cette nouvelle fonctionnalité.

Quelques explications en préambule

Code MSIL

Compilation

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

MSIL vs CIL

Le code MSIL (pour MicroSoft Intermediate Language) correspond à un ensemble d’instructions exécutables par le CLR .NET. Le code CIL (pour Common Intermediate Language) correspond aux mêmes jeux d’instructions toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Fonctionnement générale du code IL

Le code IL généré après la compilation est un code lisible. Ce code se trouvant dans les assemblies peut facilement être décompilé avec ILDasm (i.e. Intermediate Language Dissambler) ou par DotPeek.
ILDasm est fourni avec le SDK du framework .NET accessible, par exemple, avec des chemins du type: C:\Program Files (x86)\Microsoft SDKs\Windows\<version>\bin\NETFX 4.8 Tools\ildasm.exe.
Avec .NET Core, il est possible de l’utiliser avec le package NuGet Microsoft.NETCore.ILDasm.

L’exécution d’instructions MSIL consiste d’une façon générale à effectuer 3 types d’opérations:

  1. Pousser les opérandes des commandes ou les paramètres de fonction dans la pile
  2. Exécuter la commande ou la fonction MSIL. Cette exécution récupère les opérandes et les paramètres dans la pile pour effectuer son traitement puis éventuellement pousse le ou les résultats dans la pile.
  3. Lire et récupérer le résultat dans la pile.

D’une façon générale, on distingue 2 catégories d’objets en .NET: les objets de type valeur et les objets de type référence:

  • Les objets de type valeur sont manipulés par valeur et sont généralement stockés dans la pile. Dans certains cas, ces objets peuvent être stockés dans le tas (par exemple dans le cas du boxing, d’objets statiques etc…)
  • Les objets de type référence sont manipulés par référence et sont stockés dans le tas managé. Les références des objets de type référence sont des objets de type valeur qui sont stockés dans la pile.

Les manipulations de ces objets correspondent à les stocker dans une variable ou à les passer en argument de fonction.
Pour davantage de détails, voir Type valeur vs type référence.

Ainsi dans la pile, on peut retrouver:

  • Les variables locales d’une fonction
  • Les arguments d’une fonction

Une pile fonctionne en mode LIFO (i.e. Last In First Out). Les opérations effectuées sur la pile sont:

  • Pousser une objet sur la pile c’est-à-dire ajouter une valeur. L’objet est rajouté au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe ld... pour load.
  • Enlever un objet de la pile. L’objet enlevé est celui se trouvant au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe st... pour store. Généralement l’objet est enlevé de la pile pour être stocké dans une variable.

Pour comprendre comment fonctionne le code MSIL, on propose quelques exemples:

Exemple simple d’une fonction

Code C# Code MSIL
namespace Cs9
{
  public class SimpleFunctionTests
  {
    public int AddNumbers(int startNumber)
    {
      int result = startNumber;

      {
        Console.WriteLine("Enter number: ");
        string numberAsString = 
          Console.ReadLine();
        if (int.TryParse(numberAsString, 
          out int number))
        {
          result += number;
        }

        Console.WriteLine
          ($"Result is: {result}");
      }

      return result;
    }
  }
}
.class public auto ansi beforefieldinit 
  Cs9.SimpleFunctionTests extends [System.Runtime]System.Object
{
  .method public hidebysig instance default 
  int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  .maxstack 2
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  IL_0000: nop
  IL_0001: ldarg.1
  IL_0002: stloc.0
  IL_0003: nop
  IL_0004: ldstr "Enter number: "
  IL_0009: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  IL_000f: call string class 
    [System.Console]System.Console::ReadLine()
  IL_0014: stloc.1
  IL_0015: ldloc.1
  IL_0016: ldloca.s class V_2
  IL_0018: call bool class 
    int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  IL_002d: box class System.Int32
  IL_0032: call string class 
    string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  IL_0045: ret
  } 
  
  .method public hidebysig specialname rtspecialname instance default 
  void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class 
    [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
} 

Dans le code MSIL, d’une façon générale les méthodes et fonctions appelées récupèrent la valeur de leur argument dans la pile. Lorsqu’une valeur est récupérée, elle est supprimée de la pile. Le résultat d’une fonction est ajoutée dans la pile.

Explication du code MSIL:

// Un objet de type référence dérive toujours de System.Object
.class public auto ansi beforefieldinit Cs9.SimpleFunctionTests 
  extends [System.Runtime]System.Object
{
  // Signature de la méthode AddNumbers() avec son argument
  // hidebysig signifie "hide by name-and-signature" pour 
  // indiquer que les fonctions doivent être identifiées en 
  // utilisant le nom et la signature (et non seulement le nom). 
  .method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  // Indique la profondeur maximale de la pile nécessaire à 
  // l’exécution de la fonction. 
  .maxstack 2
  // Indique les variables locales
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  // Signifie "No OPeration". Cette instruction indique au compilateur JIT 
  // les emplacements où le code machine peut être associé à une instruction MSIL. 
  IL_0000: nop
  // Ajoute la valeur de l’argument 1 c’est-à-dire startNumber à la pile (load argument 1) 
  IL_0001: ldarg.1
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale 
  // loc.0 (store local 0).
  IL_0002: stloc.0
  IL_0003: nop
  // Ajoute la chaine de caractère "Enter number: " dans la pile
  IL_0004: ldstr "Enter number: "
  // Appelle la méthode statique Console.WriteLine. 
  // Cette méthode va récupérer la 1ère valeur de la pile
  IL_0009: call void class [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  // Appelle la méthode Console.ReadLine. 
  // Cette méthode va placer son résultat dans la pile
  IL_000f: call string class [System.Console]System.Console::ReadLine()
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale loc.1
  IL_0014: stloc.1
  // Ajoute la valeur de la variable locale loc.1 dans la pile
  IL_0015: ldloc.1
  // Ajoute l’adresse de la variable V_2 dans la pile (load local short form)
  IL_0016: ldloca.s class V_2
  // Appelle de la fonction int32.TryParse(). Cette fonction va récupérer 
  // la valeur de ses arguments dans la pile. Elle ajoute son résultat dans la pile. 
  IL_0018: call bool class int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  // Va à l’instruction IL_0027 si la 1ère valeur dans la pile est false (branch false short).
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  // Ajoute les 2 premières valeurs de la pile (ces valeurs sont supprimées de la pile). 
  // La fonction ajoute le résultat de l’addition dans la pile. 
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  // Effectue une opération de boxing (conversion d’un objet de type valeur en un objet 
  // de type référence dérivant de System.Object). Cette opération est nécessaire pour 
  // exécuter ToString() sur un objet dérivant de System.Object. Le résultat de ToString() 
  // est utilisé pour "Result is: {0}".
  IL_002d: box class System.Int32
  IL_0032: call string class string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  // Va à l’instruction IL_0043 (branch short)
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  // Retour de la méthode ou de la fonction. Dans le cas d’une fonction, 
  // le résultat se trouve dans la pile.
  IL_0045: ret
  } 

  // Un constructeur par défaut est rajouté par le compilateur
  .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
}

Le code indiqué précédemment est un code généré en mode debug, on peut voir que de nombreuses instructions peuvent sembler inutile comme par exemple:

  • Les instructions nop
    • IL_0000: nop
    • IL_0003: nop
    • etc…
  • Des instructions où on stocke la 1ère valeur de la pile dans une variable alors que l’instruction suivante repousse la valeur de la variable dans la pile:
    • IL_0014: stloc.1
    • IL_0015: ldloc.1
  • Des instructions inutiles indiquant de passer à la ligne suivante:
    • IL_0041: br.s IL_0043
    • IL_0043: ldloc.s class V_4

La raison est que le compilateur effectue peu d’optimisation, les instructions du code C# sont directement traduites en instructions MSIL. Si on compile le même code en mode release, on peut voir que les instructions inutiles ne sont plus présentes, par exemple pour la fonction AddNumbers():

.method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
{
  // Method begins at Relative Virtual Address (RVA) 0x216C
  // Code size 53 (0x35)
  .maxstack 2
  .locals init(int32 V_0, int32 V_1)
  IL_0000: ldarg.1
  IL_0001: stloc.0
  IL_0002: ldstr "Enter number: "
  IL_0007: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_000c: call string class [System.Console]System
    .Console::ReadLine()
  IL_0011: ldloca.s class V_1
  IL_0013: call bool class int32::TryParse(string, byreference)
  IL_0018: brfalse.s   IL_001e
  IL_001a: ldloc.0
  IL_001b: ldloc.1
  IL_001c: add
  IL_001d: stloc.0
  IL_001e: ldstr "Result is: {0}"
  IL_0023: ldloc.0
  IL_0024: box class System.Int32
  IL_0029: call string class string::Format(string, 
    [System.Runtime]System.Object)
  IL_002e: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_0033: ldloc.0
  IL_0034: ret
}

Dans la suite, on présentera le code MSIL en mode release.

Exemple d’un appel de fonction

Si on considère le code suivant:

Code C#
public class SimpleClass
{
  public void ExecuteMe()
  {
    Console.WriteLine("OK");
  }
}

class Program
{
  static void Main(string[] args)
  {
    var simpleClass = new SimpleClass();
    simpleClass.ExecuteMe();
  }
}
    
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       11 (0xb)
  .maxstack  8
  IL_0000:  newobj     instance void FunctionPointerTests
    .SimpleClass::.ctor()
  IL_0005:  callvirt   instance void FunctionPointerTests
    .SimpleClass::ExecuteMe()
  IL_000a:  ret
} // end of method Program::Main
        

Dans ce code, 2 instructions sont importantes:

  • newobj permettant d’instancier un objet de type référence et d’ajouter la référence à la pile.
  • callvirt permettant d’appeler dans un objet une méthode correspondant à une signature particulière en utilisant la référence de cet objet dans la pile. D’autres explications sont apportées sur la fonction callvirt par la suite.

Exemple d’un appel Platform/Invoke

Si on considère le code suivant permettant d’appeler la fonction native Multiply() dans la DLL appelée NativeDll.dll:

Code C#
class Program
{
    static void Main(string[] args)
    {
        Multiply(2, 4);
    }

    [DllImport("NativeDll.dll", 
      CallingConvention = CallingConvention.StdCall, 
      CharSet = CharSet.Unicode)]
    public extern static int Multiply(int arg1, int arg2);
}
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       9 (0x9)
  .maxstack  8
  IL_0000:  ldc.i4.2
  IL_0001:  ldc.i4.4
  IL_0002:  call       int32 FunctionPointerTests.Program
    ::Multiply(int32,int32)
  IL_0007:  pop
  IL_0008:  ret
} // end of method Program::Main

.method public hidebysig static 
  pinvokeimpl("NativeDll.dll" unicode stdcall) 
  int32  Multiply(int32 arg1,int32 arg2) cil managed preservesig
{
}

L’instruction importante est call qui permet d’appeler une méthode particulière suivant sa signature. Dans le cas de l’appel Platform/Invoke, la méthode Multiply() est statique.

Delegates

Les delegates en C# sont des références vers une méthode comportant une signature particulière. Un delegate définit le type de la référence et non la référence elle-même. Par exemple, un delegate peut se définir de cette façon:

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

La fonction suivante possède une signature compatible avec le delegate:

public static int MultiplyIntegers(int a, int b)
{
  return a * b;
}

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

ArithmeticOperation operation = MultiplyIntegers;
int result = operation(2, 6);

Dans cet exemple, le delegate operation contient une référence vers la méthode statique MultiplyIntegers().

On peut aussi utiliser des méthodes d’instance plutôt que des méthodes statiques, par exemple si on considère la classe:

public class DelegateExample
{
  public int ExecuteOperation(int arg1, int arg2, ArithmeticOperation operation)
  {
    return operation(arg1, arg2);
  }

  public int AddIntegers(int arg1, int arg2)
  {
    return arg1 + arg2;
  }

  public int MultiplyIntegers(int arg1, int arg2)
  {
    return arg1 * arg2;
  }
}

On peut instancier un delegate avec une méthode d’instance:

var delegateExampleInstance = new DelegateExample();
ArithmeticOperation operation = new ArithmeticOperation(delegateExampleInstance.MultiplyIntegers);
int result = delegateExampleInstance.ExecuteOperation(3, 4, operation);

Ou plus directement:

int result = delegateExampleInstance.ExecuteOperation(3, 4, delegateExampleInstance.AddIntegers);

Par rapport à des pointeurs de fonction classiques, l’intérêt des delegates est qu’ils sont sûrs, vérifiables par le compilateur et le type est déterminé à la compilation. Etant donné qu’il s’agit de références managées, ils sont compatibles avec les traitements du Garbage Collector. Enfin lors d’appels Platform/Invoke, les delegates peuvent être convertis en pointeurs de fonctions et vice-versa, des pointeurs peuvent être convertis en delegates.

Du point de vue du code MSIL, les delegates sont compilés en classe dans laquelle se trouve les membres suivants:

  • Un constructeur avec pour arguments l’instance de la classe de la méthode déléguée et un entier contenant un pointeur vers la méthode déléguée.
  • Une méthode Invoke() utilisée pour exécuter la méthode déléguée de façon synchrone. La signature de Invoke() est la même que celle de la méthode déléguée.
  • Des méthodes BeginInvoke() et EndInvoke() utilisées pour exécuter la méthode déléguée de façon asynchrone.

Pointeur de fonction C++

Les pointeurs de fonction sont représentés en C++ par une déclaration du type <type de retour> (*<nom du pointeur>)(<type arguments en entrée>). Par exemple, pour déclarer un pointeur de fonction nommé fcnPtr permettant de pointer vers une fonction dont la signature est int(double, bool):

int (*fcnPtr)(double, bool);

La déclaration de la fonction peut être du type:

int PointedFunction(double arg1, bool arg2) 
{ 
  // ...
}

Si la signature de la fonction cible est int* (double, bool) c’est-à-dire que l’argument de retour est un pointeur d’un entier, par exemple:

int* PointedFunction(double arg1, bool arg2) 
{ 
  // ... 
}

Le pointeur de fonction doit être déclaré de cette façon:

int* (*fcnPtr)(double, bool);

Initialisation et affectation

A ce stade le pointeur de fonction plus haut, est juste déclaré et non initialisé. Pour l’initialiser, on peut écrire:

int (*fcnPtr)(double, bool) { &PointedFunction };

Dans cet exemple, la fonction PointedFunction peut être une fonction d’instance ou une fonction statique.
Ce pointeur est instancié sur la pile, il est donc perdu à la sortie de la méthode dans laquelle il a été instancié.

Pour affecter une méthode à un pointeur de fonction:

int (*fcnPtr)(double, bool); // Déclaration
fcnPtr = &PointedFunction; // Affectation

Appel en utilisant un pointeur de fonction

On peut appeler une méthode en utilisant un pointeur de fonction avec 2 syntaxes:

  • Par référencement explicite en utilisant la forme (*<nom du pointeur>), par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = (*fcnPtr)(5, false);
    
  • Par déférencement implicite en utilisant directement la forme <nom du pointeur>, par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = fcnPtr(5, false);

Passage de pointeur de fonction en argument

Un pointeur de fonction en tant qu’argument doit être indiqué de la même façon que les autres arguments, en utilisant sa déclaration. Par exemple:

int ExecuteOperation(int arg1, int arg2, int (*operationToExecute)(int, int))
{
  return operationToExecute(arg1, arg2);
}

On peut appeler cette méthode en indiquant directement les fonctions, par exemple si on déclare la fonction:

int MultiplyIntegers(int arg1, int arg2)
{
  return arg1 * arg2;
}

// ...
int result = ExecuteOperation(2, 3, MultiplyIntegers);

Cast void*

Comme pour tous les pointeurs, un pointeur de fonction peut être casté en pointeur void*

  • Conversion implicite en void*, par exemple:
    int (*fcnPtr)(double, bool){ &PointedFunction };
    void* voidFcnPtr = fcnPtr; // Conversion implicite
    
  • Conversion explicite de void* vers un pointeur de fonction avec reinterpret_cast, par exemple:
    void* voidFcnPtr = ... 
    int (*otherFcnPtr)(double, bool) = reinterpret_cast<int(*)(double, bool)>(voidFcnPtr);

Type alias

La déclaration d’un pointeur de fonction peut être simplifiée en utilisant un type alias, par exemple:

using AliasName = int(*)(double, bool);

Cet alias peut être utilisé directement pour remplacer la déclaration du pointeur:

AliasName fcnPtr; // déclaration du pointeur
// ...
AliasName fcnPtr { &PointedFunction }; // déclaration + affectation
fcnPtr = &PointedFunction; // Affectation

L’alias peut être utilisé aussi pour les arguments:

using OperationAlias = int(*)(int, int);

// ...
int ExecuteOperation(int arg1, int arg2, OperationAlias operationToExecute)
{
  return operationToExexute(arg1, arg2);
}

Utiliser des pointeurs de fonction avant C# 9

Avant C# 9, dans certaines conditions, il était possible de manipuler des pointeurs de fonctions toutefois ces différentes approches ne permettant pas d’utiliser l’instruction MSIL calli. D’autre part, ces approches ne sont possibles qu’entre des appels entre du code managé et du code natif. Par exemple, on peut utiliser:

Manipuler des pointeurs de fonction

Dans cet exemple, l’appel à la méthode peut se faire en utilisant le delegate. Si on considère une méthode externe fournissant un pointeur de fonction sous la forme void* dont la signature est int(int, int):

using unsafe class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();

  public int Multiply(int a, int b)
  {
    void* nativePtr = GetFunctionPointer();
    IntPtr ptr = new IntPtr(nativePtr);
    MultiplyDelegate multiplyDelegate = Marshal.GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}

Pour que ces méthodes soient exécutables, il faut que le code unsafe soit autorisé.

Comment compiler du code unsafe ?

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

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

Une autre syntaxe plus directe permet d’éviter d’utiliser du code unsafe:

Code C#
public class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();  
  
  public int Multiply(int a, int b)
  {
    IntPtr ptr = GetFunctionPointer();
    MultiplyDelegate multiplyDelegate = Marshal
      .GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}
Code MSIL de
Multiply()
.method public hidebysig instance 
  int32  Multiply(int32 a, int32 b) cil managed
{
  // Code size     18 (0x12)
  .maxstack  8
  // Appel Platform/Invoke pour récupérer 
  // un pointeur de fonction
  IL_0000: call   native int FunctionPointerTests
    .CallFunctionPointer::GetFunctionPointer()
  // "Conversion" en délégué managé
  IL_0005: call   !!0 [System.Runtime.InteropServices]
    System.Runtime.InteropServices.Marshal
    ::GetDelegateForFunctionPointer
      <class FunctionPointerTests
      .CallFunctionPointer/MultiplyDelegate>(native int)
  IL_000a: ldarg.1
  IL_000b: ldarg.2
  // Appel du delegate
  IL_000c: callvirt  instance int32 FunctionPointerTests
    .CallFunctionPointer/MultiplyDelegate
    ::Invoke(int32,int32)
  IL_0011:  ret
} // end of method CallFunctionPointer::Multiply

Cette méthode génère un appel à callvirt car l’appel se fait une utilisant un délégué managé.

Fournir un pointeur de fonction

La conversion d’un delegate en pointeur de fonction est aussi possible en utilisant les capacités de marshalling de Platform/Invoke:

Code C#
public unsafe class FunctionPointerProvider
{
  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static int MultiplyWithFunctionPointer(
    int arg1, 
    int arg2, 
    [MarshalAs(UnmanagedType.FunctionPtr)]MultiplyDelegate 
      functionDelegate);

  private int Multiply(int arg1, int arg2)
  {
    return arg1 * arg2;
  }

  public int MultiplyIntegers(int a, int b)
  {
    MultiplyDelegate functionDelegate = Multiply;
    int result = MultiplyWithFunctionPointer(a, b, 
      functionDelegate);
  }
}
Code MSIL de
MultiplyIntegers()
.method public hidebysig instance 
  int32  MultiplyIntegers(int32 a, int32 b) cil managed
{
  // Code size     22 (0x16)
  .maxstack  3
  .locals init (class FunctionPointerTests
  .FunctionPointerProvider/MultiplyDelegate V_0)
  IL_0000:  ldarg.0
  // Ajout dans la pile du pointeur de fonction
  // natif vers la function Multiply()
  IL_0001:  ldftn  instance int32 FunctionPointerTests
    .FunctionPointerProvider::Multiply(int32,int32)
  // Instanciation d'un délégué managé 
  // avec le pointeur natif
  IL_0007:  newobj instance void FunctionPointerTests
    .FunctionPointerProvider/MultiplyDelegate::
    .ctor(object,native int)
  IL_000c:  stloc.0
  IL_000d:  ldarg.1
  IL_000e:  ldarg.2
  IL_000f:  ldloc.0
  // Appel Platform/Invoke 
  IL_0010:  call   int32 FunctionPointerTests
    .FunctionPointerProvider
    ::MultiplyWithFunctionPointer(int32,int32,
      class FunctionPointerTests
      .FunctionPointerProvider/MultiplyDelegate)
  IL_0015:  ret
} 

Dans cet exemple, durant le marshalling, le delegate est directement converti en pointeur de fonction. L’attribut UnmanagedFunctionPointerAttribute permet d’indiquer que le delegate peut être utilisé par du code natif.

Utiliser les pointeurs de fonction delegate* à partir de C# 9

Le but de cette partie est d’expliquer la fonctionnalité des pointeurs de fonction en C# 9 en justifiant son intérêt par rapport aux autres solutions existantes. On explicitera quelques cas d’utilisation de cette fonctionnalité.

call vs callvirt vs calli

Comme on a pu le voir précédemment, call et callvirt sont des instructions MSIL pour appeler des méthodes:

  • call permet d’appeler des méthodes non virtuelles, statiques ou des surcharges d’une méthode se trouvant dans une classe mère.
  • callvirt permet d’appeler une méthode virtuelle dans le cas où la méthode à exécuter se trouve dans une classe fille.

Dans la pratique le compilateur C# utilise quasi toujours callvirt pour effectuer des appels de méthode lorsqu’il s’agit d’autres méthodes managées. call sera utilisé lorsqu’il n’y a pas de doutes sur l’emplacement de la méthode à appeler (comme dans le cas de méthodes statiques puisqu’une classe statique ne peut pas hériter d’une autre classe et une méthode statique ne peut pas être overrider). Les appels Platform/Invoke avec DllImport rentre aussi dans le cadre des utilisations de call.

Ainsi:

  • call effectue une recherche dans la table de méthodes de la classe. Le résultat de cette recherche fournit un pointeur correspondant à un décalage par rapport à l’adresse de la classe.
  • callvirt effectue une recherche dans la table de méthodes virtuelles de l’instance de la classe. Le résultat fournit un pointeur correspondant à un décalage par rapport à l’adresse de l’instance de la classe.

D’un point de vue de la syntaxe MSIL, call et callvirt utilisent les objets se trouvant dans la pile en tant qu’argument de la fonction à appeler. Dans le code MSIL, les instructions call ou callvirt sont suivies d’indications sur la méthode à appeler:

  • instance pour indiquer s’il s’agit d’une méthode faisant partie d’un objet instancié:
    call  instance  void Cs9.Example::MethodName()
  • [<Assembly où se trouve la méthode à appeler>] éventuellement une indication sur l’assembly dans laquelle se trouve la méthode statique à appeler, par exemple:
    call  void [System.Console]System.Console::WriteLine(int32)

L’instruction calli est différente de call et callvirt puisqu’elle utilise un pointeur de fonction dans la pile pour effectuer l’appel. calli pour call indirect permet d’effectuer un appel indirect en utilisant un pointeur se trouvant au sommet de la pile. Le pointeur doit être poussé au préalable en utilisant les instructions ldftn ou ldvirtftn:

  • ldftn: charge le pointeur de la fonction à appeler en utilisant la table de méthodes de la classe. La fonction est reconnue à partir de sa signature. Le pointeur de fonction est poussé dans la pile.
  • ldvirtftn: cette instruction a la même fonction que ldftn La différence est que ldvirtftn effectue la recherche dans la table des fonctions virtuelles de l’instance de la classe.

ldftn et ldvirtftn permettent de pousser un pointeur dans la pile, ce pointeur peut ensuite être utilisé par calli pour appeler une méthode:

  • L’utilisation de ldftn et calli est un équivalent de call.
  • L’utlisation de ldvirtftn et calli est un équivalent de callvirt.

Il n’y a pas forcément de différences significatives de performance entre les utilisations de ldftn/ldvirtftn + calli et call/callvirt, la différence est que ldftn/ldvirtftn et calli étant des instructions séparées, elles peuvent faire l’objet d’optimisation par le compilateur au moment où elles sont appelées.

Pourquoi manipuler des pointeurs de fonction en C# ?

Une fonction comporte des arguments, cette fonction effectue un traitement et éventuellement renvoie un résultat. Les arguments sont généralement des variables contenant des valeurs utilisées lors du traitement. Ce paradigme de programmation est de type impératif: une fonction sert à appliquer un traitement comme s’il s’agissait d’une fonction mathématique.
Un autre paradigme comme la programmation fonctionnelle nécessite de pouvoir passer en paramètre d’autres fonctions (cf. “higher-order function) et de renvoyer une fonction en résultat.
Sans aller jusqu’à l’application stricte des principes de la programmation fonctionnelle, on peut avoir le besoin de passer en paramètre de fonction un comportement. Les pointeurs de fonction ou les delegates en C# permettent d’effectuer ce type de manipulation en autorisant le passage de fonction en argument d’une autre fonction. On peut, ainsi, passer en argument un comportement plutôt que simplement des valeurs. Le gain est, par exemple, de composer une suite de traitements sans avoir à réellement exécuter ce traitement.

Les delegates en C# permettent de passer en argument de fonction d’autres fonctions. Techniquement, si des appels s’effectuent seulement de code managé vers du code managé, il n’y a pas de nécessité d’utiliser autre chose que les delegates pour plusieurs raisons:

  • Ils sont supportés par le Garbage Collector
  • Ils permettent des appels rapides
  • Ils peuvent être appelés de façon asynchrone

Dans le cadre d’appels entre du code managé et du code natif, on peut aussi utiliser les delegates car ils peuvent être marshalé et transformé en pointeurs de fonction lors d’appels Platform/Invoke. Cette solution utilise les instructions call dans le code MSIL car le delegate est implémenté sous la forme d’un wrapper de méthode (voir plus haut).
A la différence, les pointeurs de fonction en C# apportent la même solution technique lors d’appels entre du code managé et du code natif toutefois ils permettent de tirer partie de l’instruction MSIL calli. Cette instruction va directement utilisée un pointeur de fonction pour appeler le code de la méthode à exécuter.

Limitations de C# concernant les pointeurs de fonctions avant C# 9

Avant C# 9, les utilisations des pointeurs de fonction sont possibles toutefois ils utilisent call lors des appels (comme on a pu le voir plus haut). L’instruction MSIL calli n’est pas utilisée alors que cette instruction est celle qui est le plus adaptée pour appeler des méthodes en utilisant un pointeur. Le choix d’utiliser call peut s’expliquer par le fait de privilégier un procédé plus sûr pour appeler la méthode via un pointeur.

Ainsi malgré l’existence de l’instruction MSIL calli, il n’existe pas de possibilité de l’utiliser en utilisant du code C# usuel. Pour des besoins d’optimisation (cf. Inline IL ASM), certains développeurs ont forcé l’utilisation de calli en passant par du code C# émettant directement l’instruction avec OpCodes.Calli et DynamicMethod.GetILGenerator().

Pour palier à cette difficulté d’utiliser calli, une nouvelle syntaxe a été introduite en C# 9 permettant réellement de générer cette instruction.

Manipuler des pointeurs de fonction en C# 9

A partir de C# 9, il est possible d’utiliser une syntaxe permettant de manipuler les pointeurs de fonction et d’autoriser des appels sans passer par du code Platform/Invoke. Les appels peuvent être fait entre du code managé ⇔ managé et du code managé ⇔ natif. L’inconvénient est que ces manipulations nécessitent toujours un contexte unsafe.

Ces pointeurs sont représentés par la syntaxe:

  • delegate* managed<int, float, long> cette syntaxe correspond à un pointeur de fonction dont la signature est long (int, float) c’est-à-dire:
    • Le type de retour est long
    • Les arguments sont de type int et float dans cet ordre.
    • Ce pointeur de fonction ne peut être utilisé que dans le code managé (à cause de la convention d’appel).
  • delegate* unmanaged<int, float, long> cette syntaxe correspond à un pointeur de fonction à utiliser dans le cadre d’appels à du code natif. Sans précision, le CLR détermine la convention d’appel suivant le contexte.
  • delegate* unmanaged[StdCall]<int, float, long> cette syntaxe permet de préciser des éléments comme la convention d’appels:
    • StdCall pour désigner la convention par défaut de l’API Win32
    • Cdecl pour la convention d’appels des programmes C et C++.
    • Fastcall pour des appels optimisés en C++.
    • Thiscall qui fournit un pointeur this à la méthode lors de l’appel.

L’intérêt le plus direct des delegate* est de pouvoir remplacer l’utilisation des delegates managés et de permettre les conversions de pointeurs de fonction en void*.
Par exemple si on reprend l’exemple précédent qui permettait de fournir et d’utiliser un pointeur de fonction en utilisant un délégué managé, l’implémentation est directe en utilisant delegate*:

  • Pour utiliser un pointeur de fonction
    Code C#
    public unsafe class CallFunctionPointer
    {
      [DllImport(...)]
      public extern static delegate* unmanaged<int, int, int> 
        GetFunctionPointer();  
      
      public int Multiply(int a, int b)
      {
        delegate* unmanaged<int, int, int> fcnPtr = 
          GetFunctionPointer();
        return fcnPtr(a, b);
      }
    }
    
    Code MSIL de
    Multiply()
    .method public hidebysig instance 
        int32  Multiply(int32 a,int32 b) cil managed
    {
      // Code size     15 (0xf)
      .maxstack  3
      .locals init (method unmanaged cdecl int32 *(int32,
        int32) V_0)
      IL_0000:  call     method unmanaged cdecl 
        int32 *(int32,int32) FunctionPointerTests
        .CallFunctionPointer::GetFunctionPointer()
      IL_0005:  stloc.0
      IL_0006:  ldarg.1
      IL_0007:  ldarg.2
      IL_0008:  ldloc.0
      // Appel de fonction en utilisant le pointeur avec 
      // calli
      IL_0009:  calli    unmanaged cdecl int32(int32,int32)
      IL_000e:  ret
    } // end of method CallFunctionPointer::Multiply
    
  • Pour fournir un pointeur de fonction:
    Code C#
    public unsafe class FunctionPointerProvider
    {
      [UnmanagedFunctionPointer(CallingConvention.StdCall)]
      public delegate int MultiplyDelegate(int arg1, int arg2);
    
      public static MultiplyDelegate MultiplyAction = Multiply;
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      public int MultiplyIntegers(int a, int b)
      {
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr = 
          (delegate* unmanaged[Stdcall]<int, int, int>)
          Marshal.GetFunctionPointerForDelegate(MultiplyAction);
        return MultiplyWithFunctionPointer(a, b, fcnPtr);
      }
    
      [DllImport(...)]
      public extern static int MultiplyWithFunctionPointer(
        int arg1, 
        int arg2,
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr);
    }
    Code MSIL de
    MultiplyIntegers()
    .method public hidebysig instance 
        int32  MultiplyIntegers(int32 a,int32 b) cil managed
    {
      // Code size     22 (0x16)
      .maxstack  3
      .locals init (class FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate V_0)
      IL_0000:  ldarg.0
      // Ajout dans la pile du pointeur natif
      // vers Multiply() avec ldftn
      IL_0001:  ldftn  instance int32 FunctionPointerTests
        .FunctionPointerProvider::Multiply(int32,int32)
      IL_0007:  newobj instance void FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate::
        .ctor(object,native int)
      IL_000c:  stloc.0
      IL_000d:  ldarg.1
      IL_000e:  ldarg.2
      IL_000f:  ldloc.0
      // Appel Platform/Invoke
      IL_0010:  call   int32 FunctionPointerTests
        .FunctionPointerProvider
        ::MultiplyWithFunctionPointer(int32,int32,
          class FunctionPointerTests
          .FunctionPointerProvider/MultiplyDelegate)
      IL_0015:  ret
    } 
    

    Dans cet exemple, il n’y a pas d’utilisation de calli puisqu’on ne fait que fournir le pointeur de fonction, il n’y a pas d’appels de fonction en utilisant un pointeur.

Un delegate* ne peut pas être initialisé en C# qu’avec une fonction statique

Contrairement aux delegates managés, il n’est possible d’instancier un delegate* qu’avec une fonction statique en C#.
On peut écrire:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Mais ce code provoque une erreur à la compilation:

public unsafe class FunctionPointerProvider
{
  private int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // ⚠ ERREUR ⚠
    // ...
  }
}

Une solution est d’utiliser une fonction statique et de fournir une instance de la classe, par exemple:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(FunctionPointerProvider instance, int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <FunctionPointerProvider, int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Conversions de delegate*

Comme on peut le voir dans les exemples précédents, il est possible d’effectuer quelques manipulations sur les pointeurs de fonction comme:

  • Effectuer des conversions de delegate* vers void* et inversement:
    • La conversion est implicite dans le sens delegate*void*:
      delegate* managed<int, int, int> functionPointer = ...
      void* voidPointer = functionPointer; // Conversion implicite
      
    • La conversion doit être explicite dans le sens void*delegate*:
      void* voidPointer = ...;
      // Conversion explicite
      delegate* managed<int, int, int> functionPointer = (delegate* managed<int, int, int>)voidPointer; 
      
  • Dans le même sens, on peut convertir les delegate* en IntPtr:
    delegate* managed<int, int, int> functionPointer = ...
    IntPtr pointer = new IntPtr(functionPointer);
    

    Pour afficher l’adresse du pointeur:

    Console.WriteLine(pointer.ToString("X"));

Benchmark

De façon à comparer les performances des appels en utilisant des pointeurs de fonctions, on se propose plusieurs cas de figure d’exécution d’un algorithme. Cet algorithme effectue un traitement qui n’a pas de sens mathématique et dont la complexité est Ο(loopCount * 20)

  • loopCount est un nombre de boucles qu’on choisit suffisamment grand pour que l’exécution de l’algorithme soit significatif.
  • 20 car dans l’algorithme, un tableau de 20 entiers est parcouru. Ce nombre d’entiers est choisi arbitrairement.

Durant ce traitement une multiplication entre 2 entiers est effectuée et répétée loopCount * 20 fois. On effectue volontairement cette multiplication dans une fonction séparée de façon à modifier les appels suivant les différents cas de figure:

  • Un appel normal à une fonction managée: cet appel sert de référence.
  • Un appel en utilisant un délégué managé: cet appel s’effectue seulement dans le code managé. Techniquement cet appel est très semblable à un appel normal puisque le delegate est une fonction managée.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé: ce scénario ne s’effectue que dans du code managé. Il permet d’instancier un pointeur d’une fonction managée. Les appels sont ensuite effectués en utilisant ce pointeur de fonction managé.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif: ce scénario permet d’effectuer plusieurs appels à une fonction se trouvant dans du code natif en utilisant un pointeur de fonction. Le pointeur de fonction se trouvant dans le code natif est récupéré avec un appel Platform/Invoke.
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée: ce scénario permet d’utiliser un pointeur vers une fonction managée à partir de code natif.

L’implémentation de ce benchmark est la suivante:

public void RunBenchmark()
{
  var firstArray = new int[] { 23, 87,  51, 98, 29, 75, 93, 48, 24, 83, 47, 38, 62, 22, 97, 15, 52, 41, 74, 13 };
  var secondArray = firstArray.Reverse().ToArray();

  int arrayLength = firstArray.Length;
  int value = 0;
  int offset = 0;
  bool add = true;
  for (int i = 0; i < loopCount; i++)
  {
     for (int j = 0; j < arrayLength; j++)
     {
        int index = (offset + j) % arrayLength;
        int multiplicationResult = Multiply(firstArray[index], secondArray[index]);
        if (add)
          value += multiplicationResult;
        else
          value -= multiplicationResult;

        add = !add;
     }

     offset++;
  }
}

avec

private int Multiply(int arg1, int arg2)
{
  return arg1 * arg2;
}
Code sur GitHub

Le code de cet exemple se trouve dans le repository GitHub: github.com/msoft/Cs9_FunctionPointer

On décline ensuite cette implémentation suivant les différents types d’appels à effectuer en ne modifiant que l’appel à la fonction effectuant la multiplication.

  • Un appel normal à une fonction managée:
    On crée la classe suivante:

    public class MultiplyClass
    {
      public int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    }
    

    On instancie cette classe pour l’utiliser dans la fonction exécutant le benchmark:

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
      }
    
      [Benchmark]
      public void InstanceFunctionCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyClass.Multiply(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un délégué managé
    On crée un delegate managé pour wrapper l’appel à la fonction MultiplyClass.Multiply()

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
      private delegate int multiplyDelegate(int arg1, int arg2); // Définition du delegate
      private readonly multiplyDelegate multiplyManagedDelegate;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
        this.multiplyManagedDelegate = this.multiplyClass.Multiply;
      }
    
      [Benchmark]
      public void ManagedDelegateCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedDelegate(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé:
    On ajoute une fonction statique permettant d’effectuer la multiplication et on crée un pointeur de fonction vers cette fonction statique. On appelle ensuite le pointeur dans la méthode du benchmark:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void ManagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif
    On crée une fonction native permettant de renvoyer un pointeur vers une fonction dans le code natif. Ce code se trouve dans un projet permettant de générer une DLL C++:

    • Dans le fichier .cpp:
      int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
      
      void* GetMultiplyFunctionPointer()
      {
      	int (*)(int, int) fcnPtr = &Multiply;
      	return reinterpret_cast<void*>(fcnPtr);
      }
      
    • Dans le fichier .h:
      extern "C" __delspec(dllexport) void* GetMultiplyFunctionPointer();
      static int Multiply(int arg1, int arg2);
      

    Dans le code C#, on crée une indication pour effectuer un appel Platform/Invoke avec DllImport:

    public unsafe class Benchmark
    {
      private readonly delegate* unmanaged<int, int, int> multiplyUnmanagedPointer;
    
      [DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
      public extern static delegate* unmanaged<int, int, int> GetMultiplyFunctionPointer();
    
      public Benchmark()
      {
        this.multiplyUnmanagedPointer = GetMultiplyFunctionPointer();
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
              // ...      
            int multiplicationResult = multiplyUnmanagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée:
    La méthode d’exécution du benchmark est codée coté code natif. Un paramètre de cette méthode permet d’indiquer un pointeur de fonction qui va effectuer la multiplication. Dans le cadre de ce test, on fournit le pointeur d’une fonction managée.

    Coté code natif, l’implémentation est:

    • Dans le fichier .cpp:
      void PerformBenchmarkWithFunctionPointer(int loopCount, int(*multiplyFcn)(int, int))
      {
        const int arrayLength = 20;
      
        int firstArray[arrayLength] = { 23, 87, 51, 98, 29, 75, 93, 48, 24, 83, 47,
          38, 62, 22, 97, 15, 52, 41, 74, 13 };
        int secondArray[arrayLength];
      
        for (int i = 0; i < arrayLength; i++)
        {
          secondArray[i] = firstArray[arrayLength - i];
        }
      
        int value = 0;
        int offset = 0;
        bool add = true;
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            int index = (offset + j) % arrayLength;
            int multiplicationResult = multiplyFcn(firstArray[index], secondArray[index]);
            if (add)
              value += multiplicationResult;
            else
              value -= multiplicationResult;
      
            add = !add;
          }
      
          offset++;
        }
      }
      
    • Dans le fichier .h:
      extern "C" __declspec(dllexport) void PerformBenchmarkWithFunctionPointer(int loopCount, 
          int(*multiplyFcn)(int, int));
        

    Le code C# permettant d’appeler le code natif est:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      [DllImport("NativeCallee.dll", 
        CallingConvention = CallingConvention.StdCall, 
        CharSet = CharSet.Unicode)]
      public extern static int PerformBenchmarkWithFunctionPointer(int loopCount, 
        delegate* <int, int, int> multiplFcn);
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        PerformBenchmarkWithFunctionPointer(loopCount, this.multiplyManagedPointer);
      }
    }
    

Les résultats de l’exécution sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1679 (1909/November2019Update/19H2)
Intel Xeon CPU ES-2697 v3 2,6Ghz, 2 CPU, 4 logical and physical cores
.NET SDK=5.0.302
  [Host]      : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob  : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                   Method	|    Mean	|   Error	|  StdDev	|
|------------------------------------------	|--------	|--------	|--------	|
|                     InstanceFunctionCall	| 49.51ms	| 1.054ms	| 3.075ms	|
|                      ManagedDelegateCall	| 65.37ms	| 1.306ms	| 3.130ms	|
|                ManagedFuntionPointerCall	| 77.87ms	| 1.549ms	| 4.266ms	|
|             UnmanagedFunctionPointerCall	| 75.76ms	| 1.494ms	| 2.281ms	|
|   ProvideFunctionPointerToNativeFunction      | 49.04ms       | 0.979ms	| 1.089ms	|

Si on exécute plusieurs fois ces tests, les résultats peuvent être sensiblement différents toutefois les différences de performances entre les différents cas de figure sont les mêmes:

  • InstanceFunctionCall() l’appel normal à une fonction managée est la référence. Le temps d’exécution est le plus court.
  • ManagedDelegateCall() l’utilisation d’un delegate managé introduit un temps de traitement plus long dans ce test bien que dans la pratique l’utilisation d’un delegate managé n’entraîne pas des performances moins bonnes.
  • ManagedFuntionPointerCall() et UnmanagedFunctionPointerCall() les appels utilisant un pointeur de fonction delegate* provoquent tous les 2 un temps de traitement plus long. Le choix des delegate* n’est pas anodin et doit se faire s’il apporte un gain par rapport à des appels à du code natif sans passer par des pointeurs de fonction.
  • ProvideFunctionPointerToNativeFunction() ce cas de figure n’est pas vraiment pertinent par rapport aux tests précédents puisque la majorité du code est exécutée par le runtime C++. Les performances semblent égalées celles d’un appel normal malgré l’utilisation d’un pointeur de fonction.

On peut juste retenir que l’utilisation de pointeurs de fonction dégrade les performances par rapport à un appel normal. L’utilisation de ces pointeurs doit se faire si le gain est avéré et permet d’éviter, par exemple, d’effectuer une succession d’appels de type Platform/Invoke.

Références

Compiler intrinsics

MSIL/CIL:

Calli

Delegates

GC Premptive vs Cooperative

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

Les records (C# 9.0)

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

C# 9 introduit un nouveau type d’objets dont le but est de fournir une syntaxe simple pour déclarer des objets de type référence contenant des propriétés. Ces objets peuvent être définis en utilisant le mot-clé record.

Cet article a pour but de passer en revue les propriétés des objets record.

Suivant la façon dont ces objets sont instanciés, le compilateur peut rendre ces objets immutables et ajouter des méthodes à l’implémentation existante.

Il existe 2 syntaxes pour déclarer des objets records:

  • Une syntaxe condensée appelée positional record, par exemple:
    public record Car(string Brand, string Model); 
    

    Cette syntaxe permet de générer implicitement un objet immutable: les propriétés Car.Brand et Car.Model ne sont accessibles qu’en lecture seule.

  • Une syntaxe plus classique avec un constructeur:
    public record Car
    {
        public Car(string brand, string model)
        {
            this.Brand = brand;
            this.Model = model;
        }
    
        public string Brand { get; }
        public string Model { get; }
    }
    

    Les caractéristiques de l’objet record généré avec cette syntaxe sont explicites: les propriétés sont en lecture seule parce-qu’il n’existe que l’accesseur get.

De base, le compilateur rajoute des méthodes à l’implémentation des objets record de façon à faciliter leur utilisation et à étendre leurs caractéristiques:

  • La comparaison d’objets record se fait en comparant les propriétés membre des objets et non en comparant les références des objets (comme c’est le cas par défaut pour les objets de type référence).
  • Un constructeur est implicitement rajouté pour facilement instancier les objets record. Le constructeur rajouté permet d’affecter toutes les propriétés du record.
  • Ces objets peuvent être affichés directement avec ToString() sans avoir à surcharger cette méthode.
  • Ces objets supportent la déconstruction de ses propriétés sans effectuer d’implémentation particulière.

Ainsi si on définit un objet record de cette façon (syntaxe positional record):

public record Car(string Brand, string Model); 

Le compilateur va complêter l’implémentation pour produire une classe dont l’implémentation équivalente pourrait être:

public class Car 
{ 
    public string Brand { get; } // Propriétés accessibles en lecture seule
    public string Model { get; } 

    // Ajout d'un constructeur implicite
    public Car(string Brand, string Model) 
    {
        this.Brand = Brand;
        this.Model = Model;
    }   

    // Implémentation de ToString() 
    public override string ToString() 
    { 
        return $"Car {{ Brand = {this.Brand}, Model = {this.Model} }}"; 
    } 

    // Implémentation de Equals() 
    public override bool Equals(object obj) { ... } 
    public override int GetHashCode() { ... } 

    // Surcharge d'opérateurs 
    public static bool operator ==(object a, Car b) 
    { ... }; 

    public static bool operator !=(object a, Car b) 
    { ... }; 

    // Implémentation d'un déconstructeur
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Ainsi l’intérêt principal des objets record est de complêter l’implémentation en rajoutant des fonctions courantes sans avoir à surcharger la syntaxe.

Même si l’implémentation des objets record est complêtée par un constructeur ou des méthodes à la compilation, il reste possible d’implémenter d’autres constructeurs ou d’autres méthodes comme pour une classe:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { get; set; } 

    public Car(string brand) 
    { 
        this.Brand = brand; 
        this.Model = string.Empty; 
    } 

    public string GetCompleteName() 
    { 
        return $"{this.Brand} {this.Model}"; 
    } 
} 

Un record est une classe

Un objet record est compilé sous forme d’une classe. Si on regarde le code MSIL (i.e. MicroSoft Intermediate Language) obtenu si on compile le code C# suivant, on remarque que les instructions sont quasiment les mêmes que pour une classe:

Code C# Instructions MSIL
public class CarAsClass
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsClass 
    extends [System.Runtime]System.Object
{ 
    //...  
} // End of class Cs9.CarAsClass 
public record CarAsRecord
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsRecord 
    extends [System.Runtime]System.Object 
    implements [System.Runtime]System.IEquatable`1<Cs9.CarAsRecord>
{ 
    // ... 
} // End of class Cs9.CarAsRecord 

La différence entre le 2 objets est que le compilateur génère implicitement davantage de fonctions pour l’objet record comme par exemple ToString(), PrintMembers(), Equals(), GetHashCode() etc…

Déclaration et construction

Comme indiqué plus haut, il existe 2 syntaxes pour déclarer un objet record:

  • Une syntaxe explicite similaire à celle des classes: avec cette syntaxe, les accès aux propriétés sont explicitement implémentés.

    Par exemple:

    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    } 
    

    Un objet record déclaré de cette façon peut être instancié en utilisant des initializers:

    var car = new Car{ Brand = "Renault", Model = "4L" }; 
    
  • Une syntaxe condensée (i.e. positional record): les accès aux propriétés sont implicitement en lecture seule.

    Par exemple:

    public record Car(string Brand, string Model); 
    

    Dans ce cas, cet objet peut être instancié en utilisant un constructeur:

    var car  = new Car("Renault", "4L"); 
    

    Ou en omettant le type après new:

    Car car  = new ("Renault", "4L"); 
    

    Avec cette construction, les propriétés sont en lecture seule:

    var brand = car.Brand; // OK 
    car.Brand = "Peugeot"; // ⚠ ERREUR ⚠
    

    D’autres constructions sont possibles pour préciser d’autres propriétés ou des méthodes en dehors du constructeur, par exemple:

    public record Car(string Brand, string Model) 
    { 
        public int Power { get; set; } 
    
        public string GetCompleteName() 
        { 
            return $"{this.Brand} {this.Model}"; 
        } 
    } 
    

    Avec cette dernière déclaration, on peut instancier en utilisant un initializer:

    var car  = new Car("Renault", "4L") { Power = 40 }; 
    

Construction en utilisant une expression avec with

On peut instancier des objets record à partir d’autres objets en utilisant with, par exemple:

public record Car(string Brand, string Model); 

// ...

var sedan = new Car("Tesla", "3"); 
var suv = sedan with { Model = "X" }; 

Héritage

Les objets record supportent l’héritage ce qui est un avantage par rapport aux objets struct, par exemple:

public record Vehicle 
{ 
    public Vehicle(int wheelCount, int doorCount) 
    { 
        this.WheelCount = wheelCount; 
        this.DoorCount = doorCount; 
    } 

    public int WheelCount { get; } 
    public int DoorCount { get; } 
} 

public record Car : Vehicle 
{ 
    public Car(string brand, string model, int wheelCount, int doorCount):  
        base(wheelCount, doorCount) 
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; } 
    public string Model { get; } 
} 

Si on utilise la syntaxe positional record, l’implémentation équivalente est:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): 
    Vehicle(WheelCount, DoorCount); 

Comparaison

Comme indiqué plus haut, la comparaison entre des objets record est facilitée puisque qu’il n’est pas nécessaire de surcharger explicitement la fonction Equals(). Implicitement, le compilateur rajoute une implémentation de la fonction Equals() permettant de comparer toutes les propriétés, par exemple:

var car1 = new Car("Tesla", "3"); 
var car2 = new Car("Tesla", "3"); 
 
Console.WriteLine(car1.Equals(car2)); // true 

Les objets sont égaux par comparaison des valeurs des propriétés toutefois ils sont bien distincts en comparant les références:

Console.WriteLine(ReferenceEquals(car1, car2)); // false 

Les opérateurs d’égalité et d’inégalité sont surchargés:

Console.WriteLine(car1 == car2); // true 
Console.WriteLine(car1 != car2); // false 

Surcharger Equals() et les opérateurs d’égalité et d’inégalités

Même si le compilateur rajoute implicitement une implémentation pour les fonctions:

  • bool Equals(Object object),
  • int GetHashCode() et
  • Des opérateurs == et =!.

Il est possible de proposer une autre implémentation pour GetHashCode() par override toutefois ce n’est pas possible pour bool Equals(Object object) et pour les opérateurs == et =!:

public record Car(string Brand, string Model) 
{ 
    public override bool Equals(Object obj) { ... } // ⚠ ERREUR ⚠
    public override int GetHashCode() {... } //OK 

    public static bool operator ==(Car car1, Car car2) => { ... } // ⚠ ERREUR ⚠
    public static bool operator !=(Car car1, Car car2) => { ... } // ⚠ ERREUR ⚠
} 

Il est possible de proposer une nouvelle implémentation Equals() si:

  • La signature n’est pas Equals(Object obj) et
  • Si la nouvelle implémentation est une fonction virtuelle ou si l’objet record est sealed (i.e. avec sealed il n’est pas possible d’hériter de l’objet record).

Par exemple, pour proposer une nouvelle implémentation de Equals() l’argument ne doit pas être de type object. Dans ce cas, on définit une nouvelle surcharge de Equals(), il n’y a plus d’override:

public record Car(string Brand, string Model) 
{ 
    public virtual bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Ou l’objet record doit être sealed:

public sealed record Car(string Brand, string Model) 
{ 
    public bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Dans ce cas, si on ne propose pas une implémentation pour GetHashCode(), un warning est généré à la compilation.

La comparaison prend en compte le type

Dans le cas de l’implémentation par défaut de Equals(), la comparaison implique la prise en compte des types des objets record. Ainsi même si les valeurs des membres sont identiques, le type des objets est pris en compte.

Par exemple si on considère des objets record héritant du même objet, possédant des propriétés identiques et dont les valeurs sont aussi identiques, les objets ne pourront être égaux puisqu’ils ne sont pas de même type.

Par exemple:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 

public record Truck(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 
 
// ...

var car = new Car { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 
var truck = new Truck { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 

Console.WriteLine(car.Equals(truck)); // False 
Console.WriteLine(car == truck); // False 

Immutabilité

L’accès en écriture des propriétés d’un objet record est le même que pour une classe. Un objet record n’est pas forcément immatuble. On peut le rendre immutable comme pour une classe en n’utilisant des accesseurs n’autorisant que l’accès en lecture après la construction.

Plusieurs possibilités:

  • En omettant l’accesseur set; la propriété ne peut être initilisée que dans le constructeur:
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
    
    var car2  = new Car{ Brand = "Nio" }; // ⚠ ERREUR ⚠ 
    
  • Un accesseur init; pour ne permettre l’initialisation qu’avec un constructeur ou un initializer.
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; init; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
    
    var car2  = new Car{ Brand = "Tesla" }; // OK 
    
  • Utiliser un champ readonly et un accesseur init, l’affectation n’est possible que dans un constructeur ou avec un initializer:
    public record Car 
    { 
        private readonly string brand; 
    
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand  
        {  
            get => this.brand; 
            init => this.brand = value; 
        } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
     
    var car2 = new Car { Brand = "Nio" }; //OK  
    

Implémentation implicite de ToString()

Pour les objets record, le compilateur génère une implémentation de ToString() de façon à afficher facilement les valeurs des propriétés.

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

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 
} 

// ...

var car = new Car{ Brand = "Tesla", Model = "3" }; 
Console.WriteLine(car); 

On obtient:

Car { Brand = Tesla, Model = 3 } 

Il est possible de surcharger la méthode ToString():

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 

    public override string ToString() 
    { 
        // ... 
    } 
} 

PrintMembers()

Dans l’implémentation par défaut de ToString(), pour afficher les valeurs des propriétés, une fonction dont la signature est bool PrintMembers(StringBuilder stringBuilder) est rajoutée à la compilation et est appelée par ToString(). Cette fonction ajoute dans l’objet stringbuilder, les valeurs des propriétés.

Un exemple de l’implémentation de ToString() appelant PrintMembers() est:

public override string ToString() 
{ 
    StringBuilder stringBuilder = new StringBuilder(); 
    stringBuilder.Append("Car");  
    stringBuilder.Append(" { "); 
  
    if (PrintMembers(stringBuilder)) 
    { 
        stringBuilder.Append(" "); 
    } 
 
    stringBuilder.Append("}"); 
    
    return stringBuilder.ToString(); 
} 

On peut proposer une autre implémentation pour PrintMembers() toutefois il faut permettre que cette méthode soit surchargée dans une classe qui hériterait de la classe où se trouve PrintMembers(). Ainsi 2 solutions sont possibles pour cette implémentation:

  • Implémenter une fonction virtuelle et protected de PrintMembers():
    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        protected virtual bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    } 
    
  • Si l’objet record est sealed (on ne peut donc pas en hériter), il faut implémenter PrintMembers() sous forme d’une fonction privée:

    public sealed record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        private bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    }
    

PrintMembers() dans le cas d’héritage

Dans le cas où l’objet record dérive d’un autre objet, les implémentations de PrintMembers() changent suivant si l’objet record est sealed ou non:

  • Si l’objet n’est pas sealed (c’est-à-dire qu’on peut en hériter), PrintMembers() doit être protected override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
  • Si l’objet est sealed (c’est-à-dire qu’on ne peut pas en hériter), PrintMembers() doit être une fonction protected sealed override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public sealed record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected sealed override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    

Implémentation d’un destructeur avec la syntaxe positional record

Si on utilise la syntaxe positional record pour déclarer un objet record, le compilateur rajoute une implémentation pour un constructeur et pour un deconstructeur.

Ainsi, si on considère un record défini de la façon suivante:

public record Car(string Brand, string Model); // déclaration avec la syntaxe positional record 

Un constructeur et un deconstructeur sont ajoutés à la compilation, on peut donc écrire:

var car = new Car("Tesla", "3"); // OK 

(string brand, string model) = car; // OK  
Ajout du constructeur et du deconstructeur par le compilateur si la syntaxe utilisée est positional record

L’ajout du constructeur et du deconstructeur n’est effectué par le compilateur que si on utilise la syntaxe positional record pour déclarer le record.

Si on définit le record de cette façon:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { set; set; } 
} 

var car = new Car("Tesla", "3"); // ⚠ ERREUR ⚠, pas de constructeur 
(string brand, string model) = car; // ⚠ ERREUR ⚠, pas de destructeur 

Il est possible de rajouter un deconstructeur explicitement à un objet record (au même titre que le constructeur):

public record Car 
{ 
    public Car(string brand, string model)  
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; set; } 
    public string Model { set; set; } 
    
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Pour résumer…

Un objet record est une classe dans laquelle le compilateur rajoute implicitement une implémentation pour des fonctions usuelles comme Equals(), ToString(), un déconstructeur et des surcharges pour les opérateurs d’égalité et d’inégalité.
2 syntaxes permettent de déclarer des objets record:

  • Une syntaxe classique proche de celle des classes: dans ce cas, l’objet record n’est pas forcément immutable, c’est l’implémentation qui détermine explicitement les propriétés de l’objet.
  • Une syntaxe condensée appelée positional record qui permet d’implémenter un objet immutable avec un constructeur permettant d’affecter toutes les propriétés, par exemple:
    public record Car(string Brand, string Model); 
    

    Ce record peut être instancié en utilisant le constructeur implicite:

    var car = new Car("Car brand", "Car model");
    
Classe
(class)
Record
(record)
Structure
(struct)
Type d’objet Objet de type référence Objet de type valeur
Manipulation des variables, passage de paramètre Par copie de référence Par copie de valeur
Stockage Dans le tas managé Dans la pile mais peut être stocké dans le tas managé, par exemple:

  • si la struct contient une référence
  • en cas de boxing
Peut être statique
Oui
Non
Non
Héritage
Supporté
Non
Constructeur sans paramètre Implicite en cas d’absence d’implémentation de constructeur Un constructeur sans paramètre ne peut pas être implémenté.
Constructeur de copie
Non

(A implémenter explicitement)

Oui

si on utilise with

Toute affectation est une copie
Immutable
Non
Non
Oui
Oui si on utilise la syntaxe positional record
Comportement par défaut en cas de comparaison (avec ==, != ou Equals()) Comparaison des références Comparaison des données membre
Surchage de Equals(Object obj)
Possible
Impossible
Possible
Comportement par défaut de ToString() Chaine contenant le type de la classe Chaine contenant un affichage amélioré des valeurs des membres Chaine contenant le type de la struct
Type d’objet dans le code MSIL class struct
Comportement en cas d’absence d’opérateur de portée des membres Privé par défaut Public par défaut
Durée de vie gérée par le Garbage Collector Oui Non
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les composants Angular

Cet article fait partie de la série d’articles Angular from Scratch.

@brodanoel

Les composants font partie des objets les plus importants d’Angular car ils permettent d’afficher des vues. Chaque vue correspond à une unité capable d’afficher une partie d’une application Angular.

Pour ordonner cette unité d’implémentation, un composant est formé de différents éléments:

  • La vue du composant appelée template: cette partie comporte du code HTML correspondant aux objets statiques. Ce squelette statique est enrichi par du code interprété par Angular pour permettre des interactions dynamiques entre les objets statiques et du code métier se trouvant dans le reste du composant.
  • La classe du composant: c’est une classe Typescript dans laquelle on peut définir:
    • des membres contenant les données affichées par la vue,
    • des méthodes et fonctions pour exécuter des traitements déclenchés par des actions sur la vue ou par des évènements divers.
  • Des métadonnées: il s’agit d’informations supplémentaires d’implémentation qui permettront à Angular d’interfacer le template avec la classe du composant.

Cet article permet de présenter les fonctionnalités les plus importantes des composants de façon à pouvoir en créer et à en comprendre les principales caractéristiques. Les détails de chaque fonctionnalité seront présentés dans d’autres articles.

Fonctionnement général d’un composant

Une vue Angular est composée d’éléments HTML avec une hiérarchie comme une page HTML classique. Angular s’interface avec le DOM correspondant à ces éléments de façon à en modifier les caractéristiques de façon dynamique. Ainsi, la vue comporte des éléments qui vont rester statiques durant l’exécution de l’application et d’autres éléments dont l’affichage pourrait être modifié suivant différents évènements. L’implémentation de ces éléments se fait dans la classe du composant et dans le fichier template.

Création d’un composant

Par exemple, après avoir créé une application Angular avec le CLI Angular en utilisant ng build (voir Créer une application Angular “from scratch” pour plus de détails), on peut créer un composant nommé Example en exécutant:

ng generate component example 

Ou de façon plus condensée:

ng g c example 

Cette étape effectue plusieurs opérations:

~% ng g c example 
CREATE src/app/example/example.component.css (0 bytes) 
CREATE src/app/example/example.component.html (22 bytes) 
CREATE src/app/example/example.component.spec.ts (635 bytes) 
CREATE src/app/example/example.component.ts (279 bytes) 
UPDATE src/app/app.module.ts (815 bytes) 

Dans un premier temps, 4 fichiers sont créés:

  • example.component.html qui est le fichier template dans lequel on peut implémenter les éléments graphiques de la vue.
  • example.component.ts qui est la classe Typescript du composant.
  • example.component.css qui contient de façon facultative le style CSS utilisé par la vue du composant.
  • example.component.spec.ts contenant les tests unitaires à implémenter pour le composant.

Dans un 2e temps, le composant sera rajouté dans le Root Module de l’application dans app.module.ts:

@NgModule({ 
  declarations: [ 
    AppComponent, 
    ExampleComponent
  ], 
  imports: [ 
    BrowserModule, 
    AppRoutingModule 
  ], 
  providers: [], 
  bootstrap: [AppComponent] 
}) 
export class AppModule { } 

Affichage d’un composant avec le paramètre selector

L’ajout du composant au module de l’application permet à Angular de configurer la factory de composants de façon à instancier le composant dans le cas où il faudrait l’afficher. Toutefois à ce stade, il manque un élément important: il n’y a pas d’éléments dans le code permettant d’indiquer où la vue du composant sera affichée.

On peut indiquer où le composant sera affiché en utilisant le paramètre selector dans les metadatas du composant. Ce paramètre est indiqué dans la classe du composant (cf. example.component.ts):

@Component({ 
  selector: 'app-example',
  templateUrl: './example.component.html', 
  styleUrls: ['./example.component.css'] 
}) 
export class ExampleComponent implements OnInit { 
  constructor() { } 

  ngOnInit(): void { 
  } 
} 

La valeur 'app-example' du paramètre selector indique que le composant Example sera affiché si le template d’un autre composant contient:

<app-example></app-example> 

Ainsi, si on indique ce code dans le template du composant principal (dans le fichier src/app/app.component.html) après en avoir supprimé tout le contenu:

<app-example></app-example> 

Si on lance l’application en exécutant la commande ng server --watch, la vue est directement affichée de cette façon:

example work! 

Afficher plusieurs vues

On peut afficher plusieurs vues en même temps suivant les besoins de l’application. Par exemple si on considère les fichiers index.html et styles.css suivants de façon à afficher 4 zones: header, content, sidebar et footer:

index.html styles.css
<html> 
  <head> 
    <title>Test</title> 
    <link rel="stylesheet" 
      type="text/css" 
      href="styles.css" /> 
  </head> 
  <body> 
    <div id="header"> 
      Header 
    </div> 
    <div id="sidebar"> 
      Sidebar 
    </div> 
    <div id="content"> 
      Content 
    </div> 
    <div id="footer"> 
      Footer 
    </div> 
  </body> 
</html> 
html, body { 
  background: lightgreen; 
  margin: 0; 
  padding: 0; 
  height: 100%; 
} 

div#header { 
  padding: 10px; 
  height: 90px; 
  background: yellow; 
} 

div#footer { 
  position: fixed; 
  height: 30px; 
  bottom: 0; 
  width: 100%; 
  background: lightsalmon; 
  padding-left: 10px; 
} 

div#content { 
  position: relative; 
  margin: 0 210 0 0; 
  bottom: 0; 
  padding-left: 10px; 
  padding-right: 10px; 
} 

div#sidebar { 
  background: lightblue; 
  float: right; 
  width: 190px; 
  position: fixed; 
  right: 0; 
  height: inherit; 
} 

Ces fichiers permettant d’afficher une page comportant 4 zones:

Dans le cadre d’une application Angular, on peut utiliser un composant pour chaque zone et ainsi afficher plusieurs vues. Par exemple, si on reprend l’exemple précédent en créant 4 nouveaux composants:

  1. On exécute les commandes suivantes avec le CLI Angular pour créer les composant header, footer, content et sidebar:
    ~% ng g c header 
    ~% ng g c footer  
    ~% ng g c content 
    ~% ng g c sidebar 
    
  2. On modifie les fichiers templates des composants créés pour que le contenu soit similaire au fichier d’exemple d’origine:
    • app/src/header/header.component.html:
      Header 
      
    • app/src/footer/footer.component.html:
      Footer 
      
    • app/src/sidebar/sidebar.component.html:
      Sidebar 
      
    • app/src/content/content.component.html:
      Content
      
  3. On copie les styles CSS de le fichier src/styles.css de façon à ce que les styles CSS soient disponibles dans toute l’application.
  4. On modifie le template du composant principal dans src/app/app.component.html pour afficher tous les composants en utilisant les paramètres selector de ces composants:
    <div id="header"> 
      <app-header></app-header> 
    </div> 
    <div id="sidebar"> 
      <app-sidebar></app-sidebar> 
    </div> 
    <div id="content"> 
      <app-content></app-content> 
    </div> 
    <div id="footer"> 
      <app-footer></app-footer> 
    </div> 
    

L’affichage obtenu est similaire à l’exemple de départ. On peut voir que chaque partie correspond à un composant différent et à une vue différente.

Liens entre le template et la classe du composant

Pour rendre dynamique l’affichage de la vue d’une composant, Angular permet d’implémenter des interactions entre un template et la classe d’un composant. Ces interactions se font par l’intermédiaire de bindings. L’implémentation de ces bindings permet d’enrichir la vue avec des données ou de déclencher l’exécution de code dans la classe du composant.

Sur le schéma suivant provenant de la documentation Angular, on peut voir 2 types de bindings:

  • Property binding permettant d’échanger des données de la classe du composant vers le template. Ce type de binding permet de modifier une propriété d’un objet dans le DOM à partir d’un membre de la classe du composant.
  • Event binding pour exécuter du code dans la classe du composant à partir d’évènements déclenchés dans le template.

Il existe d’autres types de bindings qui sont décrits de façon plus détaillée dans l’article Les vues des composants Angular:

Interpolation

Le binding le plus simple est l’interpolation. Il permet d’exécuter directement une expression Typescript contenant des membres ou des fonctions publiques dans la classe du composant, par exemple:

Template
<div>{{textToDisplay}}</div>
Classe du composant
@Component({ ... })
export class ExampleComponent {  
  textToDisplay = 'Texte à afficher'; 
} 

Dans cet exemple, à l’exécution {{textToDisplay}} dans le template sera évalué et remplacé par la valeur du membre textToDisplay de la classe.

Property binding

Ce type de binding permet de mettre à jour le contenu d’une propriété DOM d’un élément affiché avec la valeur d’un membre dans la classe du composant, par exemple:

Template
<div [innerText]="textToDisplay"></div> 
Classe du composant
@Component({ ... })  
export class ExampleComponent {  
  textToDisplay = 'Texte à afficher'; 
} 

Dans cet exemple, la propriété innerText de l’objet du DOM sera liée au membre textToDisplay de la classe.

Event binding

Ce binding déclenche l’exécution d’une fonction dans le classe du composant à partir du déclenchement d’un évènement dans un objet du DOM, par exemple:

Template
<p>{{valueToDisplay}}</p> 
<button (click)="incrementValue()">Increment</button> 
Classe du composant
@Component({ ... })
export class ExampleComponent {  
  valueToDisplay = 0; 
 
  incrementValue(): void { 
    this.valueToDisplay++; 
  } 
} 

Dans cet exemple, l’exécution de la fonction incrementValue() dans la classe du composant est lancée quand l’évènement click survient dans l’objet button.

Les autres types de bindings sont décrits dans Les vues des composants Angular.

Composants enfants

Une fonctionnalité importante des composants est qu’ils peuvent contenir d’autres composants. Ainsi, pour imbriquer un composant dans un autre:

  • D’abord il faut que le composant enfant soit déclaré dans le module du composant parent pour que la résolution réussisse.
  • Ensuite le template du composant parent doit contenir le contenu du paramètre selector du composant enfant:
    Par exemple si le paramètre selector du composant est:

    @Component({ 
      selector: 'app-child',
      templateUrl: './child.component.html' 
    }) 
    export class ChildComponent {} 
    
  • Le template du composant doit contenir:
    <app-child></app-child> 
    

Les interactions entre le composant parent et un composant enfant sont possibles en utilisant:

  • Des paramètres d’entrée du composant enfant avec @Input().
  • Des évènements de sortie du composant enfant avec @Output().
  • Injecter du contenu dans le composant enfant avec la fonctionnalité content projection.

Content projection

La fonctionnalité content projection permet de projeter un contenu à partir du composant parent dans un composant enfant.

Si 'app-child' est le selector du composant enfant, pour qu’un contenu soit projeté à partir du composant parent, la syntaxe dans le template doit être:

<app-child> 
    <!-- Contenu projeté --> 
</app-child> 

L’emplacement du contenu à projeter doit être indiqué dans le composant enfant avec:

<ng-content></ng-content> 

Si <ng-content> est omis dans le composant enfant, il n’y aura pas d’erreur.

On peut projeter plusieurs contenus en les nommant avec l’attribut select. Par exemple:

<ng-content select='h4'></ng-content> 
<ng-content></ng-content> 
<ng-content select='span'></ng-content> 

Si le template du composant parent est:

<app-child [identifier]='1'> 
  <span>Input value is: {{inputElement.value}}</span> 
  <h4>The content is:</h4> 
  <p><input #inputElement ngModel /></p> 
</app-child> 

Alors:

  • Le contenu de <span></span> est projeté dans la partie <ng-content select='span'></ng-content>.
  • Le contenu de <h4></h4> est projeté dans la partie <ng-content select='h4'></ng-content>.
  • Le contenu de <p></p> est projeté dans <ng-content></ng-content> car il ne correspond à aucuns autres attributs select.

Paramètre d’entrée

Le composant parent peut injecter un paramètre dans le composant enfant en utilisant un property binding. Ainsi le composant enfant doit déclarer les propriétés exposées en tant que paramètre d’entrée avec le décorateur @Input(), par exemple:

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

Dans cet exemple, le paramètre d’entrée est identifier.

Le composant parent peut effectuer un property binding:

Template
<h1>Parent component</h1> 
<app-child [identifier]='childIdentifier'></app-child>
Classe du composant
import { Component } from '@angular/core'; 
 
@Component({ 
  templateUrl: './parent.component.html' 
}) 
export class ParentComponent { 
  childIdentifier = 1; 
} 

Le membre childIdentifier du composant parent est injecté par property binding dans le paramètre identifier du composant enfant.

Evènement de sortie

Le composant enfant peut déclencher un évènement qui exécutera une fonction dans le composant parent par event binding.

La déclaration de l’évènement dans le composant enfant se fait en utilisant @Output() et l’objet EventEmitter. EventEmitter permettra d’émettre l’évènement, par exemple:

Template
<p>Child component with identifier: {{identifier}}</p> 
<p> 
    <button (click)='incrementValue()'>Increment</button> 
</p> 
Classe du composant
import { Component, Input, Output, EventEmitter } from '@angular/core'; 
     
@Component({ 
  selector: 'app-child', 
  templateUrl: './child.component.html' 
}) 
export class ChildComponent { 
  internalCount = 0; 

  @Ouput() countUpdated: EventEmitter<number>= new EventEmitter<number>();
 
  incrementValue(): void { 
    this.internalCount++; 
    this.countUpdated.emit(this.internalCount); 
  } 
}

Le composant parent peut être notifié du déclenchement de l’évènement en utilisant un event binding sur l’évènement du composant enfant, par exemple:

Template
<h1>Parent component</h1> 
<app-child (countUpdated)='updateTotalCount($event)'></app-child> 
Classe du composant
import { Component } from '@angular/core'; 
     
@Component({ 
  templateUrl: './parent.component.html' 
}) 
export class AppComponent { 
  totalCount = 0; 

  updateTotalCount(count: number): void { 
    this.totalCount = count; 
  } 
}

La fonction updateTotalCount() dans le composant parent est exécutée à chaque déclenchement de l’évènement countUpdated dans le composant enfant.

Il est possible d’implémenter d’autres types d’interactions entre un composant parent et un composant enfant, pour plus de détails voir Les composants enfant.

Cycle de vie d’un composant

Les bindings entre la vue et la classe d’un composant peuvent provoquer des changements dans l’affichage des objets dans la vue d’un composant. La répercussion de ces changements dans la vue se fait par Angular de façon transparente. Par exemple, dans le cas d’une interpolation avec le membre d’une classe, chaque nouvelle valeur du membre provoquera un changement de la valeur affichée:

Template
<div>{{textToDisplay}}</div>
Classe du composant
@Component({ ... })   

export class ExampleComponent {
  
textToDisplay = 'Texte à afficher';  

}

Pour mettre à jour un élément graphique, Angular doit s’interfacer avec le DOM pour créer ou supprimer des objets ou en modifier des propriétés.

Ces accès multiples au DOM sont coûteux en performance. Ainsi, afin d’en limiter au maximum les accès et de modifier les propriétés au minimum, la solution d’Angular est de détecter automatiquement les changements nécessitant une modification dans le DOM. Cette détection s’exécute dans la version des objets maintenue par Angular. Si Angular détecte un changement, il répercute ce changement dans le DOM de façon à ce que les éléments graphiques correspondant puissent être modifiés.

L’algorithme de détection de changements effectue les mises à jour des éléments graphiques suivant un ordre précis. Tout au long de ces mises à jour et suivant les éléments qui sont mis à jour, il va aussi exécuter les callbacks du cycle de vie des composants. La détection de changements est donc très liée au cycle de vie des composants.

Les callbacks peuvent être implémentées au niveau de la classe d’un composant en héritant de l’interface correspondante, par exemple pour OnInit():

import { OnInit } from '@angular/core';  

@Component({ ... })  
export class ExampleComponent implements OnInit {  

  ngOnInit() {  
  }  
}  

Pour implémenter plusieurs callbacks, il suffit de satisfaire plusieurs interfaces. Par exemple pour OnInit() et DoCheck():

import { OnInit, DoCheck } from '@angular/core';  

@Component({ ... })  
export class ExampleComponent implements OnInit, DoCheck {  

  ngOnInit() {  
  }  

  ngDoCheck() {  
  }  
}  

Le but des callbacks est de pouvoir interagir finement avec le framework durant les différentes phases de création d’un composant et de sa vue dans un premier temps ou lors de la détection de changement dans un 2e temps. La succession de ces différentes phases permet d’intervenir:

  • Dans le cas de l’initialisation d’un composant: avant ou après l’initialisation d’un élément particulier de ce composant (paramètres d’entrée, contenu projeté, requête sur la vue etc…)
  • Dans le cas de la détection de changement: avant ou après la vérification d’un changement sur un élément particulier de ce composant.

Ainsi, à l’initialisation d’un composant, les callbacks de ce cycle de vie (i.e. lifecycle hooks) sont, dans l’ordre de déclenchement:

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

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

    void ngOnChanges(changes: SimpleChanges): void {}
    

    Elle sera déclenchée une seule fois.

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

A chaque détection de changements, les callbacks déclanchées sont, dans l’ordre:

  1. ngOnChanges() si les paramètres en entrée du composant sont modifiés.
  2. ngDoCheck()
  3. ngAfterContentChecked() est déclenchée même s’il n’y a pas de contenu projeté.
  4. ngAfterViewChecked().

Il faut avoir en tête l’ordre d’appels des callbacks du cycle de vie du composants puisque chaque appel survient avant ou après une opération particulière d’Angular concernant l’initialisation d’objets de la vue, la détection des changements sur ces objets et leur destruction.

Pour plus de détails sur le cycle de vie des composants et sur la détection de changements voir les articles:

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

Les bindings permettent d’interfacer des objets de la classe du composant vers le template. A l’opposé si on souhaite effectuer un traitement sur un objet de la vue à partir de la classe du composant, il faut effectuer une requête sur la vue. Cette requête permet de récupérer l’instance d’un composant enfant, d’une directive ou d’un objet du DOM.

Pour effectuer une requête sur la vue, on peut s’aider des décorateurs @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentCildren(). Ces décorateurs se placent devant la propriété ou le membre de la classe du composant dans lesquels l’instance doit être renseignée. Le choix du décorateur à utiliser dépend du type d’objet à requêter:

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

Par exemple, pour requêter un élément dans la vue d’un composant:

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

@Component({
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
  @ViewChild('spanElement', { static: true }) spanReference: ElementRef;

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

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

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

Pour requêter des objets d’une vue, les critères utilisés peuvent être de nature différente.

Requêter suivant un type

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

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

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

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

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

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

Requêter suivant une variable référence

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

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

@ViewChild('directiveRef') requestedDirective: TypedDirective;

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

Le contenu de la vue d’un composant est séparé en 2 parties:

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

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

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

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

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

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

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

QueryList

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

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

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

Injection de dépendances

Angular permet d’effectuer de l’injection des dépendances d’un composant à son instanciation. Suivant la façon dont les dépendances sont déclarées, le framework d’injection de dépendances pourra instancier un nouvel objet ou utiliser une instance existante sous forme de singleton. Les objets injectés dans un composant peuvent être des classes ou des services.

Par exemple, si on considère un service à injecter dans un composant. Ce composant est déclaré dans un module:

@NgModule({  
  declaration: [ ExampleComponent ],  
  ...  
})  

Le service est déclaré avec le décorateur @Injectable() pour indiquer qu’il s’agit d’une classe injectable:

import { Injectable } from '@angular/core';  

@Injectable({  
  providedIn: 'root'  
})  
export class InjectedService {  
}  

Un singleton de ce service peut être injecté dans le composant ExampleComponent simplement en ajoutant la paramètre du service en tant qu’argument du constructeur:

import { Component } from '@angular/core';  
import { InjectedService } from '../InjectedService';  

@Component({  
  ...  
})  
export class ExampleComponent {  
  constructor(private InjectedService: InjectedService) {}  
}  

Avec cette implémentation, le service est un singleton dont l’instance est accessible dans toute l’application à cause de la déclaration:

@Injectable({  
  providedIn: 'root'  
})  

Il existe d’autres configurations possible pour déclarer un objet à injecter. il possible de configurer l’injection pour qu’une instance soit créée au chargement d’un module ou qu’une nouvelle instance soit créée à chaque injection. Pour voir plus en détails tous ces éléments de configuration voir l’article Injection de dépendances dans une application Angular.

Méthodes pour afficher un composant

La vue d’un composant peut être affichée suivant différentes méthodes:

  • En utilisant le paramètre selector comme on a pu le voir plus haut.
  • Avec le module de routing.
  • Par programmation.

Utiliser un module de routing

Un module de routing permet de configurer l’affichage d’un composant en fonction d’indication dans l’URL. Avec un module de routing, il suffit de configurer le composant devant être affiché en fonction d’un chemin indiqué dans l’URL.

En créant une application Angular avec l’option --routing:

ng new <nom application> —-routing  

Avec cette configuration, le module de routing est créé dans le fichier src/app/app-routing.module.ts. Ce module est importé dans le Root Module dans src/app/app.module.ts:

@NgModule({  
  declarations: [ ... ],  
  imports: [  
    AppRoutingModule,  
    ...  
  ]  
})  
export class AppModule { }  

Le module de routing (dans notre exemple ce module se trouve dans src/app/app-routing.module.ts) contient des routes indiquant le chemin de l’URL et le composant à afficher suivant ce chemin, par exemple:

import { NgModule } from '@angular/core';  
import { Routes, RouterModule } from '@angular/router';  
import { AppComponent } from '../app.component';  
import { ExampleComponent } from './example/example.component';  

const routes: Routes = {  
  { path: 'example', component: ExampleComponent }  
  { path: '', component: AppComponent }  
}  

@NgModule({  
  imports: [RouterModule.forRoot(routes)],  
  exports: [RouterModule]  
})  

Ainsi cette configuration permet d’afficher:

  • Le composant ExampleComponent si l’URL est http://local host:4200/#example.
  • Le composant AppComponent si l’URL est http://local host:4200/#.

Ces composants seront affichés au niveau du composant principal (i.e. src/app/app.component.html) car le template du fichier principal app.component.html contient:

<router-outlet></router-outlet>  

Plus concrètement, à l’exécution, <router-outlet></router-outlet> sera remplacé par la vue du composant à afficher.

Afficher un composant par programmation

On peut afficher des composants par programmation en utilisant une factory pour instancier un composant à afficher.

Par exemple si on considère 2 composants Parent et Child. On souhaite afficher Child par programmation dans la vue de Parent.

  1. Pour créer les 2 composants, on exécute les instructions:
    ~% ng g c Parent  
    ~% ng g c Child  
    

    Ces instructions vont créer les composants et les rajouter au module principal dans src/app/app.module.ts.

  2. On indique l’emplacement dans la vue du composant Parent dans lequel on placera la vue du composant Child. Cet emplacement est de type <ng-template> qui est un élément Angular dans lequel on peut placer une vue. On modifie le template du comparent Parent (dans src/app/parent/parent.component.html) de cette façon:
    <p>Composant Parent</p>  
    <ng-template #childComponentPlace></ng-template>  
    

    #childComponentPlace correspond à une variable référence qui permet de nommer l’élément <ng-template>.

  3. Dans la classe du composant Parent (dans src/app/parent/parent.component.ts), on effectue une requête dans la vue de ce composant pour récupérer l’emplacement dans lequel on va placer la vue du composant Child. On effectue cette requête en utilisant @ViewChild:
    import { Component, ViewContainerRef, ViewChild } from '@angular/core';  
    
    @Component({  
      selector: 'app-parent',  
      templateUrl: './parent.component.html'  
    })  
    export class ParentComponent {  
      @ViewChild('childComponentPlace', { read: ViewContainerRef }) childComponentRef: ViewContainerRef;  
    }
    
  4. On modifie le template du composant principal (dans src/app/app.component.html) pour afficher le composant Parent (on peut supprimer tout ce qui se trouve dans ce fichier):
    <app-parent></app-parent>
    
  5. On injecte l’objet ComponentFactoryResolver dans le composant Parent par injection de dépendances et on implémente la callback ngAfterViewInit() pour instancier le composant Child et placer la vue de ce composant dans l’emplacement childComponentPlace:
    import { Component, ViewContainerRef, ViewChild, AfterViewInit, ComponentFactoryResolver } 
      from '@angular/core';  
    import { ChildComponent } from '../child/child.component';  
    
    @Component({  
      selector: 'app-parent',  
      templateUrl: './parent.component.html'  
    })  
    export class ParentComponent implements AfterViewInit {  
      @ViewChild('childComponentPlace', { read: ViewContainerRef }) childComponentRef: ViewContainerRef;  
    
      constructor(private componentFactoryResolver: ComponentFactoryResolver) {}  
    
      ngAfterViewInit() {  
        const childComponentFactory = this.componentFactoryResolver.resolveComponentFactory(ChildComponent);  
        const containerRef = this.childComponentRef;  
        containerRef.clear();  
        containerRef.createComponent(childComponentFactory);  
      }  
    }  
    

    On place le code qui permet l’instanciation du composant Child en utilisant la callback ngAfterViewInit() de façon à ce que le membre childComponentRef soit instancié. En effet si on considère le cycle de vie d’un composant, les requêtes sur la vue avec @ViewChild() sont exécutées juste avant que la callback ngAfterViewInit() soit appelée. Si on place le code avant dans le cycle de vie du composant, childComponentRef sera undefined.

En exécutant ce code on obtient:

Composant Parent
child work!

On peut voir que le composant Child est présent dans la vue du composant Parent.

Pour aller plus loin…

Les fonctionnalités indiquées dans cet article sont détaillées dans d’autres articles:

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

Angular from scratch

@john_artifexfilms

Angular est un framework permettant d’implémenter des applications Javascript front-end exécutées coté client sous forme de Single Page Application (i.e. application web monopage). Le langage utilisé pour implémenter des applications Angular est Typescript.

Le but de cet article est d’indiquer les caractéristiques et les éléments de syntaxe principaux d’Angular en partant de zéro c’est-à-dire sans avoir de connaissances préalables sur le framework, sur Javascript ou sur d’autres technologie web. Le but n’est pas de paraphraser la documentation angular.io mais de donner un point de départ pour comprendre les éléments les plus importants du framework.

Avant de rentrer dans les détails de la syntaxe, on va indiquer quelques éléments de contexte pour mieux comprendre Angular.

Quelques généralités

Angular vs. AngularJS

Initialement, la première version d’Angular était implémentée en Javascript. Après cette première version, l’équipe d’Angular a réécrit tout le framework en Typescript. Cette nouvelle version en Typescript correspondait à Angular 2. Les versions suivantes d’Angular sont toutes en Typescript toutefois la première version est encore maintenue car c’est la seule qui soit en Javascript. Pour éviter la confusion la version 1 d’Angular en Javascript est appelée AngularJS; toutes les versions suivantes sont appelées Angular.

La maintenance des 2 frameworks est distincte, toutefois l’arrêt de la maintenance d’AngularJS est programmmé pour le 31 décembre 2021(*).

Typescript vs. Javascript

Malgré ce titre, ces 2 langages ne sont pas opposés car le code Typescript est transpilé en Javascript pour être exécuté. Fonctionnellement, le code Typescript apporte des améliorations de syntaxe par rapport au Javascript dans le but de l’enrichir et de le sécuriser du point de vue du typage et de la vérification syntaxique avant l’exécution. Ces points sont, toutefois, de moins en moins vrais avec l’avènement d’EcmaScript 6 (i.e. EcmaScript 2015).

Le code Typescript n’est utilisé que pour la programmation, il est transpilé en Javascript pour être exécutable sur les browsers. Le transpilage est effectué par un compilateur.

Pour davantage de détails sur la syntaxe Typescript, voir L’essentiel de la syntaxe Typescript en 10 min.

Single Page Application (i.e. SPA)

Angular permet de faciliter l’implémentation d’application s’exécutant en mode Single Page Application. Les applications SPA permettent d’améliorer l’expérience utilisateur lorsqu’elles sont exécutées sur un browser puisqu’elles évitent de devoir recharger toute la page entre différentes actions. Toutefois elles sont plus complexes à implémenter puisqu’elles nécessitent d’être exécutées principalement sur le browser d’où l’avènement de frameworks aidant à leur implémentation comme React, Vue.js, Knockout.js ou Angular.

Dans le cas d’Angular, il faut garder en tête que le code est principalement exécuté coté client et non du coté du serveur comme, par exemple, pour ASP.NET MVC. Ainsi tout le code doit être chargé par le browser pour être exécuté. Ce chargement peut poser quelques problèmes:

  • d’abord en terme de temps de chargement puisque plus le code est volumineux et plus il prendra du temps pour être chargé par le browser. Il existe des optimisations comme la minification du code pour en réduire la taille, l’utilisation de cache avec les content delivery networks (i.e. CDN) ou le chargement de modules par lazy-loading.
  • Ensuite, sachant que le code se trouve du coté du browser, des problèmes de sécurité peuvent survenir puisque le code est lisible directement sur le browser. Il existe des méthodes pour sécuriser les traitements par exemple en permettant le chargement conditionnel de modules d’une application en fonction des droits de l’utilisateur identifié.

Le plus souvent, une application Angular est exécutée dans le browser en effectuant un rendu des pages en utilisant le DOM. Toutefois il est possible d’exécuter une application coté serveur en générant des pages statiques avec la fonctionnalité Angular Universal.

DOM

Une page HTML est composée d’éléments avec une certaine hiérarchie (comme html, body, div, span, p, a etc…). Ces éléments peuvent être représentés dans un graphe d’objets qui est appelé DOM (i.e. Document Object Model). Ce DOM est utilisé par les navigateurs pour déterminer les objets qui peuvent être rendus à l’écran. En s’interfaçant avec le DOM avec du code Javascript, il est possible de modifier le contenu, la structure et le style de la page.

Invervenir fréquement dans le DOM est couteux en performance. Angular utilise le DOM pour s’interfacer avec les objets de la page, toutefois il minimise autant que possible les parcours et les modifications du DOM pour améliorer les performances. Une partie importante du framework est de procéder par incrémentation en cherchant à détecter des changements lorsqu’un évènement survient sur la page. En cas de changement détecté, le framework intervient dans le DOM en minisant le parcours du graphe d’objets et le nombre de modifications. Cet aspect de détection de changements est transparent pour le développeur.

Attributs et propriétés

Les caractéristiques des éléments HTML peuvent être précisées ou modifiées avec des attributs, par exemple:

<button name="clickMeButton">Click me</button>

Dans cet exemple name est un attribut de l’élément HTML button.
Les attributs peuvent posséder une valeur sous forme de chaînes de caractères ou ils peuvent ne pas avoir de valeur. Dans l’exemple suivant, disabled est un attribut sans valeur:

<button name="clickMeButton" disabled>Click me</button>

Symétriquement aux attributs, les objets du DOM possèdent des propriétés. Par exemple, le code Javascript suivant permet de créer un élément dans le DOM et de paramétrer la propriété innerHtml:

var btn = document.createElement("BUTTON");
btn.innerHTML = "Click me";
document.body.appendChild(btn);

De la même façon, on peut affecter une valeur à la propriété disabled équivalente à l’attribut du même nom:

btn.disabled = true;

La plupart du temps, les attributs servent à initialiser les propriétés toutefois ils ne sont pas modifiés si la valeur des propriétés change. Il peut exister un mapping strict entre les attributs et les propriétés (comme par exemple id). Dans certains cas, il n’existe pas de propriété correspondant à un attribut (par exemple: l’attribut colspan de l’élément td). Enfin pour d’autres cas, une propriété peut ne pas avoir de correspondance en attribut (comme textContent).
Angular utilise différent type de bindings pour affecter des valeurs aux propriétés des objets. Ces objets sont des élément HTML, des composants ou des directives.

Webpack

Angular est un framework utilisant de nombreux projets comme par exemple: Typescript, Node.js, RxJS, jQuery, Karma, Jasmine, Protractor, Babel etc… Parmi ces projets, on peut trouver Webpack qui est une des dépendances les plus importantes.

La fonctionalité initiale de Webpack est de permettre de générer un bundle à partir du code source. Un bundle est un code Javascript optimisé pour permettre un téléchargement et une exécution rapide par le browser. Webpack est un apport important à Angular puisque outre la génération du bundle, il permet de nombreuses autres fonctionnalités:

  • Transpilation du code Typescript: le code Typescript, Javascript, CSS, SASS ou less est transpilé en Javascript.
  • Permettre l’utilisation des fichiers “source map” pour faciliter le débugage du code Typescript directement dans le browser.
  • La prise en compte des différents types de modules (module ES2015, CommonJS, AMD etc…).
  • Fonctionnalité HMR (i.e. Hot Module Replacement) pour mettre à jour un module Angular sans avoir à recompiler et à recharger tout le projet.
  • Permettre le chargement de module en mode lazy-loading.
  • Minifier le code pour minimiser la taille du code Javascript du bundle par exemple, en supprimant les caractères espace, supprimant les variables inutiles ou en diminuant la taille des noms de variables.
  • Permettre l’uglification pour rendre le code du bundle plus difficile à lire.

Implémenter une application Angular

La plupart des manipulations d’une application Angular peuvent se faire en utilisant le CLI Angular. Il est possible d’installer le CLI avec le gestionnaire de package NPM.

NPM

Angular nécessite l’installation de Node.js et l’utilisation du gestionnaire de package NPM.

Pour plus de détails sur:

Créer une application Angular “from scratch”

On peut rapidement créer une application Angular en utilisant le CLI Angular (i.e. Command Line Interface). Pour installer le CLI Angular avec NPM, il faut exécuter l’instruction suivante:

npm install @angular/cli --global 

Cette commande permet d’installer le CLI dans le répertoire global de NPM. Par défaut, le répertoire global se trouve dans:

  • Sur Linux: /usr/local/lib/node ou /usr/local/lib/node_modules/
  • Sur Windows: %USERPROFILE%\AppData\Roaming\npm\node_modules

Pour avoir ce chemin, il faut exécuter:

npm config get prefix 

Dans le répertoire node_modules, ng se trouve dans:

node_modules/@angular/cli/bin 

Après avoir installé le CLI Angular, on peut créer un squelette d’une application Angular en exécutant l’instruction:

ng new <nom application> --style css --routing 

Pour compiler l’application, il faut exécuter:

ng build 

Pour exécuter l’application et l’afficher dans un browser (par défaut l’application est accessible à l’adresse http://localhost:4200):

ng serve --watch

Pour avoir davantage de détails sur le CLI Angular et le détail des options, voir Angular CLI en 5 min.

Comment débugger une application Angular ?

En exécutant l’application avec ng serve, il est possible de débugger en pas à pas avec le browser en affichant les outils de développement:

  1. Pour afficher les outils de développement dans un browser:
    • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] (sous MacOS: [⌥] + [⌘] + [Z], sous Linux: [Ctrl] + [Maj] + [Z]) ou en allant dans le menu “Outils” ⇒ “Développement web” ⇒ “Débogueur”.
    • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Sources”. A partir du menu, il faut aller dans “Afficher” ⇒ “Options pour les développeurs” ⇒ “Outils de développement”.
  2. Dans l’onglet “Debugger” dans Firefox ou “Sources” dans Chrome, il faut déplier le nœud
    webpacksrcapp
    ou
    webpack://.srcapp
  3. Il est possible de placer des points d’arrêt en cliquant à coté de la ligne:
  4. On peut débugguer si on recharge la page avec [F5]:

    Ensuite, on peut taper:

    • [F8] pour relancer l’exécution jusqu’au prochain point d’arrêt,
    • [F10] pour exécuter la ligne de code sans entrer dans le corps des fonctions exécutées
    • [F11] pour exécuter la ligne de code en rentrant dans le corps des fonctions exécutées.

    Dans le débugger, on peut accéder à d’autres outils pour vérifier le contenu d’une variable, afficher la pile d’appels ou placer des points d’arrêts lorsque des évènements surviennent:

Bacs à sable pour tester du code

Il est possible d’exécuter du code directement en ligne pour tester une exécution sans avoir à effectuer toute une installation, par exemple:

  • Pour tester une application Angular: stackblitz.com permet d’implémenter une application avec l’interface de Visual Studio Code.
  • Pour exécuter du code Typescript et/ou Javascript: jsbin.com permet d’éditer et d’exécuter en utilisant des bibliothèques web courantes.
  • Pour exécuter du code HTML, Typescript ou Javascript et voir le rendu: https://jsfiddle.net.

Les principaux objets Angular

Pour permettre d’implémenter une application, Angular met à disposition des objets qui n’apparaîtront pas dans leur forme originelle dans le code Javascript après transpilation. Ces objets proposent un cadre pour organiser l’implémentation du code Typescript, HTML ou CSS. Ces objets sont composées:

  • D’une classe Typescript,
  • D’un décorateur qui permet d’indiquer quelle est la fonction de l’objet dans l’application. Par exemple, pour définir un composant, on utilise le décorateur @Component():
    @Component()
    export class ExampleComponent {}
    
  • De metadonnées qui sont renseignées avec le décorateur et qui permettent d’affiner les caractéristiques d’un objet. Ces métadonnées sont indiquées dans le décorateur, par exemple:
    @Component({
      selector: 'app-example',
      templateUrl: './example.component.html'
    })
    export class ExampleComponent {}
    

Les principaux objets Angular sont:

  • Composant (décorateur @Component()): chaque composant comporte un classe, une vue et éventuellement un style CSS. Ils permettent d’implémenter une vue et d’interagir dynamiquement avec les éléments de cette vue.
  • Module (décorateur @NgModule()): regroupement logique d’objets de façon à structurer, à faciliter la réutilisation et à partager le code. Il existe différents types de modules:
    • Le Root Module: il en existe qu’un seul qui est obligatoirement chargé au démarrage d’application.
    • Feature Module: ces modules permettent de regrouper des objets Angular par fonctionnalité. Ils peuvent être spécialisés suivant leurs caractéristiques: Domain Feature Module (module regroupant une fonctionalité entière), Service Feature Module (module ne comportant que des services), Widget Feature Module (regroupant des objets graphiques), Routed Module (module chargé en mode lazy-loading) ou Routing Module (permattant la fonctionnalité de routing).
  • Service (décorateur @Service()): classe permettant d’effectuer un traitement technique ou de garder en mémoire des objets. L’intérêt des services est de pouvoir être facilement injecté sous la forme d’un singleton ou d’instances distinctes dans des composants ou des directives.
  • Directive (décorateur @Directive()): objet permettant d’enrichir des éléments HTML. La différence entre un composant et une directive est que la directive ne possède pas de vue. Elle modifie des éléments HTML par programmation. Il existe 2 types de directives:
    • Structural directive: elle modifie le rendu graphique en ajoutant, supprimant ou en remplaçant des éléments du DOM.
    • Attribute directive: elle altère l’apparence ou le composant d’un élément HTML.
  • Pipe (décorateur @Pipe()): cet objet permet de transformer les données avant de les afficher en utilisant une syntaxe du type {{ expression | filter }}.

Détails d’une application Angular

Après avoir créé une application en utilisant l’instruction du CLI ng new, on obtient un squelette contenant:

  • Des fichiers permettant d’afficher une vue:
    • src/index.html: premier fichier HTML permettant de tirer la code Javascript et d’exécuter l’application.
    • src/main.ts: fichier Typescript permettant d’exécuter l’application en indiquant le premier module qui sera chargé.
    • src/app: répertoire contenant le code de l’application et en particulier:
      • le module principal AppModule dans app.module.ts,
      • éventuellement le module de routing AppRoutingModule dans app-routing.module.ts,
      • le composant AppComponent dans les fichiers app.component.ts, app.component.html et app.component.css.
    • src/styles.css: ce fichier est vide toutefois il permet de définir des styles et classes CSS utilisables dans l’application.
  • Des répertoires comme:
    • dist: répertoire de sortie par défaut des résultats de compilation (ce répertoire peut être modifié dans tsconfig.json).
    • nodes_modules: ensemble des packages NPM des dépendances de l’application.
    • src/environnements: éléments de configuration pour les différentes configuration de compilation (développement, production, etc…). Ce répertoire peut être modifié dans angular.json.
    • e2e: répertoire contenant les tests de “bout en bout” (i.e. end-to-end tests).
  • Des fichiers de configuration comme:
    • angular.json: ce fichier permet d’apporter des éléments de configuration pour le CLI Angular comme par exemple: les différents mode de configuration (dévelopement, production etc…); les indications des répertoires contenant les sources, répertoire de sortie, fichier de style; les éléments de configuration pour les commandes du CLI.
    • tsconfig.json: ce sont les options de compilation du compilateur Typescript.
    • package.json: éléments de configuration de NPM c’est-à-dire les actions à exécuter au lancement de scripts avec NPM; les dépendances de package.
    • Karma.conf.js: fichier de configuration du runner de tests Karma.
    • tslint.json: fichier de configuration du linter tslint (outil permettant d’augmenter la qualité du code). Tslint est remplacé par ESLint à partir d’Angular 11.

Si on exécute l’instruction du CLI ng serve --watch, on obtient l’affichage de l’application dans le browser à l’adresse http://localhost:4200 par défaut. Pour comprendre cet affichage, on peut suivre l’ordre d’exécution des différents objets en partant des fichiers src/index.html et src/main.ts:

  1. Les fichiers src/index.html et src/main.ts font partie de l’amorce de l’application car ils sont indiqués dans le fichier de configuration angular.json au niveau de "projects""<nom du projet>""architect""build""options":
    "options": {
      "index": "src/index.html",
      "main": "src/main.ts"
    }
    
  2. src/index.html: ce fichier est le point de départ lorsque le browser charge les fichiers de l’application. Dans ce fichier se trouve plusieurs éléments importants:
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <title>Example</title>
      <base href="/">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <link rel="icon" type="image/x-icon" href="favicon.ico">
    </head>
    <body>
      <app-root></app-root>
    </body>
    </html>
    

    Dans ce code, on peut voir les lignes:

    • Base href:
      <base href="/">
      

      Cette ligne permet d’indiquer au browser l’adresse du base de l’application. Suivant la stratégie de routing utilisée (PathLocationStrategy ou HashLocationStrategy) si l’URL est modifiée:

      • après la partie href: il n’y aura pas d’appels au serveur web. Le traitement sera effectué par l’application Angular dans le browser.
      • avant la partie href: un appel est effectué au serveur web.
    • Appel au composant principal:
      <body>
        <app-root></app-root>
      </body>
      

      Cette indication permet d’instancier le composant dont le paramètre selector dans le décorateur @Component() est app-root. Ce composant est src/app/app.component.ts.

  3. src/main.ts: ce fichier permet de charger le Root Module grâce aux lignes:
    import { AppModule } from './app/app.module';
    platformBrowserDynamic.bootstrapModule(AppModule)
      .catch(err => console.error(err));
    
  4. src/app/app.module.ts: il s’agit du Root Module de l’application. Il permet de charger tous les objets Angular:
    @NgModule({
      declarations: [
        AppComponent
      ],
      imports: [
        BrowserModule,
        AppRoutingModule
      ],
      providers: [],
      bootstrap: [AppComponent]
    })
    export class AppModule { }
    

    Dans l’implémentation, le Root Module charge:

    • Le module BrowserModule,
    • Le module de routing AppRoutingModule dans le fichier src/app/app-routing.module.ts,
    • Le composant AppComponent dans src/app/app.component.ts.
  5. src/app/app.component.ts: il s’agit du composant principal, il est affiché car il est appelé par <app-root></app-root> dans src/index.html. La vue de ce composant (i.e. fichier template) est src/app/app.component.html. La ligne importante de la vue est:
    <router-outlet></router-outlet>
    

    Cette ligne permet d’appeler le module de routing dans src/app/app-routing.module.ts.

  6. src/app/app-routing.module.ts: ce fichier est le module de routing. Il permet de rediriger l’affichage vers la vue d’un composant en fonction de ce qui est indiqué dans l’URL après la partie href:
    const routes: Routes = [];
    
    @NgModule({
      imports: [RouterModule.forRoot(routes)],
      exports: [RouterModule]
    })
    export class AppRoutingModule { }
    

    Etant donné qu’aucune route n’est configurée, ce module ne dirigera l’affichage que vers le composant principal dans src/app/app.component.ts.

Pour aller plus loin…

Les fonctionnalités principales d’Angular sont détaillées dans d’autres articles:

  1. Les composants: un composant correspond à une unité d’implémentation permettant d’afficher une vue. Chaque composant est formé:
    • D’une vue appelée template: cette partie comporte du code HTML enrichi. Elle permet d’implémenter les objets à afficher.
    • D’une classe en Typescript permettant d’exécuter des traitements utilisables par la vue.

    Les articles détaillants les fonctionnalités des composants sont:

  2. Injection de dépendances: cet article détaille la fonctionnalité d’injection de dépendances d’Angular.
  3. Les modules (article à venir)
  4. Le routing (article à venir)
  5. La détection de changements: il s’agit d’une fonctionnalité importante qui permet de lier les templates aux classes en utilisant différent type de bindings.
  6. Les directives: ces objets permettent de modifier ou d’enrichir un élément du DOM en rajoutant ou en modifiant une propriété par programmation.
  7. Angular CLI: décrit les commandes les plus importantes du CLI Angular.

Détails des versions d’Angular

Pour terminer, le tableau suivant résume les fonctionnalités d’Angular suivant les versions:

Version Date Dépendances Fonctionnalités importantes
AngularJS Octobre 2010 Appelée AngularJS, c’est la première version d’Angular écrite Javascript. La dernière version majeure est la 1.8.
Angular 2 Septembre 2016
  • Typescript 2.0
  • RxJs: 5.0
  • TsLib: N/A
  • Node 5.4
  • Webpack: 1.12
Angular 2 correspond à la réécriture d’AngularJS en Typescript
Angular 4 Mars 2017
  • Typescript 2.1
  • RxJs: 5.5
  • TsLib: N/A
  • TSLint 3.15
  • Node: 6.9
  • Webpack: 1.7
Pas de release en version 3.

Cette version ne comporte pas de fonctionnalités majeures par rapport à la version 2:

  • Compilation plus rapide
  • Les animations ne sont plus dans @angular/core
  • La directive *ngIf supporte else.
  • Les animations ne sont plus dans @angular/core mais dans @angular/platform-browser/animations.
  • Renderer2 remplace Renderer dans @angular/core.
  • Intégration de Angular Universal qui permet d’exécuter un pré-rendu (pre-rendering ahead of time) d’une application Angular coté serveur de façon à améliorer, le cas échéant, les performances, permettre de répondre aux problématiques SEO (Search Engine Optimization) et des aperçus effectués par les médias sociaux.
Angular 5 Novembre 2017
  • Typescript 2.4
  • RxJs: 5.5
  • TsLib 1.7
  • TSLint 5.7
  • Node: 6.9
  • Amélioration de la compilation
  • Activation de la fonctionnalité Build Optimizer par défaut. Cette fonctionnalité permet de réduire la taille du bundle résultant de la compilation.
  • Support de l’API Transfer State dans Angular Universal pour éviter la double création des objets XMLHttpRequest (i.e. XHR) coté client et coté serveur lorsque la vue client est créée.
  • @angular/http est remplacé par @angular/common/http. HttpModule est remplacé par HttpClientModule dans @angular/common/http.
  • Pour les pipes Plural, Decimal, Percent et Currency, l’internationalisation est supportée avec l’introduction du paramètre locale permettant de prendre en compte les spécificités régionales.
  • Ajout d’une option updateOn dans les objets FormControls, FormGroups et FormArrays permettant de retarder la mise à jour des controls jusqu’à ce qu’un évènement blur, submit ou change soit lancé. Cette option peut être rajouté sur les objets ngForm ou ngModel avec le paramètre, respectivement, ngFormOptions ou ngModelOptions.
  • Ajout du package @angular/service-worker.
Angular 6 Mai 2018
  • Typescript 2.7
  • RxJs: 6.0
  • TsLib: 1.7
  • TSLint: 5.7
  • Node: 8.9
  • Les packages du framework sont synchronisés à la version 6.0.0.
  • Les commmandes ng update et ng add sont rajoutés au CLI Angular pour respectivement mettre à jour ou ajouter un package NPM.
  • Ajout de Angular Elements (@angular/elements) pour créer des éléments HTML personnalisés.
  • Ajout du Component Dev Kit (@angular/cdk) pour permettre le développement de composant et tirer partie des fonctionnalités de Angular Material (@angular/material).
  • Amélioration de Angular Material (@angular/material) avec de nouveaux schematics accessibles avec le CLI Angular pour générer des modèles pour faciliter l’implémentation de composants de Material.
  • Adaptation du CLI pour utiliser des workspaces pour avoir plusieurs projets ou bibliothèques dans un même workspace.
  • Fonctionnalité de tree-shaking pour les services: cette fonctionnalité est une optimisation pour éviter d’inclure dans la build finale, des services qui ne sont jamais utilisés.
Angular 7 Octobre 2018
  • Typescript: 3.1
  • RxJs: 6.3
  • TsLib: 1.7
  • TSLint: 5.7
  • Node: 10.9
  • Amélioration du CLI Angular avec les CLI Prompts pour poser des questions lors de l’exécution de commandes du CLI et éviter l’utilisation d’options.
  • Ajout des bundle budgets dans le CLI pour contrôler la taille des bundles.
  • Ajout du Virtual Scrolling et du Drap and Drop dans Angular Material (@angular/material) et CDK (@angular/cdk).
  • Support de la projection de contenu dans Angular Elements (@angular/elements).
  • Amélioration des performances.
Angular 8 Mai 2019
  • Typescript: 3.4
  • RxJs: 6.4
  • TsLib: 1.9
  • TSLint: 5.7
  • Node: 10.9
  • Activation du Differential Loading par défaut permettant au browser de choisir un bundle Javascript en fonction de ses caractéristiques.
  • Imports dynamiques (i.e. Lazy-loading) dans la configuration des routes.
  • Builder API dans le CLI pour personnaliser des commandes comme ng serve, ng build, ng test, ng lint ou ng e2e en permettant l’exécution de fonctions.
  • Support des web workers pour exécuter des threads en arrière-plan. Le CLI supporte la création de web workers avec ng g web-worker.
  • Ajout des méthodes AbstractControl.
  • markAllAsTouched() pour marquer les éléments d’un formulaire comme “touched” et FormArray.clear() pour vider tous les controls dans un objet FormArray.
Angular 9 Février 2020
  • Typescript: 3.7
  • RxJs: 6.5
  • TSLib: 1.10
  • TSLint: 5.7
  • Node: 10.9
  • Ivy est utilisé par défaut comme compilateur et moteur de rendu. Les fonctionnalités et les performances d’Ivy sont meilleures que le view engine utilisé dans les versions précédentes notamment avec un rendu plus rapide avec la compilation AOT, la fonctionnalité tree-shaking, une meilleure capacité de debug, l’exécution plus rapide des tests et un runtime qui permet de mettre à jour le DOM de façon plus performante.
  • Deux nouvelles options pour le paramètre providedIn du décorateur @Injectable() utilisé pour les services:
    • 'platform' pour indiquer qu’un service est disponible à partir d’un injecteur singleton de niveau “platform”. Cet injecteur est partagé par toutes les applications de la page.
    • any pour fournir une instance unique d’un service pour tous les modules.
  • Les composants Youtube et Google Maps ont été rajoutés à Angular Material.
Angular 10 Juin 2020
  • Typescript: 4.0
  • RxJs: 6.5
  • TSLib: 2.0
  • TSLint: 6.0
  • Node: 10.9
  • Nouveau composant Date Range Picker dans Angular Material.
  • Warnings à la compilation quand des packages utilisent des imports CommonJS.
  • Ajout de l’option --strict pour la création de nouveaux workspaces avec ng new.
  • Cette option active des paramètres permettant de signaler des bugs plus en amont et autorise le CLI à effectuer des optimisations avancées.
Angular 11 Novembre 2020
  • Typescript: 4.0
  • RxJs: 6.5
  • TsLib: 2.0
  • TSLint: 6.1
  • Node: 10.9
  • Inlining automatique des polices de caractères dans index.html. Les polices sont automatiquement téléchargées et configurées en inline dans index.html par le CLI à la compilation. La fonctionnalité est activée par défaut mais peut être désactivée avec les paramètres suivants dans angular.json:
    "configurations": { 
      "optimization": true
    }
    

    Et

    "configurations": {
      "optimization": {
        "fonts": false
      }
    }
    

    Ou

    "configurations": {
      "optimization": {
        "fonts": { 
          "inline": false
        }
      }
    }
  • Les logs et le report d’erreurs ont été améliorés dans les retours sur la console du CLI.
  • L’utilisation de la fonctionnalité Hot Module Replacement (HMR) est facilitée pour les développeurs. Cette fonctionnalité permet de remplacer des modules sans rafraîchir tout le browser. HMR existait dans les versions précédentes d’Angular mais l’ajout d’une option dans la commande ng serve du CLI permet de faciliter son utilisation en développement:
    ng server --configuration hmr
    
  • TSLint est remplacé par ESLint.
  • Dans la configuration des routes, il désormais est possible de configurer des router outlets nommés (Named outlets) pour un composant se trouvant dans un module chargé en mode lazy-loading.
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page