Взаимодействие с консолью: ввод, разбор, вывод
- Зачем это нужно
- Две функции, и одна из них устарела
- Что на самом деле приходит
- Превратить строку в число
- Спрашивать, пока не ответят правильно
- Несколько значений в одной строке
- Вывод: print, println и формат
- Консоль — это слой, а не программа
- Как запускать и отлаживать
- Типичные ошибки
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
До сих пор все программы в главах были немыми: значения зашиты в код, результат виден в выводе. Первый шаг к настоящей программе — дать ей поговорить с человеком.
Консоль для этого — самый дешёвый инструмент. Ни экранов, ни разметки, ни эмулятора: две строчки, и программа спрашивает и отвечает. Именно поэтому с консоли начинают, и именно поэтому она стоит в программе подготовки отдельным пунктом.
Но есть и вторая причина, менее очевидная. Работа с вводом — это первое место, где приходится всерьёз иметь дело с чужими данными: человек введёт не то, что вы ждали. Пустую строку. Пробелы. «сорок два» вместо «42». Привычка проверять ввод, приобретённая на консоли, потом переносится на формы в приложении — где цена ошибки уже в баллах.
Две функции, и одна из них устарела
Прочитать строку с клавиатуры можно так:
val line = readLine() // String? — вернёт null, когда ввод кончился
val line = readlnOrNull() // String? — то же самое, современное имя
val line = readln() // String — бросит исключение, когда ввод кончился
Во всех старых учебниках стоит readLine(). И это имеет значение:
Начиная с Kotlin 2.1.0 компилятор помечает readLine() устаревшей:
'fun readLine(): String?' is deprecated. Use `readln()` instead of `readLine()!!`,
and `readlnOrNull()` instead of `readLine()`.
Проверено по версиям: на 2.0.21 предупреждения ещё нет, начиная с 2.1.0 — есть во всех. То есть в проекте на старом компиляторе readLine() промолчит, а в свежем подчеркнётся. Пишите сразу readlnOrNull(): это то же самое, только без будущих претензий.
Разница между readln() и readlnOrNull() — в том, что происходит, когда читать больше нечего:
readlnOrNull()вернётnull;readln()бросит исключение.
Вот настоящий текст этого исключения:
kotlin.io.ReadAfterEOFException: EOF has already been reached
За клавиатурой человек ввод не «заканчивает» — поэтому в учебной программе readln() выглядит безопасным. Но как только программу запустят, подав ей файл (программа < data.txt) или нажав Ctrl+D, разница станет заметной. Привыкайте к readlnOrNull() и явной обработке конца ввода.
Что на самом деле приходит
Главная ловушка ввода — думать, что приходит то, что человек «имел в виду». Приходит строка, и она бывает неожиданной.
Живой прогон — программа запущена с настоящим вводом:
── 1. пустая строка: просто Enter ──
readlnOrNull() → «», длина 0
isEmpty=true isBlank=true
── 2. пробелы по краям ──
как есть: « Олег » (длина 10)
trim(): «Олег» (длина 4)
── 3. число строкой ──
«42».toIntOrNull() = 42
── 7. ввод кончился ──
readlnOrNull() = null
readln() бросил kotlin.io.ReadAfterEOFException: EOF has already been reached
Три вывода отсюда:
- Нажатый Enter даёт пустую строку, а не
null.nullбывает только когда ввод закончился. Путать эти случаи нельзя:if (line == null)не поймает пользователя, который просто нажал Enter. - Пробелы приходят как есть. Строка
« Олег »имеет длину 10, и сравнениеline == "Олег"будет ложным. - С клавиатуры всегда приходит строка. Даже когда ввели
42.
isEmpty() и isBlank() — разные проверки. Первая спрашивает «длина ноль?», вторая — «нет ничего, кроме пробелов?». Для строки из трёх пробелов isEmpty() даст false, а isBlank() — true. Для ввода почти всегда нужна вторая.
Превратить строку в число
Строку «42» нельзя складывать. Нужен разбор, и здесь у Kotlin две пары функций:
"42".toInt() // 42, но бросит исключение на «абв»
"42".toIntOrNull() // 42, а на «абв» вернёт null
"3.5".toDouble() // 3.5, но бросит исключение на «абв»
"3.5".toDoubleOrNull() // 3.5, а на «абв» вернёт null
Вот настоящий текст исключения от toInt():
NumberFormatException: For input string: "сорок два"
Для данных, введённых человеком, используйте только варианты с OrNull. Не потому, что исключения плохи, а потому, что неверный ввод — это не исключительная ситуация, а самая обычная. Программа, падающая от опечатки, — сломанная программа.
А теперь неочевидное. Посмотрите, что эти функции делают с разными строками:
fun main() {
val cases = listOf("42", "3.5", "3,5", "-5", "007", " 42 ", "99999999999", "")
println("строка toIntOrNull toDoubleOrNull")
for (s in cases) {
println("«%-13s» %-13s %s".format(s, s.toIntOrNull().toString(), s.toDoubleOrNull().toString()))
}
}
Настоящий вывод:
строка toIntOrNull toDoubleOrNull
«42 » 42 42.0
«3.5 » null 3.5
«3,5 » null null
«-5 » -5 -5.0
«007 » 7 7.0
« 42 » null 42.0
«99999999999 » null 9.9999999999E10
« » null null
Разберём строки, которые удивляют:
«3,5»—nullу обеих. Запятая как разделитель дробной части не понимается. Только точка. А человек в России напишет именно запятую.« 42 »—toIntOrNullдаётnull, аtoDoubleOrNullдаёт42.0. Асимметрия неожиданная и обидная: разбор целого пробелов не прощает, разбор дробного — прощает.«007»—7. Ведущие нули срезаются молча.«99999999999»—nullуtoIntOrNull. Не потому, что это не число, а потому, что оно не влезает вInt: предел — 2 147 483 647.
Отсюда простое правило: trim() перед разбором ставится всегда. Это не аккуратность, а условие работы — без него строка с лишним пробелом не превратится в целое число, и программа скажет «это не число» про совершенно правильные 42.
Спрашивать, пока не ответят правильно
Собираем всё вместе. Задача: получить возраст — целое число от 1 до 120, и не сдаваться, пока не получим.
// Плейграунд не даёт клавиатуру, поэтому ввод подменён заранее заготовленным.
// В настоящей программе на месте input() стоит readlnOrNull().
private val scripted = listOf("абв", "200", "0", "17").iterator()
fun input(): String? = if (scripted.hasNext()) scripted.next().also { println(it) } else null
/** Спрашивает, пока не введут целое число в заданных границах. */
fun askInt(prompt: String, range: IntRange): Int {
while (true) {
print("$prompt ")
val line = input() ?: error("ввод кончился")
val n = line.trim().toIntOrNull()
when {
n == null -> println(" Нужно целое число, а получено «${line.trim()}»")
n !in range -> println(" Число должно быть от ${range.first} до ${range.last}, а получено $n")
else -> return n
}
}
}
fun main() {
val age = askInt("Сколько вам лет?", 1..120)
println("Принято: $age")
}
Настоящий вывод:
Сколько вам лет? абв
Нужно целое число, а получено «абв»
Сколько вам лет? 200
Число должно быть от 1 до 120, а получено 200
Сколько вам лет? 0
Число должно быть от 1 до 120, а получено 0
Сколько вам лет? 17
Принято: 17
Обратите внимание на сообщения об ошибках. Каждое говорит что не так и что получено: «Нужно целое число, а получено «абв»». Не «Ошибка ввода», не «Неверное значение». Эта привычка потом пригодится на формах: подпись под полем должна объяснять, а не констатировать.
Несколько значений в одной строке
Часто удобнее ввести всё сразу: Олег 17 Самара. Разбирается это через split:
val parts = line.split(" ")
Живой прогон:
split даёт 3 части: [Олег, 17, Самара]
имя=Олег возраст=17 город=Самара
Соблазнительно разложить сразу по переменным:
val (name, age, city) = parts
Так делать опасно. Если частей окажется меньше, чем переменных, программа падает:
IndexOutOfBoundsException: Index: 1, Size: 1
Это настоящий вывод: строку «Олег» разложили на две переменные. Проверять размер надо до разбора:
val parts = line.trim().split(" ").filter { it.isNotBlank() }
if (parts.size != 3) {
println("Нужно три значения через пробел, а получено ${parts.size}")
return
}
filter { it.isNotBlank() } здесь не украшение: split(" ") по строке с двойным пробелом даст пустую часть посередине, и размер не сойдётся.
Вывод: print, println и формат
Две функции вывода отличаются одним:
println("текст")— печатает и переводит курсор на новую строку;print("текст")— печатает и оставляет курсор на месте.
Именно поэтому вопрос печатают через print: ответ вводится сразу после него, на той же строке.
Для чисел часто нужен формат — скажем, две цифры после точки:
"%.2f".format(1234.5)
И здесь ждёт ловушка, которую видно только на настоящем запуске.
Один и тот же код даёт разный результат в зависимости от языка системы. Проверено на двух машинах:
локаль ru_RU: "%.2f".format(1234.5) = 1234,50 ← запятая
локаль en_US: "%.2f".format(1234.5) = 1234.50 ← точка
А теперь соедините это с предыдущей секцией: строку «1234,50» функция toDoubleOrNull() прочитать не может. То есть программа печатает то, что сама же не в состоянии разобрать обратно.
Если формат должен быть предсказуемым, локаль задаётся явно:
"%.2f".format(Locale.US, 1234.5) // всегда 1234.50
Отдельно стоит запомнить: в главах этой платформы примеры выполняются в плейграунде, где локаль en_US, а телефон студента, скорее всего, живёт в ru_RU. Проверить форматирование в плейграунде и решить, что на устройстве будет так же, — ошибка.
Консоль — это слой, а не программа
Самая важная мысль главы, и она про то, что будет дальше.
Программу с консолью легко написать так, что чтение, подсчёт и печать перемешаны. Работать будет. Но перенести такой код в приложение невозможно: на экране Android печатать нечем — там нет консоли.
Правильное разделение: класс считает и возвращает результат, а печатает и спрашивает только main.
// Логика ничего не печатает и ничего не читает — она только считает.
class Cart {
private val lines = mutableMapOf<String, Int>()
fun add(title: String, count: Int): Result<Int> {
if (title.isBlank()) return Result.failure(IllegalArgumentException("пустое название"))
if (count <= 0) return Result.failure(IllegalArgumentException("количество должно быть больше нуля"))
val now = lines.getOrDefault(title, 0) + count
lines[title] = now
return Result.success(now)
}
fun total(): Int = lines.values.sum()
fun items(): List<Pair<String, Int>> = lines.toList()
}
// Консоль — отдельный слой: он читает, спрашивает и печатает.
private val scripted = listOf("Nike 2", "Adidas 0", " 3", "Puma 1", "").iterator()
fun input(): String? = if (scripted.hasNext()) scripted.next().also { println(it) } else null
fun main() {
val cart = Cart()
println("Вводите «название количество», пустая строка — конец.")
while (true) {
print("> ")
val line = input()?.trim() ?: break
if (line.isEmpty()) break
val parts = line.split(" ").filter { it.isNotBlank() }
if (parts.size != 2) {
println(" Нужно два значения через пробел, а получено ${parts.size}")
continue
}
val count = parts[1].toIntOrNull()
if (count == null) {
println(" «${parts[1]}» — не число")
continue
}
cart.add(parts[0], count)
.onSuccess { println(" ${parts[0]}: теперь $it шт.") }
.onFailure { println(" Не добавлено: ${it.message}") }
}
println()
println("В корзине ${cart.total()} шт.:")
cart.items().forEach { (title, count) -> println(" $title × $count") }
}
Настоящий вывод:
Вводите «название количество», пустая строка — конец.
> Nike 2
Nike: теперь 2 шт.
> Adidas 0
Не добавлено: количество должно быть больше нуля
> 3
Нужно два значения через пробел, а получено 1
> Puma 1
Puma: теперь 1 шт.
>
В корзине 3 шт.:
Nike × 2
Puma × 1
Посмотрите на класс Cart: в нём нет ни одного println. Он не знает, что его вызывают из консоли. Тот же класс без единой правки переносится в приложение — там его вызовет экран вместо main.
Это ровно та мысль, на которой позже строится вся мобильная разработка: логика отдельно, показ отдельно. В приложении роль main играет ViewModel, но принцип не меняется.
Как запускать и отлаживать
В Android Studio консольную программу запускают так же, как любую другую: зелёный треугольник напротив fun main(). Ввод набирается прямо в панели Run внизу.
Две вещи, которые экономят время:
- Подать ввод файлом. Запуск вида
программа < data.txtизбавляет от набора одного и того же двадцать раз подряд. В настройках запуска Android Studio это поле «Redirect input from». - Заготовленный ввод в коде. Ровно тот приём, что использован в примерах этой главы: подменить чтение на список готовых строк. Так программа проверяется одним нажатием, а на настоящий
readlnOrNull()переключается одной строкой.
Второй приём — это, по сути, первый шаг к тестам. Функция, которая получает строки откуда-то извне, а не читает их сама, проверяется автоматически. Функция с readlnOrNull() внутри — нет. Именно поэтому чтение выносят наружу, а не прячут в логике.
Типичные ошибки
1. Проверка if (line == null) для пустого ввода. Enter даёт «», а не null. Нужна isBlank().
2. Разбор без trim(). « 42 ».toIntOrNull() — это null, и программа скажет «не число» про правильное число.
3. toInt() вместо toIntOrNull(). Опечатка пользователя роняет программу с NumberFormatException.
4. Ожидание запятой в дробном. «3,5».toDoubleOrNull() — null. Либо заменять запятую на точку перед разбором, либо честно просить точку.
5. Разложение split по переменным без проверки размера. IndexOutOfBoundsException на первой же короткой строке.
6. println вместо print для вопроса. Ответ вводится на следующей строке, и диалог выглядит рвано.
7. Форматирование без явной локали. На русской системе %.2f печатает запятую, и такое число уже не прочитать обратно.
8. println внутри класса с логикой. Работает в консоли и не переносится никуда дальше.
9. Ввод без подсказки. Программа молча ждёт, человек не понимает, чего от него хотят. Перед каждым чтением — вопрос.
Куда углубиться
readLine()и кодировки: почему кириллица из файла иногда приходит «кракозябрами» и при чём тутCharsets.UTF_8.Scannerиз Java: почему в Kotlin он не нужен и чемnextInt()опасен.- Аргументы командной строки:
fun main(args: Array<String>)— ввод, который не нужно спрашивать. - Разбор строки регулярным выражением, когда
splitуже не справляется. - Цветной вывод в терминале через ANSI-последовательности — и почему в Android Studio он иногда не работает.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Kotlin — чтение стандартного ввода —
readln,readlnOrNullи разбор значений. - Kotlin — строки —
trim,split, шаблоны и форматирование. - Kotlin — исключения —
Result,runCatchingи когда бросать, а когда возвращать. - Kotlin — диапазоны —
1..120, операторinи проверка границ.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.