Підрозділ 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 копій символів і стільки ж алокацій heap-об'єктів. Кожен такий об'єкт у кінцевому рахунку стає сміттям для збирача — тиск на GC зростає, і в певний момент він зупиняє виконання для збирання. У медичних застосунках, де будуються тисячі виписок або журналів — це помітна деградація продуктивності.
Алгоритм подвоєння ємності
StringBuilder виділяє початковий буфер на 16 символів (якщо рядок у конструкторі не перевищує 16 символів). При переповненні буфер подвоюється: 16 → 32 → 64 → 128 і т.д. Це амортизована O(1) вставка — в середньому кожен символ вимагає постійної кількості операцій незалежно від поточного розміру.
Математично: при N символах відбувається log₂(N) реалокацій. Кожна реалокація копіює поточний вміст буфера (тому найдорожча — остання). Загальна кількість скопійованих символів по всіх реалокаціях: 1 + 2 + 4 + 8 + ... + N/2 = N - 1 ≈ N. Тобто 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 ГБ) — практично недосяжний ліміт.
Важлива деталь: 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
Усі методи StringBuilder повертають this — посилання на сам об'єкт. Це дозволяє ланцюгувати виклики (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 заради «продуктивності» — це надмірна оптимізація, яка ускладнює код без реальної вигоди.