JavaRush /Курси /Kotlin SELF /Диспетчеризація команд: нормалізація введення та when

Диспетчеризація команд: нормалізація введення та when

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

1. Диспетчеризація команд і when як «таблиця дій»

Коли ви пишете перші програми, часто все виглядає лінійно: запитали число, порахували, вивели. Та досить швидко зʼявляється бажання зробити програму «живою»: щоб вона приймала команди на кшталт help, add 10, show, exit і не завершувалася після однієї дії. Це і є найпростіший CLI (command-line interface). У такій програмі потрібно вміти вибирати дію за текстом, який увів користувач.

Уявіть, що ви робите міні застосунок «Гаманець v0» (без списків, без файлів, без баз даних — просто тренуємо логіку). Користувач вводить команди, а програма або змінює поточний стан (наприклад, баланс), або показує інформацію, або друкує довідку. Головне питання: як акуратно зіставити рядок команди з потрібною дією, щоб код не перетворився на «ліс ifʼів», у якому легко загубитися.

Тут when — ідеальний «диспетчер». Він читається як «таблиця команд». Kotlin позиціонує when як конструкцію для багатьох варіантів, схожу на switch, і підкреслює, що when можна використовувати і як оператор, і як вираз.

Мінімальний диспетчер команд на when

Коли у вас є нормалізована команда, when стає майже ідеальним диспетчером: кожна гілка — це команда, а else — зрозуміле повідомлення «я не знаю, що це». Такий код добре читається навіть людиною, яка бачить його вперше: зверху вниз — перелік підтримуваних команд.

Kotlin підкреслює, що when порівнює значення з гілками за порядком. Також він може бути або оператором (виконуємо дію), або виразом (повертаємо значення). Для командного диспетчера частіше підходить саме варіант оператора: «якщо команда така — зроби те».

fun main() {
    val command = readln().trim().lowercase()

    when (command) {
        "help" -> println("Команди: help, exit")     // Команди: help, exit
        "exit" -> println("Завершую роботу...")      // Завершую роботу...
        else -> println("Невідома команда: $command")
    }
}

Тут важливо, що else стоїть наприкінці. Це не просто «традиція»: якщо ви поставите else раніше, нижні гілки стануть недосяжними — і компілятор (і майбутній ви) будуть цим незадоволені.

2. Підготовка введення: нормалізація, порожнє введення, команда та аргумент

Нормалізація введення: trim() і lowercase()

Якщо порівнювати введення користувача з командами «як є», програма буде «ображатися» на пробіли та регістр. Команда Help, HELP, help і help з погляду людини — одне й те саме. А з погляду «сирого рядка» — чотири різні значення. Тому перед тим як щось аналізувати, введення зазвичай нормалізують: прибирають пробіли по краях і приводять рядок до одного регістру.

Найбазовіший (і майже завжди достатній) набір дій: trim() (прибираємо пробіли по краях) і lowercase() (робимо все малими літерами). Важливо: trim() не видаляє пробіли всередині рядка — і це добре. Інакше команда add 10 перетворилася б на add10, а аргумент загубився б, як шкарпетка після прання.

Подивімося на мініприклад: читаємо рядок і робимо з нього «сталу версію», придатну для порівняння.

fun main() {
    val raw = readln()
    val normalized = raw.trim().lowercase()

    println("raw='$raw'")                 // raw='   HeLp  '
    println("normalized='$normalized'")   // normalized='help'
}

Тепер програма порівнює лише normalized. Це не «магія», а дисципліна: ми один раз приводимо дані до стандарту — і далі працюємо спокійно.

Щоб краще відчути сенс нормалізації, корисно тримати в голові таку таблицю:

Введення користувача Після trim() Після trim().lowercase()
"help"
"help"
"help"
" Help "
"Help"
"help"
"EXIT"
"EXIT"
"exit"
"  add 10  "
"add 10"
"add 10"

Зверніть увагу: "add 10" після lowercase() не ламається — пробіл усередині лишається, тож ми зможемо відокремити аргумент пізніше.

Порожній рядок — це окремий випадок

Коли користувач натискає Enter, інколи він вводить порожній рядок. Це не рідкість: люди помиляються, промахуються, перевіряють «а що буде, якщо…», або просто кіт пройшовся по клавіатурі (кіт — головний QA-інженер у світі, просто він не підписує баг-репорти).

Порожній рядок варто обробляти явно. Інакше в when ви отримаєте «Невідома команда: ""», і користувачу буде неясно, що сталося. Технічно програма має рацію, але психологічно це звучить як: «невідомий запит: (тиша)».

Найпростіший шаблон такий: після trim() перевірити isEmpty() і дати зрозумілу реакцію — наприклад, перейти до наступної ітерації циклу або вивести підказку.

fun main() {
    val input = readln().trim()
    if (input.isEmpty()) {
        println("Введіть команду. Для довідки: help") // Введіть команду. Для довідки: help
        return
    }

    println("Команду прийнято: '$input'") // наприклад: Команду прийнято: 'help'
}

Тут ми робимо return, бо приклад короткий. У реальній «живій» програмі (де є цикл) ви частіше використаєте continue, щоб просто перейти до наступного введення.

Команда + аргумент без split(): indexOf(' ') і substring(...)

Команди часто приходять з аргументом: add 10, set 100, echo привіт. Можна було б використати split(" "), але сьогодні ми свідомо не йдемо цим шляхом. По-перше, це окрема тема — «розбір рядків» у всій красі. По-друге, split легко породжує зайві деталі (порожні частини, багато пробілів тощо), а нам зараз потрібен максимально керований і зрозумілий ручний розбір.

Ідея проста: шукаємо перший пробіл. Усе ліворуч — команда. Усе праворуч — аргумент (можливо, порожній). Якщо пробілу немає — аргумента немає.

Схематично це можна уявити так:

flowchart TD
    A["raw input: '  add  10  '"] --> B["trim(): 'add  10'"]
    B --> C["lowercase(): 'add  10'"]
    C --> D["indexOf(' ') -> 3"]
    D --> E["command = substring(0, 3) -> 'add'"]
    D --> F["arg = substring(4).trim() -> '10'"]
    E --> G["when(command) {...}"]

Тепер — практичний код. Зробімо виділення command і arg максимально прямолінійно, щоб було видно, що саме відбувається.

fun main() {
    val raw = readln().trim()
    val spaceIndex = raw.indexOf(' ')

    val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
    val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

    println("command='$command', arg='$arg'") // наприклад: command='add', arg='10'
}

Є кілька нюансів, які зазвичай рятують від дивних багів:

  • По-перше, аргумент ми додатково trim()-имо, щоб add 10 усе одно дав аргумент 10, а не " 10".
  • По-друге, команду ми нормалізуємо через lowercase() (і зазвичай після trim()), бо саме її будемо порівнювати.

3. Проєкт: «Гаманець v0»

Тепер зберемо все в маленьку консольну програму: вона живе в циклі, приймає команди й змінює одне число — баланс. Це не «фінальний застосунок мрії», а навчальний тренажер. Він учить вас робити передбачувану обробку команд і не плутатися в рядках. У наступних темах курсу ми будемо підсилювати підхід (і тоді зʼявляться потужніші інструменти), але сьогодні наша мета — чиста диспетчеризація.

Каркас циклу: читаємо команди, доки не попросять вийти

Почнімо з найпростішого: нескінченний цикл, читання рядка, команда exit — вихід.

fun main() {
    while (true) {
        val cmd = readln().trim().lowercase()

        if (cmd == "exit") break
        println("Ви ввели: $cmd") // наприклад: Ви ввели: help
    }

    println("Бувайте!") // Бувавайте!
}

Це ще не when, але вже мінімальний «пульс» командної програми.

Додаємо таблицю команд через when

Тепер замінимо if (cmd == "exit") на when. Важливо: ми хочемо, щоб exit справді завершував цикл, тому всередині гілки використовуємо break.

fun main() {
    while (true) {
        val cmd = readln().trim().lowercase()

        when (cmd) {
            "help" -> println("Команди: help, show, exit") // Команди: help, show, exit
            "show" -> println("Поки що показувати нічого")     // Поки що показувати нічого
            "exit" -> break
            else -> println("Невідома команда: $cmd")
        }
    }
}

Зверніть увагу: when тут використовується як оператор. У такій формі ви не зобовʼязані покривати всі варіанти, але для CLI else майже завжди потрібен. Інакше «тиша» виглядатиме як збій.

Переходимо до «команда + аргумент» і вводимо стан balance

Тепер додамо змінну balance і команди, які змінюють її. Знову застосуємо підхід «прочитав → підготував → розібрав», щоб не намагатися викликати toInt() для випадкового тексту.

fun main() {
    var balance = 0
    val raw = readln().trim()
    val spaceIndex = raw.indexOf(' ')

    val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
    val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

    println("Баланс=$balance, command=$command, arg=$arg") // Баланс=0, command=add, arg=10
}

Поки що ми лише «розібрали рядок». Тепер — найцікавіше: команда визначає дію, а аргумент дає дані для цієї дії.

Команди, яким потрібне число: toIntOrNull() і зрозумілі повідомлення

Числовий аргумент потрібно перетворювати на число безпечно. Ми вже знаємо toIntOrNull() і те, що він повертає Int?, тобто або число, або null. Далі у вас є два хороші варіанти: або if (value == null), або when (value) з гілкою null. Це зручний спосіб не плодити вкладені перевірки.

Зробімо невеликий фрагмент логіки: команда add, аргумент — число.

fun main() {
    val arg = "10"
    val delta: Int? = arg.toIntOrNull()

    when (delta) {
        null -> println("Потрібне число, а не '$arg'") // якщо arg="abc"
        else -> println("Гаразд, додамо $delta")      // Гаразд, додамо 10
    }
}

Тепер перенесімо цей підхід в обробку команд.

Майже фінальна версія «Гаманця v0»

Нижче — компактна версія, яку можна повністю зібрати у себе. Вона все ще коротка, але вже справді корисна як шаблон. Повідомлення зроблено «людськими»: CLI — це не іспит із телепатії.

fun main() {
    var balance = 0

    while (true) {
        val raw = readln().trim()
        if (raw.isEmpty()) continue

        val spaceIndex = raw.indexOf(' ')
        val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
        val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

        when (command) {
            "help" -> println("Команди: help, show, set N, add N, sub N, exit")
            "show" -> println("Баланс: $balance")               // Баланс: 0
            "exit" -> break
            "set", "add", "sub" -> {
                val value = arg.toIntOrNull()
                if (value == null) {
                    println("Потрібне число після '$command'")      // Потрібне число після 'add'
                } else {
                    balance = when (command) {
                        "set" -> value
                        "add" -> balance + value
                        else -> balance - value                 // sub
                    }
                    println("Гаразд. Баланс: $balance")
                }
            }
            else -> println("Невідома команда: $command")
        }
    }

    println("Вихід. Фінальний баланс: $balance")
}

Усередині гілки "set", "add", "sub" ми використали ще один when як вираз: він повертає нове значення балансу. when як вираз має бути вичерпним (щоб завжди повертати значення), і тому у нас є гілка else. Тут вона логічно означає sub.

Так, це «подвійний when», але він читається нормально: зовнішній when вибирає команду, внутрішній — формулу оновлення балансу. І головне: ми не змішали все в одну кашу — розпізнавання команди окремо, математика окремо. Спочатку виділили command/arg, а потім уже обробили.

4. when (value) і when { ... } у командному коді

На цьому етапі багато хто починає плутатися: «А можна я завжди писатиму when { ... }, бо він схожий на if?» Можна, але не завжди варто. Головне правило — обирати форму, яка робить код очевиднішим.

Якщо ви порівнюєте одне значення з набором варіантів (наприклад, command з рядками "help", "exit"), то when (command) читається як довідник команд. А якщо у вас логіка побудована на умовах (наприклад, «команда порожня», «занадто коротка», «починається з #»), то зручніше when { ... }.

Мініприклад: «якість введення» як набір умов.

fun main() {
    val input = readln().trim()

    val status = when {
        input.isEmpty() -> "порожньо"
        input.length < 3 -> "занадто коротко"
        else -> "схоже на команду"
    }

    println(status) // наприклад: схоже на команду
}

А ось when (command) — для диспетчеризації:

fun main() {
    val command = readln().trim().lowercase()

    when (command) {
        "help" -> println("Допомога")           // Допомога
        "exit" -> println("Вихід")            // Вихід
        else -> println("Не розумію: $command")
    }
}

У командному інтерфейсі найчастіше ви поєднуєте обидва підходи: спочатку when { ... } — щоб «перевірити й відфільтрувати сміття», потім when (command) — щоб отримати «таблицю команд».

5. Типові помилки

Помилка №1: порівнювати команди без нормалізації.
Якщо ви робите when (readln()) { "help" -> ... }, то команда " help" (із пробілом) або "HELP" (у верхньому регістрі) раптово стає «невідомою». Формально програма має рацію, але користувач почувається винним — і перестає їй довіряти. Правильніше спочатку зробити trim().lowercase(), а вже потім порівнювати.

Помилка №2: намагатися перетворити аргумент на число до того, як ви переконалися, що він узагалі потрібен.
Іноді початківець пише val n = arg.toInt() одразу після читання рядка, а вже потім думає, яку команду було введено. У результаті команда help теж намагається «розібрати» аргумент, якого немає, і програма падає. Спочатку визначаємо команду, потім розуміємо, чи потрібен аргумент, і лише після цього перетворюємо його на число.

Помилка №3: не обробляти порожній рядок.
Якщо не виділити порожнє введення в окремий випадок, ви отримуватимете або «невідому команду» без пояснень, або дивні помилки, якщо далі в коді припускається, що команда не порожня. Пара рядків із if (raw.isEmpty()) continue помітно підвищує дружність і стійкість програми.

Помилка №4: робити гілки when занадто великими й «багатоповерховими».
when добрий як диспетчер, але швидко перетворюється на «мініпрограму всередині програми», якщо в кожній гілці по 20 рядків логіки. Сьогодні ми свідомо тримали гілки короткими: розпізнавання команди — окремо, обчислення балансу — окремо. Kotlin підкреслює, що гілки можуть бути блоками, і це зручно. Але це не запрошення писати роман усередині однієї гілки.

Помилка №5: забути про else там, де він потрібен за змістом.
Навіть якщо when-оператор компілюється без else, у командному інтерфейсі «нічого не сталося» виглядає як баг. А якщо ви використовуєте when як вираз, то компілятор узагалі вимагатиме вичерпної обробки (зазвичай через else).

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