Esercitazione: scrivere un gestore di interpolazione di stringhe personalizzato

Questa esercitazione illustra come:

  • Implementare il modello del gestore di interpolazione di stringhe
  • Interagire con il ricevitore in un'operazione di interpolazione di stringhe.
  • Aggiungere argomenti al gestore di interpolazione di stringhe
  • Comprendere le nuove funzionalità della libreria per l'interpolazione di stringhe

Prerequisiti

È necessario configurare il computer per eseguire .NET 6, incluso il compilatore C# 10. Il compilatore C# 10 è disponibile a partire da Visual Studio 2022 o .NET 6 SDK.

Per questa esercitazione si presuppone che l'utente abbia familiarità con C# e .NET, inclusa l'interfaccia della riga di comando di Visual Studio o di .NET.

Nuova struttura

C# 10 aggiunge il supporto per un gestore di stringhe interpolato personalizzato. Un gestore di stringhe interpolato è un tipo che elabora l'espressione segnaposto in una stringa interpolata. Senza un gestore personalizzato, i segnaposto vengono elaborati in modo simile a String.Format. Ogni segnaposto viene formattato come testo e poi i componenti vengono concatenati per formare la stringa risultante.

È possibile scrivere un gestore per qualsiasi scenario in cui si usano informazioni sulla stringa risultante. Verrà usato? Quali sono i vincoli del formato? Alcuni esempi includono:

  • Si può richiedere che nessuna delle stringhe risultanti sia maggiore di un certo limite, ad esempio 80 caratteri. È possibile elaborare le stringhe interpolate per riempire un buffer di lunghezza fissa e interrompere l'elaborazione una volta raggiunta la lunghezza del buffer.
  • È possibile avere un formato tabellare e ogni segnaposto deve avere una lunghezza fissa. Un gestore personalizzato può imporlo, anziché forzare tutto il codice client a conformarsi.

In questa esercitazione, verrà illustrato come creare un gestore di interpolazione delle stringhe per uno degli scenari di prestazioni principali: le librerie di registrazione. A seconda del livello di registro configurato, la creazione di un messaggio di registro non è necessaria. Se la registrazione è disattivata, la creazione di una stringa da un'espressione stringa interpolata non è necessaria. Il messaggio non viene mai stampato, quindi è possibile ignorare qualsiasi concatenazione di stringhe. Inoltre, le espressioni usate nei segnaposto, inclusa la generazione di analisi dello stack, non devono essere eseguite.

Un gestore di stringhe interpolate può determinare se verrà usata la stringa formattata ed eseguire il lavoro necessario, se serve.

Implementazione iniziale

Si inizierà da una classe Logger di base che supporta livelli diversi:

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);
    }
}

Questo Logger supporta sei livelli diversi. Quando un messaggio non supera il filtro del livello di registrazione, non viene emesso alcun messaggio. L'API pubblica del logger accetta una stringa (completamente formattata) come messaggio. Tutto il lavoro per creare la stringa è già stato fatto.

Implementare il modello del gestore

Questo passaggio consiste nel compilare un gestore di stringhe interpolato che ricrea il comportamento corrente. Un gestore di stringhe interpolate è un tipo che deve avere le caratteristiche seguenti:

  • System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute applicato al tipo.
  • Costruttore con due parametri int, literalLength e formattedCount. (Sono ammessi altri parametri).
  • Metodo pubblico di AppendLiteral con la firma: public void AppendLiteral(string s).
  • Metodo pubblico generico AppendFormatted con la firma: public void AppendFormatted<T>(T t).

Internamente, il generatore crea la stringa formattata e fornisce un membro che consente a un client di recuperare tale stringa. Il codice seguente illustra un tipo di LogInterpolatedStringHandler che soddisfa questi requisiti:

[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();
}

È ora possibile aggiungere un overload a LogMessage nella classe Logger per provare il nuovo gestore di stringhe interpolate:

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

Non è necessario rimuovere il metodo LogMessage originale, il compilatore preferirà un metodo con un parametro del gestore interpolato rispetto a un metodo con un parametro string quando l'argomento è un'espressione stringa interpolata.

È possibile verificare che il nuovo gestore venga richiamato usando il codice seguente come programma principale:

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'esecuzione dell'applicazione produce un output simile al testo seguente:

        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.

Tracciando l'output, è possibile vedere come il compilatore aggiunga codice per chiamare il gestore e creare la stringa:

  • Il compilatore aggiunge una chiamata per costruire il gestore, passando la lunghezza totale del testo letterale nella stringa di formato e il numero di segnaposto.
  • Il compilatore aggiunge chiamate a AppendLiteral e AppendFormatted per ogni sezione della stringa letterale e per ogni segnaposto.
  • Il compilatore richiama il metodo LogMessage usando CoreInterpolatedStringHandler come argomento.

Si noti infine che l'ultimo avviso non richiama il gestore di stringhe interpolato. L'argomento è un string, quindi questa chiamata richiama l'altro overload con un parametro stringa.

Aggiungere altre funzionalità al gestore

La versione precedente del gestore di stringhe interpolate implementa il modello. Per evitare l'elaborazione di ogni espressione segnaposto, sono necessarie più informazioni nel gestore. In questa sezione, verrà illustrato come migliorare il gestore in modo che lavori meno quando la stringa creata non verrà scritta nel registro. Si usa System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute per specificare un mapping tra i parametri a un'API pubblica e i parametri al costruttore di un gestore. In questo modo fornisce al gestore le informazioni necessarie per determinare se la stringa interpolata deve essere valutata.

Iniziamo dalle modifiche al gestore. Per prima cosa, aggiungere un campo per verificare se il gestore è abilitato. Aggiungere due parametri al costruttore: uno per specificare il livello di registrazione per questo messaggio e l'altro un riferimento all'oggetto del registro:

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}");
}

Usare quindi il campo in modo che il gestore aggiunga valori letterali o oggetti formattati solo quando viene usata la stringa finale:

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");
}

Sarà quindi necessario aggiornare la dichiarazione di LogMessage in modo che il compilatore passi i parametri aggiuntivi al costruttore del gestore. Questo viene gestito usando System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute nell'argomento del gestore:

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

Questo attributo specifica l'elenco degli argomenti di LogMessage che eseguono il mapping ai parametri che seguono i parametri richiesti literalLength e formattedCount. La stringa vuota (""), specifica il ricevitore. Il compilatore sostituisce il valore dell'oggetto Logger rappresentato da this come argomento successivo del costruttore del gestore. Il compilatore sostituisce il valore di level per l'argomento seguente. È possibile specificare un numero qualsiasi di argomenti per qualsiasi gestore scritto. Gli argomenti aggiunti sono argomenti stringa.

È possibile eseguire questa versione usando lo stesso codice di test. Questa volta verranno visualizzati i risultati seguenti:

        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.

È possibile notare che vengono richiamati i metodi AppendLiteral e AppendFormat, ma non svolgono alcuna attività. Il gestore ha stabilito che la stringa finale non sarà necessaria, quindi non la crea. Ci sono ancora un paio di miglioramenti da apportare.

In primo luogo, si può aggiungere un overload di AppendFormatted che vincola l'argomento a un tipo che implementa System.IFormattable. Questo overload consente ai chiamanti di aggiungere stringhe di formato nei segnaposto. Mentre si apporta questa modifica, si cambia anche il tipo restituito degli altri metodi AppendFormatted e AppendLiteral, da void a bool (se uno di questi metodi ha un tipo restituito diverso, si otterrà un errore di compilazione). Questa modifica cosente il corto circuito. I metodi restituiscono false per indicare che l'elaborazione dell'espressione stringa interpolata deve essere arrestata. La restituzione di true indica che deve continuare. In questo esempio, viene usato per interrompere l'elaborazione quando la stringa risultante non è necessaria. Il corto circuito supporta azioni più granulari. È possibile interrompere l'elaborazione dell'espressione una volta raggiunta una determinata lunghezza, per supportare i buffer a lunghezza fissa. In alternativa, alcune condizioni potrebbero indicare che gli elementi rimanenti non sono necessari.

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");
}

Con questa aggiunta, è possibile specificare le stringhe di formato nell'espressione stringa interpolata:

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.");

Il valore :t nel primo messaggio specifica il "formato orario breve" per l'ora corrente. Nell'esempio precedente è stato illustrato uno degli overload del metodo AppendFormatted che è possibile creare per il proprio gestore. Non è necessario specificare un argomento generico per l'oggetto formattato. Potrebbero essere disponibili modi più efficienti per convertire i tipi creati in stringa. È possibile scrivere overload di AppendFormatted che accetta tali tipi anziché un argomento generico. Il compilatore sceglierà l'overload migliore. Il runtime usa questa tecnica per convertire System.Span<T> in output di stringa. È possibile aggiungere un parametro integer per specificare l'allineamento dell'output, con o senza un IFormattable. System.Runtime.CompilerServices.DefaultInterpolatedStringHandler fornito con .NET 6 contiene nove overload di AppendFormatted per usi diversi. È possibile usarlo come riferimento durante la creazione di un gestore per i propri scopi.

Eseguire ora l'esempio e si noterà che per il messaggio Trace, viene richiamata solo il primo AppendLiteral:

        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.

È possibile eseguire un aggiornamento finale al costruttore del gestore che migliora l'efficienza. Il gestore può aggiungere un parametro out bool finale. L'impostazione del parametro su false indica che il gestore non deve essere chiamato affatto per elaborare l'espressione stringa interpolata:

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!;
}

Questa modifica significa che è possibile rimuovere il campo enabled. È quindi possibile modificare il tipo restituito di AppendLiteral e AppendFormatted in void. A questo punto, quando si esegue l'esempio, verrà visualizzato l'output seguente:

        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.

L'unico output quando è stato specificato LogLevel.Trace è l'output del costruttore. Il gestore ha indicato che non è abilitato, quindi nessuno dei metodi Append è stato richiamato.

Questo esempio illustra un punto importante per i gestori di stringhe interpolate, soprattutto quando si vengono usate librerie di registrazione. Eventuali effetti collaterali nei segnaposto potrebbero non verificarsi. Aggiungere il codice seguente al programma principale e vedere questo comportamento in azione:

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}");

È possibile vedere che la variabile index viene incrementata cinque volte a ogni iterazione del ciclo. Poiché i segnaposto vengono valutati solo per Critical, Error e Warning livelli, non per Information e Trace, il valore finale di index non corrisponde alle aspettative:

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

I gestori di stringhe interpolate offrono un maggiore controllo sul modo in cui un'espressione di stringa interpolata viene convertita in stringa. Il team del runtime .NET ha già usato questa funzionalità per migliorare le prestazioni in diverse aree. È possibile usare la stessa funzionalità nelle proprie librerie. Per approfondire ulteriormente, vedere System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. Offre un'implementazione più completa rispetto a quella compilata qui. Verranno visualizzati molti altri overload possibili per i metodi di Append.