1. Чому + у циклі може стати проблемою
Коли ви лише починаєте програмувати, хочеться розвʼязувати задачі найпрямішим способом: «треба зібрати текст — ну то й робитиму так: text = text + piece». Це нормально: мозок економить енергію й обирає найочевидніший хід. Проблема в тому, що рядки в Kotlin (як і в багатьох мовах) незмінні. Тож за частого склеювання ми мимоволі створюємо купу тимчасових рядків.
Уявіть, що рядок — це аркуш паперу. Ви не можете дописати щось на ньому «на місці». Щоразу доводиться брати новий аркуш, переписувати на нього попередній вміст і додавати новий фрагмент. На трьох склейках ви цього не помітите. На трьох тисячах — помітить ваш компʼютер (і, можливо, сусідній теж, бо вентилятори починають розмовляти з космосом).
Подивімося на типовий «наївний» приклад:
fun main() {
var text = ""
for (i in 1..5) {
text += "Line $i\n"
}
print(text)
// Line 1
// Line 2
// Line 3
// Line 4
// Line 5
}
З погляду результату все чудово. Але «під капотом» на кожній ітерації створюється новий рядок: у нього копіюється попередній, а потім додається новий фрагмент. Що більший підсумковий текст, то більше копіювань. Це не «помилка»: компілятор вас не насварить, програма працюватиме — просто виконуватиме зайву роботу.
Важливий практичний висновок: якщо ви збираєте текст у циклі — особливо коли рядків багато — майже завжди варто подумати про StringBuilder.
2. StringBuilder: збираємо текст ефективно й передбачувано
Якщо конкатенація через + схожа на те, щоб щоразу переписувати текст на новий аркуш паперу, то StringBuilder — це як блокнот, у який ви дописуєте сторінки, не передруковуючи попередні. Він зберігає всередині змінний буфер символів і вміє ефективно «нарощувати» вміст.
У Kotlin StringBuilder використовується постійно — просто часто непомітно для вас. Наприклад, деякі види шаблонів рядків компілятор може оптимізувати. Але в циклах і під час складного збирання тексту краще взяти керування у свої руки й явно використовувати StringBuilder.
Мінімальний «скелет» майже завжди виглядає так:
- створюємо StringBuilder()
- робимо багато append(...) / appendLine(...)
- наприкінці один раз викликаємо toString() і отримуємо готовий String
Ось мікроприклад:
fun main() {
val sb = StringBuilder()
sb.append("Hello")
sb.append(", ")
sb.append("Kotlin")
println(sb.toString()) // Hello, Kotlin
}
Тут уже видно важливу відмінність у стилі мислення: ми не створюємо заново підсумковий рядок на кожному кроці. Ми накопичуємо фрагменти, а наприкінці «заморожуємо» результат.
Ще один приємний момент: багато методів append(...) повертають сам StringBuilder. Тому інколи код можна писати ланцюжком (але без фанатизму — читабельність важливіша за «красу»):
fun main() {
val text = StringBuilder()
.append("A")
.append(" + ")
.append("B")
.toString()
println(text) // A + B
}
Перенесення рядків: '\\n' і appendLine()
Коли ви збираєте багаторядковий текст, головний «клей» між рядками — це перенесення рядка. І тут часто починається маленька побутова трагедія: десь забули "\n", десь додали два, десь отримали зайвий порожній рядок наприкінці — і звіт виглядає так, ніби його форматував кіт, який пройшовся по клавіатурі.
У Kotlin є два поширені підходи.
Перший — вручну додавати символ перенесення рядка '\\n'. Це просто й прозоро: ви явно керуєте кожним перенесенням.
fun main() {
val sb = StringBuilder()
sb.append("First").append('\n')
sb.append("Second").append('\n')
print(sb.toString())
// First
// Second
}
Другий — використовувати appendLine(...). Це зручний спосіб «додати рядок і перенесення». Важливо розуміти, що appendLine() — це функція-розширення стандартної бібліотеки для StringBuilder: вона викликається ніби «метод», хоча технічно це розширення.
Приклад:
fun main() {
val sb = StringBuilder()
sb.appendLine("First")
sb.appendLine("Second")
print(sb.toString())
// First
// Second
}
Практичне правило таке: якщо ви збираєте «рядки звіту» по одному, appendLine(...) зазвичай робить код охайнішим. Якщо ж вам потрібно десь точніше керувати символами (наприклад, додавати перенесення не завжди), '\\n' інколи зрозуміліше.
Дисципліна «зібрали → повернули рядок»
Дуже хочеться (особливо новачкам) робити так: «ну я ж збираю звіт, отже в процесі буду викликати println(...) для кожного рядка». Здається, що це простіше. Але так швидко виходить, що логіка обчислень, форматування й виведення в консоль перемішуються в один великий клубок. А клубок, як відомо, найчастіше доводиться розплутувати або на іспиті, або вже в реальному проєкті. Іноді — одразу в обох ситуаціях.
Дисципліна «зібрали → повернули рядок» означає простий підхід: функція формує текст і повертає його, а друкує вже код, який її викликає. Це дає три вагомі переваги.
- Функцію простіше тестувати: вона детерміновано повертає рядок, і ви можете порівняти його з очікуваним.
- Її простіше перевикористовувати: сьогодні друкуємо в консоль, завтра захочемо вивести кудись іще — а текст уже готовий.
- Ви чітко розділяєте відповідальність: функція «будує», main «показує».
Невеликий приклад:
fun buildGreeting(name: String): String {
val sb = StringBuilder()
sb.append("Hello, ")
sb.append(name)
sb.append('!')
return sb.toString()
}
fun main() {
val text = buildGreeting("Alice")
println(text) // Hello, Alice!
}
Зверніть увагу: всередині buildGreeting() немає жодного println. Вона робить одну роботу — збирає рядок.
Перенесення без хаосу: два робочі стилі
Коли ви збираєте багато рядків, проблема «зайвого перенесення» зʼявляється майже завжди. І тут важливо не стільки «який спосіб правильний», скільки «щоб ви обрали правило й дотримувалися його». Тоді виведення буде стабільним, а мозок перестане щоразу наново вгадувати, чи зʼявиться порожній рядок наприкінці.
Є два популярні стилі.
Перший стиль — «перенесення після кожного рядка, включно з останнім». Він простіший у реалізації й часто цілком нормальний для консольних звітів. Ви просто робите appendLine(...) на кожній ітерації — і готово.
fun buildList(items: List<String>): String {
val sb = StringBuilder()
for (item in items) {
sb.appendLine(item)
}
return sb.toString()
}
Другий стиль — «перенесення лише між рядками». Він корисний, якщо вам принципово важливо, щоб останній рядок не закінчувався перенесенням рядка (наприклад, інколи це важливо для порівняння рядків або для деяких форматів). Тоді ми додаємо '\\n' не завжди, а за умовою — зазвичай орієнтуючись на індекс.
fun buildListNoTrailingNewline(items: List<String>): String {
val sb = StringBuilder()
for (i in items.indices) {
sb.append(items[i])
if (i != items.lastIndex) sb.append('\n')
}
return sb.toString()
}
Обидва способи робочі. У консольних утилітах частіше обирають перший, бо він простіший і не змушує вас щоразу думати про останній елемент. Але якщо ви почали використовувати другий стиль — дотримуйтеся його всюди в межах одного модуля. Інакше звіти почнуть поводитися «то так, то сяк».
3. Приклад: звіт про витрати через StringBuilder
До цього моменту ми вчилися форматувати рядки так, щоб вони виглядали пристойно. Тепер уявімо, що наш навчальний консольний застосунок зберігає витрати як список пар: категорія та сума. Ми хочемо побудувати багаторядковий звіт: заголовок, рядки з категоріями, наприкінці — підсумок. І от тут StringBuilder справді проситься до рук.
Почнемо з невеликого форматування одного рядка. Ми вже знаємо padEnd/padStart, тому зробимо «табличку» у дві колонки: категорія зліва, сума справа.
fun formatRow(category: String, amount: Int): String {
val left = category.padEnd(12)
val right = amount.toString().padStart(6)
return left + right
}
Тут + цілком нормальний: ми склеюємо два короткі рядки один раз. Проблема починається тоді, коли таких рядків сотні й ми склеюємо їх у циклі в один великий.
Тепер зробімо функцію, яка будує весь звіт і повертає один рядок:
fun buildExpensesReport(expenses: List<Pair<String, Int>>): String {
val sb = StringBuilder()
sb.appendLine("== EXPENSES REPORT ==")
var total = 0
for ((category, amount) in expenses) {
sb.appendLine(formatRow(category, amount))
total += amount
}
sb.appendLine("Total: $total")
return sb.toString()
}
Зверніть увагу, наскільки тут «рівна» логіка: ми проходимо по даних, додаємо рядки, наприкінці додаємо підсумок. Жодних змішаних println посередині, жодних result += ... у циклі.
І перевіримо це в main:
fun main() {
val expenses = listOf(
"food" to 1200,
"transport" to 350,
"coffee" to 250
)
val report = buildExpensesReport(expenses)
print(report)
// == EXPENSES REPORT ==
// food 1200
// transport 350
// coffee 250
// Total: 1800
}
Зверніть увагу на маленьку деталь: ми використали print(report), а не println(report), тому що всередині звіту вже є перенесення рядків. Це не «обовʼязкове правило», але так легше контролювати, чи не зʼявиться випадково зайвий порожній рядок наприкінці.
Трохи ускладнимо: додамо «шапку» таблиці
Коли звіт стає більшим, хочеться візуально відокремлювати блоки. StringBuilder дозволяє робити це дуже явно:
fun buildExpensesReportV2(expenses: List<Pair<String, Int>>): String {
val sb = StringBuilder()
sb.appendLine("== EXPENSES REPORT ==")
sb.appendLine("CATEGORY".padEnd(12) + "AMOUNT".padStart(6))
sb.appendLine("------------------")
for ((category, amount) in expenses) {
sb.appendLine(formatRow(category, amount))
}
return sb.toString()
}
Тут видно головне: звіт — це, по суті, «сценарій тексту», а StringBuilder допомагає писати цей сценарій крок за кроком.
4. Коли StringBuilder не потрібен: joinToString() і готові рядки
Дуже легко закохатися в StringBuilder і почати використовувати його всюди, як універсальний молоток. Але практика спокійніша: якщо у вас уже є List<String> готових рядків, то joinToString("\n") часто простіше й читабельніше. StringBuilder корисніший тоді, коли рядки формуються в процесі, є умови, блоки, заголовки, підсумки — і ви хочете збирати текст «за сценарієм».
Порівняймо варіанти в невеликій табличці:
| Спосіб | Коли добре | Коли погано |
|---|---|---|
|
1–3 склейки, короткий рядок, «швидко накидати» | Цикли, сотні рядків, великі тексти |
|
Уже є List<String> і треба зʼєднати | Потрібне складне розгалуження, шапки/підсумки, керовані порожні рядки |
|
Покрокове збирання, цикли, великі звіти, контроль перенесень | Занадто багато «шуму» для маленьких рядків |
Ключова ідея: StringBuilder — це не «магія швидкості», а інструмент дисципліни. Він буквально підштовхує вас до підходу «спочатку будуємо результат, потім повертаємо», а це робить код стабільнішим і зрозумілішим.
Можна навіть намалювати маленьку схему того, як має виглядати потік:
flowchart TD
A[Дані: витрати] --> B[Форматуємо рядки]
B --> C[StringBuilder: append / appendLine]
C --> D["Готовий рядок: toString()"]
D --> E[print/println у main]
5. Типові помилки під час роботи зі StringBuilder
Помилка №1: продовжувати робити result += ... у циклі «за звичкою».
Зазвичай це трапляється не через незнання, а через автоматизм: рука тягнеться до +=, бо так швидше написати. У результаті програма починає створювати багато тимчасових рядків. Хороша звичка — якщо ви бачите цикл і «збирання тексту», майже рефлекторно заводити val sb = StringBuilder().
Помилка №2: друкувати всередині функції, яка має будувати рядок.
Коли buildReport() всередині себе робить println(...), ви втрачаєте контроль над виведенням: не можна легко змінити місце друку, складно тестувати, складно перевикористовувати. Значно надійніше, коли функція повертає рядок, а друк відбувається в main.
Помилка №3: плутатися з перенесеннями рядків і отримувати зайві порожні рядки.
Найчастіша побутова версія — наприкінці звіту раптом зʼявляється «зайва порожнеча», бо ви зробили і appendLine(), і потім ще println(report). Оберіть правило: або звіт сам містить перенесення й ви робите print(report), або ви збираєте без фінального перенесення й друкуєте через println.
Помилка №4: змішувати різні стилі збирання в одному місці.
Якщо частина рядків збирається через StringBuilder, частина через joinToString, а частина через + у циклі, код швидко стає важко читати: ви постійно перемикаєте модель у голові. Краще обрати один домінантний стиль для конкретної задачі: «звіт збираємо через StringBuilder», «список готових рядків зʼєднуємо через joinToString».
Помилка №5: забувати викликати toString() і намагатися повернути StringBuilder.
Іноді новачки повертають із функції сам StringBuilder, а потім дивуються, чому типи не сходяться або чому «це ж майже рядок». StringBuilder — це контейнер для збирання. Фінальний продукт — рядок. Тому майже завжди фінальний рядок має зʼявлятися в одному місці: return sb.toString().
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ