.NET Tools
Essential productivity kit for .NET and game developers
Improvements and Optimizations for Interpolated Strings – A Look at New Language Features in C# 10
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:
- File-Scoped Namespaces
- Caller Argument Expressions
- Global Usings
- Improvements and Optimizations for Interpolated Strings
Now hold your breath! Interpolated strings received quite some attention and got not just one but two new features – constant 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:
In case you missed it, you can also use code-completion to quickly turn a string literal into an interpolated string:
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!
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):
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:
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:
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):
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!