.NET Tools How-To's Tech Writing

Improvements and Optimizations for Interpolated Strings – A Look at New Language Features in C# 10

ReSharper and Rider support for C# 8

Welcome to the fourth part of our series, where we take a closer look at the new C# language features the .NET team has implemented, and how ReSharper and Rider make it easy to adopt them in your codebase. Welcome to C# 10 and .NET 6!

In this series, we are looking at:

Now hold your breath! Interpolated strings received quite some attention and got not just one but two new featuresconstant interpolated strings and the ability for custom construction, also known as interpolated string handlers. We will discuss them one by one!

Constant Interpolated Strings

Let’s start with constant interpolated strings. Simply put, they can now be assigned as constant expressions if they don’t reference any other non-constant expression. This removes a lot of noise that was previously needed to construct string literals:

const string name = "Mads";
const string message = "Hello " + name + "!"; // Before C# 10
const string message = $"Hello {name}!";      // With C# 10

This really comes in handy in a number of situations, for instance with attributes, parameter default values, and switch statements/expressions:

[DebuggerDisplay($"{nameof(Prop1)} = {{{{{nameof(Prop1)}}}}}")]
[DebuggerDisplay(nameof(Prop1) + " = {{" + nameof(Prop1) + "}}")]
public class ComplexType
{
    public static void M(string value = $"{Foo}.{Bar}")
    {
        switch (value)
        {
            case $"{nameof(Foo)}.{nameof(Bar)}": break;
            // ...
        }
    }
}

To be fair, using them in DebuggerDisplay attributes looks a bit excessive – you’ll need 10 curly braces and just save 2 characters! But it should still come around much easier to type than the 3 string concatenations.

We are sure that many of you will want to convert old constant string concatenations to the new constant interpolations in their codebases. ReSharper and Rider make this very easy with the Convert concatenation to interpolation context action, which can also be applied in bulk on your whole solution:

Convert String Concatenation to String Interpolation
Convert String Concatenation to String Interpolation

In case you missed it, you can also use code-completion to quickly turn a string literal into an interpolated string:

Convert to String Interpolation from Code-Completion
Convert to String Interpolation from Code-Completion

Interpolated String Handlers

Performance optimization time! With interpolated string handlers the construction of interpolated strings can be handed over to your optimized custom implementation. For the purpose of illustration, we will consider the following example:

public class Logger
{
    public LogLevel Level { get; set; }

    public void Log(LogLevel level, string message)
    {
        Console.WriteLine($"{level}: {message}");
    }
}

public enum LogLevel { Trace, Debug, Info, Warn, Error }

Usually, you want the convenience of dedicated methods per log-level, for instance, by providing additional methods like logger.Info(...), logger.Warn(...), etc. that check whether the related log level is enabled, and – if enabled – forward the call to our standard Log method (Nick Chapsas talked about this in more detail). Logging a debug-level entry would then look like this:

logger.Debug($"Start processing at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}");

One drawback with this approach is that passing an interpolated string would always construct the full string, even if it’s not passed along further and consumed by our general Log method. For example, when our log level is Warn, all interpolated strings passed for logger.Trace(...), logger.Debug(...), etc. would be constructed but not outputted. And remember that string concatenation can cause a lot of memory pressure!

With the new interpolated string handlers, the compiler will rewrite the above example – with some changes to the Debug method that we will explain in a moment – into the following:

var handler = new DebugLoggerStringHandler(20, 1, logger, out var handlerIsValid);
if (handlerIsValid) // checks if LogLevel.Debug is enabled
{
    handler.AppendLiteral("Start processing at ");
    handler.AppendFormatted(DateTime.UtcNow, 0, "yyyy-MM-dd HH:mm:ss");
}
logger.Debug(ref handler);

For now, we will focus only on handlerIsValid. This variable is set by the DebugLoggerStringHandler and communicates whether the string construction should actually happen or not. If not, you can save yourself from unnecessary string concatenation! Also, it reminds a bit of using a StringBuilder, right? But it’s all generated!

Implementing an Interpolated String Handler

Let’s take a closer look at the implementation details, starting with the method that receives the interpolated string that gets handled:

public void LogDebug(
    [InterpolatedStringHandlerArgument("")]
    ref DebugLoggerStringHandler handler)
{
    if (Level <= LogLevel.Debug)
        Log(LogLevel.Debug, handler.ToStringAndClear());
}

Instead of a string parameter, you can use a custom type – here we’re choosing a ref struct DebugLoggerStringHandler, which we will later implement. The compiler will create an instance of this type when rewriting the method call. Additionally, the parameter may have an InterpolatedStringHandlerArgument attribute, which you can use to pass additional context from parameters to the custom type’s constructor. For instance, if we were implementing LogDebug as an extension method, we could pass the logger parameter to the constructed handler. The neat part: ReSharper and Rider help you avoid typos when referencing named parameters!

Completing String Handler Arguments
Completing String Handler Arguments

When used with an instance method, we can pass a reference to this (our logger) by using "" as an argument to the attribute. Unfortunately, we can’t reference parameters with the nameof operator just yet.

Now we’re coming to the truly interesting part – implementing the DebugLoggerStringHandler. As a first step, you have to annotate the type with the InterpolatedStringHandler attribute to let the compiler know that it can act as an interpolated string handler. After that, you have to implement your type in an expected shape that is dictated by the compiler. This works similar to how deconstruction and foreach-statements can be supported for custom types. For interpolated string handlers, it means they have to provide a suitable constructor, an AppendLiteral method, and at least one AppendFormatted method (more details after the minimal snippet):

[InterpolatedStringHandler]
public ref struct DebugLoggerStringHandler
{
    private DefaultInterpolatedStringHandler _builder;

    public DebugLoggerStringHandler(
        int literalLength,
        int formattedCount,
        Logger logger,
        out bool handlerIsValid)
    {
        handlerIsValid = logger.Level <= LogLevel.Debug;
        _builder = new(literalLength, formattedCount);
    }

    public void AppendLiteral(string value)
        => _builder.AppendLiteral(value);

    public void AppendFormatted<T>(T t, int alignment = 0, string? format = null)
        => _builder.AppendFormatted(t, alignment, format);

    public string ToStringAndClear()
        => _builder.ToStringAndClear();
}

There is really a lot to grasp here! Let’s try to go through the details.

String handler constructor

The constructor must always start with two arguments literalLength – the number of constant characters outside of interpolated parts – and a formattedCount – the number of interpolated parts. Since we’ve used the InterpolatedStringHandlerArgument in the LogDebug method, you can add an additional parameter to capture the Logger instance. This allows you to determine whether the string should be constructed and then to return that information through the trailing handlerIsValid out-parameter. So to recap: if the Debug level is not enabled, you can return false and the string construction won’t happen due to the compiler-generated code.

Usage of DefaultInterpolatedStringHandler

Implementing a handler from scratch could be a tedious task. However, as you can see in the AppendFormatted and AppendLiteral methods, we aren’t doing a lot more than delegating to a local DefaultInterpolatedStringHandler. This should become a common practice, as it is also the default implementation used for regular string interpolation, so it’s well-prepared. Be aware that one instance of this type should never be reused after the ToStringAndClear method has been called!

Generic AppendFormatted<T> method

Quite obviously, the generic version will work with any expression from the string interpolation. It also allows the arguments for alignment and format to be passed or omitted. In case you don’t know what these two are for, check out the structure of interpolated strings.

The even more interesting part about the AppendFormatted method is that you can create any combination of a specific type (instead of T) and the presence/absence of the alignment and format parameters. All of this is to give you the full freedom to implement possible optimizations or to replace the formatting:

public void AppendFormatted(DateTime datetime)
    => _builder.AppendFormatted(datetime, format: "u");

public void AppendFormatted(bool boolean)
    => _builder.AppendLiteral(boolean ? "TRUE" : "FALSE");

ReSharper and Rider Support

ReSharper and Rider know how to deal with interpolated string handlers, so you can proceed as usual and pass an interpolated string argument as long as the parameter type is an interpolated string handler (regular strings are no longer allowed):

Passing a string to a String Handler
Passing a string to a String Handler

If you’re interested in how an interpolated string is being handled, you can use Goto Declaration on the dollar sign. This way you can even decompile and inspect the DefaultInterpolatedStringHandler that is used for interpolated strings by default:

Navigate to String Handler from Usage
Navigate to String Handler from Usage

Due to their open structure and missing direct usage, string handlers can feel a bit loosely coupled. How can you know that a particular AppendFormatted method is being used in a large codebase? Especially, when it comes with and without the alignment and format parameters? Maybe there’s even a mistake in the signature? Luckily, ReSharper and Rider have you covered with the Goto Usage navigation action, so you will immediately know where AppendFormatted methods are used:

Find Usages for AppendFormatted methods
Find Usages for AppendFormatted methods

Remember that you can hold Control/Command and click on the declaration, too! With solution-wide analysis enabled, you will also instantly see if a method is not used in your codebase when it’s grayed out.

One last related tip: if you have an accompanying method that acts as an old-style string.Format, you can annotate it with StringFormatMethod attribute that comes from the JetBrains.Annotations NuGet package. ReSharper and Rider will allow you to convert to an interpolated string easily (even in bulk):

Convert string.Format to String Interpolation
Convert string.Format to String Interpolation

Conclusion

Interpolated strings received some great updates with C# 10. We can use them as constants in various places like attributes, switch-cases, and parameter default values. With the new interpolated string handlers we can optimize our codebase to avoid string concatenation when it’s not necessary and even implement custom formatting.

Make sure to download ReSharper or check out Rider to start taking advantage of C# 10 in the best possible way. We’d love to hear your thoughts!

image description