1. Введение
Когда программа только учится ходить, достаточно println("OK"). Но чем дальше вы пишете свой консольный проект, тем чаще у вас появляется «внутренний пользователь» — вы сами через 20 минут отладки. И вот здесь внезапно выясняется, что вывод «как попало» превращает любую проверку в археологию: вы пытаетесь понять, что означает total=1200, почему рядом нет валюты, и где вообще границы записей.
Форматирование — это не про красоту, а про контракт между вашим кодом и человеком: что является заголовком, что строкой данных, где число, где текст, какие поля «колонками», и что делать, если строка длиннее ожидаемого. Плюс хороший формат часто помогает обнаружить баги: например, если колонка сумм «поехала», значит где-то неожиданно пришёл текст, а не число.
Чтобы всё было практично, будем продолжать наш условный консольный проект «учёт расходов». Он пока без классов, поэтому расходы будем хранить как Triple(category, amount, note).
2. Интерполяция строк: $x и ${expr}
Интерполяция строк в Kotlin — это когда вы вставляете значение прямо внутрь строки: $x для простого имени и ${expr} для выражения. Это базовая вещь, но именно она чаще всего делает вывод читаемым без лишних + и без «склеек на соплях».
Важно почувствовать разницу: конкатенация через + буквально склеивает куски, а string templates позволяют писать почти «человеческий текст» с подстановками. Для отчётов и сообщений пользователю это почти всегда выигрывает.
Базовый пример: печатаем расход
Начнём с простого. Пусть расход — это Triple<String, Int, String>: категория, сумма, комментарий.
fun main() {
val expense = Triple("food", 450, "coffee and croissant")
val category = expense.first
val amount = expense.second
val note = expense.third
println("Expense: category=$category, amount=$amount, note=\"$note\"")
// Expense: category=food, amount=450, note="coffee and croissant"
}
Обратите внимание на кавычки вокруг note: это мелочь, но иногда полезно визуально видеть, где начинается и где заканчивается текст.
Когда нужен ${expr}
Иногда хочется встроить результат вычисления. Например, показать сумму с «знаком» или посчитать налог.
fun main() {
val amount = 450
val tax = (amount * 0.2).toInt()
println("Amount=$amount, tax(20%)=$tax, total=${amount + tax}")
// Amount=450, tax(20%)=90, total=540
}
Тут как раз видно, зачем нужны фигурные скобки: внутри ${...} можно писать выражение.
Про читаемость: не делайте строку «диссертацией»
Хорошая эвристика такая: если внутри строки появляется выражение в три строки, значит пора вынести вычисление в отдельный val. То есть форматирование не должно скрывать логику. Kotlin позволяет вставить «всё на свете», но вы ведь пишете код не только для компилятора, но и для будущего себя.
3. Разделяем «посчитать» и «показать»
Одна из частых проблем новичка: вычисления, разбор строк и печать мешаются в один салат. Работает? Иногда. Понятно? Обычно нет. Если вам когда-нибудь приходилось отлаживать программу по выводу, вы знаете: чем «чище» разделены этапы, тем легче заметить ошибку.
Будем придерживаться дисциплины: функции, которые считают, возвращают данные, а функции, которые форматируют, возвращают строку. А уже main() решает, печатать её или нет. Это делает программу предсказуемее и проще для тестов (даже если мы ещё не изучали тестирование — принцип всё равно полезный).
Форматируем одну строку отдельной функцией
fun formatExpense(expense: Triple<String, Int, String>): String {
val category = expense.first
val amount = expense.second
val note = expense.third
return "[$category] $amount — $note"
}
fun main() {
val e = Triple("transport", 120, "metro")
println(formatExpense(e)) // [transport] 120 — metro
}
Обратите внимание: formatExpense ничего не печатает, она только возвращает строку. Это маленький, но очень важный стиль.
Форматирование и логирование — разные вещи
В консольных учебных проектах хочется печатать всё подряд. Это нормально на старте. Но именно поэтому полезно уже сейчас привыкать: «форматирование» — это про сообщение пользователю или отчёт, а не про хаотичное println("x=$x") посреди алгоритма.
4. Выравнивание колонок: padEnd() и padStart()
Когда данных становится больше одного поля, вывод «в одну строку через запятую» быстро перестаёт читаться. Глаз человека любит колонки: текст слева, числа справа. В графическом интерфейсе это делает таблица, а в консоли — пробелы. И вот тут в Kotlin появляется очень дружелюбный инструмент: padEnd(width) и padStart(width).
Смысл простой: строка дополняется пробелами до нужной ширины. padEnd добавляет пробелы справа, padStart — слева. И да, звучит как «просто пробелы», но это те пробелы, которые внезапно экономят вам кучу нервов.
Мини-таблица: заголовок и одна строка
fun main() {
val nameW = 12
val amountW = 8
val header = "CATEGORY".padEnd(nameW) + "AMOUNT".padStart(amountW)
val row = "food".padEnd(nameW) + "450".padStart(amountW)
println(header)
// CATEGORY AMOUNT
println(row)
// food 450
}
Здесь важный момент: числа мы пока выводим как строки "450".
Чем отличаются padEnd и padStart
Иногда проще увидеть глазами:
| Метод | Куда добавляет пробелы | Типичный кейс |
|---|---|---|
|
вправо | текстовая колонка слева |
|
влево | числовая колонка справа |
В консоли числа обычно выравнивают по правому краю, потому что так легче сравнивать величины, особенно когда разная длина: 5, 50, 5000.
Выравниваем числа: сначала toString(), потом padStart()
Частая микро-ошибка: пытаться сделать padStart у числа напрямую. padStart — метод строк, поэтому число нужно превратить в строку.
fun main() {
val amount = 450
val amountW = 8
val cell = amount.toString().padStart(amountW)
println("|$cell|") // | 450|
}
Это выглядит смешно, но |...| реально помогает увидеть пробелы.
Почему ширины лучше хранить в переменных
Если вы в одном месте сделаете ширину 10, а в другом 12, колонки «поедут». Поэтому лучше договориться: ширины — это маленькая конфигурация форматирования.
fun main() {
val categoryW = 14
val amountW = 8
val cat = "transport".padEnd(categoryW)
val amt = 120.toString().padStart(amountW)
println(cat + amt) // transport 120
}
Если текст длиннее ширины
padEnd не режет строку, он только дополняет. Если категория пришла длиной 50 символов, то она просто раздвинет всё вправо. В консоли это выглядит как «форматирование сломалось». Поэтому иногда вводят правило: «если длиннее — обрежем».
Мы не используем пока ничего «продвинутого», только то, что уже знакомо: length и substring.
fun fitLeft(text: String, width: Int): String {
val cut = if (text.length > width) text.substring(0, width) else text
return cut.padEnd(width)
}
fun main() {
val w = 10
println(fitLeft("very-very-long-category", w) + "|") // very-very-|
}
Да, слово обрезалось. Это нормально: консольная таблица — не резиновая. Главное, что правило теперь явное и одинаковое везде.
5. Многострочный вывод: joinToString() и raw strings
Когда вы печатаете отчёт, у вас обычно есть набор строк: заголовок, строка-разделитель, строки данных, итог. Самый прямолинейный путь — много println(...). Но есть более аккуратная идея: собрать список строк, а потом объединить в один многострочный текст.
Для этого отлично подходит joinToString(...): он превращает коллекцию элементов в одну строку с разделителем, префиксом, постфиксом и другими настройками. А если разделитель сделать "\n", получится многострочный текст.
Собираем отчёт как List<String>
fun main() {
val lines = listOf(
"CATEGORY".padEnd(12) + "AMOUNT".padStart(8),
"------------" + "--------",
"food".padEnd(12) + "450".padStart(8),
"transport".padEnd(12) + "120".padStart(8)
)
val report = lines.joinToString(separator = "\n")
println(report)
}
Здесь ключевая мысль: вы сначала получаете «логические строки отчёта», а потом склеиваете их в итоговый текст. Это проще поддерживать, чем десять println вперемешку с вычислениями.
prefix и postfix для оформления
joinToString умеет добавлять префикс и постфикс, что иногда удобно для оформления.
fun main() {
val rows = listOf("A", "B", "C")
val text = rows.joinToString(
separator = "\n",
prefix = "Report:\n",
postfix = "\n(end)"
)
println(text)
// Report:
// A
// B
// C
// (end)
}
Префикс и постфикс — это не «магия форматирования», а просто ещё два кусочка строки. Но когда их даёт стандартная функция — меньше шансов ошибиться.
Многострочные строки """...""" для шаблона
Иногда удобнее не собирать отчёт из множества маленьких кусочков, а иметь «шаблон» из нескольких строк — как бланк. В Kotlin для этого есть raw string: тройные кавычки """...""".
Плюс таких строк в том, что внутри можно спокойно писать кавычки и переносы строк, а ещё удобно использовать trimIndent(), чтобы красиво оформить отступы в коде, но не тащить их в вывод.
fun main() {
val title = "Expenses report"
val header = """
=== $title ===
Columns: category | amount | note
""".trimIndent()
println(header)
// === Expenses report ===
// Columns: category | amount | note
}
Обратите внимание: мы всё ещё используем $title. Raw string не отменяет string templates — он просто делает переносы строк естественными.
6. Практический пример: отчёт по расходам
Сейчас сделаем небольшой «практический» кусок для нашего проекта учёта расходов: функция, которая принимает список расходов и возвращает красивый текст отчёта. Мы используем только то, что уже обсудили: string templates, padStart/padEnd, сбор строк в список и joinToString("\n").
Данные расходов — всё ещё Triple(category, amount, note). Да, Triple не самый удобный тип на свете, но он честно работает до тех пор, пока мы не перешли к классам.
Форматирование одной строки таблицы
fun formatExpenseRow(
expense: Triple<String, Int, String>,
categoryW: Int,
amountW: Int
): String {
val category = expense.first
val amount = expense.second
val note = expense.third
val safeCategory = if (category.length > categoryW) category.substring(0, categoryW) else category
val catCell = safeCategory.padEnd(categoryW)
val amountCell = amount.toString().padStart(amountW)
return "$catCell$amountCell $note"
}
Здесь мы сделали два «контракта»: ширина категории и ширина суммы. Плюс заметили «опасное место» — длинную категорию — и заранее приняли решение её резать.
Полный отчёт как одна многострочная строка
fun buildExpensesReport(expenses: List<Triple<String, Int, String>>): String {
val categoryW = 14
val amountW = 8
val title = "EXPENSES"
val header = "CATEGORY".padEnd(categoryW) + "AMOUNT".padStart(amountW) + " NOTE"
val separator = "-".repeat(categoryW) + "-".repeat(amountW) + " ----"
val rows = expenses.map { e ->
formatExpenseRow(e, categoryW, amountW)
}
val total = expenses.sumOf { it.second }
val footer = "TOTAL".padEnd(categoryW) + total.toString().padStart(amountW)
val lines = listOf(
"== $title ==",
header,
separator
) + rows + listOf(separator, footer)
return lines.joinToString(separator = "\n")
}
Тут несколько важных наблюдений.
Во-первых, мы не печатаем ничего внутри buildExpensesReport. Мы возвращаем строку — это удобнее, чем «печать по дороге». Во-вторых, мы явно фиксируем ширины колонок и используем их везде одинаково. В-третьих, отчёт строится как список строк, а в конце превращается в многострочный текст через joinToString("\n").
Использование в main()
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee and croissant"),
Triple("transport", 120, "metro"),
Triple("books", 900, "kotlin book")
)
val report = buildExpensesReport(expenses)
println(report)
// == EXPENSES ==
// CATEGORY AMOUNT NOTE
// ------------------------ ----
// food 450 coffee and croissant
// transport 120 metro
// books 900 kotlin book
// ------------------------ ----
// TOTAL 1470
}
Если вы сейчас подумали «это похоже на маленький терминальный UI», то да — именно так и рождаются консольные отчёты: из пробелов, дисциплины и лёгкой паранойи насчёт ширин.
7. Типичные ошибки
Ошибка №1: смешивать вычисления и печать в одном месте, пока код не станет «лапшой».
Новички часто делают так: посчитали сумму — сразу println, потом в середине цикла ещё println, потом где-то в if тоже. В итоге вы не можете переиспользовать вывод, не можете собрать отчёт в строку, и сложно проверить корректность. Лечится привычкой: форматирующая функция возвращает String, а печать живёт в одном-двух местах.
Ошибка №2: ожидать, что padStart/padEnd «сами поймут», как выравнивать число.
padStart работает со строкой, а не с числом. Поэтому «правильный ритуал» такой: amount.toString().padStart(width). Если забыть toString(), вы либо получите ошибку компиляции, либо начнёте делать странные обходные пути.
Ошибка №3: не фиксировать ширины колонок и размазывать числа 10, 12, 8 по коду.
Сначала кажется, что «и так понятно». Потом вы меняете ширину заголовка в одном месте, а строки данных в другом — и таблица разваливается. Гораздо надёжнее иметь val categoryW = ..., val amountW = ... и пользоваться ими как мини-настройкой отчёта.
Ошибка №4: не думать о длинных строках, из-за чего «едет» вся таблица.
padEnd не обрезает строку. Если в категории внезапно оказался текст на 40 символов, он сдвинет остальные колонки, и отчёт станет нечитаемым. Нужно заранее выбрать политику: обрезать (substring), сокращать с троеточием, или переносить на следующую строку. В учебном варианте достаточно простого «обрезать до ширины».
Ошибка №5: собирать многострочный отчёт десятками println, а потом удивляться, что формат сложно менять.
Когда отчёт создаётся как единая строка через joinToString("\n"), вы можете легко его сохранить, вернуть из функции, показать частично или дописать сверху/снизу. Когда отчёт печатается «по ходу», вы теряете контроль.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ