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

Форматування виводу

Kotlin SELF
Рівень 27 , Лекція 1
Відкрита

1. Вступ

Коли програма тільки вчиться ходити, достатньо println("OK"). Але що далі ви пишете консольний проєкт, то частіше зʼявляється «внутрішній користувач» — ви самі, але вже через 20 хвилин налагодження. І тут раптом зʼясовується: виведення «як завгодно» перетворює будь-яку перевірку на археологію. Ви намагаєтеся зрозуміти, що означає total=1200, чому поруч немає валюти та де взагалі межі записів.

Форматування — це не про красу, а про контракт між вашим кодом і людиною. Що є заголовком, що — рядком даних, де число, а де текст, які поля мають бути «стовпцями», і що робити, якщо рядок довший за очікуваний. До того ж добре форматування часто допомагає виявити баги. Наприклад, якщо стовпець із сумами «поїхав», значить, десь несподівано прийшов текст, а не число.

Щоб усе було практично, продовжимо наш умовний консольний проєкт «облік витрат». Він поки без класів, тож витрати зберігатимемо як Triple(category, amount, note).

2. Інтерполяція рядків: $x і ${expr}

Інтерполяція рядків у Kotlin — це коли ви підставляєте значення прямо всередину рядка: $x для простого імені та ${expr} для виразу. Це базова річ, але саме вона найчастіше робить виведення читабельним — без зайвих + і без «склейок нашвидкуруч».

Важливо відчути різницю: конкатенація через + буквально склеює шматки, а шаблони рядків дають змогу писати майже «людський текст» із підстановками. Для звітів і повідомлень користувачу це майже завжди виграє.

Базовий приклад: друкуємо витрату

Почнімо з простого. Нехай витрата — це 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("Витрата: категорія=$category, сума=$amount, примітка=\"$note\"")
    // Витрата: категорія=food, сума=450, примітка="coffee and croissant"
}

Зверніть увагу на лапки навколо note: це дрібниця, але інколи корисно візуально бачити, де починається і де закінчується текст.

Коли потрібен ${expr}

Іноді хочеться вбудувати результат обчислення. Наприклад, показати суму зі «знаком» або порахувати податок.

fun main() {
    val amount = 450
    val tax = (amount * 0.2).toInt()

    println("Сума=$amount, податок(20%)=$tax, разом=${amount + tax}")
    // Сума=450, податок(20%)=90, разом=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 = "КАТЕГОРІЯ".padEnd(nameW) + "СУМА".padStart(amountW)
    val row = "food".padEnd(nameW) + "450".padStart(amountW)

    println(header)
    // КАТЕГОРІЯ      СУМА
    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() і «сирі» рядки

Коли ви друкуєте звіт, зазвичай маєте набір рядків: заголовок, рядок-роздільник, рядки даних, підсумок. Найпрямолінійніший шлях — багато println(...). Але є охайніша ідея: зібрати список рядків, а потім обʼєднати їх в один багаторядковий текст.

Для цього чудово підходить joinToString(...): він перетворює колекцію елементів на один рядок із роздільником, префіксом, постфіксом та іншими налаштуваннями. А якщо роздільником зробити "\n", вийде багаторядковий текст.

Збираємо звіт як List<String>

fun main() {
    val lines = listOf(
        "КАТЕГОРІЯ".padEnd(12) + "СУМА".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 = "Звіт:\n",
        postfix = "\n(кінець)"
    )
    println(text)
    // Звіт:
    // A
    // B
    // C
    // (кінець)
}

Префікс і постфікс — це не «магія форматування», а просто ще два шматочки рядка. Але коли їх додає стандартна функція, шансів помилитися менше.

Багаторядкові рядки """...""" для шаблону

Іноді зручніше не збирати звіт із багатьох маленьких шматочків, а мати «шаблон» із кількох рядків — як бланк. У Kotlin для цього є «сирий рядок»: потрійні лапки """...""".

Перевага таких рядків у тому, що всередині можна спокійно писати лапки й переноси рядків. Також зручно використовувати trimIndent(), щоб гарно оформити відступи в коді, але не тягнути їх у виведення.

fun main() {
    val title = "Звіт про витрати"
    val header = """
        === $title ===
        Колонки: категорія | сума | примітка
    """.trimIndent()

    println(header)
    // === Звіт про витрати ===
    // Колонки: категорія | сума | примітка
}

Зверніть увагу: ми все ще використовуємо $title. «Сирий рядок» не скасовує шаблони рядків — він лише робить переноси природними.

6. Практичний приклад: звіт про витрати

Зараз зробимо невеликий практичний фрагмент для нашого проєкту обліку витрат: функцію, яка приймає список витрат і повертає охайний текст звіту. Ми використаємо лише те, що вже обговорили: шаблони рядків, 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 = "ВИТРАТИ"
    val header = "КАТЕГОРІЯ".padEnd(categoryW) + "СУМА".padStart(amountW) + "  ПРИМІТКА"
    val separator = "-".repeat(categoryW) + "-".repeat(amountW) + "  ----"

    val rows = expenses.map { e ->
        formatExpenseRow(e, categoryW, amountW)
    }

    val total = expenses.sumOf { it.second }
    val footer = "РАЗОМ".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)
    // == ВИТРАТИ ==
    // КАТЕГОРІЯ         СУМА  ПРИМІТКА
    // ------------------------  ----
    // food               450  coffee and croissant
    // transport          120  metro
    // books              900  kotlin book
    // ------------------------  ----
    // РАЗОМ             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"), ви можете легко його зберегти, повернути з функції, показати частково або дописати зверху/знизу. Коли звіт друкується «на ходу», ви втрачаєте контроль.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ