Tutorial: Escritura de un controlador de interpolación de cadenas personalizado

En este tutorial, aprenderá a:

  • Implementar el patrón del controlador de interpolación de cadenas.
  • Interactuar con el receptor en una operación de interpolación de cadenas.
  • Agregar argumentos al controlador de interpolación de cadenas.
  • Comprender las nuevas características de la biblioteca para la interpolación de cadenas.

Prerrequisitos

Deberá configurar la máquina para ejecutar .NET 6, incluido el compilador de C# 10. El compilador de C# 10 está disponible a partir de Visual Studio 2022 o del SDK de .NET 6.

En este tutorial se da por supuesto que conoce bien C# y. NET, incluidos Visual Studio o la CLI de .NET.

Nuevo esquema

C# 10 agrega compatibilidad con un controlador de cadenas interpoladas personalizado. Un controlador de cadenas interpoladas es un tipo que procesa la expresión del marcador de posición en una cadena interpolada. Sin un controlador personalizado, los marcadores de posición se procesan de forma similar a String.Format. Cada marcador de posición tiene formato de texto y, luego, los componentes se concatenan para formar la cadena resultante.

Puede escribir un controlador para cualquier escenario en el que use información sobre la cadena resultante. ¿Se usará? ¿Qué restricciones hay en el formato? Estos son algunos ejemplos:

  • Es posible que no necesite que ninguna de las cadenas resultantes sea mayor de algún límite, por ejemplo, 80 caracteres. Puede procesar las cadenas interpoladas para rellenar un búfer de longitud fija y detener el procesamiento una vez que se alcanza esa longitud del búfer.
  • Puede tener un formato tabular y cada marcador de posición debe tener una longitud fija. Un controlador personalizado puede aplicar esto, en lugar de obligar a que todo el código de cliente se ajuste.

En este tutorial, creará un controlador de interpolación de cadenas para uno de los escenarios de rendimiento principales: las bibliotecas de registro. Según el nivel de registro configurado, se puede prescindir del trabajo para construir un mensaje de registro. Si el registro está desactivado, no es necesario el trabajo para construir una cadena a partir de una expresión de cadena interpolada. El mensaje nunca se imprime, por lo que se puede omitir cualquier concatenación de cadenas. Además, no es necesario realizar las expresiones usadas en los marcadores de posición, incluida la generación de seguimientos de pila.

Un controlador de cadena interpolada puede determinar si se usará la cadena con formato, y realizar solo el trabajo necesario.

Implementación inicial

Partiremos de una clase Logger básica que admite distintos niveles:

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

Este elemento Logger admite seis niveles diferentes. Cuando un mensaje no pasa el filtro de nivel de registro, no hay ninguna salida. La API pública del registrador acepta una cadena (con formato completo) como mensaje. Ya se ha realizado todo el trabajo para crear la cadena.

Implementación del patrón del controlador

Este paso consiste en crear un controlador de cadenas interpoladas que vuelva a crear el comportamiento actual. Un controlador de cadenas interpoladas es un tipo que debe tener las siguientes características:

  • Tener aplicado System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute al tipo.
  • Un constructor que tenga dos parámetros int: literalLength y formattedCount. (Se permiten más parámetros).
  • Un método AppendLiteral público con la signatura public void AppendLiteral(string s).
  • Un método AppendFormatted púbico genérico con la signatura public void AppendFormatted<T>(T t).

Internamente, el compilador crea la cadena con formato y proporciona un miembro para que un cliente recupere esa cadena. El código siguiente muestra un tipo LogInterpolatedStringHandler que satisface estos requisitos:

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

Ahora puede agregar una sobrecarga a LogMessage en la clase Logger para probar el nuevo controlador de cadenas interpoladas:

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

No es necesario quitar el método LogMessage original; el compilador prefiere un método con un parámetro de controlador interpolado antes que un método con un parámetro string cuando el argumento es una expresión de cadena interpolada.

Puede comprobar que se invoca el nuevo controlador mediante el código siguiente como programa 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.");

La ejecución de la aplicación genera una salida similar al texto siguiente:

        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.

Al realizar el seguimiento mediante la salida, puede ver cómo el compilador agrega código para llamar al controlador y compilar la cadena:

  • El compilador agrega una llamada para construir el controlador y pasa la longitud total del texto literal en la cadena de formato y el número de marcadores de posición.
  • El compilador agrega llamadas a AppendLiteral y AppendFormatted para cada sección de la cadena literal y para cada marcador de posición.
  • El compilador invoca el método LogMessage utilizando CoreInterpolatedStringHandler como argumento.

Por último, observe que la última advertencia no invoca el controlador de cadenas interpoladas. El argumento es un valor string, por lo que la llamada invoca a la otra sobrecarga con un parámetro de cadena.

Adición de más funcionalidades al controlador

La versión anterior del controlador de cadenas interpoladas implementa el patrón. Para evitar el procesamiento de cada expresión de marcador de posición, necesitará más información en el controlador. En esta sección, mejorará el controlador para que haga menos trabajo cuando la cadena construida no se escriba en el registro. Se usará System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute para especificar una asignación entre parámetros a una API pública y parámetros al constructor de un controlador. De esta forma, el controlador tendrá la información necesaria para determinar si se debe evaluar la cadena interpolada.

Comencemos con los cambios en el controlador. En primer lugar, agregue un campo para comprobar si el controlador está habilitado. Agregue dos parámetros al constructor: uno para especificar el nivel de registro de este mensaje y el otro una referencia al objeto de 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}");
}

A continuación, use el campo para que el controlador solo anexe literales u objetos con formato cuando se use la cadena final:

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

A continuación, deberá actualizar la declaración LogMessage para que el compilador pase los parámetros adicionales al constructor del controlador. Para ello, se usa System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute en el argumento del controlador:

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

Este atributo especifica la lista de argumentos para LogMessage que se asignan a los parámetros que siguen a los parámetros literalLength y formattedCount necesarios. La cadena vacía (""), especifica el receptor. El compilador sustituye el valor del objeto Logger que representa this por el siguiente argumento para el constructor del controlador. El compilador sustituye el valor de level por el siguiente argumento. Puede proporcionar cualquier número de argumentos para cualquier controlador que escriba. Los argumentos que agregue son argumentos de cadena.

Puede ejecutar esta versión con el mismo código de prueba. Esta vez, verá los siguientes resultados:

        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.

Puede ver que se llama a los métodos AppendLiteral y AppendFormat, pero no están haciendo nada. El controlador ha determinado que la cadena final no será necesaria, por lo que el controlador no la compila. Todavía hay que realizar un par de mejoras.

En primer lugar, puede agregar una sobrecarga de AppendFormatted que restrinja el argumento a un tipo que implementa System.IFormattable. Esta sobrecarga permite a los autores de llamada agregar cadenas de formato a los marcadores de posición. Al realizar este cambio, vamos a cambiar también el tipo de valor devuelto de los otros métodos, AppendFormatted y AppendLiteral, de void a bool (si alguno de estos métodos tiene tipos de valor devuelto diferentes, se producirá un error de compilación). Ese cambio permite el cortocircuito. Los métodos devuelven false para indicar que se debe detener el procesamiento de la expresión de cadena interpolada. Al devolver true se indica que se debe continuar. En este ejemplo, se usa para detener el procesamiento cuando la cadena resultante no es necesaria. El cortocircuito admite acciones más específicas. Podría detener el procesamiento de la expresión una vez que alcance una longitud determinada con el fin de admitir búferes de longitud fija. O bien, alguna condición podría indicar que los elementos restantes no son necesarios.

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 esa adición, puede especificar cadenas de formato en la expresión de cadena interpolada:

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

La :t del primer mensaje especifica el "formato de hora corto" de la hora actual. En el ejemplo anterior se mostraba una de las sobrecargas al método AppendFormatted que puede crear para el controlador. No es necesario especificar un argumento genérico para el objeto al que se va a dar formato. Es posible que tenga maneras más eficaces de convertir los tipos creados en cadenas. Puede escribir sobrecargas de AppendFormatted que toma esos tipos en lugar de un argumento genérico. El compilador elegirá la mejor sobrecarga. El tiempo de ejecución usa esta técnica para convertir System.Span<T> en una salida de cadena. Puede agregar un parámetro entero para especificar la alineación de la salida, con o sin IFormattable. El elemento System.Runtime.CompilerServices.DefaultInterpolatedStringHandler que se incluye con .NET 6 contiene nueve sobrecargas de AppendFormatted para distintos usos. Puede usarlo como referencia al crear un controlador para sus fines.

Ejecute el ejemplo ahora y verá que, para el mensaje Trace, solo se llama al primer 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.

Puede realizar una actualización final del constructor del controlador que mejore la eficacia. El controlador puede agregar un parámetro out bool final. Establecer ese parámetro en false indica que no se debe llamar al controlador para procesar la expresión de cadena interpolada:

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

Ese cambio significa que puede quitar el campo enabled. Para cambiar el tipo de valor devuelto de AppendLiteral y AppendFormatted a void. Ahora, al ejecutar el ejemplo, verá el resultado siguiente:

        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 única salida cuando se especificaba LogLevel.Trace era la salida del constructor. El controlador indicaba que no está habilitado, por lo que no se invocaba ninguno de los métodos Append.

En este ejemplo se muestra un punto importante para los controladores de cadena interpolada, especialmente cuando se usan bibliotecas de registro. Es posible que no se produzcan efectos secundarios en los marcadores de posición. Agregue el código siguiente al programa principal y vea este comportamiento en acción:

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

Puede ver que la variable index se incrementa cinco veces con cada iteración del bucle. Dado que los marcadores de posición solo se evalúan para los niveles Critical, Error y Warning, no para Information y Trace, el valor final de index no cumple la expectativa:

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

Los controladores de cadenas interpoladas proporcionan un mayor control sobre cómo se convierte una expresión de cadena interpolada en una cadena. El equipo del entorno de ejecución de .NET ya ha usado esta característica para mejorar el rendimiento en varias áreas. Puede usar la misma funcionalidad en sus propias bibliotecas. Para explorar más a fondo, examine System.Runtime.CompilerServices.DefaultInterpolatedStringHandler, que proporciona una implementación más completa de la que se ha creado aquí. Verá muchas más sobrecargas que son posibles para los métodos Append.