Les fonctionnalités C# 12

Etienne Girardet

Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 12.0. Dans un premier temps, on explicitera le contexte de C# 12.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.

Précisions sur les versions de C#

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

Chronologie des releases

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

Date Version C# Version Visual Studio Version .NET Compilateur
Novembre 2020 C# 9.0 VS2019 (16.8) .NET 5.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 3.8.0
Février 2021 VS2019 (16.9) Roslyn 3.9.0
Mai 2021 VS2019 (16.10) Roslyn 3.10.0
Août 2021 VS2019 (16.11)
Novembre 2021 C# 10.0 VS2022 (17.0) .NET 6.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.0.1
Décembre 2021 Roslyn 4.1.0
Février 2022 VS2022 (17.1)
Avril 2022 Roslyn 4.2.0
Mai 2022 VS2022 (17.2)
Août 2022 VS2022 (17.3) Roslyn 4.3.1
Novembre 2022 C# 11.0 VS2022 (17.4) .NET 7.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.4.0
Février 2023 VS2022 (17.5) Roslyn 4.5.0
Mai 2023 VS2022 (17.6) Roslyn 4.6.0
Août 2023 VS2022 (17.7) Roslyn 4.7.0
Novembre 2023 C# 12.0 VS2022 (17.8) .NET 8.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.8.0
Février 2024 VS2022 (17.9) Roslyn 4.9.2
Mai 2024 VS2022 (17.10) Roslyn 4.10.0
Août 2024 VS2022 (17.11) Roslyn 4.11.0
Novembre 2024 C# 13.0 VS2022 (17.12) .NET 9.0 Roslyn 4.12.0
Février 2025 VS2022 (17.13) Roslyn 4.13.0
Mai 2025 VS2022 (17.14) Roslyn 4.14.0
Novembre 2025 ? C# 14.0 VS2022 (17.?) .NET 10.0 Roslyn 4.?.?

(1): .NET Standard n’est plus nécessaire puisque les 2 environnements framework .NET et .NET Core n’évoluent plus fonctionnellement. Ils ont laissé place à l’environnement uniformisé .NET (voir .NET 5+ and .NET Standard pour plus de détails).

Lien entre la version C# et le compilateur

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

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

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

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

csc -help

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

csc -langversion:? 

Limiter la version C# à compiler

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

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

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

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

Fonctionnalités C# 12

Les fonctionnalités les plus basiques de C# 12.0 sont présentées dans cet article. Les inline arrays sont présentés dans un article séparé.

Primary constructors

Cette fonctionnalité permet en une seule ligne de:

  • Créer un constructeur implicite,
  • D’ajouter des données membres à une classe, une struct ou un record,
  • D’initialiser ces données membres.

Ainsi, les 2 extraits de code suivants sont équivalents:

public class Rectangle(int length, int width);

Est équivalent à:

public class Rectangle
{
  private int length;
  private int width;
  
  public Rectangle(int length, int width)
  {
    this.length = length;
    this.width = width;
  }
}

Lorsqu’un constructeur primaire est déclaré, cela entraîne quelques implications suivant le type d’objet:

  • Pour une classe: il n’y a plus de constructeur implicite sans paramètres.
    Ainsi:

    var rectangle = new Rectangle();  // ATTENTION: génère une erreur
    
  • Pour une struct: un constructeur sans paramètre implicite est rajouté à la compilation. Ce constructeur sans paramètre initialise toutes les données membres.
    Par exemple, si on déclare une struct de cette façon:

    public struct Circle(int radius);
    

    Un constructeur sans paramètre implicite est créé:

    var circle = new Circle();  // OK
    
  • Pour un record: un accesseur en lecture est rajouté implicitement pour chaque donnée membre.
    Ainsi:

    public record Car(string Brand, string Model);
    

    Est équivalent à:

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

Pour plus de détails sur les records: cdiese.fr/csharp9_records/.

Collection expressions

L’objectif de cette fonctionnalité est de faciliter l’instanciation de collections en permettant 2 nouvelles écritures syntaxiques:

  • L’instanciation d’une collection directement avec les éléments entre crochets.
  • Opérateur spread.

Instancier une collection directement avec les éléments entre crochets

Par exemple, on peut désormais instancier un liste de cette façon:

List<int> items1 = [1, 2, 3, 4];

Cette syntaxe a plusieurs conséquences:

  • La syntaxe [ ] permet de créer des collections de type différent. Ainsi le type de la collection créée ne peut être déterminé que par ce qui est indiqué devant la variable (dans l’exemple List<int>). Cela signifie qu’il n’est pas possible d’utiliser l’opérateur var:
    var items1 = [1, 2, 3, 4]; // NE COMPILE PAS
    
  • Il est possible de créer d’autres types de collection en utilisant la même syntaxe:
    List<int> collection1 = [1, 2, 3, 4];
    int[] collection2 = [1, 2, 3, 4];
    Span<int> collection3 = [1, 2, 3, 4];
    IEnumerable<int> collection4 = [1, 2, 3, 4];
    ICollection<int> collection5 = [1, 2, 3, 4];
    

On peut aussi créer des tableaux en 2 dimensions en indiquant des tableaux entre crochets:

int[] array1 = [1, 2, 3, 4];
int[] array2 = [5, 6, 7, 8];
int[] array3 = [8, 9, 10, 11];
int[][] twoDimArray = [array1, array2, array3];

Le contenu de twoDimArray est:

[[1, 2,  3,  4]
 [5, 6,  7,  8]
 [8, 9, 10, 11]]

Cette syntaxe n’est possible que pour les tableaux, elle ne permet pas de créer d’autres types de collection.
Pour créer une autre collection à 1 dimension à partir d’une collection existante avec la syntaxe [ ], il faut utiliser l’opérateur spread introduit aussi avec C# 12.

Opérateur spread

Cet opérateur permet d’instancier une collection à partir d’un énumérable en utilisant la syntaxe:

[.. <Enumerable>]

Comme précédemment, la syntaxe de la nouvelle collection est déterminée par le type indiqué avant le nom de la variable:
Si on considère:

int[] array1 = [1, 2, 3, 4];

On peut écrire:

List<int> collection1 = [..array1];
int[] collection2 = [..array1];
Span<int> collection3 = [..array1];
IEnumerable<int> collection4 = [..array1];
ICollection<int> collection5 = [..array1];

Si on utilise directement la syntaxe [ ] sans indiquer explicitement le type, il y aura une erreur à la compilation:

foreach (var element in [..array1])   // NE COMPILE PAS
{   }

Il faut indiquer le type explicitement:

foreach (var element in (List<int>)[..array1])
{   }

L’opérateur spread énumère l’enumerable qui se trouve derrière les .., on peut donc écrire:

int[] array1 = [1, 2, 3, 4];
int[] array2 = [5, 6, 7, 8];
List<int> collection1 = [..array1, ..array2];

Le contenu de collection1 sera:

1, 2, 3, 4, 5, 6, 7, 8
Ne pas confondre l’opérateur spread (C# 12) et l’opérateur range (C# 11)

Avant C# 12, il existait déjà une notation .. qui correspond à l’opérateur range (introduit en C# 8) qui permet d’énumérer le contenu d’une collection avec une syntaxe:

<Index> .. <Index>

L’opérateur range permet d’instancier un objet de type System.Range qui servira à effectuer une énumération, par exemple:

List<int> example = new List<int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
var sample1 = example[2..5];    // Intervalle de 2 à 5 exclus: 2, 3, 4
var sample2 = example[2..];     // Intervalle de 0 à la fin: 2, 3, 4, 5, 6, 7, 8, 9
var sample3 = example[..5];     // Intervalle de 0 à 5 exclus: 0, 1, 2, 3, 4
var sample4 = example[..^3];    // Intervalle de 0 jusqu'à 3 index avant la fin: 0, 1, 2, 3, 4, 5, 6
var sample5 = example[..];      // Intervalle de 0 à la fin: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

Il existe une autre notation utilisant aussi .. qui correspond au range pattern dans le cadre du pattern matching (à partir de C# 11). Voir List pattern dans cdiese.fr/cheat-sheet-pattern-matching/.

ref readonly en argument de méthode

Cette fonctionnalité est plus avancée que les précédentes et nécessite la compréhension de plusieurs notions pour être appréhendée. Elle consiste à permettre d’utiliser ref readonly pour les paramètres d’une méthode. Le modificateur ref readonly existait déjà quand on déclarait une variable depuis C# 7.2 mais il n’était pas possible de l’utiliser en paramètre de méthode (on ne pouvait utiliser que ref). Depuis C# 7.2, il existait l’opérateur in en paramètre de méthode qui assurait à peu près la même fonctionnalité.

Pour comprendre cette fonctionnalité, il est nécessaire de rappeler quelques caractéristiques de C#:

  • En C#, on considère les objets de type référence (classe et record) et les objets de type valeur (struct, record struct, enum, type primitif comme int, floatw, double, byte etc…).
  • Les objets de type référence sont manipulés par référence lors des copies de variable ou lors des passages en argument de méthode. Une référence est un objet de type valeur. Ainsi la référence est copiée mais l’objet référencé n’est pas copié.
  • Les objets de type valeur sont manipulés en effectuant des copies lors des copies de variable ou lors des passages en argument de méthode. L’objet est copié entièrement lors de ces manipulations.

Avant C# 12

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

  • Pour un objet de type valeur, on manipule une référence vers cet objet (plus précisément on utilise un objet ref qui pointe vers l’objet de type valeur).
  • Pour un objet de type référence, on manipule une référence de la référence vers l’objet (on utilise un objet ref qui pointe vers la référence de l’objet de type référence, la référence d’un objet de type référence étant elle-même un objet de type valeur).

Ainsi:

Syntaxe Remarques
Passage en argument
par référence
(ref)
void MethodName(ref <type> argument)
{ ... }
Par exemple:

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

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

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

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

Par exemple:

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

Par exemple:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

struct Circle
{
  public int Radius;

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

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

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

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

Pour plus de détails, voir Type valeur vs type référence.

A partir de C# 12

Il est possible d’utiliser ref readonly en argument de méthode pour éviter les modifications d’un objet à l’intérieur de la méthode.

Par exemple si on considère:

struct Circle
{
  public int Radius;

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

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

// circle peut être modifié dans la méthode ChangeRadiusFromOutside():
var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadiusFromOutside(ref circle, 2); // circle est passé par référence 
Console.WriteLine(circle.Radius); // 2

Pour éviter de modifier circle dans ChangeRadiusFromOutside(), on peut utiliser ref readonly:

static void ChangeRadiusFromOutside(ref readonly Circle circle, int newRadius) 
{ 
  // circle.Radius = newRadius; // ERREUR car on ne peut pas modifier circle dans le corps de la méthode 
  circle.SetNewRadius(newRadius); // Pas d'erreur mais cette ligne n'aura pas d'effets
  Console.WriteLine(circle.Radius);  
}

Si on effectue l’appel suivant:

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

// Dans le corps de ChangeRadiusFromOutside(), Console.WriteLine(circle.Radius) affiche 4 

Avec ref readonly, l’argument circle est passé par référence toutefois le compilateur effectue des optimisations en sachant que circle ne peut être modifié dans le corps de la méthode. circle.SetNewRadius() ne modifie pas l’objet circle. Le résultat est toujours 4 quand on essaie de modifier circle à l’intérieur de ChangeRadiusFromOutside().
La valeur de Radius n’est pas modifiée à cause d’une defensive copy effectuée par le compilateur pour éviter que la structure ne soit modifiée dans le corps de ChangeRadiusFromOutside(). Cette copie peut être couteuse en performance, c’est pourquoi il est préférable d’utiliser ref readonly avec des objets immutables comme readonly struct.

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

Il existe une différence entre utiliser ref et ref readonly avec des structures mutables. Pour garantir que la structure mutable déclarée avec ref readonly n’est pas modifiée, le compilateur effectue une copie par valeur de la structure pour chaque déclaration d’une variable ref readonly (i.e. defensive copy). Cette copie est effectuée si la structure est mutable (c’est-à-dire qu’elle n’est pas déclarée avec readonly struct).
La copie de la structure peut être évitée si la structure est immutable en la déclarant avec readonly struct. Dans ce cas, le compilateur effectue des optimisations en évitant d’effectuer des copies par valeur à chaque déclaration d’une variable ref readonly.

Par exemple, la structure Circle est mutable:

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

Si Circle est immutable:

readonly struct ImmutableCircle
{
  public readonly int Radius { get; }

  public ImmutableCircle(int radius)
  {
    Radius = radius;
  }

  void ChangeRadiusFromInside(int radius)
  {
    // this.Radius = radius;  // Provoque une erreur de compilation
  }
}

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

ref readonly et in ont le même objectif fonctionnel, empêcher qu’un argument ne soit modifié dans le corps d’une méthode. La différence est que des messages de warning sont émis différemment par le compilateur suivant les différentes implémentations possibles.

Par exemple le code MSIL correspondant aux méthodes suivantes est identique:

public static void ChangeRadiusFromOutside(ref readonly Circle circle,
  int newRadius)
{
    circle.SetRadiusFromInside(newRadius);
}

public static void ChangeRadiusFromOutsideWithIn(in Circle circle,
  int newRadius)
{
    circle.SetRadiusFromInside(newRadius);
}

Si on effectue les appels suivants, des messages de warning différents seront émis:

var circle = new CollectionExpressions.Circle(5);

// Appels à un argument déclaré avec ref readonly 	
// L'appel suivant produit le message de warning suivant:
// warning CS9192: Argument 1 should be passed with 'ref' or 'in' keyword
CollectionExpressions.ChangeRadiusFromOutside(circle, 10);     
CollectionExpressions.ChangeRadiusFromOutside(ref circle, 10);     // Pas de warning
CollectionExpressions.ChangeRadiusFromOutside(in circle, 10);       // Pas de warning

// Appels à un argument déclaré avec in
CollectionExpressions.ChangeRadiusFromOutsideWithIn(circle, 10);       // Pas de warning
// L'appel suivant produit le message de warning suivant: 
// warning CS9191: The 'ref' modifier for argument 1 corresponding to 'in' parameter is equivalent to 'in'. 
// Consider using 'in' instead.
CollectionExpressions.ChangeRadiusFromOutsideWithIn(ref circle, 10);
CollectionExpressions.ChangeRadiusFromOutsideWithIn(in circle, 10);        // Pas de warning

Tous ces appels génèrent le même code MSIL.

Attribut “Experimental”

Cette fonctionnalité permet d’indiquer du code qui est expérimental. Si du code expérimental est utilisé, par défaut, une erreur de compilation est générée sauf si ce code est appelé par un autre code qui est aussi expérimental. Pour indiquer que du code est expérimental, il suffit d’utiliser l’attribut: System.Diagnostics.CodeAnalysis.ExperimentalAttribute

Cet attribut peut être appliqué sur une méthode, classe, structure, interface, enum, delegate, propriété ou au niveau d’une assembly entière.

Syntaxe

Le constructeur de l’attribut permet de renseigner un identifiant sous la forme d’une chaîne de caractères qui sera indiqué dans l’éventuelle erreur de compilation, par exemple si on applique cet attribut sur une classe:

[Experimental("ExperimentalClass")]
internal class ExperimentalFeature
{

}

L’identifiant ne doit pas comporter de caractères d’espacement ou des caractères spéciaux. Si l’identifiant excède 8 caractères, il sera tronqué dans l’erreur de compilation.

Si on essaie d’utiliser cette classe à partir de code qui n’est pas expérimental comme par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();    // ERREUR à la compilation
    }
}

L’erreur de compilation sera:

DoNotUs'CS12.ExperimentalFeature' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. 

Comme on peut le voir, l’identifiant DoNotUse comportant plus de 8 caractères est tronqué.

Si la classe FeatureConsumer ou la méthode ExecuteMe() comporte l’attribut Experimental, il n’y aura plus d’erreur de compilation:

[Experimental("CanUse")]
internal class FeatureConsumer
{
    //[Experimental("CanUse")]
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();    // OK
    }
}

Il n’est pas nécessaire que l’identifiant de compilation soit le même.

Par défaut, il y a un lien dans l’erreur de compilation qui renvoie la page learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/feature-version-errors?f1url=%3FappId%3Droslyn%26k%3Dk(CS9204).

Si on indique une URL dans l’attribut:

[Experimental("DoNotUse", UrlFormat = "http://microsoft.com")]

Le lien sera "http://microsoft.com" plutôt que celui par défaut.

Supprimer l’erreur de compilation

On peut supprimer l’erreur de compilation en utilisant une directive de préprocesseur:

#pragma warning disable <identifiant>

Par exemple pour l’exemple précédent:

#pragma warning disable DoNotUse
internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();  // OK
    }
}

Lorsque le préprocesseur parcourt le code ligne par ligne, dès qu’il atteint la ligne #pragma, il ne générera plus l’erreur de compilation. Si du code se trouve avant la directive #pragma, le compilateur générera une erreur, par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();   // ERREUR de compilation
    }

#pragma warning disable DoNotUse
}

On peut restaurer la prise en compte de l’erreur de compilation avec:

#pragma warning restore <identifiant>

Par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
#pragma warning disable DoNotUse
        var feature = new ExperimentalFeature();      // OK
#pragma warning restore DoNotUse
        var feature2 = new ExperimentalFeature();     // ERREUR de compilation
    }
}

Indiquer l’attribut “Experimental” au niveau d’une assembly

On peut indiquer l’attribut Experimental à un niveau plus global au niveau d’une assembly entière en indiquant en tête d’un fichier .cs compilé (par exemple, le fichier Properties.cs):

[assembly: ExperimentalAttribute("Id")]

Autre fonctionnalité

Les “tableaux en ligne” (i.e. inline arrays) sont présentés dans un article séparé.

Références

Inline arrays (C# 12)

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

Cette fonctionnalité est très avancée. Elle convient à un besoin très précis d’optimisation et très peu nombreux seront les développeurs qui auront le réel besoin de s’en servir. Il est probable que Microsoft a eu un besoin d’optimisation dans le cadre du framework et a été amené à effectuer cette optimisation.

Inline arrays (littéralement “tableaux en ligne”) qu’on appellera par la suite “tableau inline”, permet de créer des tableaux à taille fixe d’un type struct. Ces tableaux permettent, par exemple, de créer un buffer en ligne aux caractéristiques similaires à un buffer non managé à taille fixe. L’intérêt de ce type de buffer est d’améliorer le temps d’accès aux éléments dans le buffer.

Par exemple, si une liste classique List<int> est un objet de type référence stocké dans le tas managé, accessible par l’intermédiaire d’une référence. Les objets stockés sont des entiers Int32 qui sont des objets de type valeur toutefois comme la liste est un objet de type référence, les entiers seront stockés aussi dans le tas managé comme la liste. L’accès aux éléments de la liste est complexe puisqu’il se fait par référence et dans le tas managé ce qui rend cette accès plus lent. D’autre part, le stockage dans le tas managé par l’intermédiaire de références ne permet pas d’avoir des zones continues en mémoire ce qui rend plus complexe de partager ces données avec du code natif.

L’objet Span introduit en C# 7.2 permet de corriger certains de ces problèmes (pour davantage de détails concernant Span: cdiese.fr/csharp7-ref-struct/#cs7-ref_readonly_struct-span). Span est un objet ref struct qui est donc exclusivement stocké sur la pile (voir Structure exclusivement stockée dans la pile: “ref struct” (C# 7, C# 8.0)). De la même façon que les tableaux “inline”, il donne la possibilité d’allouer des espaces de taille fixe et continus en mémoire de façon à améliorer l’accès aux données. Ce type d’objet facilite le partage de données avec du code natif puisque leur représentation en mémoire est simple. Il suffit d’indiquer au code natif l’emplacement du premier objet. Etant donné que l’espace alloué est continu, que les éléments stockés ont une taille fixe, l’accès à ces éléments est facile et prévisible.

Syntaxe

Du point de vue de la syntaxe, un tableau “inline” doit être déclaré de cette façon:

Par exemple pour déclarer un tableau “inline” d’entiers:

[InlineArray(10)]
public struct IntegerArray
{
  private int element;
}

L’attribut [InlineArray(10)] permet d’indiquer que le tableau contient 10 éléments.
Le parcours se fait en itérant sur le tableau directement:

IntegerArray integers = new IntegerArray();
for (int i = 0; i < 10; i++)
{
  integers[i] = i;
}

Pour le contenu, on peut utiliser des structures plus complexes, par exemple:

public struct Point
{
  public int X;
  public int Y;
}

[InlineArray(10)]
public struct PointInlineArray
{
  private Point point;
}

Par exemple pour y accéder:

PointInlineArray points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
  Point newPoint = new Point();
  newPoint.X = 4;
  newPoint.Y = 6;
  points[i] = newPoint;
}

Pour rendre le tableau “inline” plus générique, on peut aussi utiliser un generic:

[InlineArray(10)]
public struct PointInlineArray<T>
{
  private T element;
}

Allocation sur la pile

Comme indiqué précédemment, un des grands intérêts des tableaux “inline” est d’effectuer des allocations sur la pile ce qui permet d’augmenter les performances concernant les manipulations des éléments de cet objet.
Par exemple, si on effectue un benchmark sur le code suivant:

[MemoryDiagnoser(false)]
public class InlineArrays
{
  [Benchmark]
  public void UseInlineArray()
  {
    PointInlineArray points = new PointInlineArray();
    for (int i = 0; i < 10; i++)
    {
      Point newPoint = new Point();
      newPoint.X = 4; 
      newPoint.Y = 6;
      points[i] = newPoint;
    }
  }
}

En lançant l’exécution avec:

BenchmarkRunner.Run<InlineArrays>();

On obtient:

| Method         | Mean     | Error     | StdDev    | Allocated |
|--------------- |---------:|----------:|----------:|----------:|
| UseInlineArray | 4.362 ns | 0.1174 ns | 0.1040 ns |         - |

On peut voir qu’il n’y a pas eu d’allocations dans le tas managé (car la colonne “Allocated” est vide).

Comparaison des performances

On va juste essayer dans cette partie, de comparer les performances quant à l’accès des éléments d’un tableau “inline”. Pour cet exemple, on va se contenter d’effectuer des écritures dans le tableau à partir de code managé.

On considère 4 méthodes qui effectuent des affectations dans un tableau:

  • Affectation dans un tableau simple: Point[].
  • Affectation dans un objet de type Span<Point> instancié avec new.
  • Affectation dans un objet de type Span<Point> instancié avec stackalloc.
  • Affectation dans un tableau “inline”: PointInlineArray.

Le code avec un tableau simple Point[] est:

[Benchmark]
public void UseSimpleArray()
{
  var points = new Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Le code avec un objet de type Span<Point> instancié avec new est:

[Benchmark]
public void UseSpanWithNew()
{
  Span<Point> points = new Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Comme on l’a évoqué précédemment, l’objet Span<> propose des fonctionnalités semblables aux tableaux “inline”. L’instanciation avec new effectue un allocation dans le tas managé avant de rendre l’objet accessible dans la pile.

Le code avec un objet de type Span<Point> instancié avec stackalloc est:

[Benchmark]
public void UseSpanWithStackalloc()
{
  Span<Point> points = stackalloc Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

La différence avec le code précédent est d’utiliser stackalloc qui permet d’effectuer l’allocation directement sur la pile ce qui rend le code plus rapide à l’exécution qu’en utilisant le tas managé.

Le code avec un tableau “inline” PointInlineArray est:

[InlineArray(10)]
public struct PointInlineArray
{
  private Point Point;
}

[Benchmark]
public void UseInlineArray()
{
  var points = new PointInlineArray();
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

En exécutant le code pour effectuer des comparaisons, on obtient:

| Method                | Mean     | Error     | StdDev    | Allocated |
|---------------------- |---------:|----------:|----------:|----------:|
| UseSimpleArray        | 8.807 ns | 0.2067 ns | 0.4268 ns |     104 B |
| UseSpanWithNew        | 8.633 ns | 0.1757 ns | 0.2887 ns |     104 B |
| UseSpanWithStackalloc | 4.030 ns | 0.1090 ns | 0.1564 ns |         - |
| UseInlineArray        | 4.400 ns | 0.1010 ns | 0.0896 ns |         - |

On peut constater que l’utilisation d’un tableau “inline” est plus performant que Span avec new mais moins performant que Span avec stackalloc. Les tableaux “inline” n’utilisent pas le tas managé comme Span avec stackalloc ce qui explique ce gain en performance. Le tableau simple utilise le tas d’où l’accès aux éléments moins bon.

L’inconvénient d’utiliser Span + stackalloc est que l’objet créé doit être utilisé exclusivement dans le corps de la méthode, il ne peut pas être retourné en résultat de fonction ou en donnée membre d’un objet. Ce n’est pas le cas pour un tableau “inline” qui est considéré dans le code comme une structure: on peut donc l’utiliser comme donnée membre ou en retour d’une fonction.

Code MSIL

Si on regarde le code MSIL pour voir les objets qui sont générés à la suite de l’utilisation d’un tableau “inline”, on peut voir ce sont des objets Span qui sont utilisés.
Par exemple pour le code correspondant aux objets précédents, on peut voir que le compilateur rajoute une classe <PrivateImplementationDetails> contenant les fonctions InlineArrayAsSpan() et InlineArrayElementRef():

L’implémentation de ces fonctions est:

  • Pour InlineArrayAsSpan():
    • Code MSIL:
      .method assembly hidebysig static valuetype [System.Runtime]System.Span`1<!!TElement> 
              InlineArrayAsSpan<TBuffer,TElement>(!!TBuffer& buffer, int32 length) cil managed
      {
        // Code size       13 (0xd)
        .maxstack  8
        IL_0000:  ldarg.0
        IL_0001:  call       !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&)
        IL_0006:  ldarg.1
        IL_0007:  call       valuetype [System.Runtime]System.Span`1<!!0> [System.Memory]System.Runtime.InteropServices.MemoryMarshal::CreateSpan<!!1>(!!0&, int32)
        IL_000c:  ret
      } 
      
    • L’équivalent en C# est:
      public Span<TElement> InlineArrayAsSpan<TBuffer, TElement>(TBuffer buffer, int length)
      {
        return MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length);
      }
      

      Ce code est appelé pour obtenir un objet Span qui permettra d’accéder aux objets du tableau “inline”. Dans un premier temps, la référence du tableau “inline” est fournie par l’intermédiaire de l’argument buffer. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objet TElement en appelant la fonction Unsafe.As(). Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.

      Ensuite, un objet Span est créé à partir du cast du tableau “inline” avec MemoryMarshal.CreateSpan(). Bien-qu’un objet Span soit créé quand on appelle MemoryMarshal.CreateSpan(), il n’y a pas de déplacements de données en mémoire. Les objets TElement ou le tableau “inline” sont manipulés par référence. Ces objets étant de type valeur, se trouvent dans la pile. Les casts effectuent une réinterprétation des objets en mémoire dans des types différents sans changer l’emplacement de ces objets. Enfin, MemoryMarshal.CreateSpan() crée un objet Span mais l’objet fourni dans le constructeur de Span est une référence. Ce constructeur n’effectue donc pas de déplacement ou de copie des données se trouvant dans le tableau “inline” (cf. code source de l’objet Span), il donne une possitilité d’accéder rapidement aux objets TElement en mémoire.

      Pour résumer, la ligne: MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length) permet d’obtenir un objet Span qui facilitera les manipulations du contenu du tableau “inline” de façon optimisée.

  • Pour InlineArrayElementRef():
    • Code MSIL:
      .method assembly hidebysig static !!TElement& 
              InlineArrayElementRef<TBuffer,TElement>(!!TBuffer& buffer,
                                                      int32 index) cil managed
      {
        // Code size       13 (0xd)
        .maxstack  8
        IL_0000:  ldarg.0
        IL_0001:  call       !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&)
        IL_0006:  ldarg.1
        IL_0007:  call       !!0& [System.Runtime]System.Runtime.CompilerServices.Unsafe::Add<!!1>(!!0&, int32)
        IL_000c:  ret
      } 
      
    • L’équivalent en C# est:
      public ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index)
      {
        return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index);
      }
      

      Cette méthode permet d’obtenir la référence d’un élément à un index donné du tableau “inline”. La référence du tableau “inline” est fournie par l’intermédiaire de l’argument buffer. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objet TElement en appelant la fonction Unsafe.As(). Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.

      Ensuite, la fonction Unsafe.Add() trouve la référence d’un objet TElement se trouvant à un index donné dans la tableau “inline”

L’implémentation de la méthode UseInlineArray() est:

public void UseInlineArray()
{
  var points = new PointInlineArray();
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Le code MSIL généré est:

  .maxstack  3
  .locals init (valuetype CS12.PointInlineArray V_0,
           int32 V_1,
           valuetype CS12.Point V_2,
           valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_3)
  IL_0000:  ldloca.s   V_0
  IL_0002:  initobj    CS12.PointInlineArray
  IL_0008:  ldc.i4.0
  IL_0009:  stloc.1
  IL_000a:  br.s       IL_0044
  IL_000c:  ldloca.s   V_2
  IL_000e:  initobj    CS12.Point
  IL_0014:  ldloca.s   V_2
  IL_0016:  ldloc.1
  IL_0017:  ldc.i4.4
  IL_0018:  add
  IL_0019:  stfld      int32 CS12.Point::X
  IL_001e:  ldloca.s   V_2
  IL_0020:  ldloc.1
  IL_0021:  ldc.i4.6
  IL_0022:  add
  IL_0023:  stfld      int32 CS12.Point::Y
  IL_0028:  ldloca.s   V_0
  IL_002a:  ldc.i4.s   10
  IL_002c:  call       valuetype [System.Runtime]System.Span`1<!!1> '<PrivateImplementationDetails>'::InlineArrayAsSpan<valuetype CS12.PointInlineArray,valuetype CS12.Point>(!!0&, int32)
  IL_0031:  stloc.31
  IL_0032:  ldloca.s   V_3
  IL_0034:  ldloc.1
  IL_0035:  call       instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
  IL_003a:  ldloc.2
  IL_003b:  stobj      CS12.Point
  IL_0040:  ldloc.1
  IL_0041:  ldc.i4.1
  IL_0042:  add
  IL_0043:  stloc.1
  IL_0044:  ldloc.1
  IL_0045:  ldc.i4.s   10
  IL_0047:  blt.s      IL_000c
  IL_0049:  ret

On peut voir que le compilateur utilise InlineArrayAsSpan() pour obtenir un objet Span qui permettra d’accéder aux éléments du tableau “inline”. On pourrait se demander pourquoi la fonction InlineArrayAsSpan() est exécutée à chaque exécution de la boucle (ligne IL_002c), il suffirait de créer l’objet Span à l’extérieur de la boucle et de l’utiliser par la suite (la boucle est de la ligne IL_000c à IL_0047). Comme on a pu le voir plus haut, les appels à InlineArrayAsSpan() ne sont pas très couteux car cette fonction effectue principalement de la réinterprétation de type en mémoire sans copier ou déplacer des objets.

Cette exécution récurrente de la fonction InlineArrayAsSpan() pourrait expliquer la différence de performance entre les exemples UseInlineArray() et UseSpanWithStackalloc() que l’on a observé plus haut. Le code MSIL de la méthode UseSpanWithStackalloc() semble plus optimisé:

    .maxstack  3
  .locals init (valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_0,
           int32 V_1,
           valuetype CS12.Point V_2)
  IL_0000:  ldc.i4.s   10
  IL_0002:  conv.u
  IL_0003:  sizeof     CS12.Point
  IL_0009:  mul.ovf.un
  IL_000a:  localloc
  IL_000c:  ldc.i4.s   10
  IL_000e:  newobj     instance void valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::.ctor(void*,
                                                                                                          int32)
  IL_0013:  stloc.0
  IL_0014:  ldc.i4.0
  IL_0015:  stloc.1
  IL_0016:  br.s       IL_0046
  IL_0018:  ldloca.s   V_2
  IL_001a:  initobj    CS12.Point
  IL_0020:  ldloca.s   V_2
  IL_0022:  ldloc.1
  IL_0023:  ldc.i4.4
  IL_0024:  add
  IL_0025:  stfld      int32 CS12.Point::X
  IL_002a:  ldloca.s   V_2
  IL_002c:  ldloc.1
  IL_002d:  ldc.i4.6
  IL_002e:  add
  IL_002f:  stfld      int32 CS12.Point::Y
  IL_0034:  ldloca.s   V_0
  IL_0036:  ldloc.1
  IL_0037:  call       instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
  IL_003c:  ldloc.2
  IL_003d:  stobj      CS12.Point
  IL_0042:  ldloc.1
  IL_0043:  ldc.i4.1
  IL_0044:  add
  IL_0045:  stloc.1
  IL_0046:  ldloc.1
  IL_0047:  ldc.i4.s   10
  IL_0049:  blt.s      IL_0018
  IL_004b:  ret

Dans cet extrait, l’instanciation de l’objet Span se trouve à la ligne IL_000e et la boucle va de la ligne IL_0018 à la ligne IL_0049.

Même si dans l’exemple présenté, on a pu observer des performances un peu moins bonnes entre les tableaux “inline” et l’utilisation d’un Span avec stackalloc, il faut garder en tête que cette différence est très liée à l’exemple et à la boucle qu’on exécute. L’utilisation de Span + stackalloc ne convient pas à tous les cas d’utilisation puisque:

  • Un objet Span est une ref struct qui ne peut être une donnée membre que d’une autre ref struct. Un objet Span ne peut pas être une donnée membre d’une classe.
  • L’instanciation avec stackalloc contraint à effectuer l’instanciation dans le corps d’une méthode, elle ne peut pas être faite dans une constructeur ou auto-implémentée.

A la différence, il est possible de stocker un tableau “inline” en tant que donnée membre d’un objet de type référence.

Lire un dump mémoire à partir de Visual Studio

Capturer et lire un dump mémoire peut être très utile pour aider à comprendre l’origine d’un crash ou d’une erreur survenue dans un autre environnement qu’une machine de développement. Le but de cet article est de montrer comment on peut facilement capturer un dump mémoire et de le lire directement dans Visual Studio.

Lorsqu’une exception ou une erreur survient dans une application .NET déployée sur une machine de production, il n’est pas toujours facile de comprendre l’origine du problème pour plusieurs raisons:

  • L’environnement de production n’est pas tout à fait le même que la machine sur laquelle le développement a été effectué. Souvent la charge ou les données de l’application en production sont assez différentes pour qu’un cas de figure soit difficilement reproductible dans un environnement de développement ou de test.
  • Le plus souvent, on observe les conséquences d’une erreur et il faut trouver la cause d’un problème à partir de ses conséquences. Par exemple, on constate qu’un comportement est différent de celui attendu, qu’un exécutable a crashé ou on lit les détails d’une exception dans des logs. Ces éléments permettent de donner des indices sur l’erreur survenue sans toutefois indiquer précisément les conditions ayant menées au problème.
  • Il est généralement difficile, voir impossible, de débuguer dans l’environnement de production.

Ainsi une possibilité pour comprendre plus finement l’état d’un exécutable au moment d’un crash ou d’une exception, est de capturer un dump mémoire dans l’environnement de production et de le lire avec Visual Studio sur une machine de développement.

Qu’est-ce qu’un dump ?

Un dump mémoire d’un processus correspond à une copie du contenu de la mémoire virtuelle (pile, tas managé, pile d’appels des différents threads etc…). Un débugueur peut écrire le contenu de la mémoire virtuelle dans un fichier sur le disque de façon à pouvoir le lire plus tard. Avec les sources, on pourra ensuite lire le dump et voir une instance “gelée” du processus de façon à identifier plus précisement la ligne de code qui a menée au crash.

Contenu d’un dump

Un dump peut contenir:

  • La pile en mémoire: contient les objets et variables créés par un processus.
  • Pile d’exécution (i.e. call stack) de tous les threads: on peut savoir précisement les fonctions qui étaient exécutées au moment du dump.
  • Blocs de l’environnement des threads: contient des informations sur les threads en cours d’exécution de façon à en connaître l’état et le thread ID.
  • Code assembleur: dans le pire des cas, on peut avoir à lire le code assembleur. Cette solution est généralement trop fastidieuse et trop couteuse. Toutefois en rapprochant le dump des fichiers de symboles .pdb, on peut avoir les piles d’appels par rapport au code source, ce qui est plus facile pour débuguer.
  • Information sur les modules: le processus charge souvent plusieurs assemblies. Le dump permet d’avoir des informations sur les dépendances qui ont été chargées notamment la version des assemblies.

Différents types de “dumps”

Il existe des types différents de dumps suivant les informations qu’il contient:

  • Full dump: les “full memory dumps” contiennent tout le contenu de la mémoire virtuelle. Ce type de dump est particulièrement utile lorsqu’on a aucune idée de l’origine du problème. L’inconvénient majeur de ce dump est qu’il faut du temps pour le collecter. Si le serveur à partir duquel on récupère le dump est saturé, la collecte pourrait encore ralentir l’exécution des processus.
  • Mini dump: ce type de dump concerne un processus spécifique et est configurable de façon à choisir les informations qu’il contiendra.

Capturer un dump

Plusieurs outils permettent de capturer des dumps d’un processus:

  • Le gestionnaire de tâches (i.e. Task Manager): assez pratique car il est présent sur tous les systèmes Windows.
  • Visual Studio: après s’être attaché à un processus, on peut générer un dump.
  • dotnet-dump: l’intérêt de cet outil est qu’il est possible de l’installer à la ligne de commande avec dotnet. Ce qui le rend disponible facilement sur toutes les plateformes.
  • ProcDump: c’est une espèce de debugger qu’on peut attacher à un processus. Cet outil peut monitorer un processus en scrutant certaines métriques. En cas de dépassement d’une métrique, il peut générer un dump automatiquement.

Gestionnaire de tâches (i.e. Task Manager)

Le gestionnaire de tâches permet de capturer des dumps à la demande. Le plus gros intérêt du gestionnaire de tâche est qu’il est présent directement sur tous les systèmes Windows, il n’est donc pas nécessaire de l’installer.
Pour capturer un dump, il faut:

  1. Ouvrir le gestionnaire de tâches: [Ctrl] + [Maj] + [Echap].
  2. Trouver le processus pour lequel on veut effectuer le dump
  3. Clique droit puis sélectionner “Create dump file”.
  4. Le dump sera écrit dans un répertoire temporaire et le chemin sera indiqué dans une popup.

Visual Studio

On peut capturer un dump avec Visual Studio en effectuant les étapes suivantes:

  1. S’attacher à un processus en cours d’exécution en cliquant sur “Debug” puis “Attach to process…”
  2. Cliquer sur “Pause” pour stopper l’exécution du processus ou cliquer sur “Debug” puis “Break All”:
  3. Cliquer sur “Debug” puis “Save Dump as…”

dotnet-dump

dotnet-dump peut être installé avec la commande dotnet à partir d’un package Nuget:

dotnet tool install --global dotnet-dump

Pour capture un dump, il suffit d’exécuter:

dotnet-dump.exe collect -p <PID du processus>

Plus de précisions concernant dotnet-dump sur: learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump?WT.mc_id=DT-MVP-5003978#install

ProcDump

ProcDump est une espèce de débugger qui peut monitorer un processus et générer un dump lorsque certaines conditions sont réunies. Il faut avoir en tête que comme il s’agit d’un débugger, l’utiliser peut avoir certains inconvénients:

  • Il ralentit un peu l’exécution du processus. Le plus souvent, on ne s’en rend pas compte mais pour des processus exigeants qui utilisent beaucoup des capacités de la machine, il peut y avoir un impact.
  • Lorsque ProcDump monitore un processus, il n’est plus possible d’attacher ce processus à Visual Studio pour le débugguer.

On peut aussi utiliser ProcDump simplement pour générer un dump mémoire sans utiliser la fonctionnalité de monitoring.

ProcDump appartient à la suite d’outils Windows Sysinternals (plus de détails sur ProcDump sur: learn.microsoft.com/fr-fr/sysinternals/downloads/procdump).

Pour capturer un dump sans conditions:

procdump -ma <nom processus ou PID>

Pour capturer un dump pour n’importe quelle exception (exception de plus bas niveau):

procdump -e 1 -ma <nom processus ou PID>

Dans le cas d’une exception spécifique, par exemple d’une exception de type System.NullReferenceException:

procdump -e 1 -f "System.NullReferenceException" -ma <nom processus ou PID>

Dans le cas où le processus utilise plus de 500 Mo de mémoire:

procdump -m 500 -ma <nom processus ou PID>

On peut déclencher la capture en fonction de la valeur d’un compteur de performances, par exemple l’argument -p \Process(Name_PID)\<counterName> <threshold> permet d’indiquer un seuil pour une valeur spécifique d’un compteur de performance Windows (Windows Performance Counter).

Par exemple, pour baser le seuil de génération sur le nombre de threads d’un processus avec un seuil de déclenchement à 85 threads, le processus ayant pour nom "w3mp" et pour PID "66666", on peut utiliser les arguments suivants:

procdump -p "\Process(w3wp_66666)\Thread Count" 85 -ma 66666

Il est recommandé d’utiliser le nom et le PID pour désigner le processus pour lequel on veut effectuer la capture. Dans le cas où 2 processus ont le même nom, l’utilisation seule du nom pour désigner le processus peut mener à la capture d’un autre processus.

Lire un dump avec Visual Studio

Visual Studio permet de facilement analyser et lire un dump. Un autre outil plus puissant permet de lire un dump et est plus puissant comme WinDbg mais il est beaucoup plus difficile à utiliser.

Quelques précisions concernant WinDbg

D’autres articles présentent quelques fonctionnalités de WinDbg:

On peut installer WinDbg en suivant: learn.microsoft.com/fr-fr/windows-hardware/drivers/debugger/.
Après installation, il se trouve dans C:\Users\<Windows user>\AppData\Local\Microsoft\WindowsApps\WinDbgX.exe

D’autres infos concernant l’interface sur: learn.microsoft.com/fr-fr/windows-hardware/drivers/debuggercmds/windbg-overview.

Pour illustrer comment on peut lire un fichier dump directement à partir de Visual Studio, on va:

  • Générer un dump d’un exécutable,
  • Ouvrir ce fichier dans Visual Studio
  • Indiquer les manipulations nécessaires pour récupérer quelques informations du dump.

On considère le code suivant permettant d’incrémenter un entier dans une task et de récupérer la valeur de cet entier périodiquement:

internal class BasicCounter
{
  private Task runningTask;
  private long counter;
  private CancellationToken cancellationToken;
  private EventWaitHandle waitingHandle = new AutoResetEvent(false);

  public BasicCounter(CancellationToken cancellationToken)
  {
    this.cancellationToken = cancellationToken;
    this.counter = 0;
  }

  public void Launch()
  {
    this.runningTask = Task.Run(() => {
      while (!this.cancellationToken.IsCancellationRequested)
      {
        Interlocked.Increment(ref counter);
        waitingHandle.Set();
      }
    });
  }

  public long GetCounterValue()
  {
    this.waitingHandle.WaitOne();
    return this.counter;
  }
}

On affiche la valeur de cet entier dans le main:

public static class Program
{
  public static void Main()
  {
    CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

    var counter = new BasicCounter(cancellationTokenSource.Token);
    counter.Launch();

    while (true)
    {
      long counterValue = counter.GetCounterValue();
      if (counterValue % 10000 == 0)
        Console.WriteLine(counterValue);
    }
  }
}

Comme l’incrémentation du nombre est faite dans une task séparée, cela donnera la possibilité d’observer plusieurs piles d’appels (i.e. call stack).

On exécute ce code, puis on capture un fichier dump à partir du gestionnaire de tâches (i.e. Task Manager):

  1. On appuie simultanément sur les touches [Ctrl] + [Maj] + [Echap].
  2. Il faut chercher le processus par nom dans l’onglet “Details”, faire un clique droit sur “Create a dump file”.
  3. La popup indiquera l’emplacement du fichier dump.

Pour lire le contenu d’un dump avec Visual Studio, il suffit d’ouvrir directement le fichier avec Visual Studio. Une fois ouvert, Visual Studio affichera un onglet “Minidump file summary” contenant les caractéristiques du dump:

Cet onglet présente les caractéristiques du processus, les dépendances de l’exécutable et les actions possibles qu’il est possible d’effectuer pour analyser le dump (dans le coin en haut à droite):

Comme ce dump a été capturé au cours de l’exécution du processus, il n’y a pas d’informations concernant une éventuelle exception.

Définir les chemin des fichiers de symboles

La 1ère étape consiste à paramétrer les chemins des fichiers de symboles pour les assemblies ou DLL système ou du framework (fichiers .pdb). Les fichiers de symboles contiennent des informations concernant le code des fichiers de dépendance comme le nom des objets, leur adresse dans le module, le type des variables, les signatures des fonctions etc… Ces informations permettent de débugguer le processus.

Il suffit de cliquer sur “Set symbol paths” dans l’onglet “Actions”:

Il faut ensuite cocher:

  • Microsoft Symbol Servers: pour que le debugguer récupère les fichiers de symboles pour les assemblies ou DLL système ou du framework.
  • NuGet.org Symbol Server: pour les fichiers des dépendances Nuget.
  • Ajouter les chemins des fichiers de symboles pour le code de l’application.

Le chemin indiqué dans la partie “Cache symbols in this directory” sera le répertoire dans lequel sera téléchargé les fichiers de symboles.

Version des fichiers de symboles de l’application

A la lecture d’un dump mémoire, il faut que les fichiers de symboles soient de la même version que les fichiers dont sont issus le dump. Le risque si les fichiers ne correspondent pas est que les lignes indiquées lors de débuggage ne correspondent pas. L’idéal est d’utiliser les fichiers de symboles issus du même build, des fichiers provenant d’un build en debug ne sont pas les mêmes que des fichiers issues d’un build en release (les optimisations de code n’étant pas complêtement appliquées en mode debug).

Si les fichiers contenant le code source ne correspondent pas au processus dont le dump est issu, une erreur de ce type pourrait être affichée:

Passer en mode debug

Suivant le type de code exécuté par le processus, on peut débugger suivant des modes différents:

  • “Debug with managed only” permet de limiter le debug au code managé uniquement lorsque cela est possible. Tous les dumps ne contiennent pas forcément les informations permettant de débugguer dans ce mode. Dans ce cas on peut avoir l’erreur suivante:

    Dans ce mode, il est possible d’obtenir des informations sur les tasks exécutés et les piles d’appels avec le code source.

  • “Debug with mixed”: ce mode correspond aux assemblies mixtes c’est-à-dire les assemblies contenant du code managé MSIL et du code natif. Dans le code managé, on peut voir les piles d’appels avec le code source et les tasks en cours d’exécution. Pour le code natif, on ne peut voir que le code assembleur où il est plus difficile d’analyser le code en cours d’exécution.
  • “Debug with Native only”: ce mode limite le debug au code natif uniquement. Le code managé n’est pas traité. Lorsque le dump ne contient pas d’information sur le code managé, on ne peut utiliser que ce mode ou le mode mixed.

A ce stade, on peut afficher les threads ou les tasks en cours d’exécution (cliquer sur “Debug” ⇒ “Window” ⇒ “Threads” ou sur “Debug” ⇒ “Window” ⇒ “Tasks” pour afficher le panneau suivant):

On peut voir une vue différente des threads et tasks en affichant le panneau “Parallel Stacks” (cliquer sur “Debug” ⇒ “Window” ⇒ “Parallel Stacks”):

En sélectionnant “Threads” puis en cliquant sur le bon thread par exemple Program.Main, on peut voir la ligne à partir de laquelle le dump a été générée.

Si le dump a été capturé sur une autre machine que la machine sur laquelle on effectue le debug, l’activation du debug dans un mode managé (Managed ou Mixed) n’affichera pas le code source en C#. On ne verra, à ce stade, que le code assembleur.

Afficher le code source en C#

Pour voir le code source en C#, il faut préciser l’emplacement du code source si ce n’est pas fait automatiquement:

  • Afficher les panneaux “Solution Explorer”, “Threads“, “tasks” et “Call Stack“.
    Pour les afficher:

    • “Solution Explorer”: cliquer sur View ⇒ Solution Explorer
    • Threads, tasks et Call Stack: cliquer sur “Debug” ⇒ “Windows”
  • Dans le panneau “Solution Explorer”, il faut sélectionner la solution en cliquant sur son nom puis cliquer sur la clé pour “Properties”:

    Ensuite il faut préciser le chemin des fichiers du code source en cliquant sur “Debug Source Code” puis en indiquant tous les répertoires:

Ensuite si on affiche les panneaux “Parallel Stacks”, “Stacks” ou “Call Stack”, on peut ensuite cliquer sur la ligne du dernier appel de façon à voir le code source correspondant.

Partie “Analyse” en cas d’exception

Cette partie permet d’aider à l’analyse d’un dump mémoire en identifiant le thread dans lequel une exception pourrait s’être produite. Cette fonctionnalité permet d’indiquer le code ayant provoqué l’exception. Avoir la ligne où une exception a été lancée ne veut pas forcément dire qu’on va comprendre immédiatement ce qui a provoqué l’exception.

Si on considère le code suivant:

public static class Program
{
  public static void Main()
  {
    Console.WriteLine("Waiting...");
    Console.ReadLine();

    throw new NullReferenceException();
  }
}

Et si on lance ce code, l’exécution va s’interrompre à la ligne Console.ReadLine(). On s’attache alors au processus avec ProcDump.exe en lançant une ligne de ce type:

procdump.exe  -e 1 -f "System.NullReferenceException" -ma SimpleCounter.exe

Comme expliqué plus haut, ProcDump s’attache au processus en attendant qu’une exception de type System.NullReferenceException soit lancée:

ProcDump v11.0 - Sysinternals process dump utility
Copyright (C) 2009-2022 Mark Russinovich and Andrew Richards
Sysinternals - www.sysinternals.com

Process:         SimpleCounter.exe (26416)
Process image:     C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\SimpleCounter.exe
CPU threshold:     n/a
Performance counter:   n/a
Commit threshold:    n/a
Threshold seconds:   n/a
Hung window check:   Disabled
Log debug strings:   Disabled
Exception monitor:   First Chance+Unhandled
Exception filter:    [Includes]
             *System.NullReferenceException*
             [Excludes]
Terminate monitor:   Disabled
Cloning type:      Disabled
Concurrent limit:    n/a
Avoid outage:      n/a
Number of dumps:     1
Dump folder:       C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\
Dump filename/mask:  PROCESSNAME_YYMMDD_HHMMSS
Queue to WER:      Disabled
Kill after dump:     Disabled

Si on poursuit l’exécution du processus en cliquant sur [Enter], l’exception est lancée et procdump génère le dump:

[12:53:59] Exception: E0434352.CLR
[12:53:59] Unhandled: E0434352.CLR
[12:53:59] Dump 1 initiated: C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\SimpleCounter.exe_250405_125359.dmp
[12:53:59] Dump 1 writing: Estimated dump file size is 122 MB.
[12:53:59] Dump 1 complete: 122 MB written in 0.8 seconds
[12:54:00] Dump count reached.

Si on ouvre le dump avec Visual Studio, on peut analyser le dump directement:

  • Ouvrir l’onglet “Diagnostic Analysis” en allant dans “Debug” ⇒ “Windows” ⇒ “Diagnostic Analysis”:
  • En cliquant sur “Analyze”, VisualStudio va indiquer directement la ligne où l’exception a été lancée. Il faut ajouter le chemin correspondant aux fichiers du code source pour voir la ligne dans le fichiers .cs sinon c’est la code assembleur qui est présenté (voir Afficher le code source en C# pour ajouter les chemins correspondant aux fichiers du code source).
Références

Async/await en bref…

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

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

Fonctionnement général

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

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

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

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

Async

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

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

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

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

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

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

  return "Finished";
}

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

Si on utilise une boucle:

Task GetWebPageAsync(string uri) 
{ 
  ... 
}

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

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

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

Await

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

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

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

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

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

  return "Finished";  
}

Si on exécute ce code:

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

On obtient:

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

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

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

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

  int taskThreadId = await delayTask;

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

  return "Finished";
}

On lance l’exécution:

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

On obtient:

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

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

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

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

Exemple WPF

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

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

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

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

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

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

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

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

public class UselessProcess
{
  private readonly UselessProcessSync uselessProcessSync;

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

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

Le traitement synchrone est lancé sans async/await:

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

Le traitement asynchrone est lancé avec async/await:

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

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

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

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

Modèle awaitable

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

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

TaskAwaiter GetAwaiter();

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

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

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

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

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

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

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

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

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

Exemple d’implémentation d’un objet awaitable

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

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

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

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

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

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

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

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

}

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

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

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

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

int result = await timeSpan;

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

A l’exécution, on obtient:

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

Avec un temps d’attente nul:

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

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

Références

Modèles de programmation asynchrone (async/await)

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

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

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

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

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

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

Modèle de programmation asynchrone (Asynchronous Programming Model)

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

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

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

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

Byte[] buffer = new Byte[100];

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

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

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

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

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

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

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

La fonction BeginMakeProcess() a pour argument:

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

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

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

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

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

Byte[] buffer = new Byte[100];

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

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

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

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

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

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

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

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

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

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

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

Comme pour le modèle Asynchronous Programming Model:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Contexte d’exécution

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

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

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

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

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

Aide mémoire syntaxe Markdown

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

Code HTML

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

Styles du texte

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

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

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

Titres

Différents types de titres

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

Lien vers les titres

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

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

Par exemple:

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

Liens

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

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

    Par exemple:

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

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

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

Tableaux

Sans alignement du contenu

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

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

Pour obtenir:

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

Indiquer un alignement du contenu

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

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

Ainsi avec la syntaxe:

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

Pour obtenir:

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

Bloc de texte

Citation

On rajoute le caractère > avant le texte:

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

Pour obtenir:

Texte ligne 1
Texte ligne 2
Texte ligne 3

Bloc de code

On entoure le texte avec ```:

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

Pour obtenir:

Bloc de code ligne 1
ligne 2
ligne 3

Elément de code dans le texte

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

`code inline`

Paragraphe d’alerte

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

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

ⓘ Note


Texte de la note.

> [!TIP]
> Texte du conseil.

💡 Tip


Texte du conseil.

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

⚠ Important


Texte de la note importante.

> [!WARNING]
> Texte warning.

⚠ Warning


Texte warning.

> [!CAUTION]
> Texte attention.

⚠ Caution


Texte attention.

Listes

Listes non ordonnées

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

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

Listes ordonnées

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

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

Listes imbriquées

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

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

Pour obtenir:

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

Liste de tâches

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

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

Quelques remarques:

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

Par exemple:

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

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

Images

Pour afficher une image:
Logo wikipedia
La syntaxe est:

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

    Par exemple:

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

    Par exemple:

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

    Par exemple:

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

    Par exemple:

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

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

    Par exemple:

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

    Par exemple:

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

Ligne horizontale

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


La syntaxe est:

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

Caractères à échapper

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

Editeurs en ligne

On peut trouver quelques éditeurs en ligne de markdown:

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

Eviter les éditeurs en ligne pour du texte sensible

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

Aide-mémoire pattern matching C#

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

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

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

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

class Vehicle
{
    public string Name;
}

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

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

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

if (vehicle is Car)  
  Console.WriteLine($"{nameof(vehicle)} is a car.");  
else if (vehicle is Motobike)  
  Console.WriteLine($"{nameof(vehicle)} is a motobike.");  
else  
  Console.WriteLine($"{nameof(vehicle)} has not been identified."); 
C# 7.0 Test par rapport à un type et cast (utilisation implicite de as).

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

if (vehicle is Car car)  
  Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} passagers.");  
else if (vehicle is Motobike motoBike)  
  Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
else  
  Console.WriteLine($"{nameof(vehicle)} has not been identified.");
Construction switch...case C# 7.0
object carAsObj = new Car();  
switch (carAsObj)  
{  
  case null:  // Null pattern
    Console.WriteLine("Is null");  
    break;  
  case "45":  // Constant pattern 
    Console.WriteLine("Is a constant, not a vehicle.");  
    break;  
  case Car car:  // Type pattern
    Console.WriteLine($"{nameof(carAsObj)} is a car with {car.PassengerCount} passagers.");  
    break;  
  case Motobike motobike:  // Type pattern
    Console.WriteLine($"{nameof(carAsObj)} is a motobike of {motobike.Power} horsepower.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(carAsObj)} has not been identified.");  
    break;  
} 
Ajouter des conditions avec when et switch...case C# 7.0
Vehicle vehicle = new Car();  
switch (vehicle)  
{  
  case Car car when car.PassengerCount < 1:  
    Console.WriteLine($"{nameof(vehicle)} is an empty car.");  
    break;  
  case Car car when car.PassengerCount > 3 && car.PassengerCount <= 5:  
    Console.WriteLine($"{nameof(vehicle)} is a fully loaded car.");  
    break;  
  case Car car when car.PassengerCount > 8:  
    Console.WriteLine($"{nameof(vehicle)} is a heavy loaded car.");  
    break;  
  default:  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    break;  
}
var pattern C# 7.0 Ce motif donne l’impression d’appliquer une condition avec l’opérateur is toutefois ce n’est pas le cas, la clause est toujours vraie. L’intérêt est d’effectuer une affectation du résultat de l’expression dans une variable qui pourra être utilisée pour d’autres conditions:
<expression> is var <nom de la variable> est toujours vraie, le résultat de <expression> est affecté dans la variable <nom de la variable>.

Par exemple:

List<Vehicle> vehicles = new List<Vehicle>{ new Car() }; 
// Toujours vrai, le résultat est dans bigVehicle
if (vehicles.FirstOrDefault(v => v.GetWheelCount() > 3) is var bigVehicle)
{ 
  if (bigVehicle.GetWheelCount() == 4) 
    Console.WriteLine("The vehicle is a car"); 
  else if (bigVehicle.GetWheelCount() == 6) 
    Console.WriteLine("The vehicle is a little truck"); 
  else if (bigVehicle.GetWheelCount() > 6) 
    Console.WriteLine("The vehicle is a big truck"); 
}

Utilisation avec switch...case:

switch(vehicles.FirstOrDefault(v => v.GetWheelCount() > 3)) 
{ 
  case null: 
    Console.WriteLine("No big vehicle round"); 
    break; 
  case var car when car.GetWheelCount() == 4: 
    Console.WriteLine("The vehicle is a little truck"); 
    break; 
  case var truck when truck.GetWheelCount() == 6: 
    Console.WriteLine("The vehicle is a little truck"); 
    break; 
  case var bigTruck when bigTruck.GetWheelCount() > 6: 
    Console.WriteLine("The vehicle is a big truck"); 
    break; 
}
Expression switch C# 8 Syntaxe permettant de faciliter l’affectation d’une variable suivant plusieurs conditions:

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

Par exemple:

Vehicle vehicle = new Car{ Name = "Car1"  };
string text = vehicle switch
{
  Car car => $"The vehicle is a car: {car.Name}",
  MotoBike moto => $"The vehicle is a motobike: {moto.Name}",
  null => "No vehicle", // null pattern
  _ => throw new InvalidOperationException("Vehicle is unknown"), // discard pattern (cas par défaut)
};
Type pattern dans une expression switch C# 8 Pour tester par rapport à un type dans une expression switch.

Vehicle vehicle = new Car{ Name = "Car1"  };
string text = vehicle switch
{
  Car car => $"The vehicle is a car: {car.Name}",
  MotoBike moto => $"The vehicle is a motobike: {moto.Name}",
  // [...]
};
C# 9 On peut supprimer la variable si on ne l’utilise pas par la suite.

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

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

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

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

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

string text = vehicle switch
{
  // [...]
  _ => throw new InvalidOperationException("Vehicle is unknown"),
};
Ajouter des conditions avec when dans une expression switch C# 9
string text = vehicle switch
{
  Car car when string.IsNullOrEmpty(car.Name) => $"The vehicle is a car",
  Car car when car.Name.Equals("Car1") => $"The vehicle is the first car",
  Car car => $"The vehicle is a car: {car.Name}",
  _ => throw new InvalidOperationException("Vehicle is unknown"), // cas par défaut
};
var pattern avec une expression switch C# 9 Ce motif s’applique quelque soit le type de variable (la condition est toujours vraie).

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

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

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

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

(int valueAsInt, string valueAsString, float valueAsFloat) tuple = (5, "5", 5f);
string result = tuple switch
{
  (5, "5", 5f) => "All values are 5",
  (6, "5", 5f) => "Int is 6",
  (7, "7", 7f) => "All values are 7",
  (_, _, _) => "No matches", // Cas par défaut 
};
Positional pattern avec un tuple C# 9 On utilise le caractère _ si on ne veut pas que la condition s’applique à un élément d’un tuple.

(int valueAsInt, string valueAsString, float valueAsFloat) tuple = (5, "5", 5.0f);
string result = tuple switch
{
  (5, "5", 5.0f) => "All values are equal", // la condition porte sur tous les éléments
  (5, _, _) => "Ints are equal",            // la condition porte seulement sur le 1er élément
  (_, "5", _) => "Strings are equal",       // la condition porte seulement sur le 2e élément
  (_, _, 5.0f) => "Floats are equal",       // la condition porte seulement sur le 3e élément
  (_, _, _) => "No matches",                // cas par défaut
};

On peut créer un nouveau tuple pour l’utiliser dans l’expression et appliquer une condition avec when:

var tuple = (5, "6", 6f);
string result = tuple switch
{
  (5, "5", 5f) => "All values are equal",
  (5, _, _) tupleWithSameInt => 
    $"Ints are equal (string values are {tupleWithSameInt.Item2})", // Utilisation du nouveau tuple dans l'expression
  (_, _, _) matchingTuple when matchingTuple.Item1 == 5 && matchingTuple.Item2 == "6" => 
    "Ints and strings are equal", // Utilisation du nouveau tuple avec une condition when
  (_, _, _) => "No matches",
};

Quelques détails sur les conditions utilisées:

  • (5, _, _) tupleWithSameInt: la condition porte seulement sur le 1er élément qui doit être égal à 5. Le tuple tupleWithSameInt est instancié et utilisable dans le reste de la condition si on utilise when ou dans l’expression.
  • On peut créer un nouveau tuple contenant des éléments dont les noms sont différents du tuple d’origine.
    Par exemple, si on utilise la condition (var x, var y, var z) => $"{x} {y} {z}", on crée un tuple dont les éléments sont nommés x, y, z qui sont utilisable dans une condition when et dans l’expression.
  • On peut utiliser le caractère discard (i.e. _) si on crée un nouveau type de tuple.
    Par exemple, avec la condition (var x, _, _) => $"{x}". Le caractère _ permet d’ignorer les autres éléments.
  • Il n’est pas possible d’utiliser une condition avec un tuple dont le nombre d’éléments n’est pas égal à celui du tuple d’origine.
    Par exemple, la condition (var x, var y) => ... provoque une erreur de compilation.
Relational pattern C# 9 On peut utiliser directement les opérateurs >, <, >= et <= pour tester une condition.

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

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

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

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

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

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

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

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

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

Si on considère:

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

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

On peut écrire:

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

Avec une clause if:

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

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

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

<structure> is <conditions>

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

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

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

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

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

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

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

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

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

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

On ne peut pas utiliser 2 fois le range pattern:

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

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

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

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

L’encodage base64

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

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

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

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

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

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

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

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

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

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

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

Principe de l’encodage base64

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

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

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

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

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

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

Par exemple, 'Q' est:

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

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

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

Ainsi:

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

01010100

01001000

01001111

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

111101

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

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

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

Padding

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

Exemple de padding avec 2 bits

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

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

01011001

01010100

01001000

01001111

01001110

01000101

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

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

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

Lots 8 bits ⇒ Sextet 010100 000101 100101 010100 010010 000100

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

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

Ajout du caractère '='

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

NbreBits = 8 * i + j

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

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

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

"UFlUSE9ORVM="

Exemple de padding avec 4 bits

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

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

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

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

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

'UFlUSE9ORQ=='

Comment encoder/decoder en base64 ?

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

Powershell

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

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

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

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

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

A la ligne de commande

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

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

Avec OpenSSL

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

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