Tutorial: Write a custom string interpolation handler
In this tutorial, you learn how to:
- Implement the string interpolation handler pattern
- Interact with the receiver in a string interpolation operation.
- Add arguments to the string interpolation handler
- Understand the new library features for string interpolation
Prerequisites
You need to set up your machine to run .NET. The C# compiler is available with Visual Studio 2022 or the .NET SDK.
This tutorial assumes you're familiar with C# and .NET, including either Visual Studio or the .NET CLI.
You can write a custom interpolated string handler. An interpolated string handler is a type that processes the placeholder expression in an interpolated string. Without a custom handler, placeholders are processed similar to String.Format. Each placeholder is formatted as text, and then the components are concatenated to form the resulting string.
You can write a handler for any scenario where you use information about the resulting string. Will it be used? What constraints are on the format? Some examples include:
- You might require none of the resulting strings are greater than some limit, such as 80 characters. You can process the interpolated strings to fill a fixed-length buffer, and stop processing once that buffer length is reached.
- You might have a tabular format, and each placeholder must have a fixed length. A custom handler can enforce that, rather than forcing all client code to conform.
In this tutorial, you create a string interpolation handler for one of the core performance scenarios: logging libraries. Depending on the configured log level, the work to construct a log message isn't needed. If logging is off, the work to construct a string from an interpolated string expression isn't needed. The message is never printed, so any string concatenation can be skipped. In addition, any expressions used in the placeholders, including generating stack traces, doesn't need to be done.
An interpolated string handler can determine if the formatted string will be used, and only perform the necessary work if needed.
Initial implementation
Let's start from a basic Logger
class that supports different levels:
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);
}
}
This Logger
supports six different levels. When a message doesn't pass the log level filter, there's no output. The public API for the logger accepts a (fully formatted) string as the message. All the work to create the string has already been done.
Implement the handler pattern
This step is to build an interpolated string handler that recreates the current behavior. An interpolated string handler is a type that must have the following characteristics:
- The System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute applied to the type.
- A constructor that has two
int
parameters,literalLength
andformattedCount
. (More parameters are allowed). - A public
AppendLiteral
method with the signature:public void AppendLiteral(string s)
. - A generic public
AppendFormatted
method with the signature:public void AppendFormatted<T>(T t)
.
Internally, the builder creates the formatted string, and provides a member for a client to retrieve that string. The following code shows a LogInterpolatedStringHandler
type that meets these requirements:
[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();
}
You can now add an overload to LogMessage
in the Logger
class to try your new interpolated string handler:
public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
if (EnabledLevel < level) return;
Console.WriteLine(builder.GetFormattedText());
}
You don't need to remove the original LogMessage
method, the compiler prefers a method with an interpolated handler parameter over a method with a string
parameter when the argument is an interpolated string expression.
You can verify that the new handler is invoked using the following code as the main program:
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.");
Running the application produces output similar to the following text:
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.
Tracing through the output, you can see how the compiler adds code to call the handler and build the string:
- The compiler adds a call to construct the handler, passing the total length of the literal text in the format string, and the number of placeholders.
- The compiler adds calls to
AppendLiteral
andAppendFormatted
for each section of the literal string and for each placeholder. - The compiler invokes the
LogMessage
method using theCoreInterpolatedStringHandler
as the argument.
Finally, notice that the last warning doesn't invoke the interpolated string handler. The argument is a string
, so that call invokes the other overload with a string parameter.
Important
The version of the Logger
for this section is a ref struct
. A ref struct
minimizes allocations because it must be stored on the stack. However, ref struct
types, in general, can't implement interfaces. This can cause compatibility issues for unit test frameworks and mocking types that use ref struct
types for implementation.
Add more capabilities to the handler
The preceding version of the interpolated string handler implements the pattern. To avoid processing every placeholder expression, you need more information in the handler. In this section, you improve your handler so that it does less work when the constructed string isn't written to the log. You use System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute to specify a mapping between parameters to a public API and parameters to a handler's constructor. That provides the handler with the information needed to determine if the interpolated string should be evaluated.
Let's start with changes to the Handler. First, add a field to track if the handler is enabled. Add two parameters to the constructor: one to specify the log level for this message, and the other a reference to the log object:
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}");
}
Next, use the field so that your handler only appends literals or formatted objects when the final string will be used:
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");
}
Next, you need to update the LogMessage
declaration so that the compiler passes the additional parameters to the handler's constructor. That's handled using the System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute on the handler argument:
public void LogMessage(LogLevel level, [InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler builder)
{
if (EnabledLevel < level) return;
Console.WriteLine(builder.GetFormattedText());
}
This attribute specifies the list of arguments to LogMessage
that map to the parameters that follow the required literalLength
and formattedCount
parameters. The empty string (""), specifies the receiver. The compiler substitutes the value of the Logger
object represented by this
for the next argument to the handler's constructor. The compiler substitutes the value of level
for the following argument. You can provide any number of arguments for any handler you write. The arguments that you add are string arguments.
You can run this version using the same test code. This time, you see the following results:
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.
You can see that the AppendLiteral
and AppendFormat
methods are being called, but they aren't doing any work. The handler determined that the final string isn't needed, so the handler doesn't build it. There are still a couple of improvements to make.
First, you can add an overload of AppendFormatted
that constrains the argument to a type that implements System.IFormattable. This overload enables callers to add format strings in the placeholders. While making this change, let's also change the return type of the other AppendFormatted
and AppendLiteral
methods, from void
to bool
(if any of these methods have different return types, then you get a compilation error). That change enables short circuiting. The methods return false
to indicate that processing of the interpolated string expression should be stopped. Returning true
indicates that it should continue. In this example, you're using it to stop processing when the resulting string isn't needed. Short circuiting supports more fine-grained actions. You could stop processing the expression once it reaches a certain length, to support fixed-length buffers. Or some condition could indicate remaining elements aren't needed.
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");
}
With that addition, you can specify format strings in your interpolated string expression:
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.");
The :t
on the first message specifies the "short time format" for the current time. The previous example showed one of the overloads to the AppendFormatted
method that you can create for your handler. You don't need to specify a generic argument for the object being formatted. You might have more efficient ways to convert types you create to string. You can write overloads of AppendFormatted
that takes those types instead of a generic argument. The compiler picks the best overload. The runtime uses this technique to convert System.Span<T> to string output. You can add an integer parameter to specify the alignment of the output, with or without an IFormattable. The System.Runtime.CompilerServices.DefaultInterpolatedStringHandler that ships with .NET 6 contains nine overloads of AppendFormatted for different uses. You can use it as a reference while building a handler for your purposes.
Run the sample now, and you see that for the Trace
message, only the first AppendLiteral
is called:
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.
You can make one final update to the handler's constructor that improves efficiency. The handler can add a final out bool
parameter. Setting that parameter to false
indicates that the handler shouldn't be called at all to process the interpolated string expression:
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!;
}
That change means you can remove the enabled
field. Then, you can change the return type of AppendLiteral
and AppendFormatted
to void
.
Now, when you run the sample, you see the following output:
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.
The only output when LogLevel.Trace
was specified is the output from the constructor. The handler indicated that it's not enabled, so none of the Append
methods were invoked.
This example illustrates an important point for interpolated string handlers, especially when logging libraries are used. Any side-effects in the placeholders might not occur. Add the following code to your main program and see this behavior in 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}");
You can see the index
variable is incremented five times each iteration of the loop. Because the placeholders are evaluated only for Critical
, Error
and Warning
levels, not for Information
and Trace
, the final value of index
doesn't match the expectation:
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
Interpolated string handlers provide greater control over how an interpolated string expression is converted to a string. The .NET runtime team used this feature to improve performance in several areas. You can make use of the same capability in your own libraries. To explore further, look at the System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. It provides a more complete implementation than you built here. You see many more overloads that are possible for the Append
methods.