JavaRush /Курси /Kotlin SELF /Сценарії поведінки та налагодження: шукаємо помилки в спр...

Сценарії поведінки та налагодження: шукаємо помилки в спробах і розгалуженнях

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

1. Сценарій поведінки: що це і навіщо він потрібен

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

Сценарій — це не про автоматизовані тести й не про «суперінженерію» (сьогодні ми цього не чіпаємо). Це про звичку мислити так: «Якщо користувач вводить не число — що буде?», «Якщо він вводить мінімальну межу діапазону — що буде?», «Якщо спроби закінчилися — що буде?». По суті, сценарій — це маленька угода між вами й вашою програмою: ви обіцяєте дати певне введення, а програма зобовʼязана видати визначений результат.

Щоб було легше тримати все в голові, можна мислити як у мініісторії: «Дано → Коли → Тоді». Дано: секрет фіксований, спроб 3, діапазон 1..10. Коли: користувач вводить abc, потім 11, потім 7. Тоді: програма двічі свариться на введення й лише втретє порівнює число із секретом. (І так, уже тут спливає найважливіше питання: «некоректне введення витрачає спробу чи ні?» — ви це вирішували раніше, а тепер перевіряєте.)

2. Передбачуваність і debug-логи

Фіксуємо секрет і вимикаємо випадковість

Будь-яке налагодження страждає, коли результат змінюється сам по собі. Random — чудовий інструмент для гри, але жахливий компаньйон для перевірки логіки: сьогодні секрет 42, завтра 17, а ви намагаєтеся зрозуміти, чому на третій спробі програма раптом програла. Тому перший прийом у налагодженні такий: на час перевірок зробіть секрет фіксованим. Тоді ви зможете відтворювати поведінку знову й знову.

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

val debug = true

val secret = if (debug) {
    42
} else {
    generateSecret(min, max)
}

println("Гру розпочато!") // Гру розпочато!

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

Ще один зручний прийом — вивести підказку, що ви в debug-режимі. Тоді ви не сидітимете в здивуванні: «чому я завжди вгадую за одну спробу, я геній» (бо секрет ви самі й написали).

if (debug) {
    println("DEBUG: секрет зафіксовано = $secret") // DEBUG: секрет зафіксовано = 42
}

Тільки, будь ласка, не забудьте потім вимкнути debug. Гра, яка чесно пише «секрет = 42», — це вже не гра, а спойлер-тренажер.

Налагоджувальні повідомлення: куди ставити println("DEBUG: ...")

Налагоджувальний println — інструмент давній, як перші баги (тобто дуже давній). Він добрий, коли ви ставите його точково і друкуєте те, що справді допомагає. Він поганий, коли ви починаєте друкувати все підряд і перетворюєте консоль на водоспад тексту, у якому губиться найважливіше.

Головна ідея така: на кожному «кроці гри» вас зазвичай цікавлять три-чотири значення — номер спроби, введене припущення, секрет і результат порівняння (наприклад -1/0/1). Тому має сенс зробити невелику функцію для debug-виводу. По-перше, щоб не повторювати один і той самий рядок. По-друге, щоб можна було вимкнути весь debug одним прапорцем.

fun debugState(debug: Boolean, attempt: Int, secret: Int, guess: Int?) {
    if (!debug) return
    println("DEBUG: attempt=$attempt secret=$secret guess=$guess")
    // DEBUG: attempt=1 secret=42 guess=10
}

І користуйтеся нею в циклі акуратно — одразу після введення (коли guess може бути null). Так ви бачите, що насправді прийшло з клавіатури.

val guess = readGuessOrNull(min, max)
debugState(debug, attempt, secret, guess)

if (guess == null) {
    println("Некоректне введення")
    continue
}

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

val result = compareGuess(guess, secret)
if (debug) {
    println("DEBUG: compareResult=$result") // DEBUG: compareResult=1
}
println(hintText(result))

3. Таблиця сценаріїв: перемога, поразка, введення і межі

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

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

Сценарій Налаштування Введення користувача (за кроками) Очікувана поведінка Що перевіряємо
Перемога одразу secret=42, maxAttempts=7 42 вивід «Вгадав!» + кінець гри коректне завершення за перемогою
Перемога не одразу secret=42, maxAttempts=7 10, 50, 42 «Занадто мало», «Занадто багато», «Вгадав!» порівняння і підказки
Поразка secret=42, maxAttempts=3 1, 2, 3 три підказки + підсумок «програли» ліміт спроб і фінальне повідомлення
Не число secret=42, maxAttempts=7 abc, потім 42 повідомлення про помилку введення, потім нормальна гра toIntOrNull() і обробка null
Порожній рядок/пробіли secret=42, maxAttempts=7 "   ", потім 42 помилка введення (після trim() рядок порожній) нормалізація trim()
Вихід за діапазон діапазон 1..100, secret=42 101, потім 42 помилка «поза діапазоном», потім нормальна гра перевірка guess in min..max
Межа мінімуму діапазон 1..100, secret=1 1 перемога коректність меж
Межа максимуму діапазон 1..100, secret=100 100 перемога коректність верхньої межі

Зауважте: навіть без автоматизації ця таблиця вже дає вам системність. Ви не просто «тикали числа», а пройшли набір обовʼязкових ситуацій. Якщо потім зʼявиться баг, ви зможете сказати не «щось зламалося», а «зламався сценарій “вихід за діапазон”». А це вже дуже близько до того, як мислять розробники в реальних командах.

4. Логіка гри: спроби, межі та розгалуження

Помилки «на 1»: спроби, <= і місце attempt++

Більшість логічних багів в іграх «вгадай число» — це не містика, а банальні помилки «на 1» (off-by-one). Вони зʼявляються там, де ви рахуєте спроби або працюєте з межами діапазону. Неприємність у тому, що програма майже працює: інколи програє на одну спробу раніше, інколи повідомляє «перемога за 4 спроби», хоча спроб було 3, а інколи «зависає» на одній і тій самій спробі через некоректне введення.

Найчастіша зона ризику — місце attempt++. Якщо некоректне введення не витрачає спробу, інкремент має відбуватися лише тоді, коли спроба справді «зіграла»: тобто коли у вас є коректне число й ви порівняли його із секретом.

Ось характерний фрагмент із правильним розташуванням attempt++ (політика: некоректне введення спробу не витрачає):

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Некоректне введення")
    continue
}

val result = compareGuess(guess, secret)
println(hintText(result))

if (result == 0) break

attempt++ // спробу витрачено лише тут

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

Ще одна тонкість — умова циклу. Наприклад, якщо спроби рахуються від 1 до maxAttempts, то «логічна» перевірка виглядає так: attempt <= maxAttempts. Якщо ви випадково напишете attempt < maxAttempts, ви «відріжете» останню спробу й отримаєте поразку на крок раніше. Ніби дрібниця, але саме з таких дрібниць і складається «чому користувач незадоволений».

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

Перевіряємо порівняння і when: де ламаються підказки

Коли підказки працюють неправильно, винен зазвичай не Kotlin, а наша логіка. Водночас Kotlin може допомогти тим, що підштовхує писати розгалуження більш явно. У нашій грі є два ключові місця: функція порівняння compareGuess і функція тексту hintText. Вони мають бути узгоджені: якщо compareGuess повертає -1 для «менше за секрет», то hintText(-1) має казати «Занадто мало», а не «Занадто багато» (інакше гра перетворюється на психологічний трилер).

Для тексту підказки зручно використовувати when як вираз: він компактний і добре читається.

fun hintText(compareResult: Int): String {
    return when (compareResult) {
        -1 -> "Занадто мало"
        1 -> "Занадто багато"
        else -> "Вгадав!"
    }
}

Щоб перевіряти такі функції «у відриві від гри», корисно зробити крихітну ручну перевірку: викликати compareGuess і hintText на заздалегідь відомих значеннях. Це не модульні тести (unit-тести), а просто невеликий самоконтроль, який можна тимчасово ввімкнути в main.

val secret = 42
println(hintText(compareGuess(10, secret))) // Занадто мало
println(hintText(compareGuess(99, secret))) // Занадто багато
println(hintText(compareGuess(42, secret))) // Вгадав!

Якщо тут усе правильно, а в грі підказки «танцюють», то проблема не в hintText, а в тому, що ви порівнюєте не ті значення. Наприклад, десь перезаписали secret, або неправильно обробили guess, або збільшуєте спробу не там — і в результаті дивитеся «не на ту ітерацію».

5. Інваріанти та запобіжні перевірки: require і check

Інваріант — це правило, яке має залишатися істинним під час роботи програми. У нашій грі типові інваріанти прості: діапазон має бути коректним (min <= max), секрет має потрапляти в діапазон, номер спроби не має ставати відʼємним, а функція порівняння має повертати лише -1, 0 або 1. У реальному житті інваріанти рятують від стану «гра працює дивно, але не падає — і це ще гірше».

Перевірки require і check — це спосіб сказати в коді: «якщо це не так, то ми в неправильному світі». Ми вже використовували require у генерації секрету, і це чудовий приклад. Краще впасти одразу зі зрозумілим повідомленням, ніж отримати секрет із «переплутаного діапазону» й потім годинами ловити примар.

fun generateSecret(min: Int, max: Int): Int {
    require(min <= max) { "min має бути <= max (min=$min, max=$max)" }
    return kotlin.random.Random.nextInt(from = min, until = max + 1)
}

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

val result = compareGuess(guess, secret)
check(result == -1 || result == 0 || result == 1) {
    "compareGuess() повернув неможливий результат: $result"
}
println(hintText(result))

Сенс таких перевірок не в тому, щоб «покарати користувача», а в тому, щоб зловити баг якомога ближче до місця його народження. Чим далі баг встигає «втекти», тим складніше потім відновити, де саме він зʼявився.

6. Міні-дебаг в IntelliJ IDEA: цикл, breakpoint і значення

Коли println("DEBUG...") уже не допомагає (або допомагає, але вам набридло вручну читати «простирадло» тексту), саме час згадати базовий відладчик. Ми вже знайомилися з ним раніше, а зараз нам достатньо найпростішого: поставити breakpoint (точку зупинки), зробити крок за рядками й подивитися значення змінних. Це схоже на «уповільнену зйомку» вашої програми: ви бачите, у який момент attempt став «не тим», де саме спрацював continue і чому гілка if не зайшла туди, куди ви очікували.

Найзручніша точка зупинки в цій грі — усередині циклу спроб: одразу після введення й одразу перед порівнянням. Тоді ви в одному місці бачите attempt, secret, guess, а також можете зрозуміти, чому guess виявився null. Наприклад, ви ввели пробіли, trim() дав порожній рядок, toIntOrNull() повернув null — і це нормально. Якщо ви поставите breakpoint занадто рано, ви ще не побачите цікавих значень. Якщо занадто пізно — уже пропустите момент, коли все пішло не так.

І ще один нюанс: відладчик особливо добре ловить помилки «на 1», бо ви буквально бачите зміну attempt. Якщо ви очікували, що після неправильного введення спроба не збільшиться, а вона збільшилася, — ви одразу зрозумієте, у якому рядку це сталося.

7. Типові помилки під час сценаріїв і налагодження

Помилка № 1: перевіряти «на удачу», а не сценаріями.
Якщо запускати гру щоразу по-різному й сподіватися, що «ну зараз точно проявиться», баг може зникати або проявлятися випадково — особливо коли в грі є Random. Набагато ефективніше зафіксувати секрет і пройти один і той самий сценарій кілька разів, доки не стане ясно, у якому місці логіка розходиться з очікуванням.

Помилка № 2: не зафіксувати політику спроб для некоректного введення.
Дуже часто програма «зависає» на одній спробі або, навпаки, спалює спроби занадто швидко через те, що розробник сам не вирішив: некоректне введення витрачає спробу чи ні. У результаті attempt++ опиняється то в одній гілці, то в іншій, то взагалі «губиться» поруч із continue — і поведінка стає хаотичною.

Помилка № 3: ставити attempt++ у неправильному місці й ловити «перемогу за N+1 спроб».
Якщо збільшувати attempt до перевірки перемоги, можна отримати красиве повідомлення «Перемога за 8 спроб!», коли спроб було 7 (і користувач, можливо, теж це помітить). Правило просте: спочатку обробили введення, порівняли, перевірили перемогу. І лише потім збільшуємо лічильник, якщо гра триває.

Помилка № 4: друкувати занадто багато debug‑тексту й перестати розуміти, що відбувається.
Налагоджувальні повідомлення мають відповідати на конкретне питання. Якщо ви друкуєте 20 рядків на кожну ітерацію, мозок перестає шукати закономірності й починає скролити, ніби стрічку соцмереж. Краще друкувати 1–2 рядки з ключовими змінними (attempt, guess, secret, result) і обовʼязково вміти вимкнути debug одним прапорцем.

Помилка № 5: переплутати гілки порівняння (< і >) і «лагодити» не там.
Класична ситуація: підказки завжди «не ті». Початківець-розробник починає правити hintText, потім readGuessOrNull, потім випадково ламає введення… хоча проблема була в одному місці: переплутані умови guess < secret і guess > secret. Перевірка з фіксованим секретом і парою заздалегідь вибраних значень (10, 99, 42) зазвичай викриває це за хвилину.

Помилка № 6: залишити «спойлер» (друк секрету) у звичайному режимі гри.
Під час налагодження друкувати секрет корисно. Після налагодження — це перетворює гру на анекдот (і не завжди смішний). Тому debug‑режим має бути явно вимикним, а рядки на кшталт println(secret) — жити лише під if (debug) і зникати з фінальної версії.

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