A Detailed Guide to String Concatenation in .NET

Written by fairday | Published 2024/02/20
Tech Story Tags: dotnet | string-concatenation | string-interpolation | stringbuilder | benchmarking | compiler-optimization | csharp-programming | string-concatenation-in-dotnet

TLDRDelve into the efficiency of string interpolation handlers in C#, uncovering compiler optimizations and performance benchmarks. Learn how to enhance code readability and optimize memory allocation for improved application performance. TLDR (Summary): The use of string interpolation handlers in C# offers significant performance benefits over traditional methods like String.Format, reducing memory allocation and optimizing code execution. Leveraging DefaultInterpolatedStringHandler or StringBuilder in multiple concatenation scenarios enhances efficiency, while custom handlers can be developed for specific use cases, promoting code readability and performance in .NET development.via the TL;DR App

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.

This article brilliantly explains in-depth how it works.


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 skipping allocating any memory for particular conditions.

Approach and Results

Let’s compare the performance of string concatenation with different options:

  1. String.Format

  2. String.Concat

  3. String.Join

  4. String interpolation

  5. StringBuilder

  6. DefaultInterpolatedStringHandler

  7. 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.

Here is the link for the benchmark source code.

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

  1. With C# 10 and higher, use string interpolation instead of string.Format, it allocates much less extra memory besides the final string
  2. Use DefaultInterpolatedStringHandler or StringBuilder in multiple concatenation statements
  3. Try to avoid using DefaultInterpolatedStringHandler directly since it could reduce code readability
  4. Develop custom string interpolation handler in scenarios like Debug.Assert, usually for hot paths and libraries


Written by fairday | Hey, I am Alex, a dedicated Software Development Engineer with experience in the .NET environment and architecture
Published by HackerNoon on 2024/02/20