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