Подробное руководство по конкатенации строк в .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 по-прежнему остается наиболее эффективным способом объединения строк.
Заключение
Оригинал