Interpolated strings: advanced usages

 
 
  • Gérald Barré

This post shows how to take advantage of C# interpolated strings beyond basic string concatenation. Interpolated strings are commonly used as a more readable way to concatenate strings. For instance:

C#
var fullname = "Gérald Barré";
var nickname = "Meziantou";

var str = fullname + " aka. " + nickname;
var str = string.Format("{0} aka. {1}", fullname, nickname);
var str = $"{fullname} aka. {nickname}"; // Interpolated string is more readable

As with string.Format, you can apply a custom format by using a colon to separate the value from the format specifier:

C#
var value = 42;
Console.WriteLine($"{value:C}"); // $42.00

You can also create multiline interpolated strings using the $@ or @$ prefix:

C#
var publishDate = new DateTime(2017, 12, 14);
var str = $@"This post published on {publishDate:yyyy-MM-dd} is about
interpolated strings.";

#Under the hood

The compiler has four different ways to convert an interpolated string. Depending on the context, it automatically chooses the most efficient one.

##Interpolated strings rewritten as string.Concat

When an interpolated string is assigned to a string variable or parameter and all arguments are of type string, the compiler rewrites it as string.Concat.

C#
string name = "meziantou";
string hello = $"Hello {name}!";

The compiler rewrites the previous code as:

C#
string name = "meziantou";
string hello = string.Concat("Hello ", name, "!");

##Interpolated strings rewritten as string.Format

When an interpolated string is assigned to a string variable or parameter and some arguments are not of type string, the compiler rewrites it as string.Format.

C#
DateTime now = DateTime.Now;
string str = $"It is {now}";

The compiler rewrites the previous code as:

C#
DateTime now = DateTime.Now;
string str = string.Format("It is {0}", now);

##Interpolated strings rewritten as FormattableString

When an interpolated string is assigned to a FormattableString variable or parameter, the compiler rewrites it to create a new FormattableString via FormattableStringFactory.Create:

C#
object value1 = "Foo";
object value2 = "Bar";
FormattableString str = $"Test {value1} {value2}";

The compiler rewrites the previous code as:

C#
object value1 = "Foo";
object value2 = "Bar";
FormattableString str = FormattableStringFactory.Create("Test {0} {1}", new object[] { value1, value2 });

The factory creates an instance of ConcreteFormattableString from the format and arguments. Here is the class implementation:

C#
class ConcreteFormattableString : FormattableString
{
    private readonly string _format;
    private readonly object[] _arguments;

    internal ConcreteFormattableString(string format, object[] arguments)
    {
        _format = format;
        _arguments = arguments;
    }

    public override string Format => _format;
    public override object[] GetArguments() => _arguments;
    public override int ArgumentCount => _arguments.Length;
    public override object GetArgument(int index) => _arguments[index];

    public override string ToString(IFormatProvider formatProvider)
    {
        return string.Format(formatProvider, _format, _arguments);
    }
}

The full source code of the factory is available on GitHub in the CoreCLR repository: FormattableStringFactory.cs, FormattableString.cs.

The ToString method calls string.Format with the arguments and the specified FormatProvider.

##Interpolated strings rewritten as constants (C# 10)

Starting with C# 10, the compiler rewrites string interpolations as constant strings when all interpolated values are constants.

C#
const string Username = "meziantou";
const string Hello = $"Hello {Username}!";

// In previous C# version, you need to use the following concat syntax
const string Hello2 = "Hello " + Username + "!";

This is useful in locations where a constant is expected, such as an attribute value.

C#
// Works with C# 10
[DebuggerDisplay($"Value: {nameof(Text)}")]
public class Sample
{
    public string Text { get; set; }
}

##Interpolated string handlers (C# 10)

C# 10 and .NET 6 introduced a new feature called interpolated string handlers, which provides a more efficient way to process interpolated strings when performance matters. Let's see how Debug.Assert uses this feature to avoid generating a string when the condition is met. Instead of a string or FormattableString argument, it uses a custom type with the InterpolatedStringHandlerArgument attribute to tell the compiler to rewrite interpolated strings.

C#
public static void Assert(
    [DoesNotReturnIf(false)] bool condition,
    [InterpolatedStringHandlerArgument("condition")] AssertInterpolatedStringHandler message)
{
    Assert(condition, message.ToString());
}

When you call the Assert method, the compiler rewrites the interpolated string to construct a new AssertInterpolatedStringHandler instance:

C#
Debug.Assert(condition: false, $"Debug info: {ComputeDebugInfo()}");)
C#
var handler = new AssertInterpolatedStringHandler(
        literalLength: 12,
        formattedCount: 1,
        condition: false,
        out var shouldAppend);
if (shouldAppend)
{
    handler.AppendLiteral("Debug info: ");
    handler.AppendFormatted(ComputeDebugInfo());
}

Debug.Assert(condition, handler);

You can read more about this new feature in the following post: String Interpolation in C# 10 and .NET 6

#Specifying culture

With the traditional String.Format, you can specify a culture for formatting values:

C#
var culture = CultureInfo.GetCultureInfo("fr-FR");
string.Format(culture, "{0:C}", 42); // 42,00 €

Interpolated string syntax does not provide a way to directly set the format provider. By default, it uses the current culture (source). You can use the invariant culture via the FormattableString.Invariant method:

C#
var value = 42;
Console.WriteLine(FormattableString.Invariant($"Value {value:C}")); // Value ¤42.00

// You can simplify the usage of Invariant with  "using static"
using static System.FormattableString;
Console.WriteLine(Invariant($"Value {value:C}")); // Value ¤42.00

To use a specific culture, implement a simple helper method:

C#
private static string WithCulture(CultureInfo cultureInfo, FormattableString formattableString)
{
    return formattableString.ToString(cultureInfo);
}

WithCulture(CultureInfo.GetCultureInfo("jp-JP"), $"{value:C}"); // ¥42.00
WithCulture(CultureInfo.GetCultureInfo("fr-FR"), $"{value:C}"); // 42,00 €

Those are the basics. Now, let's use the FormattableString class to do some trickier things 😃

Starting with .NET 6 and C# 10, you can use the more efficient string.Create method to format a string with a specific culture:

C#
string.Create(CultureInfo.InvariantCulture, $"Value {value:C}");

#Escaping command-line arguments

The first example escapes values for use as command-line arguments. The result looks like:

C#
var arg1 = "c:\\Program Files\\whoami.exe";
var arg2 = "Gérald Barré";
var commandLine = EscapeCommandLineArgs($"{arg1} {arg2}"); // "c:\Program Files\whoami.exe" "Gérald Barré"

First, install the Meziantou.Framework.CommandLine NuGet package (NuGet, GitHub) for building command lines. It follows the rules described in the post: Everyone quotes command line arguments the wrong way.

You can then escape the command-line arguments using the CommandLine class and call string.Format:

C#
string EscapeCommandLineArgs(FormattableString formattableString)
{
    var args = formattableString.GetArguments()
                   .Select(arg => CommandLineBuilder.WindowsQuotedArgument(string.Format("{0}", arg)))
                   .ToArray();
    return string.Format(formattableString.Format, args);
}

This works in most cases, but it ignores argument formats. For instance, if the string is $"{0:C}", the C format specifier is lost. A better approach is to implement a custom IFormatProvider. The formatter's Format method is called once per argument with its value and format specifier, so you can process each argument and output the value with the correct format. The code is slightly longer, but it correctly respects argument formatting:

C#
string EscapeCommandLineArgs(FormattableString formattableString)
{
    return formattableString.ToString(new CommandLineFormatProvider());
}

class CommandLineFormatProvider : IFormatProvider
{
    public object GetFormat(Type formatType)
    {
        if (typeof(ICustomFormatter).IsAssignableFrom(formatType))
            return new CommandLineFormatter();

        return null;
    }

    private class CommandLineFormatter : ICustomFormatter
    {
        public string Format(string format, object arg, IFormatProvider formatProvider)
        {
            if (arg == null)
                return string.Empty;

            if (arg is string str)
                return CommandLineBuilder.WindowsQuotedArgument(str);

            if (arg is IFormattable) // Format the argument before escaping the value
                return CommandLineBuilder.WindowsQuotedArgument(((IFormattable)arg).ToString(format, CultureInfo.InvariantCulture));

            return CommandLineBuilder.WindowsQuotedArgument(arg.ToString());
        }
    }
}

#Executing a SQL query with parameters

Now, let's see how to use interpolated strings to build a parameterized query. Parameterized queries are important for security, as they protect against SQL injection attacks, and for performance.

The idea is to replace each argument with @p0, @p1, and so on to build the SQL query, then create command parameters with the actual values.

C#
using (var sqlConnection = new SqlConnection())
{
    sqlConnection.Open();
    ExecuteNonQuery(sqlConnection, $@"
UPDATE Customers
SET Name = {"Meziantou"}
WHERE Id = {1}");
}
C#
void ExecuteNonQuery(DbConnection connection, FormattableString formattableString)
{
    using (var command = connection.CreateCommand())
    {
        // Replace values by @p0, @p1, @p2, ....
        var args = Enumerable.Range(0, formattableString.ArgumentCount).Select(i => (object)("@p" + i)).ToArray();

        command.CommandType = System.Data.CommandType.Text;
        command.CommandText = string.Format(formattableString.Format, args);

        // Create parameters
        for (var i = 0; i < formattableString.ArgumentCount; i++)
        {
            var arg = formattableString.GetArgument(i);
            var p = command.CreateParameter();
            p.ParameterName = "@p" + i;
            p.Value = arg;
            command.Parameters.Add(p);
        }

        // Execute the command
        command.ExecuteNonQuery();
    }
}

#Do not use overloads with string and FormattableString

When the compiler has a choice, it picks the string overload even when you pass an interpolated string as an argument.

C#
// Do not do that
void Sample(string value) => throw null;
void Sample(FormattableString value) => throw null;

Sample($"Hello {name}");
// ⚠ Call Sample(string)

Sample((FormattableString)$"Hello {name}");
// Call Sample(FormattableString) because of the explicit cast

Sample((FormattableString)$"Hello {name}" + "!");
// ⚠ Call Sample(string) because the operator FormattableString + string returns a string

This is inconvenient and error-prone, which is why you should avoid defining overloads for both FormattableString and string.

#Conclusion

Interpolated strings are a powerful feature introduced in C# 6, offering the capabilities of string.Format with a much cleaner syntax. Leveraging the format provider enables more readable code in many scenarios. For instance, you can automatically escape values or build parameterized SQL queries instead of simply concatenating strings.

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?