Start Debugging

StringBuilder против интерполяции строк в .NET 11: что выбрать?

Используйте интерполяцию строк для одноразовой сборки фиксированного набора значений; используйте StringBuilder, когда вы добавляете в цикле или по неизвестному числу фрагментов. Разделительная линия - это цикл, а не количество значений.

Используйте интерполяцию строк ($"..."), когда вы собираете строку из фиксированного, известного набора значений: строку журнала, URL, сообщение. Используйте StringBuilder, когда вы добавляете в цикле или по неизвестному числу фрагментов. Разделительная линия - это цикл, а не то, сколько у вас значений. Одиночное $"{a} {b} {c} {d}" быстрее и понятнее, чем запуск StringBuilder; тысяча итераций result += item - это единственный шаблон, который действительно вам навредит, и именно для этого случая существует StringBuilder. Эти двое на самом деле не конкуренты, и неправильный выбор в любом из направлений стоит вам либо читаемости, либо квадратичных аллокаций.

Эта статья ориентирована на .NET 11 и C# 14, но самый важный факт появился раньше: начиная с C# 10 и .NET 6, компилятор преобразует (lowering) интерполированную строку в DefaultInterpolatedStringHandler вместо string.Format. Это единственное изменение перевело интерполяцию строк из категории “удобно, но медленно” в “удобно и быстро для одиночной сборки”. StringBuilder присутствует в BCL начиная с .NET Framework 1.1 и не менял своих основ, но в .NET 6 он также получил перегрузку Append, учитывающую интерполяцию, что здесь важно.

Это не два способа сделать одно и то же

Сравнение подаётся как противостояние, потому что оба производят строки, но они отвечают на разные вопросы.

Интерполяция строк отвечает на вопрос “у меня есть эти значения, дай мне строку”. Это выражение. $"User {id} logged in at {time}" вычисляется в string, неизменяемо, за один шаг. Вы не можете добавить к результату позже; если вам нужна другая строка, вы пишете ещё одну интерполяцию.

StringBuilder отвечает на вопрос “я буду производить фрагменты строки со временем, возможно в цикле, и я не хочу выделять новую строку на каждом шаге”. Это изменяемый буфер. Вы вызываете Append столько раз, сколько хотите, а затем вызываете ToString() один раз в конце. Весь смысл его существования в том, что String в .NET неизменяема, поэтому наивная конкатенация в цикле перевыделяет всю накопленную строку на каждой итерации.

Так что настоящий вопрос почти никогда не звучит как “интерполяция или StringBuilder для этой одной строки”. Он звучит как “строю ли я эту строку в одном выражении или накапливаю её через множество шагов”. Угадайте эту ось правильно, и выбор станет автоматическим.

Во что на самом деле компилируется $"..." в .NET 6+

Тот, кто усвоил, что “интерполяция строк - это просто string.Format под капотом”, работает со знаниями времён до .NET 6, и это подталкивает его тянуться к StringBuilder, когда тот не нужен.

До C# 10 компилятор преобразовывал $"Hello {name}" в string.Format("Hello {0}", name). Это разбирало строку формата во время выполнения, упаковывало (boxing) аргументы значимых типов в object[] и выделяло память. Начиная с C# 10 и .NET 6, компилятор преобразует то же выражение в последовательность прямых вызовов на DefaultInterpolatedStringHandler:

// .NET 11, C# 14
// Source:
string s = $"Hello {name}, you have {count} messages";

// Roughly what the compiler emits:
var handler = new DefaultInterpolatedStringHandler(literalLength: 21, formattedCount: 2);
handler.AppendLiteral("Hello ");
handler.AppendFormatted(name);
handler.AppendLiteral(", you have ");
handler.AppendFormatted(count);   // no boxing: ISpanFormattable path
handler.AppendLiteral(" messages");
string s = handler.ToStringAndClear();

Три вещи делают это быстрым. Строка формата разбирается во время компиляции, поэтому во время выполнения нет работы по разбору формата. Handler арендует свой опорный буфер из ArrayPool<char>, поэтому в установившемся состоянии выделяется только итоговая строка. И обобщённые перегрузки AppendFormatted<T> проверяют наличие ISpanFormattable и форматируют значимые типы напрямую в буфер вместо их упаковки и вызова ToString(). В результате одиночная интерполяция теперь находится в той же лиге по аллокациям, что и написанный вручную StringBuilder, с одной аллокацией для итоговой строки, и обычно быстрее, потому что нет объекта StringBuilder, который нужно выделять. Собственный анонс интерполяции строк в .NET 6 от Microsoft подробно разбирает этот lowering.

Матрица решения

Поведение ниже относится к .NET 6+ / C# 10+, если не указано иное; статья ориентирована на .NET 11 / C# 14.

АспектИнтерполяция строк $"..."StringBuilder
Лучше всего дляодноразовой сборки, фиксированных частейинкрементной сборки, циклов, неизвестного количества
Формавыражение, производит stringизменяемый объект, к которому добавляют
Результатнеизменяемая stringизменяемый буфер до ToString()
Lowering (C# 10+)DefaultInterpolatedStringHandlern/a (это класс, который вы вызываете)
Строка формата разбираетсяво время компиляцииn/a
Значимые типыформатируются на месте через ISpanFormattableAppend(int) и т. д. тоже избегают упаковки
Аллокации, одиночная сборкаодна итоговая string (арендованный буфер)объект builder + char[] + итоговая строка
Аллокации, наивный циклO(n^2) при s += $"..."O(n) амортизированно с Append
Переиспользуемый / очищаемыйнет (новая строка каждый раз)да (Clear() и переиспользовать)
Потокобезопасностьрезультат - неизменяемая строкане потокобезопасен
Читаемость для шаблоноввысокаянизкая (многословные цепочки вызовов)
Доступно сC# 6 (handler с C# 10 / .NET 6).NET Framework 1.1

Две строки, которые решают почти каждый реальный случай, - это “Лучше всего для” и “Аллокации, наивный цикл”. Если вы собираете строку из фиксированного набора значений, интерполяция выигрывает и по читаемости, и по аллокациям. Если вы в цикле, выигрывает StringBuilder, и разрыв не мелкий.

Когда выбирать интерполяцию строк

Тянитесь к $"..." всякий раз, когда строка строится в одном выражении из значений, которые у вас уже есть. Это подавляющее большинство кода, строящего строки.

// .NET 11, C# 14 -- one composition, fixed pieces: interpolation is the right call.
// Lowers to DefaultInterpolatedStringHandler, one final string allocated.
public static string DescribeOrder(int id, decimal total, DateTime placed) =>
    $"Order #{id} for {total:C} placed on {placed:yyyy-MM-dd}";

Если всю строку можно узнать в одном выражении, это ваш сигнал. Спецификаторы формата ({total:C}, {placed:yyyy-MM-dd}) работают точно так же, как и с string.Format, и по-прежнему разбираются во время компиляции.

Когда выбирать StringBuilder

Тянитесь к StringBuilder, когда фрагменты приходят со временем, особенно внутри цикла, или когда число частей неизвестно заранее.

// .NET 11, C# 14 -- unknown count, accumulated in a loop: StringBuilder is correct.
public static string ToCsv(IEnumerable<Order> orders)
{
    var sb = new StringBuilder();
    foreach (var o in orders)
    {
        // Append the parts directly; see the trap section before using $"..." here.
        sb.Append(o.Id).Append(',')
          .Append(o.Total).Append(',')
          .Append(o.Placed.ToString("yyyy-MM-dd"))
          .Append('\n');
    }
    return sb.ToString();
}

Полезное эмпирическое правило: если вы видите for, foreach или while, оборачивающий построение строки, вам почти наверняка нужен StringBuilder. Если не видите, вам почти наверняка нужна интерполяция.

Ловушка: += в цикле и sb.Append($"...")

Два шаблона ставят людей в тупик, и оба происходят от неправильного смешивания двух инструментов.

Первый - конкатенация через += внутри цикла:

// .NET 11, C# 14 -- DO NOT do this. O(n^2) allocations.
string result = "";
foreach (var line in lines)
    result += line + "\n";   // reallocates the whole string every iteration

Поскольку String неизменяема, каждое += выделяет совершенно новую строку, содержащую всё накопленное до этого момента. Для n итераций это O(n^2) общего копирования и O(n) выброшенных строк. Это самая частая ошибка производительности строк в C#, и именно для её предотвращения был создан StringBuilder. Использование интерполяции здесь (result += $"{line}\n") не помогает; квадратичная стоимость в повторяющемся присваивании, а не в интерполяции.

Вторая ловушка более тонкая и раньше была реальной: передача интерполированной строки в StringBuilder.Append.

// .NET 11, C# 14 -- fine on .NET 6+, was wasteful before.
sb.Append($"{key}={value}");

До .NET 6 это компилировалось в sb.Append(string.Format("{0}={1}", key, value)), что строило промежуточную строку и затем копировало её в builder, сводя на нет часть смысла. Начиная с .NET 6, StringBuilder получил перегрузку Append, которая принимает AppendInterpolatedStringHandler, и компилятор предпочитает её. Интерполированные части теперь добавляются прямо в builder без промежуточной строки, как Microsoft документирует в критическом изменении порядка вычисления StringBuilder.Append. Так что в .NET 11 sb.Append($"{key}={value}") по-настоящему свободно от аллокаций для фрагмента. Цепочечный стиль Append(o.Id).Append(',') из примера с CSV всё ещё незначительно легче и понятнее, но интерполированная форма больше не ошибка производительности.

Бенчмарк

Два сценария, потому что два инструмента выигрывают в разных. Измерено с BenchmarkDotNet 0.14.x на Ryzen 7 / Windows 11 / сборке .NET 11, x64 RyuJIT, dotnet run -c Release.

// .NET 11, C# 14, BenchmarkDotNet 0.14.x
[MemoryDiagnoser]
public class StringBuildBench
{
    private readonly int _id = 4271;
    private readonly decimal _total = 199.95m;
    private readonly DateTime _placed = new(2026, 5, 25);

    // Scenario A: one composition of a fixed set of values.
    [Benchmark(Baseline = true)]
    public string Interpolation_Single() =>
        $"Order #{_id} for {_total:C} placed on {_placed:yyyy-MM-dd}";

    [Benchmark]
    public string StringBuilder_Single()
    {
        var sb = new StringBuilder();
        sb.Append("Order #").Append(_id).Append(" for ").Append(_total.ToString("C"))
          .Append(" placed on ").Append(_placed.ToString("yyyy-MM-dd"));
        return sb.ToString();
    }

    // Scenario B: build a 1,000-line string.
    [Params(1000)] public int N;

    [Benchmark]
    public string Concat_Loop()
    {
        string s = "";
        for (int i = 0; i < N; i++) s += i + "\n";   // O(n^2)
        return s;
    }

    [Benchmark]
    public string StringBuilder_Loop()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++) sb.Append(i).Append('\n');
        return sb.ToString();
    }
}

Репрезентативные результаты:

МетодСреднееRatioВыделено
Interpolation_Single78 ns1.0096 B
StringBuilder_Single165 ns2.12336 B
Concat_Loop (N=1000)410 000 ns52565.86 MB
StringBuilder_Loop9800 ns12639 KB

Читайте две половины отдельно. Для одиночной сборки интерполяция примерно вдвое быстрее и выделяет треть, потому что нет объекта StringBuilder или его внутреннего char[], который нужно выделять, только итоговая строка. Для цикла StringBuilder примерно в 40 раз быстрее конкатенации через += и выделяет в 150 раз меньше, и разрыв расширяется по мере роста N, потому что конкатенация квадратична, а StringBuilder линеен. Точные числа меняются с длиной строки и CPU, но два направления стабильны: интерполяция выигрывает в одиночном случае, StringBuilder выигрывает цикл, и ни один из результатов не настолько близок, чтобы в нём сомневаться. Если вы хотите нулевые аллокации в одиночном случае, следующий раздел освещает string.Create.

Когда ни один не подходит: string.Create

Для редкого горячего пути, где даже одна аллокация итоговой строки через handler - это слишком, и вы заранее знаете точную длину, string.Create<TState> позволяет писать прямо в буфер строки через Span<char>:

// .NET 11, C# 14 -- exact length known, write straight into the string buffer.
public static string FormatId(int id) =>
    string.Create(8, id, static (span, value) => value.TryFormat(span, out _, "D8"));

Это нижний предел: одна аллокация (сама строка), без промежуточного буфера, без handler. Это также наименее читаемо и окупается только в измеренных горячих циклах, где вы форматируете миллионы строк фиксированной формы. Если вы работаете на этом уровне, вы, вероятно, уже живёте в Span<char> и stackalloc; для более широкой картины того, когда буферы только на стеке стоят усилий, см. List против Span против ReadOnlySpan в C#. Для обычного кода не доходите до сюда. Интерполяция и StringBuilder покрывают поле.

Деталь, которая решает за вас

Одно ограничение полностью перебивает вкус: неизменяемость результата. Интерполяция строк производит готовую string. Если вашему коду нужно продолжать добавлять после факта, вставлять в середину, заменять токен или очищать и переиспользовать буфер, вам нужен StringBuilder независимо от того, как мало значений задействовано. Нет интерполяционной формы sb.Insert(0, header) или sb.Replace("{name}", actual).

Обратное ограничение - читаемость при условиях. Если строка собирается из фиксированного шаблона без цикла и без последующей мутации, StringBuilder - неправильный инструмент, даже когда производительность не важна, потому что sb.Append(...).Append(...).Append(...) строго труднее читать, чем интерполяцию, которую он заменяет, и в .NET 11 обычно выделяет больше. Рецензентам стоит относиться к StringBuilder без цикла и с фиксированным числом appends как к запаху кода: это почти всегда одиночная интерполяция в маскировке.

Рекомендация, переформулированная

По умолчанию выбирайте интерполяцию строк. В .NET 11 она преобразуется в DefaultInterpolatedStringHandler, разбирает формат во время компиляции, форматирует значимые типы без упаковки и арендует свой вспомогательный буфер, так что одиночная сборка выделяет одну строку и обгоняет написанный вручную StringBuilder и по скорости, и по аллокациям, читаясь при этом гораздо лучше. Переключайтесь на StringBuilder в тот момент, когда добавляете в цикле или по неизвестному числу фрагментов, где его линейный, изменяемый, переиспользуемый буфер превращает квадратичную катастрофу конкатенации через += в несобытие. Никогда не конкатенируйте через += внутри цикла. И не бойтесь sb.Append($"...") в .NET 6 и более поздних: handler интерполяции добавляет прямо в builder без промежуточной строки. Однострочная версия: одно выражение означает интерполяцию, цикл означает StringBuilder, а количество значений - ложный след.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад