Підрозділ 11.4
Клас StringBuilder
Розглядає StringBuilder як динамічний рядок: створення, ToString, Length, Capacity, Append, Insert, Replace, Remove і випадки, коли варто обирати String або StringBuilder.
11.4. Клас StringBuilder
З розділу 11.1 відомо: рядок string є незмінним — кожна операція створює новий об'єкт у heap. При одиничних операціях це не проблема. Але якщо потрібно побудувати рядок із 50 фрагментів (наприклад, виписку пацієнта по рядку за рядком), конкатенація через + виконає 50 алокацій, де кожен новий об'єкт копіює весь попередній вміст. Складність такого підходу — O(N²).
Клас StringBuilder з простору імен System.Text вирішує цю задачу: він зберігає мутабельний буфер символів і додає нові символи на місці, без копіювання попереднього вмісту. Алокація відбувається лише тоді, коли буфер переповнюється, — і тоді до нього додається новий шматок (чанк) пам'яті, приблизно такий самий за розміром, як уже накопичений текст, тож загальна ємність фактично подвоюється.


Чому string у циклі — це O(N²)
Розглянемо приклад: потрібно зібрати рядок зі 100 частин. При конкатенації через +:
Ітерація 1: новий рядок на 1 символ, скопіювати 1 символ
Ітерація 2: новий рядок на 2 символи, скопіювати 2 символи (1 + 1)
Ітерація 3: новий рядок на 3 символи, скопіювати 3 символи (2 + 1)
...
Ітерація N: новий рядок на N символів, скопіювати N символівЗагальна кількість скопійованих символів: 1 + 2 + 3 + ... + N = N(N+1)/2 ≈ N²/2.
При N=1000 — близько 500 000 скопійованих символів і 1000 алокацій нових рядків (кожен наступний довший за попередній, тож сумарно виділяється близько 1 МБ пам'яті під символи, які одразу стають сміттям). Кожен такий об'єкт у кінцевому рахунку стає сміттям для збирача — тиск на GC зростає, і в певний момент він зупиняє виконання для збирання. У медичних застосунках, де будуються тисячі виписок або журналів — це помітна деградація продуктивності.
Алгоритм подвоєння ємності
StringBuilder виділяє початковий буфер на 16 символів (якщо рядок у конструкторі не перевищує 16 символів). Далі важливо знати, як саме влаштовано розширення в сучасному .NET (починаючи з .NET Framework 4.0). StringBuilder — це не один масив, який подвоюють і копіюють, як у List<T>, а ланцюжок чанків (шматків пам'яті). Коли поточний чанк заповнено, виділяється новий чанк розміром приблизно з уже накопичений текст (але не більше 8000 символів) і приєднується до ланцюжка; старий вміст не копіюється взагалі. Тому Capacity спершу зростає вдвічі — 16 → 32 → 64 → … → 8192, — а далі на 8000 за кожен новий чанк: 16 192, 24 192, … (обмеження в 8000 символів тримає кожен чанк поза купою великих об'єктів, LOH).
Математично: дописування кожного символу — O(1), нових чанків при N символах — близько log₂(N) (плюс по одному на кожні 8000 символів після межі), а символи копіюються лише один раз — у ToString(), коли всі чанки збираються в підсумковий рядок. Тобто O(N) замість O(N²) — лінійна складність.
using System.Text;
var sb = new StringBuilder("Виписка:");
Console.WriteLine($"Length={sb.Length}, Capacity={sb.Capacity}"); // Length=8, Capacity=16
sb.Append(" Петренко");
Console.WriteLine($"Length={sb.Length}, Capacity={sb.Capacity}"); // Length=17, Capacity=32 <-- подвоєноЯкщо кінцевий розмір рядка відомий наперед, ємність можна задати явно в конструкторі — тоді реалокацій буде нуль:
var sb = new StringBuilder(capacity: 512); // одразу виділяємо 512 символівВластивість Capacity завжди показує поточний розмір виділеного буфера, а Length — кількість реально записаних символів. Різниця між ними — незайнята ємність, у яку можна дописувати без реалокації. Властивість MaxCapacity повертає максимально допустимий розмір буфера — за замовчуванням int.MaxValue (≈ 2,1 млрд символів); фактично ж підсумковий рядок обмежений максимальною довжиною string (≈ 1 млрд символів), тож на практиці ліміт недосяжний. Задати менший ліміт можна конструктором new StringBuilder(capacity, maxCapacity) — тоді спроба його перевищити кине ArgumentOutOfRangeException.
Важлива деталь: StringBuilder не є потокобезпечним
На відміну від string (незмінний — тому безпечний між потоками), StringBuilder не є потокобезпечним. Одночасний запис у один StringBuilder з кількох потоків призведе до непередбачуваних результатів. Для багатопотокових сценаріїв кожен потік повинен мати власний екземпляр StringBuilder, або доступ до спільного слід синхронізувати через lock.
У типових навчальних та прикладних сценаріях StringBuilder використовується в рамках одного потоку — тому потокобезпека зазвичай не є проблемою.
Індексатор StringBuilder
На відміну від string, де індексатор доступний лише для читання, StringBuilder надає індексатор з можливістю читання і запису:
var sb = new StringBuilder("I10.9");
char c = sb[0]; // 'I' — читання
sb[0] = 'J'; // заміна першого символу — запис!
Console.WriteLine(sb.ToString()); // "J10.9"Це ще одне підтвердження мутабельності StringBuilder на противагу незмінному string. Крім того, властивості Length можна присвоїти значення — це усікає буфер до вказаної довжини:
var sb = new StringBuilder("I10.9 — Гіпертензія");
sb.Length = 5; // Усікаємо до "I10.9"
Console.WriteLine(sb.ToString()); // "I10.9"Створення StringBuilder
using System.Text;
// Порожній буфер (Capacity=16)
var sb1 = new StringBuilder();
// З початковим рядком
var sb2 = new StringBuilder("Виписка пацієнта:");
// З заданою ємністю (уникаємо реалокацій)
var sb3 = new StringBuilder(256);
// З початковим рядком і ємністю
var sb4 = new StringBuilder("Виписка пацієнта:", 256);Основні методи
| Метод | Що робить |
|---|---|
Append(value) |
Додати значення в кінець |
AppendLine(value) |
Додати значення і \n |
AppendLine() |
Додати порожній рядок |
AppendFormat(format, args) |
Додати форматований рядок |
Insert(index, value) |
Вставити за індексом |
Remove(start, count) |
Видалити символи |
Replace(old, new) |
Замінити всі входження |
Clear() |
Очистити буфер (зберігає Capacity) |
ToString() |
Отримати рядок із буфера |
Метод Append(value) є перевантаженим для всіх примітивних типів: Append(bool), Append(int), Append(double), Append(char) — це означає, що значення конвертуються у символи без виклику ToString() і без проміжної алокації рядка. Це ще один аргумент на користь StringBuilder при інтенсивній роботі з числами.
Fluent API — method chaining
Методи, що змінюють вміст (Append, AppendLine, AppendFormat, Insert, Remove, Replace, Clear), повертають this — посилання на сам об'єкт (а ToString(), звісно, повертає рядок). Це дозволяє ланцюгувати виклики (fluent API):
string result = new StringBuilder()
.Append("Пацієнт: ")
.AppendLine("Петренко І.С.")
.Append("Діагноз: ")
.AppendLine("I10.9 — Гіпертензія")
.Append("Відділення: кардіологія")
.ToString();
Console.WriteLine(result);Fluent API є зручним для побудови структурованих рядків у декларативному стилі — особливо коли потрібно зібрати заголовок, тіло і підпис у єдиному виразі. Зворотна сторона: дуже довгі ланцюги стають складними для покрокового налагодження у дебаґері.
AppendLine та AppendFormat
AppendLine — найзручніший метод для побудови багаторядкових текстів:
var sb = new StringBuilder();
sb.AppendLine("Виписка з лікарні");
sb.AppendLine("==================");
sb.AppendLine("Пацієнт: Петренко Іван Степанович");
sb.AppendLine("Дата: 10.06.2026");AppendLine() без аргументів додає лише символ нового рядка (\r\n на Windows або \n на Unix) — це зручно для розділення секцій без додавання тексту.
AppendFormat — форматоване додавання з підтримкою специфікаторів (розд. 11.3):
sb.AppendFormat("АТ: {0:F0}/{1:F0} мм рт.ст.\n", 140.5, 90.0);
sb.AppendFormat("Вартість: {0:C2}\n", 1250.50);AppendFormat використовує ті самі специфікатори, що й string.Format. Комбінація AppendLine і AppendFormat покриває переважну більшість завдань із побудовою текстових звітів.
Clear() для повторного використання
Clear() обнуляє Length до нуля, але зберігає виділений буфер. Це дозволяє повторно використовувати StringBuilder без повторного виділення пам'яті:
var sb = new StringBuilder(256);
for (int i = 1; i <= 3; i++)
{
sb.Clear(); // скидаємо вміст, буфер лишається
sb.AppendLine($"Звіт #{i}");
sb.AppendLine($"Пацієнт: Петренко");
Console.WriteLine(sb.ToString());
}Це типовий патерн у серверному коді: StringBuilder з достатньою ємністю створюється один раз і очищується між ітераціями циклу. Це ефективніше за new StringBuilder() щоразу — розподіл пам'яті для великого буфера відбувається тільки одного разу.
Коли string, а коли StringBuilder?
| Ситуація | Рекомендація |
|---|---|
| До ~10 операцій конкатенації | string — компілятор може оптимізувати |
| Цикл з невідомою кількістю ітерацій | StringBuilder — O(N) vs O(N²) |
| Побудова багаторядкового звіту | StringBuilder.AppendLine |
| Шаблонне форматування | string.Format або $"..." |
Пошук (IndexOf, Contains) |
string — у StringBuilder їх немає |
StringBuilder не має методів IndexOf, Contains, StartsWith. Якщо потрібен пошук під час побудови — зберігайте проміжні string, або завершіть побудову через ToString() і шукайте вже у звичайному рядку.
Побудова виписки — runnable приклад
Демонстрація Capacity, AppendLine, AppendFormat, Clear:
Fluent chaining та повторне використання — runnable приклад
Ланцюг викликів та Clear() між ітераціями:
Вимірювання різниці — Stopwatch
Теоретичне співвідношення O(N²) проти O(N) добре підтверджується на практиці. Нижче — вимірювання часу побудови рядка з 5 000 фрагментів двома способами: конкатенацією через + і через StringBuilder:
На типовій машині при N=5 000 конкатенація через += займає сотні мілісекунд, тоді як StringBuilder — менше 1 мс. Різниця стає ще помітнішою при N=50 000 і вище, оскільки складність конкатенації зростає квадратично — кожна нова ітерація копіює весь попередній вміст рядка.
Важливо розуміти виняток: компілятор C# оптимізує конкатенацію через + поза циклом, коли всі операнди відомі на етапі компіляції або кількість операндів мала і фіксована. Вираз a + b + c + d компілятор перетворює на один виклик string.Concat(a, b, c, d) — без проміжних алокацій. Ця оптимізація не поширюється на цикли і на += у загальному випадку:
Практичне правило: якщо рядок будується у циклі або з невідомої наперед кількості частин — StringBuilder. Якщо кількість операндів фіксована і мала (умовно до 10) — string + або $"..." є повністю прийнятним і читабельнішим варіантом. Не треба замінювати кожен + на StringBuilder заради «продуктивності» — це надмірна оптимізація, яка ускладнює код без реальної вигоди.