Учебник. Написание пользовательского обработчика интерполяции строк

Из этого руководства вы узнаете, как выполнять следующие задачи:

  • Реализация шаблона обработчика интерполяции строк.
  • Взаимодействие с получателем в операции интерполяции строк.
  • Добавление аргументов в обработчик интерполяции строк.
  • Общие сведения о новых возможностях библиотеки для интерполяции строк.

Необходимые компоненты

Необходимо настроить компьютер для выполнения .NET 6, включая компилятор C# 10. Компилятор C# 10 доступен начиная с Visual Studio 2022 или пакета SDK для .NET 6.

В этом руководстве предполагается, что вы знакомы с C# и .NET, включая Visual Studio или .NET CLI.

Новая структура

В C# 10 добавлена поддержка пользовательского обработчика интерполяции строк. Обработчик интерполяции строк — это тип, который обрабатывает выражение заполнителя в интерполированной строке. Без пользовательского обработчика заполнители обрабатываются аналогично String.Format. Каждый заполнитель форматируется как текст, а затем компоненты объединяются для формирования результирующей строки.

Можно написать обработчик для любого сценария, в котором используются сведения о результирующей строке. Будет ли он использоваться? Какие ограничения в формате? Некоторыми примерами могут служить:

  • Может потребоваться, чтобы ни одна из результирующих строк не превышала определенные ограничения, например 80 символов. Вы можете обрабатывать интерполированные строки для заполнения буфера фиксированной длины и прекращать обработку после достижения этой длины буфера.
  • Возможно, у вас табличный формат и каждый заполнитель должен иметь фиксированную длину. Пользовательский обработчик может обеспечить это вместо того, чтобы приводить в соответствие весь клиентский код.

В этом учебнике описано, как создать обработчик интерполяции строк для одного из основных сценариев производительности: библиотек ведения журналов. В зависимости от настроенного уровня ведения журнала работа по созданию сообщения журнала не требуется. Если ведение журнала отключено, работа по созданию строки из выражения интерполированной строки не требуется. Сообщение не выводится, поэтому любое объединение строк можно пропустить. Кроме того, не нужно выполнять любые выражения, используемые в заполнителях, включая создание трассировок стека.

Обработчик интерполяции строк может определить, будет ли использоваться форматированная строка, и выполнять только необходимую работу.

Начальная реализация

Начнем с базового класса Logger, который поддерживает различные уровни.

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

Этот Logger поддерживает шесть разных уровней. Если сообщение не проходит фильтр уровня журнала, выходные данные отсутствуют. Общедоступный API для средства ведения журнала принимает в качестве сообщения строку (полностью отформатированную). Вся работа по созданию строки уже выполнена.

Реализация шаблона обработчика

Этот шаг нужен для создания обработчика интерполяции строк, который воссоздает текущее поведение. Обработчик интерполяции строк — это тип, который должен иметь следующие характеристики:

  • System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute, примененный к типу.
  • Конструктор с двумя параметрами int: literalLength и formattedCount. (Разрешается иметь больше параметров.)
  • Открытый метод AppendLiteral с сигнатурой: public void AppendLiteral(string s).
  • Универсальный открытый метод AppendFormatted с сигнатурой: public void AppendFormatted<T>(T t).

На внутреннем уровне построитель создает форматированную строку и предоставляет элемент клиенту для получения этой строки. В следующем коде показан тип LogInterpolatedStringHandler, который соответствует этим требованиям:

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

Теперь можно добавить перегрузку в LogMessage в классе Logger, чтобы опробовать новый обработчик интерполяции строк.

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

Нет необходимости удалять исходный метод LogMessage. Если аргумент представляет собой выражение интерполированной строки, то компилятор предпочитает метод с параметром обработчика интерполяции методу с параметром string.

Чтобы убедиться, что новый обработчик вызывается, используйте следующий код в качестве основной программы:

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

В результате запуска приложения выводятся данные, похожие на следующий текст:

        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.

Отслеживая выходные данные, можно увидеть, как компилятор добавляет код для вызова обработчика и построения строки:

  • Компилятор добавляет вызов для создания обработчика, передавая общую длину литерального текста в строке форматирования и количество заполнителей.
  • Компилятор добавляет вызовы к AppendLiteral и AppendFormatted для каждого раздела литеральной строки и заполнителя.
  • Компилятор вызывает метод LogMessage, используя CoreInterpolatedStringHandler в качестве аргумента.

Наконец, обратите внимание, что последнее предупреждение не вызывает обработчик интерполяции строк. Аргумент имеет значение string, поэтому вызов активирует другую перегрузку со строковым параметром.

Добавление дополнительных возможностей в обработчик

В предыдущей версии обработчика интерполяции строк реализован шаблон. Во избежание обработки каждого выражения заполнителя вам потребуются дополнительные сведения в обработчике. В этом разделе описано, как улучшить обработчик, чтобы он выполнял меньше работы, если построенная строка не будет записываться в журнал. Для указания сопоставления параметров с общедоступным API и параметрами конструктора обработчика используется System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute. Это предоставляет обработчику сведения, необходимые для определения того, должна ли вычисляться интерполированная строка.

Начнем с внесения изменений в обработчик. Сначала, если обработчик включен, добавьте поле для отслеживания. Добавьте в конструктор два параметра: один для указания уровня ведения журнала для этого сообщения, а вторым должна быть ссылка на объект журнала:

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

Затем используйте поле, чтобы обработчик добавлял только литералы или отформатированные объекты, когда будет использоваться последняя строка:

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

Далее необходимо обновить объявление LogMessage, чтобы компилятор передавал дополнительные параметры конструктору обработчика. Это обрабатывается с помощью System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute в аргументе обработчика:

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

Этот атрибут задает список аргументов для LogMessage, которые сопоставляются с параметрами, следующими за обязательными параметрами literalLength и formattedCount. Пустая строка ("") указывает получателя. Компилятор заменяет значение объекта Logger, представленного this, следующим аргументом конструктора обработчика. Компилятор заменяет значение level для следующего аргумента. Можно указать любое количество аргументов для любого записываемого обработчика. Добавляемые аргументы являются строковыми аргументами.

Эту версию можно запустить с помощью того же тестового кода. На этот раз отобразятся следующие результаты:

        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.

Можно увидеть, что методы AppendLiteral и AppendFormat вызываются, но не выполняют никаких действий. Обработчик определил, что последняя строка не понадобится, поэтому не создает ее. Осталось внести еще несколько улучшений.

Во-первых, можно добавить перегрузку AppendFormatted, ограничивающую аргумент типом, реализующим System.IFormattable. Эта перегрузка позволяет вызывающим объектам добавлять строки формата в заполнители. При внесении этого изменения мы также изменим тип возвращаемого значения для других методов AppendFormatted и AppendLiteral с void на bool (если какой-либо из этих методов имеет другие типы возвращаемого значения, вы получите ошибку компиляции). Это изменение обеспечивает сокращенное вычисление. Методы возвращают значение false, чтобы указать, что обработку выражения интерполированной строки необходимо остановить. Если возвращено значение true, это указывает на то, что она должна продолжаться. В этом примере оно используется для отмены обработки, когда результирующая строка не требуется. Сокращенное вычисление поддерживает более детализированные действия. Для поддержки буферов фиксированной длины обработку выражения можно прерывать после достижения определенной длины. Или определенное условие может означать, что остальные элементы не нужны.

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

С этим дополнением вы можете указать строки форматирования в своем выражении интерполированной строки.

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

:t в первом сообщении указывает краткий формат для текущего времени. В предыдущем примере показана одна из перегрузок к методу AppendFormatted, который можно создать для обработчика. Не нужно указывать универсальный аргумент для форматируемого объекта. Вы можете воспользоваться более эффективными способами преобразования создаваемых типов в строку. Вы можете записать перегрузки метода AppendFormatted, который принимает эти типы вместо универсального аргумента. Компилятор выберет наиболее подходящую перегрузку. Среда выполнения использует этот метод для преобразования System.Span<T> в выходные данные строки. Можно добавить целочисленный параметр для указания выравнивания выходных данных с или без IFormattable. Параметр System.Runtime.CompilerServices.DefaultInterpolatedStringHandler, который входит в состав .NET 6, содержит девять перегрузок AppendFormatted для различных применений. Его можно использовать в качестве ссылки при создании обработчика для своих целей.

Запустите пример сейчас, и вы увидите, что для сообщения Trace будет вызван только первый 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.

Можно выполнить еще последнее обновление конструктора обработчика, которое повышает эффективность. Обработчик может добавить последний параметр out bool. Значение false, заданное для этого параметра, указывает, что обработчик вообще не должен вызываться для обработки выражения интерполированной строки:

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

Это изменение означает, что вы можете удалить поле enabled. Затем вы можете изменить тип возвращаемого значенияAppendLiteral и AppendFormatted на void. При запуске примера выходные данные должны выглядеть следующим образом:

        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.

Если указан параметр LogLevel.Trace, должны отображаться только выходные данные конструктора. Обработчик указал, что он не включен, поэтому ни один из методов Append не был вызван.

В этом примере показан важный момент для обработчиков интерполяции строк, особенно при использовании библиотек ведения журнала. Никаких побочных эффектов в заполнителях возникнуть не должно. Добавьте следующий код в основную программу и понаблюдайте за этим поведением в действии.

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

Можно увидеть, что переменная index увеличивается в пять раз каждую итерацию цикла. Так как заполнители оцениваются только для уровней Critical, Error и Warning, а не для Information и Trace, конечное значение index не соответствует ожиданиям.

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

Обработчики интерполяции строк обеспечивают больший контроль над преобразованием выражения интерполированной строки в строку. Команда разработчиков среды выполнения .NET уже использовала эту возможность для повышения производительности в нескольких областях. Вы можете использовать те же возможности в собственных библиотеках. Чтобы узнать больше, ознакомьтесь с System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. Он предоставляет более полную реализацию, чем была создана здесь. Там также есть еще много возможных перегрузок для методов Append.