JavaRush /Курси /Kotlin SELF /Практичний приклад: парсинг введення та оновлення колекці...

Практичний приклад: парсинг введення та оновлення колекцій

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

1. Навіщо потрібна командна логіка в консольному застосунку

Консольна програма без команд — це як кавʼярня без меню: наче кава десь є, але як її замовити — незрозуміло. Командна логіка потрібна, щоб користувач міг керувати станом застосунку короткими й зрозумілими фразами: add ..., list, remove .... Так розрізнені функції складаються в єдиний сценарій: прочитали рядок → зрозуміли зміст → змінили колекцію → показали результат.

Уявімо, що ми пишемо міні застосунок TaskBox — список завдань у консолі. Це не «справжній» to-do‑список рівня космічної станції, а навчальна версія: додати завдання, показати список, видалити завдання. Нам важливе не багатство функцій, а архітектура: щоб код був зрозумілим і не ламав колекції під час обходу.

Стан застосунку буде простим:

  • MutableList<String> — список завдань (кожне завдання — просто рядок).
  • цикл читання команд — користувач вводить рядок, доки не напише exit.

Команда й аргумент

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

Ось як це виглядатиме для TaskBox:

Команда Приклад введення Зміст
add
add купити молоко
додати завдання
list
list
вивести всі завдання
remove
remove 2
видалити завдання за номером (як для людини)
remove
remove купити молоко
видалити завдання за точним текстом (якщо це не схоже на число)
help
help
показати підказку
exit
exit
вийти з програми

Ключова домовленість: номер для користувача починається з 1, а індекс у списку — з 0. Це та сама «вічна класика», через яку програмісти іноді розмовляють із людьми з легким сумом в очах.

2. Нормалізація та парсинг введення

Нормалізація рядка

Перш ніж парсити команду, рядок корисно привести до охайнішого вигляду: прибрати пробіли по краях і уніфікувати регістр команди. Це не прикрашання — це захист від введення на кшталт " LIST " або "Add купити молоко". Користувач не зобовʼязаний памʼятати наш ідеальний формат. Наше завдання — зробити програму терплячою (у розумних межах).

Правило буде таким: команду зводимо до нижнього регістру, а аргумент залишаємо як є (окрім trim()). Адже текст завдань може бути в будь-якому регістрі. Ми не хочемо раптово перетворити «Купити Milk» на «купити milk».

Мініприклад нормалізації:

fun normalizeInput(line: String): String {
    return line.trim()
}

fun main() {
    val raw = "   Add   купити молоко   "
    val normalized = normalizeInput(raw)
    println(normalized) // Add   купити молоко
}

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

Парсинг команди

Зараз ми зробимо функцію, яка приймає рядок і повертає Pair<String, String?>: first — команда, second — аргумент або null, якщо його немає. Це саме той випадок, коли Pair зручний як «конверт» для двох значень — без винайдення власних типів (власні класи будуть пізніше в курсі).

Важливо обробити три випадки: порожній рядок (після trim()), рядок без пробілів (лише команда) та рядок із командою й аргументом.

fun parseCommand(line: String): Pair<String, String?> {
    val s = line.trim()
    if (s.isEmpty()) return "" to null

    val spaceIndex = s.indexOf(' ')
    return if (spaceIndex == -1) {
        s.lowercase() to null
    } else {
        val cmd = s.substring(0, spaceIndex).lowercase()
        val arg = s.substring(spaceIndex + 1).trim()
        cmd to arg
    }
}

Тут важлива деталь: substring(spaceIndex + 1) бере «все після пробілу». Якщо користувач написав add купити молоко, то arg почнеться з пробілів. Тому ми ще раз викликаємо trim(), щоб отримати чистий текст.

Перевіримо на кількох введеннях:

fun main() {
    println(parseCommand("list"))                 // (list, null)
    println(parseCommand(" add   milk  "))        // (add, milk)
    println(parseCommand("   "))                  // (, null)
}

Формат Pair під час друку виглядає як (first, second). Це нормально: так Kotlin показує пари в toString().

3. Диспетчеризація команд через when

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

Одразу домовмося про архітектуру TaskBox:

  • main() — лише цикл читання та виклики функцій.
  • parseCommand() — лише парсинг.
  • handleCommand() — диспетчер, який викликає конкретні операції.
  • операції (addTask, printTasks, removeTask) — окремі функції, щоб не роздувати when.

Почнемо з основи: функція-обробник команд повертає Boolean: true — продовжуємо цикл, false — виходимо.

fun handleCommand(cmd: String, arg: String?, tasks: MutableList<String>): Boolean {
    return when (cmd) {
        "add" -> { addTask(tasks, arg); true }
        "list" -> { printTasks(tasks); true }
        "remove" -> { removeTask(tasks, arg); true }
        "help" -> { printHelp(); true }
        "exit" -> false
        "" -> true
        else -> { println("Невідома команда: $cmd"); true }
    }
}

Зверніть увагу на гілку "": це випадок, коли користувач увів порожній рядок або лише пробіли. Ми просто нічого не робимо й продовжуємо.

4. Команди add і list

Команда add

Додавання елемента — найпростіша операція зміни списку: ми беремо рядок і додаємо його в tasks. У Kotlin у змінюваних колекцій є метод add(), який додає елемент у кінець. Але в консольному інтерфейсі головна складність не в add(), а в тому, що користувач може забути аргумент або ввести порожній рядок.

Зробимо охайну функцію: якщо аргументу немає або він порожній, друкуємо підказку й нічого не змінюємо.

fun addTask(tasks: MutableList<String>, text: String?) {
    val task = text?.trim()
    if (task.isNullOrEmpty()) {
        println("Формат: add <текст завдання>")
        return
    }

    tasks.add(task)
    println("Додано: $task") // Додано: купити молоко
}

Тут isNullOrEmpty() — зручний спосіб сказати «або null, або порожній рядок». Ми могли б зробити дві перевірки вручну, але нехай Kotlin трохи попрацює за нас.

Команда list

Друк списку — операція «лише читання». І тут легко зробити код або зрозумілим, або в стилі «я програміст, я так бачу». Нам потрібно вивести завдання з номерами від 1, бо користувачеві так звичніше: пункт 1, пункт 2, пункт 3.

Технічно нумерувати можна по-різному: через indices або withIndex(). У цій команді логічніше використати withIndex(), бо ми хочемо одразу і індекс, і значення. У попередніх лекціях ми вже обговорювали, що індекси в списку починаються з 0 і йдуть до lastIndex, тобто до size - 1.

fun printTasks(tasks: List<String>) {
    if (tasks.isEmpty()) {
        println("Список завдань порожній.") // Список завдань порожній.
        return
    }

    for ((i, task) in tasks.withIndex()) {
        println("${i + 1}. $task")
    }
}

Зауважте: параметр тут List<String>, а не MutableList<String>. Це хороший стиль: функція друку не зобовʼязана вміти змінювати список. Отже, контракт робимо суворішим — лише читання.

5. Команда remove та безпечне видалення

Видалення за номером або за текстом

Видалення — найнервовіша операція. Не тому, що remove складний, а тому, що навколо нього часто зʼявляється думка: «видалю-но я прямо під час обходу» — і далі починаються пригоди. Ми вже знаємо безпечні стратегії видалення, але в межах команди remove найчастіше достатньо прибрати один елемент.

Є дві зручні семантики:

  • якщо аргумент — число, вважаємо це номером завдання (від 1), переводимо в індекс (мінус 1), перевіряємо межі та викликаємо removeAt(index);
  • якщо аргумент не число, видаляємо за значенням через remove(value).

Про remove(value) є важлива деталь: він видаляє одне входження й повертає Boolean — чи вдалося видалити.

Зробимо охайну реалізацію:

fun removeTask(tasks: MutableList<String>, arg: String?) {
    val s = arg?.trim()
    if (s.isNullOrEmpty()) {
        println("Формат: remove <номер|текст>")
        return
    }

    val number = s.toIntOrNull()
    if (number != null) {
        removeByNumber(tasks, number)
    } else {
        removeByText(tasks, s)
    }
}

Тепер конкретика «за номером»:

fun removeByNumber(tasks: MutableList<String>, number: Int) {
    val index = number - 1
    if (index !in tasks.indices) {
        println("Немає завдання з номером $number")
        return
    }

    val removed = tasks.removeAt(index)
    println("Видалено: $removed") // Видалено: купити молоко
}

Тут tasks.indices — найнадійніший спосіб перевірити межі: це діапазон допустимих індексів. Він автоматично коректний для будь-якого розміру списку й рятує від класичної помилки «переплутав size і останній індекс». Сам принцип індексів (0lastIndex) — базова модель списку в Kotlin.

І видалення «за текстом»:

fun removeByText(tasks: MutableList<String>, text: String) {
    val ok = tasks.remove(text)
    if (ok) {
        println("Видалено: $text") // Видалено: купити молоко
    } else {
        println("Завдання не знайдено: $text")
    }
}

Зверніть увагу: «видалення за текстом» прибере перше збігання. Якщо однакових завдань кілька, така поведінка зазвичай нормальна для простого навчального застосунку.

Множинне видалення через ітератор

Іноді ви хочете не «remove один елемент», а «видалити всі завдання, які дорівнюють X» або «видалити всі порожні чи некоректні». От тут люди й наступають на граблі: роблять for (x in tasks) tasks.remove(x) і отримують або пропуск елементів, або виняток.

Сьогодні ми не будуємо багату команду purge, але зробимо корисну допоміжну функцію removeAllEquals. Вона стане в пригоді як приклад «правильного видалення під час обходу» — через ітератор. Це саме та техніка, яку ми готували в лекції про безпечні видалення.

fun removeAllEquals(tasks: MutableList<String>, target: String) {
    val it = tasks.iterator()
    while (it.hasNext()) {
        val task = it.next()
        if (task == target) {
            it.remove()
        }
    }
}

Чому це важливо памʼятати навіть у «простому» TaskBox? Бо щойно ви розширите функціональність (наприклад, додасте команду «видалити все, що містить слово spam»), ви опинитеся в ситуації множинного видалення. І там уже не можна «сподіватися на авось» — потрібно обирати коректну стратегію обходу й видалення.

6. Збирання застосунку: main, help і цикл команд

Тепер зберемо все в один сценарій. Тут важливо тримати main() як «диригента»: він не має сам парсити, сам валідувати й сам видаляти. Його робота — читати рядки, викликати parseCommand, передавати результат у handleCommand і вирішувати, чи продовжувати цикл.

Невелика блок-схема процесу:

flowchart TD
    A["readln()"] --> B[trim + parseCommand]
    B --> C[cmd + arg]
    C --> D["when(cmd)"]
    D --> E[add/list/remove/help]
    E --> F[оновили tasks та/або вивели результат]
    F --> A
    D --> G[exit]

А тепер код main() (усе ще короткий і читабельний):

fun main() {
    val tasks = mutableListOf<String>()
    println("TaskBox. Введіть help для довідки.")

    while (true) {
        print("> ")
        val (cmd, arg) = parseCommand(readln())

        val shouldContinue = handleCommand(cmd, arg, tasks)
        if (!shouldContinue) break
    }

    println("До побачення!") // До побачення!
}

І функція довідки, щоб користувач не почувався покинутим у консолі сам на сам із долею:

fun printHelp() {
    println("Команди:")
    println("  add <текст>    — додати завдання")
    println("  list           — показати завдання")
    println("  remove <...>   — видалити за номером або точним текстом")
    println("  exit           — вихід")
}

Так, тут є мініперелік. Для довідки це нормально: довідка без списку — як інструкція до мікрохвильовки в стилі «ну ви там розберетеся».

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

Помилка №1: змішати «номер» та «індекс» і видалити не те.
Найчастіший баг у remove 2: людина думає «другий елемент», а код робить removeAt(2) і видаляє третій, бо індексація в списках починається з нуля. Лікується простим правилом: номер для користувача → index = number - 1, а потім перевірка index in tasks.indices.

Помилка №2: перевіряти межі через index < size і забувати про відʼємні значення.
Початківці часто пишуть if (index < tasks.size) ... і забувають, що index може бути -1, якщо користувач увів remove 0. Перевірка через index in tasks.indices закриває обидві сторони одразу й робить код коротшим, бо діапазон індексів уже коректний.

Помилка №3: видаляти елементи списку всередині for через tasks.remove(...).
Якщо спробувати видаляти елементи безпосередньо з колекції під час for (x in tasks), можна отримати пропуски елементів або виняток через модифікацію колекції. Для одиничного видалення краще взагалі не обходити список, а прибрати за індексом/значенням однією операцією. Для множинного видалення — використовувати ітератор із it.remove().

Помилка №4: не розрізняти remove(value) і removeAt(index).
remove("milk") — видаляє за значенням і повертає Boolean, чи вдалося. removeAt(1) — видаляє за позицією та повертає видалений елемент. Якщо переплутати, код або не скомпілюється, або поводитиметься не так, як очікує користувач.

Помилка №5: не обробляти порожній аргумент у add і remove.
Команди без аргументу — звична ситуація: користувач може натиснути Enter занадто рано або забути дописати. Якщо не перевірити arg, ви отримаєте або порожні завдання в списку, або дивні спроби видалити «нічого». Проста перевірка arg?.trim().isNullOrEmpty() робить поведінку передбачуваною та дружньою.

Помилка №6: роздувати main() до «божественного методу».
Дуже легко почати писати все прямо в main: парсинг, when, перевірки, друк, видалення, і ще пару речей «нашвидкуруч». За 20 хвилин ви отримаєте функцію, яку страшно чіпати, бо вона «працює, але я не знаю чому». Лікується дисципліною: парсинг — окремо, диспетчер — окремо, операції — окремо. Це не «архітектура заради архітектури». Це спосіб не збожеволіти під час першої ж зміни вимог.

1
Опитування
Обхід колекцій, рівень 20, лекція 4
Недоступний
Обхід колекцій
Обхід колекцій
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ