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() — это extension-функция стандартной библиотеки для 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, поэтому сделаем «табличку» в 2 колонки: категория слева, сумма справа.
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, а часть через + в цикле, код быстро становится трудно читать: вы всё время переключаете модель в голове. Лучше выбрать один доминирующий стиль для конкретной задачи: «отчёт собираем builder’ом», «список готовых строк соединяем joinToString».
Ошибка №5: забывать вызвать toString() и пытаться вернуть StringBuilder.
Иногда новички возвращают из функции сам StringBuilder, а потом удивляются, почему типы не сходятся или почему «это же почти строка». StringBuilder — это контейнер для сборки. Финальный продукт — строка. Поэтому почти всегда финальная строка должна появляться в одном месте: return sb.toString().
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ