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

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

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

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

Interpolation de chaîne de caractères

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

Par exemple:

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

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

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

Options d’alignement et de formatage

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

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

Ainsi:

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

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

Par exemple, si on exécute:

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

On obtient:

'     1'

Avec:

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

On obtient:

'1     '

Avec:

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

Le résultat es:

'1234567'

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

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

Echappement des caractères spéciaux

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

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

Le nombre 5742 est affiché avec {number}.

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

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

Verbatim string

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

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

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

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

Interpolation de chaînes constantes

C# 10.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

C# 10.0

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

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

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

Attribut InterpolatedStringHandler

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

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

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

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

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

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

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

public class ThreeDimensionPoint
{
    // ...

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

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

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

[InterpolatedStringHandler]
public class PointStringFormatter
{
    StringBuilder builder;

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

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

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

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

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

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

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

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

    AppendLiteral(pointAsString);
}

Si on définit la méthode suivante:

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

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

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

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

Le résultat est:

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

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

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

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

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

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

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

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

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

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

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

Si on regarde le code MSIL correspondant au code suivant:

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

On peut voir:

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

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

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

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

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

public ref struct PointStringFormatter
{
    ...
}

Attribut InterpolatedStringHandlerArgument

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

Pour rappel, on peut instancier un gestionnaire:

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

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

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

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

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

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

    // ...
}

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


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

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

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

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

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

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

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

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

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

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

L’implémentation de ShowPoint() est:

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

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

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

On obtient:

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

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

Leave a Reply