Everyone who develops apps and services with C# uses string concatenation. Whenever we need to build a formatted message with different types of data or combine several strings to show helpful information anywhere, we usually enlist the help of string interpolation.
In the following code snippet, we have a very primitive example of string interpolation
int orderAmount = 150;
string orderNumber = "ORDER-13";
Console.WriteLine($"The order with number {orderNumber} has amount of {orderAmount} items");
If we execute the code above, the console shows the following output
The order with number ORDER-13 has amount of 150 items
But, what is happening under the hood?
How does the compiler optimize our code?
In general, high-level programming languages offer many abstract programming constructs such as functions, loops, conditional statements, and many other useful things, which help us to be productive and write readable code. Of course, it has a significant drawback — a potential performance decrease. But should developers care about the cost of using such nice abstractions instead of focusing on writing understandable and maintainable code? Ideally, not. For this reason, compilers attempt to optimize our code to improve its performance.
For instance, for the code snippet above and using C# 10, the compiler transforms it into the following code:
int value = 150;
string value2 = "ORDER-13";
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(41, 2);
defaultInterpolatedStringHandler.AppendLiteral("The order with number ");
defaultInterpolatedStringHandler.AppendFormatted(value2);
defaultInterpolatedStringHandler.AppendLiteral(" has amount of ");
defaultInterpolatedStringHandler.AppendFormatted(value);
defaultInterpolatedStringHandler.AppendLiteral(" items");
Console.WriteLine(defaultInterpolatedStringHandler.ToStringAndClear());
And when using C# 9, it translates the same code but using string.Format
method instead:
int num = 150;
string arg = "ORDER-13";
Console.WriteLine(string.Format("The order with number {0} has amount of {1} items", arg, num));
As we can see, in the later C# version, the compiler utilizes a new feature, interpolated string handlers.
In a nutshell, interpolated string handlers optimize string-building to avoid performance problems with usingstring.Format
methods such as unnecessary allocation of object[] on the heap, boxing of arguments, and intermediate string generations. Moreover, performance improvements come almost without any changes in existing codebases. Also, the exciting side of the new strings-building design is the possibility of
Approach and Results
Let’s compare the performance of string concatenation with different options:
-
String.Format
-
String.Concat
-
String.Join
-
String interpolation
-
StringBuilder
-
DefaultInterpolatedStringHandler
-
Enumerable.Aggregate
I usually use Benchmark.DotNet for benchmarking different solutions. I will benchmark concatenation with a mix of value and reference types and only immutable strings.
//case #1
int orderAmount = 150;
string orderNumber = "ORDER-13";
For the first case, we have the following results:
BenchmarkDotNet v0.13.7, Windows 11 (10.0.22621.2134/22H2/2022Update/SunValley2)
AMD Ryzen 7 5700U with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.202
[Host] : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
.NET 7.0 : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
Job=.NET 7.0 Runtime=.NET 7.0
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|--------------------------------- |----------:|---------:|---------:|-------:|----------:|
| StringFormat | 79.34 ns | 1.002 ns | 0.783 ns | 0.0573 | 120 B |
| StringInterpolation | 54.01 ns | 0.922 ns | 0.906 ns | 0.0459 | 96 B |
| StringConcat | 51.08 ns | 0.208 ns | 0.173 ns | 0.0918 | 192 B |
| StringJoin | 74.55 ns | 0.593 ns | 0.526 ns | 0.1032 | 216 B |
| StringBuilder | 84.85 ns | 0.311 ns | 0.305 ns | 0.2104 | 440 B |
| DefaultInterpolatedStringHandler | 50.56 ns | 0.431 ns | 0.360 ns | 0.0459 | 96 B |
| EnumerableAggregate | 150.56 ns | 1.761 ns | 1.648 ns | 0.2716 | 568 B |
As we can see, StringFormat is 30% slower and allocates much more memory than using StringInterpolation or DefaultInterpolatedStringHandler, which are the same after compilator optimizations.
string StringFormat()
{
int orderAmount = 150;
string orderNumber = "ORDER-13";
return string.Format("Order number {0} has {1} items.", orderNumber, orderAmount);
}
string StringInterpolation()
{
int orderAmount = 150;
string orderNumber = "ORDER-13";
return $"Order number {orderNumber} has {orderAmount} items.";
}
string DefaultInterpolatedStringHandler()
{
int orderAmount = 150;
string orderNumber = "ORDER-13";
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(25, 2);
defaultInterpolatedStringHandler.AppendLiteral("Order number ");
defaultInterpolatedStringHandler.AppendFormatted(orderNumber);
defaultInterpolatedStringHandler.AppendLiteral(" has ");
defaultInterpolatedStringHandler.AppendFormatted(orderAmount);
defaultInterpolatedStringHandler.AppendLiteral(" items.");
return defaultInterpolatedStringHandler.ToStringAndClear();
}
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
internal static string <<Main>$>g__StringFormat|0_0()
{
int num = 150;
string arg = "ORDER-13";
return string.Format("Order number {0} has {1} items.", arg, num);
}
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
internal static string <<Main>$>g__StringInterpolation|0_1()
{
int value = 150;
string value2 = "ORDER-13";
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(25, 2);
defaultInterpolatedStringHandler.AppendLiteral("Order number ");
defaultInterpolatedStringHandler.AppendFormatted(value2);
defaultInterpolatedStringHandler.AppendLiteral(" has ");
defaultInterpolatedStringHandler.AppendFormatted(value);
defaultInterpolatedStringHandler.AppendLiteral(" items.");
return defaultInterpolatedStringHandler.ToStringAndClear();
}
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
internal static string <<Main>$>g__DefaultInterpolatedStringHandler|0_2()
{
int value = 150;
string value2 = "ORDER-13";
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(25, 2);
defaultInterpolatedStringHandler.AppendLiteral("Order number ");
defaultInterpolatedStringHandler.AppendFormatted(value2);
defaultInterpolatedStringHandler.AppendLiteral(" has ");
defaultInterpolatedStringHandler.AppendFormatted(value);
defaultInterpolatedStringHandler.AppendLiteral(" items.");
return defaultInterpolatedStringHandler.ToStringAndClear();
}
The curious observation is that the use of StringBuilder
is slower than string.Format
, but actually, StringBuilder
starts to show a drastically better performance in multiple concatenation statements.
[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
for (int i = 0; i < 100; i++)
{
sb.Append(i);
}
return sb.ToString();
}
[Benchmark]
public string StringConcat()
{
string result = string.Empty;
for (int i = 0; i < 100; i++)
{
result += i;
}
return result;
}
[Benchmark]
public string StringInterpolation()
{
string result = string.Empty;
for (int i = 0; i < 100; i++)
{
result += $"{i}";
}
return result;
}
[Benchmark]
public string StringInterpolationHandler()
{
var handler = new DefaultInterpolatedStringHandler(0, 100);
for (int i = 0; i < 100; i++)
{
handler.AppendFormatted(i);
}
return handler.ToStringAndClear();
}
BenchmarkDotNet v0.13.7, Windows 11 (10.0.22621.2134/22H2/2022Update/SunValley2)
AMD Ryzen 7 5700U with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.202
[Host] : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
.NET 7.0 : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
Job=.NET 7.0 Runtime=.NET 7.0
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|--------------------------- |-----------:|----------:|----------:|--------:|----------:|
| StringBuilder | 837.6 ns | 16.78 ns | 47.88 ns | 0.6733 | 1408 B |
| StringConcat | 2,774.6 ns | 55.47 ns | 106.87 ns | 11.3487 | 23736 B |
| StringInterpolation | 6,534.7 ns | 170.19 ns | 491.05 ns | 11.4594 | 23976 B |
| StringInterpolationHandler | 681.5 ns | 13.69 ns | 33.05 ns | 0.1945 | 408 B |
As we can see, the application of StringBuilder
completely outperforms string.Concat
and interpolation approaches, but playbacks to DefaultInterpolatedStringHandler in this scenario.
Now, for completeness, let’s test our second case by only using immutable strings in concatenation.
//case #2
string orderAmount = "150";
string orderNumber = "ORDER-13";
After running benchmarks, we have the following results:
BenchmarkDotNet v0.13.7, Windows 11 (10.0.22621.2134/22H2/2022Update/SunValley2)
AMD Ryzen 7 5700U with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.202
[Host] : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
.NET 7.0 : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
Job=.NET 7.0 Runtime=.NET 7.0
| Method | Mean | Error | StdDev | Median | Gen0 | Allocated |
|--------------------------------- |----------:|---------:|---------:|----------:|-------:|----------:|
| StringFormat | 80.89 ns | 1.662 ns | 1.847 ns | 79.89 ns | 0.0459 | 96 B |
| StringInterpolation | 44.67 ns | 0.319 ns | 0.283 ns | 44.70 ns | 0.0459 | 96 B |
| StringJoin | 48.15 ns | 0.168 ns | 0.141 ns | 48.13 ns | 0.0765 | 160 B |
| StringBuilder | 80.18 ns | 1.538 ns | 3.656 ns | 78.35 ns | 0.2104 | 440 B |
| DefaultInterpolatedStringHandler | 46.24 ns | 0.931 ns | 1.108 ns | 46.23 ns | 0.0459 | 96 B |
| EnumerableAggregate | 135.90 ns | 2.160 ns | 1.686 ns | 135.51 ns | 0.2563 | 536 B |
As we can see, using DefaultInterpolatedStringHandler is still the most efficient way to concatenate strings.
Conclusion
- With C# 10 and higher, use string interpolation instead of
string.Format
, it allocates much less extra memory besides the final string - Use DefaultInterpolatedStringHandler or StringBuilder in multiple concatenation statements
- Try to avoid using
DefaultInterpolatedStringHandler
directly since it could reduce code readability Develop custom string interpolation handler in scenarios likeDebug.Assert
, usually for hot paths and libraries