Подробное руководство по конкатенации строк в .NET

Подробное руководство по конкатенации строк в .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[] в куче, упаковка аргументов и промежуточные поколения строк. Более того, повышение производительности происходит практически без каких-либо изменений в существующих кодовых базах. Кроме того, интересной стороной новой конструкции построения строк является возможность пропуск выделения памяти для определенных условий.

Подход и результаты

Давайте сравним производительность объединения строк с разными вариантами:

  1. String.Format
  2. String.Concat
  3. String.Join
  4. Интерполяция строк
  5. StringBuilder
  6. DefaultInterpolatedStringHandler
  7. Enumerable.Aggregate
  8. Обычно я использую 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 по-прежнему остается наиболее эффективным способом объединения строк.

    Заключение

    1. В C# 10 и выше используйте интерполяцию строк вместо string.Format, она выделяет гораздо меньше дополнительной памяти, помимо последней строки.
    2. Используйте DefaultInterpolatedStringHandler или StringBuilder в нескольких операторах конкатенации
    3. Старайтесь избегать прямого использования DefaultInterpolatedStringHandler, поскольку это может ухудшить читаемость кода.
    4. Разработать собственный обработчик интерполяции строк в таких сценариях, как Debug.Assert, обычно для горячих путей и библиотек.

    5. Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE