Подробное руководство по конкатенации строк в .NET
21 февраля 2024 г.Все, кто разрабатывает приложения и сервисы на C#, используют конкатенацию строк. Всякий раз, когда нам нужно создать форматированное сообщение с различными типами данных или объединить несколько строк для отображения полезной информации в любом месте, мы обычно прибегаем к помощи интерполяции строк.
В следующем фрагменте кода мы имеем очень примитивный пример интерполяции строк
int orderAmount = 150;
string orderNumber = "ORDER-13";
Console.WriteLine($"The order with number {orderNumber} has amount of {orderAmount} items");
Если мы выполним приведенный выше код, консоль отобразит следующий вывод:
The order with number ORDER-13 has amount of 150 items
Но что происходит под капотом?
Как компилятор оптимизирует наш код?
В общем, языки программирования высокого уровня предлагают множество абстрактных конструкций программирования, таких как функции, циклы, условные операторы и многие другие полезные вещи, которые помогают нам работать продуктивно и писать читаемый код. Конечно, у него есть существенный недостаток — потенциальное снижение производительности. Но должны ли разработчики беспокоиться о стоимости использования таких красивых абстракций вместо того, чтобы сосредоточиться на написании понятного и удобного в сопровождении кода? В идеале нет. По этой причине компиляторы пытаются оптимизировать наш код, чтобы повысить его производительность.
Например, для приведенного выше фрагмента кода при использовании C# 10 компилятор преобразует его в следующий код:
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());
А при использовании C# 9 он транслирует тот же код, но вместо этого использует метод string.Format: 
int num = 150;
string arg = "ORDER-13";
Console.WriteLine(string.Format("The order with number {0} has amount of {1} items", arg, num));
Как мы видим, в более поздней версии C# компилятор использует новую функцию — интерполированные обработчики строк.
Эта статья блестяще объясняет подробно о том, как это работает.
n Короче говоря, интерполированные обработчики строк оптимизируют построение строк, чтобы избежать проблем с производительностью при использовании методов string.Format, таких как ненужное размещение object[] в куче, упаковка аргументов и промежуточные поколения строк. Более того, повышение производительности происходит практически без каких-либо изменений в существующих кодовых базах. Кроме того, интересной стороной новой конструкции построения строк является возможность пропуск выделения памяти для определенных условий.
Подход и результаты
Давайте сравним производительность объединения строк с разными вариантами:
- String.Format
 - String.Concat
 - String.Join
 - Интерполяция строк
 - StringBuilder
 - DefaultInterpolatedStringHandler
 - Enumerable.Aggregate ол>
 - В C# 10 и выше используйте интерполяцию строк вместо 
string.Format, она выделяет гораздо меньше дополнительной памяти, помимо последней строки. - Используйте DefaultInterpolatedStringHandler или StringBuilder в нескольких операторах конкатенации
 - Старайтесь избегать прямого использования 
DefaultInterpolatedStringHandler, поскольку это может ухудшить читаемость кода. - Разработать собственный обработчик интерполяции строк в таких сценариях, как 
Debug.Assert, обычно для горячих путей и библиотек. 
ол>
                    
                    
                     
Обычно я использую Benchmark.DotNet для сравнения различных решений. Я проверю конкатенацию, используя сочетание типов значений и ссылочных типов и только неизменяемые строки.
//case #1
int orderAmount = 150;
string orderNumber = "ORDER-13";
Для первого случая мы имеем следующие результаты:
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 |
Как мы видим, StringFormat на 30% медленнее и выделяет гораздо больше памяти, чем использование StringInterpolation или DefaultInterpolatedStringHandler, которые после оптимизации компилятора остаются теми же.
Вот ссылка для исходного кода теста.
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();
}
Любопытное наблюдение заключается в том, что использование StringBuilder медленнее, чем string.Format, но на самом деле StringBuilder начинает показывать значительно лучшую производительность в нескольких операторы конкатенации.
[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 |
Как мы видим, применение StringBuilder полностью превосходит string.Concat и подходы к интерполяции, но в этом сценарии воспроизводит DefaultInterpolatedStringHandler.
Теперь, для полноты картины, давайте проверим наш второй случай, используя только неизменяемые строки в конкатенации.
//case #2
string orderAmount = "150";
string orderNumber = "ORDER-13";
После запуска тестов мы получили следующие результаты:
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 |
Как мы видим, использование DefaultInterpolatedStringHandler по-прежнему остается наиболее эффективным способом объединения строк.
Заключение
Оригинал