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

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

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

1. Зачем нужна командная логика в консольном приложении

Консольная программа без команд — это как кофейня без меню: вроде бы кофе где-то есть, но как его заказать — непонятно. Командная логика нужна, чтобы пользователь мог управлять состоянием приложения короткими понятными фразами: add ..., list, remove .... Это превращает набор разрозненных функций в единый сценарий: прочитал строку → понял смысл → изменил коллекцию → показал результат.

Представим, что мы пишем мини‑приложение TaskBox — список задач в консоли. Это не «настоящий» TODO‑лист уровня космической станции, а учебная версия: добавить задачу, показать список, удалить задачу. Нам важно не богатство фич, а архитектура: чтобы код был понятным и не ломал коллекции во время обхода.

Состояние приложения будет простым:

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

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

Почти все консольные утилиты живут по принципу: есть команда (что сделать) и иногда есть аргумент (с чем это сделать). Нам не нужен сложный синтаксис с кавычками и экранированием (это отдельный вид боли), поэтому берём практичный учебный формат: команда — первое слово, аргумент — всё остальное после первого пробела. Этого достаточно, чтобы построить устойчивый сценарий без лишней магии.

Вот как это будет выглядеть для TaskBox:

Команда Пример ввода Смысл
add
add купить молоко
добавить задачу
list
list
вывести все задачи
remove
remove 2
удалить задачу по номеру (как для человека)
remove
remove купить молоко
удалить задачу по точному тексту (если не похоже на число)
help
help
показать подсказку
exit
exit
выйти из программы

Ключевая договорённость: номер для пользователя начинается с 1, а индекс в списке — с 0. Это та самая «вечная классика», из-за которой программисты иногда разговаривают с людьми с лёгкой грустью в глазах.

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

Нормализация строки

Перед тем как парсить команду, строку полезно привести в более аккуратный вид: убрать пробелы по краям и унифицировать регистр команды. Это не украшательство — это защита от ввода вроде " LIST " или "Add купить молоко". Пользователь не обязан помнить наш идеальный формат; наша задача — сделать программу терпеливой (в разумных пределах).

Правило будет таким: команду приводим к lowercase, аргумент оставляем как есть (кроме 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
Задача
Kotlin SELF, 20 уровень, 4 лекция
Недоступна
Разбор команды
Разбор команды
1
Задача
Kotlin SELF, 20 уровень, 4 лекция
Недоступна
Консольный диспетчер
Консольный диспетчер
1
Задача
Kotlin SELF, 20 уровень, 4 лекция
Недоступна
Коробка цитат
Коробка цитат
1
Задача
Kotlin SELF, 20 уровень, 4 лекция
Недоступна
Менеджер закладок
Менеджер закладок
1
Опрос
Обход коллекций, 20 уровень, 4 лекция
Недоступен
Обход коллекций
Обход коллекций
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ