Skip to content
Ian Griffiths By Ian Griffiths Technical Fellow I
C# 11.0 new features: newlines in string interpolation expressions

This third post in my series on new features in C# 11.0 describes the last of the enhancements for string literal handling. This is the simplest of the new string features.

Interpolated strings have been in C# for many years. They let us embed expressions in string literals:

Console.WriteLine($"The answer is {40 + 2}");
Programming C# 10 Book, by Ian Griffiths, published by O'Reilly Media, is now available to buy.

Before C# 11.0, you weren't allowed to split the embedded expressions over multiple lines. You couldn't write this, for example:

// Illegal before C# 11.0
Console.WriteLine($"The answer is {40
    +
    2}");

For the common use case of displaying simple expressions such as properties, e.g. "Name: {person.Name}" this was not a big problem. However, various language features added to C# since string interpolations first appeared have made it practical to express increasingly sophisticated computations in a single expression. For example, switch expressions (added in C# 8.0) can express quite complex logic, but they do not look good if you try to cram them onto a single line.

C# 11.0 relaxes the restriction, allowing you to put newlines inside the braces delimiting an interpolated expression. Space inside these expressions do not make it into the resulting string, so in this example, the newlines are there purely for layout—the string value itself contains no new newlines:

Console.WriteLine($"{individual} {
    individual switch
    {
        "I" or "You" or "They" => "have",
        _ => "has",
    }} {
    individual switch
    {
        "I" => "given a confidential security briefing",
        "You" => "leaked",
        _ => "been charged under section 2a of the Official Secrets Act"
    }}.");

What's wrong with string.Format?

You might look at that and think that it would be even more readable if we just stuck with good old string.Format style string formatting, (an idiom that Console.WriteLine supports directly so we don't even have to call string.Format explicitly):

Console.WriteLine($"{0} {1} {2}.",
    individual,
    individual switch
    {
        "I" or "You" or "They" => "have",
        _ => "has",
    },
    individual switch
    {
        "I" => "given a confidential security briefing",
        "You" => "leaked",
        _ => "been charged under section 2a of the Official Secrets Act"
    }.");

And you might have a point. However, there are sometimes reasons to prefer string interpolation. For example, C# 10 brought in some performance enhancements to string interpolation. Consider this simple benchmark:

[MemoryDiagnoser]
public class Tests
{
    private int i;

    [Benchmark]
    public string WithStringInterpolation()
    {
        i += 1;
        return $"{i} * {i} = {i * i}.";
    }

    [Benchmark(Baseline = true)]
    public string WithStringFormat()
    {
        i += 1;
        return string.Format("{0} * {0} = {1}.",
            i,
            i * i);
    }
}

This generates the same string two ways: once with interpolation and once with string.Format. There's a non-trivial performance difference:

Method Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
WithStringInterpolation 90.73 ns 1.845 ns 2.817 ns 0.72 0.02 0.0114 96 B 0.67
WithStringFormat 124.15 ns 1.725 ns 1.614 ns 1.00 0.00 0.0172 144 B 1.00

Execution time and memory consumption are both significantly lower with string interpolation.

The difference can be more dramatic with types that participate directly in string interpolation handling. Take this benchmark which uses a Logger type that integrates with string interpolation:

[MemoryDiagnoser]
public class Tests2
{
    private int i;
    private Logger logger = new();

    [Benchmark]
    public void WithStringInterpolation()
    {
        i += 1;
        logger.Log(LogLevel.Trace, $"{i} * {i} = {i * i}.");
    }

    [Benchmark(Baseline = true)]
    public void WithStringFormat()
    {
        i += 1;
        logger.Log(LogLevel.Trace, string.Format("{0} * {0} = {1}.",
            i,
            i * i));
    }
}

The difference here is enormous:

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
WithStringInterpolation 3.595 ns 0.0943 ns 0.1009 ns 0.03 - - 0.00
WithStringFormat 123.362 ns 2.1480 ns 1.9042 ns 1.00 0.0172 144 B 1.00

Why such a huge difference? Well in this benchmark, I configured things so that trace-level logging is disabled, so neither of the benchmarks actually produces any log output. A little unfair, you might think, but in practice it's common to configure logging to be relatively quiet most of the time.

Because the Logger class I'm using here (shown below) integrates with string interpolation, it's able to completely avoid creating the string when it detects that it's not going to be needed.

I could have made the second benchmark's performance much closer to the first by adding an if statement to check the log level and skip the call to logger.Log when it's not required. But when using string interpolation with an interpolation-aware class, you don't need to do that—this technique offers a much more succinct way to achieve high performance when logging is disabled.

Here is the Logger type (which is based on the examples in https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) that the benchmark uses:

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

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

    public void Log(LogLevel level, string msg)
    {
        if (level < EnabledLevel) { return; }
        Console.WriteLine(msg);
    }
}

public enum LogLevel
{
    Off, Trace, Warning, Error, Critical,
}

When you pass an interpolated string, the first overload of Log gets used. The C# compiler generates code that interacts with this LogInterpolatedStringHandler, and avoids creating the string entirely when it's not required.

We're quite a long way from this blog's topic now, but for completeness, here's the handler:

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    StringBuilder builder;

    public LogInterpolatedStringHandler(
        int literalLength,
        int formattedCount,
        Logger logger,
        LogLevel level,
        out bool isEnabled)
    {
        isEnabled = level >= logger.EnabledLevel;
        builder = isEnabled ? new StringBuilder(literalLength) : default!;
    }

    public void AppendLiteral(string s) => builder.Append(s);

    public void AppendFormatted<T>(T t) => builder.Append(t);

    internal string GetFormattedText() => builder.ToString();
}
The Introduction to Rx.NET 2nd Edition (2024) Book, by Ian Griffiths & Lee Campbell, is now available to download for FREE.

(Note: sadly most .NET logging frameworks don't seem to have taken advantage of this feature yet, so in practice we still need to clutter up our logging with if statements. It is slightly disappointing that over a year after C# 10.0 shipped, we still need to use sample code from the documentation to demonstrate the benefits—I didn't find anything in the .NET runtime libraries themselves using this mechanism that I could use to demonstrate the benefits of the string interpolation performance enhancements. To be fair, though, the structured logging code generator added in .NET 6.0 is equally efficient, and has some benefits over interpolated strings. .NET 7.0 did add some new methods to the System.Debug type that use custom string interpolation, such as certain overloads of WriteLineIf and Assert, but since those use the Conditional attribute to remove calls to such methods in non-DEBUG builds, there aren't going to be any performance benefits in release builds. It's baffling that the equivalent methods of the Trace class didn't get the same treatment when those typically are used in release builds.)

Anyway, most of that wasn't really C# 11.0 content—all this hyper-efficient interpolated string handling was added back in C# 10. However, it's relevant because it can give us a reason to prefer interpolated strings over string.Format. And at that point, C# 11.0's support for more flexible formatting of interpolated expressions becomes more interesting.

Conclusion

String interpolation is a convenient way to build up strings from data, and can sometimes offer better performance than the alternatives. Starting with C# 11.0, embedded expressions can contain newlines, which can improve the readability of our code.

Ian Griffiths

Technical Fellow I

Ian Griffiths

Ian has worked in various aspects of computing, including computer networking, embedded real-time systems, broadcast television systems, medical imaging, and all forms of cloud computing. Ian is a Technical Fellow at endjin, and Microsoft MVP in Developer Technologies. He is the author of O'Reilly's Programming C# 10.0, and has written Pluralsight courses on WPF (and here) and the TPL. He's a maintainer of Reactive Extensions for .NET, Reaqtor, and endjin's 50+ open source projects. Technology brings him joy.