JavaRush /Курси /Kotlin SELF /Декомпозиція на функції: введення, порівняння, повідомлен...

Декомпозиція на функції: введення, порівняння, повідомлення та контракти

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

1. Чому «простирадло» в main() заважає

Якщо ви лише починаєте програмувати, дуже легко написати весь код гри просто в main(): прочитали рядок, розпарсили, порівняли, вивели підказку, збільшили лічильник — і так по колу. Спершу це здається швидким рішенням. Але вже за 30–40 рядків ви помітите, що боїтеся чіпати цей код: будь-яка правка ламає щось «десь в іншому місці». Тут функції — як коробки для зберігання: розклали речі по місцях, і голова перестає перегріватися.

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

У нашій грі особливо зручно винести у функції:

  • введення та перевірку здогадки (саме тут майже завжди трапляються помилки користувача);
  • порівняння здогадки із секретом (це чиста логіка, її зручно ізолювати);
  • формування повідомлень («надто мало/багато/вгадав»), щоб рядки не розповзалися по проєкту.

Невелика схема того, до чого ми йдемо:

flowchart TD
    A["main(): сценарій гри"] --> B["readGuessOrNull(): введення + валідація"]
    A --> C["compareGuess(): порівняння"]
    A --> D["hintText(): текст підказки"]

2. Контракт функції

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

Якщо контракт не зафіксувати, виникає класична ситуація: ви пишете readGuess(), а потім за дві години самі себе питаєте: «Вона друкує запрошення? Вона перевіряє діапазон? А якщо ввели abc — вона падає чи повертає 0?». І ось ви вже налагоджуєте не гру, а власні здогадки про власний код.

Зручно тримати контракти в голові як невелику таблицю:

Функція Вхід Вихід Побічні ефекти Що означає «помилка»
readGuessOrNull(min, max)
діапазон введення
Int?
читає
readln()
, друкує запрошення
null
= «не вдалося отримати коректне число»
compareGuess(guess, secret)
два числа
Int (-1/0/1)
немає не передбачається (вхід коректний)
hintText(compareResult)
-1/0/1
String
немає «невідомий код» можна обробити через
else

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

3. Введення здогадки: readGuessOrNull(min, max)

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

Головна ідея контракту тут така: ми повертаємо Int?. Якщо все добре — повертаємо число. Якщо ні — повертаємо null. Це чесно й акуратно: за типом Int? одразу видно, що результат може бути відсутнім, і компілятор змусить нас обробити цей випадок.

Базова реалізація з trim() і toIntOrNull()

Почнемо з дуже прямого варіанта. Він не ідеальний, але вже корисний і зрозумілий.

fun readGuessOrNull(min: Int, max: Int): Int? {
    print("Введіть число від $min до $max: ")
    val raw = readln().trim()

    val guess = raw.toIntOrNull() ?: return null
    if (guess !in min..max) return null

    return guess
}

Тут є кілька важливих деталей.

По-перше, trim() прибирає пробіли по краях — тому рядок " 42 " стане "42". По-друге, toIntOrNull() намагається розпарсити Int, але замість падіння повертає null, якщо рядок не є числом. По-третє, ми перевіряємо діапазон guess !in min..max. І нарешті, ранні return null не дають функції розростатися вкладеними if.

Сам підхід «прочитали рядок → підготували → розпарсили» у Kotlin трапляється постійно, особливо в консольних завданнях. Його часто оформлюють як невеликі допоміжні функції для читання значень потрібного типу.

Друкувати чи не друкувати повідомлення про помилку всередині функції

Тепер — невелика архітектурна дилема (без гучних слів). Є два підходи.

Якщо readGuessOrNull() сама друкує «Помилка введення», то main() стає коротшим, але функція починає відповідати одразу за дві речі: введення і повідомлення. Якщо ж функція просто повертає null, а повідомлення друкує main(), то функція стає «чистішою», а сценарієм повідомлень керують в одному місці.

Для навчального проєкту найчастіше зручно залишити функцію нейтральною: нехай вона повертає null, а повідомлення виводить код, який її викликає. Тоді гра зможе легко змінити стиль повідомлень, не чіпаючи парсинг.

Якщо все ж хочеться підказати користувачеві одразу, можна зробити варіант, який друкує текст. Але контракт краще позначити явно (хоча б у назві або коментарі).

fun readGuessOrNullWithMessage(min: Int, max: Int): Int? {
    val guess = readGuessOrNull(min, max) ?: run {
        println("Некоректне введення") // повідомлення тут, але тоді функція «балакуча»
        return null
    }
    return guess
}

Увага: тут використовується run { } як блок для акуратного раннього виходу. Якщо ви ще не впевнені у run, можете не використовувати цей варіант і залишити все в main().

4. Порівняння чисел: compareGuess(guess, secret)

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

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

Домовимося про формат результату:

  • -1 якщо здогадка менша за секрет;
  • 0 якщо вгадали;
  • 1 якщо здогадка більша за секрет.
fun compareGuess(guess: Int, secret: Int): Int {
    return when {
        guess < secret -> -1
        guess > secret -> 1
        else -> 0
    }
}

Чому не Boolean? Бо Boolean дає лише два стани, а нам потрібно три. Чому не рядок? Бо рядок — це вже «подання для людини», а не логіка. Рядок ми зробимо в наступній функції.

Маленька самоперевірка (просто щоб відчути, що функція поводиться очікувано):

fun main() {
    println(compareGuess(10, 20))  // -1
    println(compareGuess(30, 20))  // 1
    println(compareGuess(20, 20))  // 0
}

5. Текст підказки: hintText(compareResult)

Повідомлення — це те, що робить гру «живою». Але якщо тексти розкидані по коду, потім важко змінити стиль, виправити описку або додати «знак оклику настрою». Тому тексти підказок логічно тримати в одному місці.

Ми вже вибрали код результату порівняння (-1/0/1), тож можна зробити функцію, яка перетворює цей код на фразу, зрозумілу людині.

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

Зверніть увагу на else. Ми могли б написати 0 -> "Вгадав!", але else тут відіграє роль «страховки»: якщо хтось випадково поверне, наприклад, 2, ми все одно отримаємо якесь повідомлення, а не порожнечу. У реальному застосунку це спірне рішення, але в навчальному коді так інколи навіть корисніше, бо гра не «замовкне».

Швидкий мінітест:

fun main() {
    println(hintText(-1)) // Надто мало
    println(hintText(1))  // Надто багато
    println(hintText(0))  // Вгадав!
}

6. Сценарій спроби та мінітести

Повноцінний цикл спроб ми зберемо в наступній лекції. Але вже зараз корисно побачити, як функції перетворюють main() на читабельний сценарій і як їх перевіряти «по частинах».

Склеюємо все в «одну спробу»

Уявіть, що ми робимо лише одну спробу (щоб не лізти в while раніше часу). Це просто демонстрація композиції.

fun main() {
    val min = 1
    val max = 100
    val secret = 42 // тимчасово фіксуємо для прикладу

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

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

Що тут приємно: код читається «як текст».

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

Функції-помічники для читабельності сценарію

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

Наприклад, функція для рядка «Спроба X із Y»:

fun attemptHeader(attempt: Int, maxAttempts: Int): String {
    return "Спроба $attempt із $maxAttempts"
}

Або функція для фінального повідомлення про перемогу (поки без деталей циклу):

fun winText(attempt: Int): String {
    return "Перемога за $attempt спроб!"
}

Чому це корисно? Бо рядок — теж частина логіки. Він впливає на те, як користувач розуміє правила гри. Якщо ви тримаєте його як окрему функцію, то менше ризикуєте випадково зробити «Спроба 0 із 7» в одному місці та «Спроба 1 із 7» в іншому.

Як вручну тестувати функції до збирання гри

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

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

fun main() {
    val secret = 50

    val result1 = compareGuess(10, secret)
    println(hintText(result1)) // Надто мало

    val result2 = compareGuess(90, secret)
    println(hintText(result2)) // Надто багато

    val result3 = compareGuess(50, secret)
    println(hintText(result3)) // Вгадав!
}

Так ви можете перевірити, що порівняння та тексти підказок «збігаються за змістом». Якщо раптом ви переплутаєте гілки guess < secret і guess > secret, це проявиться одразу: ви отримаєте «Надто багато» там, де має бути «Надто мало». І спіймаєте баг ще до того, як додасте цикл і лічильник спроб.

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

Помилка №1: функція введення повертає «магічне число» замість null.
Дуже поширений варіант: readGuess() повертає 0 у разі помилки. Потім раптом виявляється, що 0 може бути коректним числом (або стане коректним, якщо ви зміните діапазон на 0..100). У результаті помилка введення перетворюється на «звичайне введення» й ламає логіку гри тихо та неприємно. Int? розвʼязує це на рівні типів: помилку неможливо переплутати з числом.

Помилка №2: змішують введення, порівняння та друк в одній функції «бо так коротше».
Спочатку здається, що функція makeAttempt() — ідеальна: вона і читає число, і порівнює, і друкує «Надто багато», і сама ж збільшує лічильник спроб. Але щойно вам знадобиться змінити правило «чи витрачає неправильне введення спробу», ви почнете різати цю функцію на шматки вже в режимі паніки. Краще одразу відокремлювати: введення — окремо, порівняння — окремо, текст — окремо.

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

Помилка №4: перевіряють діапазон після порівняння із секретом.
Іноді пишуть так: «спочатку порівняю із секретом, а потім подивлюся, чи в діапазоні число». Тоді користувач може ввести 1000000, і ви йому чесно скажете «Надто багато», хоча за правилами гри таке число взагалі не повинно брати участі. Діапазон — частина контракту введення, отже, перевірка має бути до порівняння.

Помилка №5: тексти повідомлень розкидані по коду й починають відрізнятися.
Сьогодні ви написали «Надто багато», завтра в іншому місці — «Дуже багато!», післязавтра — «Багато». У результаті користувач отримує відчуття, що гру писали три різні людини (і одна з них — ви в понеділок зранку). Функція hintText() і дрібні текстові допоміжні функції допомагають тримати стиль єдиним.

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