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+) | DefaultInterpolatedStringHandler | n/a (это класс, который вы вызываете) |
| Строка формата разбирается | во время компиляции | n/a |
| Значимые типы | форматируются на месте через ISpanFormattable | Append(int) и т. д. тоже избегают упаковки |
| Аллокации, одиночная сборка | одна итоговая string (арендованный буфер) | объект builder + char[] + итоговая строка |
| Аллокации, наивный цикл | O(n^2) при s += $"..." | O(n) амортизированно с Append |
| Переиспользуемый / очищаемый | нет (новая строка каждый раз) | да (Clear() и переиспользовать) |
| Потокобезопасность | результат - неизменяемая строка | не потокобезопасен |
| Читаемость для шаблонов | высокая | низкая (многословные цепочки вызовов) |
| Доступно с | C# 6 (handler с C# 10 / .NET 6) | .NET Framework 1.1 |
Две строки, которые решают почти каждый реальный случай, - это “Лучше всего для” и “Аллокации, наивный цикл”. Если вы собираете строку из фиксированного набора значений, интерполяция выигрывает и по читаемости, и по аллокациям. Если вы в цикле, выигрывает StringBuilder, и разрыв не мелкий.
Когда выбирать интерполяцию строк
Тянитесь к $"..." всякий раз, когда строка строится в одном выражении из значений, которые у вас уже есть. Это подавляющее большинство кода, строящего строки.
- Строки журнала, сообщения, текст исключений.
throw new InvalidOperationException($"Order {id} is in state {state}, expected {expected}");. Одно выражение, горстка значений, прочитано один раз.StringBuilderздесь - чистая церемония и выделяет больше, а не меньше. - URL, пути к файлам, имена параметров SQL, ключи кеша.
$"/api/orders/{id}/items". Части известны в месте вызова. Интерполяция читается как результат. - Сборка от 2 до ~10 значений любых типов. Поскольку значимые типы проходят через
ISpanFormattableвместо упаковки, смешиваниеint,Guid,DateTimeиstringв одной интерполяции не платит налог на упаковку, как это делал старый путьstring.Format.
// .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, когда фрагменты приходят со временем, особенно внутри цикла, или когда число частей неизвестно заранее.
- Конкатенация в цикле. Построение CSV строка за строкой, отчёта строка за строкой, фрагмента HTML из коллекции. Это канонический случай
StringBuilder, и именно здесь наивная конкатенация становится квадратичной. - Условная сборка. Вы добавляете предложение, только если установлен флаг, затем, возможно, ещё одно, затем, возможно, завершающий разделитель. Вплести это в одну интерполяцию нечитаемо;
if (x) sb.Append(...)понятно. - Переиспользование буфера между итерациями.
StringBuilderможно очистить черезClear()и переиспользовать, сохранив его арендованную ёмкость. В горячем цикле, производящем много независимых строк, один переиспользуемый builder обгоняет множество эфемерных интерполяций по аллокациям.
// .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_Single | 78 ns | 1.00 | 96 B |
StringBuilder_Single | 165 ns | 2.12 | 336 B |
Concat_Loop (N=1000) | 410 000 ns | 5256 | 5.86 MB |
StringBuilder_Loop | 9800 ns | 126 | 39 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, а количество значений - ложный след.
Связанное
- List
против Span освещает типы буферов только на стеке, к которым вы прибегаете, когда в игру вступаютпротив ReadOnlySpan в C# string.Createиstackalloc. - C# 13: конец аллокаций params объясняет ту же философию устранения аллокаций, применённую к
params ReadOnlySpan<T>. - Как прочитать большой CSV в .NET 11, не исчерпав память строит строки и разбирает поля в масштабе, ровно там, где кусается различие между циклом и одиночным случаем.
- Интерполированные необработанные строковые литералы в C# 11 показывает синтаксис интерполяции, который сочетается с обсуждаемым здесь handler.
- Неявные преобразования Span в C# 14 - это механизм преобразований, стоящий за основанным на span форматированием, на которое опирается
string.Create.
Источники
- String Interpolation in C# 10 and .NET 6 (.NET Blog)
- Explore C# string interpolation handlers (MS Learn)
- Breaking change: new StringBuilder.Append overloads (MS Learn)
- DefaultInterpolatedStringHandler struct reference (MS Learn)
- StringBuilder class reference (MS Learn)
- string.Create reference (MS Learn)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.