Tutoriel : Créer un gestionnaire d’interpolation de chaînes personnalisé

Dans ce tutoriel, vous apprendrez à :

  • Implémenter le modèle de gestionnaire d’interpolation de chaîne
  • Interagir avec le récepteur dans une opération d’interpolation de chaîne.
  • Ajouter des arguments au gestionnaire d’interpolation de chaîne
  • Comprendre les nouvelles fonctionnalités de bibliothèque pour l’interpolation de chaîne

Prérequis

Vous devrez configurer votre ordinateur de sorte qu’il exécute .NET 6, avec le compilateur C# 10. Le compilateur C# 10 est disponible à partir de Visual Studio 2022 ou du SDK .NET 6.

Ce tutoriel suppose de connaître C# et .NET, y compris Visual Studio ou l’interface CLI .NET.

Nouveau plan

C# 10 ajoute la prise en charge d’un gestionnaire de chaînes interpolées personnalisé. Un gestionnaire de chaînes interpolées est un type qui traite l’expression d’espace réservé dans une chaîne interpolée. Sans gestionnaire personnalisé, les espaces réservés sont traités de la même façon que String.Format. Chaque espace réservé est mis en forme en tant que texte, puis les composants sont concaténés pour former la chaîne résultante.

Vous pouvez écrire un gestionnaire pour n’importe quel scénario dans lequel vous utilisez les informations sur la chaîne résultante. Sera-t-elle utilisée ? Quelles sont les contraintes concernant le format ? Voici quelques exemples :

  • Vous pouvez exiger qu’aucune des chaînes résultantes ne dépasse une certaine limite, par exemple 80 caractères. Vous pouvez traiter les chaînes interpolées pour remplir une mémoire tampon de longueur fixe et arrêter le traitement une fois cette longueur de mémoire tampon atteinte.
  • Vous pouvez avoir un format tabulaire, et chaque espace réservé doit avoir une longueur fixe. Un gestionnaire personnalisé peut appliquer ces conditions, au lieu de forcer tout le code client à se conformer.

Dans ce tutoriel, vous allez créer un gestionnaire d’interpolation de chaîne pour l’un des principaux scénarios de performances : les bibliothèques de journalisation. Selon le niveau de journalisation configuré, le travail de construction d’un message de journal n’est pas nécessaire. Si la journalisation est désactivée, le travail de construction d’une chaîne à partir d’une expression de chaîne interpolée n’est pas nécessaire. Le message n’étant jamais imprimé, toute concaténation de chaîne peut être ignorée. En outre, les expressions utilisées dans les espaces réservés, y compris la génération de traces, n’ont pas besoin d’être effectuées.

Un gestionnaire de chaîne interpolée peut déterminer si la chaîne mise en forme sera utilisée et effectuer le travail nécessaire uniquement s’il le faut.

Implémentation initiale

Commençons par une classe de base Logger qui prend en charge différents niveaux :

public enum LogLevel
{
    Off,
    Critical,
    Error,
    Warning,
    Information,
    Trace
}

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

    public void LogMessage(LogLevel level, string msg)
    {
        if (EnabledLevel < level) return;
        Console.WriteLine(msg);
    }
}

Cette classe Logger prend en charge six niveaux différents. Lorsqu’un message ne passe pas le filtre de niveau de journalisation, il n’y a pas de sortie. L’API publique de l’enregistreur d’événements accepte une chaîne (entièrement mise en forme) comme message. Tout le travail de création de la chaîne a déjà été effectué.

Implémenter le modèle de gestionnaire

Cette étape consiste à générer un gestionnaire de chaînes interpolées qui recrée le comportement actuel. Un gestionnaire de chaînes interpolées est un type qui doit avoir les caractéristiques suivantes :

  • System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute appliqué au type.
  • Constructeur avec deux paramètres int, literalLength et formattedCount. (D’autres paramètres sont autorisés).
  • Méthode publique AppendLiteral avec la signature : public void AppendLiteral(string s).
  • Méthode publique AppendFormatted générique avec la signature : public void AppendFormatted<T>(T t).

En interne, le générateur crée la chaîne mise en forme et fournit un membre pour qu’un client récupère cette chaîne. Le code suivant montre un type LogInterpolatedStringHandler qui répond à ces exigences :

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    // Storage for the built-up string
    StringBuilder builder;

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

    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");

        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

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

Vous pouvez maintenant ajouter une surcharge à LogMessage dans la classe Logger pour essayer votre nouveau gestionnaire de chaîne interpolée :

public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

Vous n’avez pas besoin de supprimer la méthode LogMessage d’origine. Le compilateur préférera une méthode avec un paramètre de gestionnaire interpolé à une méthode avec un paramètre string lorsque l’argument est une expression de chaîne interpolée.

Vous pouvez vérifier que le nouveau gestionnaire est appelé en utilisant le code suivant comme programme principal :

var logger = new Logger() { EnabledLevel = LogLevel.Warning };
var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time}. This won't be printed.");
logger.LogMessage(LogLevel.Warning, "Warning Level. This warning is a string, not an interpolated string expression.");

L’exécution de l’application génère une sortie similaire au texte suivant :

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This won't be printed.}
        Appended the literal string
Warning Level. This warning is a string, not an interpolated string expression.

En traçant la sortie, vous pouvez voir comment le compilateur ajoute du code pour appeler le gestionnaire et générer la chaîne :

  • Le compilateur ajoute un appel pour construire le gestionnaire, en passant la longueur totale du texte littéral dans la chaîne de format et le nombre d’espaces réservés.
  • Le compilateur ajoute des appels à AppendLiteral et AppendFormatted pour chaque section de la chaîne littérale et pour chaque espace réservé.
  • Le compilateur appelle la méthode LogMessage à l’aide de l’argument CoreInterpolatedStringHandler.

Enfin, notez que le dernier avertissement n’appelle pas le gestionnaire de chaînes interpolées. L’argument est une string, de sorte que l’appel invoque l’autre surcharge avec un paramètre de chaîne.

Ajouter d’autres fonctionnalités au gestionnaire

La version précédente du gestionnaire de chaînes interpolées implémente le modèle. Pour éviter de traiter chaque expression d’espace réservé, vous aurez besoin d’informations supplémentaires dans le gestionnaire. Dans cette section, vous allez améliorer votre gestionnaire afin qu’il travaille moins lorsque la chaîne construite n’a pas à être écrite dans le journal. Vous utilisez System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute pour spécifier un mappage entre les paramètres d’une API publique et les paramètres du constructeur d’un gestionnaire. Cela fournit au gestionnaire les informations nécessaires pour déterminer si la chaîne interpolée doit être évaluée.

Commençons par les modifications apportées au gestionnaire. Tout d’abord, ajoutez un champ pour effectuer le suivi si le gestionnaire est activé. Ajoutez deux paramètres au constructeur : un pour spécifier le niveau de journalisation pour ce message et l’autre en tant que référence à l’objet journal :

private readonly bool enabled;

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel logLevel)
{
    enabled = logger.EnabledLevel >= logLevel;
    builder = new StringBuilder(literalLength);
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
}

Ensuite, utilisez le champ afin que votre gestionnaire ajoute uniquement des littéraux ou des objets mis en forme lorsque la chaîne finale sera utilisée :

public void AppendLiteral(string s)
{
    Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
    if (!enabled) return;

    builder.Append(s);
    Console.WriteLine($"\tAppended the literal string");
}

public void AppendFormatted<T>(T t)
{
    Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
    if (!enabled) return;

    builder.Append(t?.ToString());
    Console.WriteLine($"\tAppended the formatted object");
}

Ensuite, vous devez mettre à jour la déclaration LogMessage afin que le compilateur transmette les paramètres supplémentaires au constructeur du gestionnaire. Cela est géré à l’aide de l’attribut System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute dans l’argument du gestionnaire :

public void LogMessage(LogLevel level, [InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

Cet attribut spécifie la liste des arguments vers LogMessage qui sont mappés sur les paramètres qui suivent les paramètres literalLength et formattedCount requis. La chaîne vide («  ») spécifie le récepteur. Le compilateur remplace la valeur de l’objet Logger représenté par this pour l’argument suivant au constructeur du gestionnaire. Le compilateur remplace la valeur de level pour l’argument suivant. Vous pouvez fournir n’importe quel nombre d’arguments pour tous les gestionnaires que vous écrivez. Les arguments que vous ajoutez sont des arguments de chaîne.

Vous pouvez exécuter cette version à l’aide du même code de test. Cette fois, vous verrez les résultats suivants :

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        AppendLiteral called: {. This won't be printed.}
Warning Level. This warning is a string, not an interpolated string expression.

Vous pouvez voir que les méthodes AppendLiteral et AppendFormat sont appelées, mais qu’elles n’effectuent aucun travail. Le gestionnaire a déterminé que la chaîne finale n’est pas nécessaire : par conséquent, le gestionnaire ne la génère pas. Il y a encore quelques améliorations à apporter.

Tout d’abord, vous pouvez ajouter une surcharge de AppendFormatted qui limite l’argument à un type qui implémente System.IFormattable. Cette surcharge permet aux appelants d’ajouter des chaînes de format dans les espaces réservés. Lors de cette modification, nous allons également modifier le type de retour de l’autre méthode AppendFormatted et AppendLiteral, de void à bool (si l’une de ces méthodes possède différents types de retour, vous obtiendrez une erreur de compilation). Cette modification permet le court-circuitage. Les méthodes renvoient false pour indiquer que le traitement de l’expression de chaîne interpolée doit être arrêté. true indique qu’il doit continuer. Dans cet exemple, vous l’utilisez pour arrêter le traitement lorsque la chaîne résultante n’est pas nécessaire. Le court-circuitage permet des actions plus précises. Vous pouvez arrêter le traitement de l’expression une fois qu’elle atteint une certaine longueur, pour prendre en charge les mémoires tampons de longueur fixe. Ou, une condition peut indiquer que les éléments restants ne sont pas nécessaires.

public void AppendFormatted<T>(T t, string format) where T : IFormattable
{
    Console.WriteLine($"\tAppendFormatted (IFormattable version) called: {t} with format {{{format}}} is of type {typeof(T)},");

    builder.Append(t?.ToString(format, null));
    Console.WriteLine($"\tAppended the formatted object");
}

Avec cet ajout, vous pouvez spécifier des chaînes de format dans votre expression de chaîne interpolée :

var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. The time doesn't use formatting.");
logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time:t}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time:t}. This won't be printed.");

Le :t dans le premier message spécifie le « format de temps court » pour l’heure actuelle. L’exemple précédent a montré l’une des surcharges de la méthode AppendFormatted que vous pouvez créer pour votre gestionnaire. Vous n’avez pas besoin de spécifier d’argument générique pour l’objet mis en forme. Vous disposez peut-être de méthodes plus efficaces pour convertir les types que vous créez en chaîne. Vous pouvez écrire des surcharges de AppendFormatted qui acceptent ces types au lieu d’un argument générique. Le compilateur choisit la meilleure surcharge. Le runtime utilise cette technique pour convertir System.Span<T> en sortie de chaîne. Vous pouvez ajouter un paramètre entier pour spécifier l’alignement de la sortie, avec ou sans IFormattable. Le System.Runtime.CompilerServices.DefaultInterpolatedStringHandler fourni avec .NET 6 contient neuf surcharges de AppendFormatted pour différentes utilisations. Vous pouvez l’utiliser comme référence lors de la création d’un gestionnaire à vos fins.

Exécutez l’exemple maintenant ; vous verrez que pour le message Trace, seul le premier AppendLiteral est appelé :

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:18:29 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:18:29 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:18:29 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:18 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: Trace Level. CurrentTime:
Warning Level. This warning is a string, not an interpolated string expression.

Vous pouvez effectuer une dernière mise à jour du constructeur du gestionnaire pour améliorer l’efficacité. Le gestionnaire peut ajouter un paramètre out bool final. La définition de ce paramètre false sur indique que le gestionnaire ne doit pas être appelé du tout pour traiter l’expression de chaîne interpolée :

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel level, out bool isEnabled)
{
    isEnabled = logger.EnabledLevel >= level;
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    builder = isEnabled ? new StringBuilder(literalLength) : default!;
}

Cette modification signifie que vous pouvez supprimer le champ enabled. Ensuite, vous pouvez modifier le type de retour de AppendLiteral et AppendFormatted vers void. À présent, lorsque vous exécutez l'exemple, vous obtenez la sortie suivante :

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:19:10 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:19:10 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:19 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
Warning Level. This warning is a string, not an interpolated string expression.

La seule sortie quand LogLevel.Trace a été spécifié est la sortie du constructeur. Le gestionnaire a indiqué qu’il n’est pas activé ; aucune des méthodes Append n’a donc été appelée.

Cet exemple illustre un point important pour les gestionnaires de chaînes interpolées, en particulier lorsque des bibliothèques de journalisation sont utilisées. Les effets secondaires dans les espaces réservés peuvent ne pas tous se produire. Ajoutez le code suivant à votre programme principal et voyez ce comportement en action :

int index = 0;
int numberOfIncrements = 0;
for (var level = LogLevel.Critical; level <= LogLevel.Trace; level++)
{
    Console.WriteLine(level);
    logger.LogMessage(level, $"{level}: Increment index a few times {index++}, {index++}, {index++}, {index++}, {index++}");
    numberOfIncrements += 5;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

Vous pouvez voir que la variable index est incrémentée cinq fois à chaque itération de la boucle. Étant donné que les espaces réservés sont évalués uniquement pour les niveaux Critical, Error et Warning, et non pour Information et Trace, la valeur finale de index ne correspond pas aux attentes :

Critical
Critical: Increment index a few times 0, 1, 2, 3, 4
Error
Error: Increment index a few times 5, 6, 7, 8, 9
Warning
Warning: Increment index a few times 10, 11, 12, 13, 14
Information
Trace
Value of index 15, value of numberOfIncrements: 25

Les gestionnaires de chaînes interpolées offrent un meilleur contrôle sur la façon dont une expression de chaîne interpolée est convertie en chaîne. L’équipe du runtime .NET a déjà utilisé cette fonctionnalité pour améliorer les performances dans plusieurs domaines. Vous pouvez utiliser la même fonctionnalité dans vos propres bibliothèques. Pour explorer plus loin, examinez le System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. Il fournit une implémentation plus complète que celle que vous avez créée ici. Vous verrez de nombreuses autres surcharges possibles pour les méthodes Append.