1. Навіщо потрібна командна логіка в консольному застосунку
Консольна програма без команд — це як кавʼярня без меню: наче кава десь є, але як її замовити — незрозуміло. Командна логіка потрібна, щоб користувач міг керувати станом застосунку короткими й зрозумілими фразами: add ..., list, remove .... Так розрізнені функції складаються в єдиний сценарій: прочитали рядок → зрозуміли зміст → змінили колекцію → показали результат.
Уявімо, що ми пишемо міні застосунок TaskBox — список завдань у консолі. Це не «справжній» to-do‑список рівня космічної станції, а навчальна версія: додати завдання, показати список, видалити завдання. Нам важливе не багатство функцій, а архітектура: щоб код був зрозумілим і не ламав колекції під час обходу.
Стан застосунку буде простим:
- MutableList<String> — список завдань (кожне завдання — просто рядок).
- цикл читання команд — користувач вводить рядок, доки не напише exit.
Команда й аргумент
Майже всі консольні утиліти живуть за принципом: є команда (що зробити) і часом є аргумент (з чим це зробити). Нам не потрібен складний синтаксис із лапками та екрануванням (це окремий вид болю). Тож візьмемо практичний навчальний формат: команда — перше слово, аргумент — усе інше після першого пробілу. Цього достатньо, щоб побудувати стійкий сценарій без зайвої магії.
Ось як це виглядатиме для TaskBox:
| Команда | Приклад введення | Зміст |
|---|---|---|
|
|
додати завдання |
|
|
вивести всі завдання |
|
|
видалити завдання за номером (як для людини) |
|
|
видалити завдання за точним текстом (якщо це не схоже на число) |
|
|
показати підказку |
|
|
вийти з програми |
Ключова домовленість: номер для користувача починається з 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 і останній індекс». Сам принцип індексів (0…lastIndex) — базова модель списку в 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 хвилин ви отримаєте функцію, яку страшно чіпати, бо вона «працює, але я не знаю чому». Лікується дисципліною: парсинг — окремо, диспетчер — окремо, операції — окремо. Це не «архітектура заради архітектури». Це спосіб не збожеволіти під час першої ж зміни вимог.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ