JavaRush /Курсы /Kotlin SELF /Форматирование вывода

Форматирование вывода

Kotlin SELF
27 уровень , 1 лекция
Открыта

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

Иногда проще увидеть глазами:

Метод Куда добавляет пробелы Типичный кейс
padEnd(width)
вправо текстовая колонка слева
padStart(width)
влево числовая колонка справа

В консоли числа обычно выравнивают по правому краю, потому что так легче сравнивать величины, особенно когда разная длина: 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"), вы можете легко его сохранить, вернуть из функции, показать частично или дописать сверху/снизу. Когда отчёт печатается «по ходу», вы теряете контроль.

1
Задача
Kotlin SELF, 27 уровень, 1 лекция
Недоступна
Статус сессии
Статус сессии
1
Задача
Kotlin SELF, 27 уровень, 1 лекция
Недоступна
Табло игрока
Табло игрока
1
Задача
Kotlin SELF, 27 уровень, 1 лекция
Недоступна
Полка категории
Полка категории
1
Задача
Kotlin SELF, 27 уровень, 1 лекция
Недоступна
Баннер приложения
Баннер приложения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ