1. Почему «простыня» в main() мешает
Если вы только начинаете программировать, очень легко написать весь код игры прямо в main(): прочитал строку, распарсил, сравнил, вывел подсказку, увеличил счётчик — и так по кругу. Сначала это кажется быстрым. Но уже через 30–40 строк вы обнаружите, что боитесь трогать этот код, потому что любая правка ломает что-то “в другом месте”. Тут функции — как коробки для хранения: разложили вещи по местам, и голова перестаёт перегреваться.
Представьте, что игра — это кухня. main() — это стол, на котором вы готовите. Если на столе лежит всё сразу (мука, яйца, ножи, кастрюли, инструкция от микроволновки и ещё случайно клавиатура), готовка превращается в квест. Функции позволяют убрать “повторяющиеся ингредиенты” и “мелкие операции” в отдельные контейнеры.
В нашей игре особенно удобно вынести в функции:
- ввод и проверку догадки (там почти всегда есть ошибки пользователя);
- сравнение догадки с секретом (это чистая логика, её удобно изолировать);
- формирование сообщений (“слишком мало/много/угадал”), чтобы строки не размазывались по проекту.
Небольшая схема того, к чему мы идём:
flowchart TD
A["main(): сценарий игры"] --> B["readGuessOrNull(): ввод + валидация"]
A --> C["compareGuess(): сравнение"]
A --> D["hintText(): текст подсказки"]
2. Контракт функции
Перед тем как писать функции, важно научиться формулировать их контракт. Контракт — это простая договорённость: “что функция ожидает на вход”, “что возвращает”, “что означает особый результат (например null)” и “делает ли она побочные эффекты (печатает ли что-то, читает ли ввод)”.
Если контракт не зафиксировать, начинается классическая ситуация: вы пишете readGuess(), а потом через два часа сами же спрашиваете у себя: “А она печатает приглашение? А она проверяет диапазон? А если ввели abc — она падает или возвращает 0?”. И вот вы уже отлаживаете не игру, а собственные догадки о собственном коде.
Удобно держать контракты в голове в виде маленькой таблицы:
| Функция | Вход | Выход | Побочные эффекты | Что значит “ошибка” |
|---|---|---|---|---|
|
диапазон ввода | |
читает , печатает prompt |
= “не получилось получить корректное число” |
|
два числа | |
нет | не предполагается (вход корректный) |
|
|
|
нет | “неизвестный код” можно трактовать как |
Обратите внимание: мы специально разделяем “грязный” мир (ввод пользователя, ошибки, пробелы, буквы) и “чистый” (сравнение двух 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 постоянно, особенно в консольных задачах, и часто оформляется в виде маленьких helper-функций для чтения типов.
Печатать ли сообщение об ошибке внутри функции
Теперь маленькая архитектурная дилемма (без громких слов). Есть два подхода.
Если 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() и мелкие текстовые helper’ы помогают держать стиль единым.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ