Samouczek: pisanie niestandardowej procedury obsługi interpolacji ciągów

Z tego samouczka dowiesz się, jak wykonywać następujące czynności:

  • Implementowanie wzorca obsługi interpolacji ciągów
  • Interakcja z odbiornikiem w operacji interpolacji ciągów.
  • Dodawanie argumentów do procedury obsługi interpolacji ciągów
  • Omówienie nowych funkcji bibliotek na potrzeby interpolacji ciągów

Wymagania wstępne

Musisz skonfigurować maszynę do uruchamiania platformy .NET 6, w tym kompilatora języka C# 10. Kompilator języka C# 10 jest dostępny od wersji Visual Studio 2022 lub .NET 6 SDK.

W tym samouczku założono, że znasz języki C# i .NET, w tym program Visual Studio lub interfejs wiersza polecenia platformy .NET.

Nowy konspekt

Język C# 10 dodaje obsługę niestandardowego programu obsługi ciągów interpolowanych. Procedura obsługi ciągów interpolowanych jest typem, który przetwarza wyrażenie zastępcze w ciągu interpolowanym. Bez niestandardowego programu obsługi symbole zastępcze są przetwarzane podobnie jak String.Format. Każdy symbol zastępczy jest sformatowany jako tekst, a następnie składniki są łączone w celu utworzenia wynikowego ciągu.

Możesz napisać procedurę obsługi dla dowolnego scenariusza, w którym są używane informacje o wynikowym ciągu. Czy będzie używany? Jakie ograniczenia dotyczą formatu? Przykłady obejmują:

  • Być może żaden z wynikowych ciągów nie jest większy niż limit, na przykład 80 znaków. Możesz przetworzyć ciągi interpolowane, aby wypełnić bufor o stałej długości i zatrzymać przetwarzanie po osiągnięciu tej długości buforu.
  • Być może masz format tabelaryczny, a każdy symbol zastępczy musi mieć stałą długość. Program obsługi niestandardowej może wymusić to, zamiast wymuszać zgodność całego kodu klienta.

W tym samouczku utworzysz procedurę obsługi interpolacji ciągów dla jednego z podstawowych scenariuszy wydajności: bibliotek rejestrowania. W zależności od skonfigurowanego poziomu dziennika nie jest wymagana praca w celu utworzenia komunikatu dziennika. Jeśli rejestrowanie jest wyłączone, nie jest potrzebna praca w celu skonstruowania ciągu z wyrażenia ciągu interpolowanego. Komunikat nigdy nie jest drukowany, więc można pominąć łączenie ciągów. Ponadto nie trzeba wykonywać żadnych wyrażeń używanych w symbolach zastępczych, w tym generowania śladów stosu.

Procedura obsługi ciągów interpolowanych może określić, czy zostanie użyty sformatowany ciąg i wykonać tylko niezbędną pracę w razie potrzeby.

Początkowa implementacja

Zacznijmy od klasy podstawowej Logger , która obsługuje różne poziomy:

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

Obsługuje to Logger sześć różnych poziomów. Jeśli komunikat nie przejdzie filtru na poziomie dziennika, nie ma żadnych danych wyjściowych. Publiczny interfejs API rejestratora akceptuje ciąg (w pełni sformatowany) jako komunikat. Wszystkie prace nad utworzeniem ciągu zostały już wykonane.

Implementowanie wzorca procedury obsługi

Ten krok polega na utworzeniu procedury obsługi ciągów interpolowanej, która ponownie tworzy bieżące zachowanie. Procedura obsługi ciągów interpolowanych jest typem, który musi mieć następujące cechy:

  • Zastosowany System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute do typu.
  • Konstruktor, który ma dwa int parametry i literalLengthformattedCount. (Dozwolone są więcej parametrów).
  • Metoda publiczna AppendLiteral z podpisem: public void AppendLiteral(string s).
  • Ogólna metoda publiczna AppendFormatted z podpisem: public void AppendFormatted<T>(T t).

Wewnętrznie konstruktor tworzy sformatowany ciąg i udostępnia element członkowski klienta w celu pobrania tego ciągu. Poniższy kod przedstawia LogInterpolatedStringHandler typ spełniający następujące wymagania:

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

Teraz możesz dodać przeciążenie do klasy, Logger aby LogMessage wypróbować nową procedurę obsługi ciągów interpolowanych:

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

Nie musisz usuwać oryginalnej LogMessage metody, kompilator preferuje metodę z interpolowanym parametrem obsługi dla metody z parametrem z parametrem string , gdy argument jest wyrażeniem ciągu interpolowanego.

Możesz sprawdzić, czy nowa procedura obsługi jest wywoływana przy użyciu następującego kodu jako głównego programu:

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

Uruchomienie aplikacji generuje dane wyjściowe podobne do następującego tekstu:

        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.

Śledzenie danych wyjściowych pozwala zobaczyć, jak kompilator dodaje kod w celu wywołania procedury obsługi i skompilowania ciągu:

  • Kompilator dodaje wywołanie do konstruowania programu obsługi, przekazując całkowitą długość tekstu literału w ciągu formatu i liczbę symboli zastępczych.
  • Kompilator dodaje wywołania do AppendLiteral i AppendFormatted dla każdej sekcji ciągu literału i dla każdego symbolu zastępczego.
  • Kompilator wywołuje metodę LogMessage przy użyciu CoreInterpolatedStringHandler argumentu .

Na koniec zwróć uwagę, że ostatnie ostrzeżenie nie wywołuje procedury obsługi ciągów interpolowanych. Argument jest argumentem string, dzięki czemu wywołanie wywołuje inne przeciążenie z parametrem ciągu.

Dodawanie większej liczby możliwości do programu obsługi

Poprzednia wersja procedury obsługi ciągów interpolowanych implementuje wzorzec. Aby uniknąć przetwarzania każdego wyrażenia zastępczego, musisz uzyskać więcej informacji w procedurze obsługi. W tej sekcji ulepszysz procedurę obsługi, tak aby mniej działała, gdy skonstruowany ciąg nie zostanie zapisany w dzienniku. System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute Służy do określania mapowania między parametrami publicznego interfejsu API i parametrów konstruktora programu obsługi. Zapewnia to procedurę obsługi z informacjami potrzebnymi do określenia, czy należy ocenić ciąg interpolowany.

Zacznijmy od zmian w programie obsługi. Najpierw dodaj pole, aby śledzić, czy program obsługi jest włączony. Dodaj dwa parametry do konstruktora: jeden, aby określić poziom dziennika dla tego komunikatu, a drugi odwołanie do obiektu dziennika:

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

Następnie użyj pola , aby program obsługi dołączał literały lub sformatowane obiekty tylko wtedy, gdy będzie używany końcowy ciąg:

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

Następnie należy zaktualizować deklarację LogMessage , aby kompilator przekazuje dodatkowe parametry do konstruktora programu obsługi. Jest to obsługiwane przy użyciu argumentu System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute programu obsługi:

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

Ten atrybut określa listę argumentów do LogMessage tej mapy do parametrów, które są zgodne z wymaganymi literalLength parametrami i formattedCount . Pusty ciąg (""), określa odbiornik. Kompilator zastępuje wartość Logger obiektu reprezentowanego przez this następny argument konstruktora programu obsługi. Kompilator zastępuje wartość level dla następującego argumentu. Można podać dowolną liczbę argumentów dla dowolnej procedury obsługi, którą piszesz. Dodawane argumenty to argumenty ciągu.

Tę wersję można uruchomić przy użyciu tego samego kodu testowego. Tym razem zobaczysz następujące wyniki:

        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.

Widać, że AppendLiteral metody i AppendFormat są wywoływane, ale nie wykonują żadnej pracy. Procedura obsługi ustaliła, że ostateczny ciąg nie będzie potrzebny, więc program obsługi nie skompiluje go. Istnieje jeszcze kilka ulepszeń, które należy wprowadzić.

Najpierw można dodać przeciążenie AppendFormatted tego ograniczenia argumentu do typu, który implementuje System.IFormattableelement . To przeciążenie umożliwia obiektom wywołującym dodawanie ciągów formatu w symbolach zastępczych. Wprowadzając tę zmianę, zmieńmy również zwracany typ innych AppendFormatted metod i AppendLiteral z void na bool (jeśli którakolwiek z tych metod ma różne typy zwracane, zostanie wyświetlony błąd kompilacji). Ta zmiana umożliwia zwarcie. Metody zwracają, false aby wskazać, że przetwarzanie wyrażenia ciągu interpolowanego powinno zostać zatrzymane. Zwracanie true wskazuje, że powinno kontynuować. W tym przykładzie używasz go do zatrzymania przetwarzania, gdy wynikowy ciąg nie jest potrzebny. Zwarcie obsługuje bardziej szczegółowe akcje. Można zatrzymać przetwarzanie wyrażenia po osiągnięciu określonej długości, aby obsługiwać bufory o stałej długości. Lub jakiś warunek może wskazywać, że pozostałe elementy nie są potrzebne.

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

Ponadto można określić ciągi formatu w wyrażeniu ciągu interpolowanego:

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

Wartość :t w pierwszym komunikacie określa "format czasu krótkiego" dla bieżącego czasu. W poprzednim przykładzie pokazano jedno z przeciążeń AppendFormatted metody, którą można utworzyć dla programu obsługi. Nie trzeba określać ogólnego argumentu dla sformatowanego obiektu. Być może masz bardziej wydajne sposoby konwertowania tworzonych typów na ciąg. Można zapisywać przeciążenia AppendFormatted , które pobierają te typy zamiast argumentu ogólnego. Kompilator wybierze najlepsze przeciążenie. Środowisko uruchomieniowe używa tej techniki do konwersji System.Span<T> na dane wyjściowe ciągu. Możesz dodać parametr liczby całkowitej, aby określić wyrównanie danych wyjściowych z wartością lub bez .IFormattable Element System.Runtime.CompilerServices.DefaultInterpolatedStringHandler dostarczany z platformą .NET 6 zawiera dziewięć przeciążeń AppendFormatted dla różnych zastosowań. Można go użyć jako odwołania podczas tworzenia programu obsługi do swoich celów.

Uruchom teraz przykład i zobaczysz, że w przypadku komunikatu Trace zostanie wywołany tylko pierwszy 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.

Możesz wprowadzić jedną ostateczną aktualizację konstruktora programu obsługi, która poprawia wydajność. Procedura obsługi może dodać końcowy out bool parametr. Ustawienie tego parametru w celu false wskazania, że program obsługi nie powinien być wywoływany w ogóle w celu przetworzenia wyrażenia ciągu interpolowanego:

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

Ta zmiana oznacza, że można usunąć enabled pole. Następnie można zmienić zwracany typ AppendLiteral i AppendFormatted na void. Teraz po uruchomieniu przykładu zobaczysz następujące dane wyjściowe:

        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.

Jedynymi danymi wyjściowymi, gdy LogLevel.Trace został określony, jest dane wyjściowe z konstruktora. Procedura obsługi wskazała, że nie jest włączona, więc żadna z Append metod nie została wywołana.

W tym przykładzie przedstawiono ważny punkt obsługi ciągów interpolowanych, szczególnie w przypadku użycia bibliotek rejestrowania. Wszelkie skutki uboczne w symbolach zastępczych mogą nie wystąpić. Dodaj następujący kod do głównego programu i zobacz to zachowanie w akcji:

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

Można zobaczyć, że zmienna index jest zwiększana pięć razy w każdej iteracji pętli. Ponieważ symbole zastępcze są oceniane tylko dla Criticalpoziomów , Error a Warning nie dla Information i Trace, końcowa wartość nie jest zgodna index z oczekiwaniami:

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

Programy obsługi ciągów interpolowanych zapewniają większą kontrolę nad sposobem konwertowania wyrażenia ciągu interpolowanego na ciąg. Zespół środowiska uruchomieniowego platformy .NET użył już tej funkcji, aby zwiększyć wydajność w kilku obszarach. Możesz korzystać z tej samej funkcji we własnych bibliotekach. Aby dowiedzieć się więcej, zapoznaj się z tematem System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. Zapewnia on bardziej kompletną implementację, niż utworzono tutaj. Zobaczysz o wiele więcej przeciążeń, które są możliwe dla Append metod.