1. Нормализация перед разбором
Когда мы читаем строку через readln(), нам кажется, что мы получили «команду» или «данные». На деле мы получили сырой текст: с пробелами, разным регистром, случайными запятыми, лишними разделителями и иногда с вводом в стиле «я нажал пробел пять раз, потому что могу». Нормализация — это шаг, на котором мы приводим ввод к понятному стандарту, чтобы разбор (парсинг) был предсказуемым.
Хорошая стратегия тут как в прошлой лекции, где мы делали «прочитал → подготовил → распарсил». Сейчас просто «подготовка» стала солиднее:
flowchart TD
A["readln(): сырой ввод"] --> B["trim(): чистим края"]
B --> C["нормализуем пробелы/разделители"]
C --> D["нормализуем регистр (если нужно)"]
D --> E["split(): режем на части"]
E --> F["проверяем size и типы частей"]
Обратите внимание: мы не доводим строку до идеала, мы делаем ровно столько, чтобы дальше логика работала стабильно. И да, это нормально, если нормализация занимает больше строк кода, чем сам полезная работа: программа — это не только «сделать», но и «сделать правильно».
Приводим регистр: lowercase() и uppercase()
Следующая типичная проблема — регистр. Один пользователь введёт Add, другой — ADD, третий — add, а четвёртый вообще напишет aDd (и будет доволен собой). Если вы сравниваете строки напрямую, то все эти варианты разные. Поэтому, когда мы хотим распознать «команду» или «тип действия», удобно приводить ввод к одному регистру.
Тут важно различать: команда и данные — не одно и то же. Команду мы обычно нормализуем (например, к нижнему регистру), а «название товара» или «комментарий» иногда лучше оставить как есть, чтобы не превращать iPhone в iphone (хотя иногда это допустимо — зависит от задачи).
Пример: нормализуем команду, но оставляем аргумент как есть:
fun main() {
val rawLine = " AdD Milk "
val line = rawLine.trim()
val parts = line.split(" ")
val command = parts[0].lowercase()
val item = if (parts.size >= 2) parts[1] else ""
println("command='$command', item='$item'") // command='add', item='Milk'
}
Здесь мы уже использовали split(), но пока без нормализации пробелов — и это нам ещё аукнется. Пока просто зафиксируем идею: lowercase() и uppercase() — это способ сделать сравнение устойчивым.
2. Убираем пробелы по краям: trim*
Почти любой пользовательский ввод начинается и заканчивается пробелами чаще, чем хотелось бы. Это не потому что люди плохие — просто клавиатура так устроена, а привычки у всех разные. Поэтому первое, что мы обычно делаем со строкой перед разбором, — это убираем пробелы по краям. Для этого в Kotlin есть trim(), а если нужно аккуратнее — trimStart() и trimEnd().
- trim() — удаляет пробелы с обоих концов строки
- trimStart() — удаляет пробелы с начала строки
- trimEnd() — удаляет пробелы с конца строки
Важно помнить одну вещь: строки в Kotlin ведут себя как «неизменяемые» (в смысле: методы возвращают новую строку). Поэтому raw.trim() не меняет raw, а возвращает новую строку. Это частая причина неработающего кода.
Пример — посмотрим разницу между тремя методами:
fun main() {
val raw = " hello "
println(">" + raw.trim() + "<") // >hello<
println(">" + raw.trimStart() + "<") // >hello <
println(">" + raw.trimEnd() + "<") // > hello<
}
Здесь можно заметить полезный приём: я специально добавил > и <, чтобы визуально увидеть пробелы. Это маленькая «отладка глазами», которая экономит много времени.
Если вы ждёте ввод вида «команда + аргументы», то trim() почти всегда должен быть самым первым шагом. Иначе у вас внезапно перестают совпадать строки при сравнении: "add" и " add" — это разные строки.
3. Режем строку на части: split()
Когда строка стала «чистой» (хотя бы более-менее), мы можем разрезать её на части. Для этого есть split(...). В базовом сценарии очень часто встречается split(" ") — «разбить по пробелам».
Результат split() — это список частей (с типом List<String>). Мы пока не уходим глубоко в коллекции (это будет сильно позже), но нам достаточно двух фактов: у результата есть size, и к элементам можно обращаться по индексу parts[i]. У списков индексация начинается с нуля: первый элемент — индекс 0, последний — size - 1.
Почему нельзя сразу делать parts[1]
Очень хочется написать так:
val parts = line.split(" ")
val second = parts[1] // «ну точно же есть!»
Но пользователь — существо непредсказуемое. Он может ввести только одно слово. И тогда parts[1] — это выход за границы, и программа падает.
Правильный стиль на нашем текущем уровне — проверять size перед доступом:
fun main() {
val line = "add"
val parts = line.split(" ")
val cmd = parts[0]
val arg = if (parts.size >= 2) parts[1] else "(нет аргумента)"
println("cmd='$cmd', arg='$arg'") // cmd='add', arg='(нет аргумента)'
}
Это выглядит чуть длиннее, но зато не ломается на пустом месте. А «не ломается на пустом месте» — это один из лучших комплиментов коду.
4. Точечные замены: replace() и replaceFirst()
Когда пробелы по краям убрали, регистр исправили, остаётся куча «мелкого мусора»: лишние запятые, двойные пробелы, случайные точки, или пользователь вместо пробела поставил , (потому что «так привычнее»). Тут на сцену выходит replace(old, new).
replace(old, new) заменяет все вхождения old на new. А replaceFirst(old, new) заменяет только первое вхождение. Это важно, потому что иногда вы хотите «поправить формат» один раз, а иногда действительно заменить всё.
Мини-пример, чтобы почувствовать разницу:
fun main() {
val s = "one-two-two"
println(s.replace("two", "X")) // one-X-X
println(s.replaceFirst("two", "X")) // one-X-two
}
Нормализация множественных пробелов без regex
Типичная боль программиста: пользователь вводит несколько пробелов подряд, а split(" ") начинает вести себя «не так, как вы ожидали». Простейшая стратегия без регулярных выражений (их мы сегодня не трогаем) — «сжимать» повторяющиеся пробелы циклом:
fun normalizeSpaces(text: String): String {
var s = text.trim()
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " milk 2 30 "
println(normalizeSpaces(raw)) // milk 2 30
}
Да, это не самый быстрый способ на планете. Но для учебных примеров и для обычного «пользовательского ввода одной строки» — более чем достаточно. А главное: он понятный. Когда код понятный, его легче чинить, чем «волшебную оптимизацию».
5. Разбор покупки
Теперь давайте свяжем всё в единый маленький сценарий и продолжим развивать наше учебное консольное приложение. Пусть оно пока умеет разбирать одну строку «запись о покупке» в формате:
<название> <кол-во> <цена>
Например:
milk 2 30
Мы хотим, чтобы приложение было терпеливым и принимало ввод вроде:
Milk, 2 30
То есть: лишние пробелы, запятая после названия, разный регистр.
Шаг 1: нормализация «как договоримся»
Сделаем функцию, которая приводит строку к более стабильному виду:
fun normalizePurchaseLine(raw: String): String {
var s = raw.trim()
// Разрешим пользователю ставить запятые: заменим их на пробелы
s = s.replace(",", " ")
// Сожмём множественные пробелы до одного
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " Milk, 2 30 "
println(normalizePurchaseLine(raw)) // Milk 2 30
}
Обратите внимание: мы не трогаем здесь регистр специально. Название товара оставляем таким, как ввёл пользователь. Если нам позже понадобится сравнивать названия (например, milk и Milk считать одним и тем же), тогда мы отдельно решим, что именно и где приводить к lowercase().
Шаг 2: разбор строки на части и проверка формы
Теперь напишем функцию, которая берёт нормализованную строку, режет на части и проверяет, что частей ровно три:
fun splitPurchase(line: String): List<String> {
val normalized = normalizePurchaseLine(line)
return normalized.split(" ")
}
fun main() {
val parts = splitPurchase(" Milk, 2 30 ")
println("parts.size=${parts.size}") // parts.size=3
println("parts[0]='${parts[0]}'") // parts[0]='Milk'
}
Мы используем тот факт, что результат split() — список, у которого есть size и индексный доступ.
Шаг 3: превращаем части в числа и формируем ответ
Количество и цена — числа. У нас уже был опыт с toIntOrNull() и проверкой на null. Не будем усложнять: если число не распарсилось, честно вернём сообщение об ошибке.
Сделаем функцию, которая возвращает готовое сообщение (успех или ошибка). Это удобное решение на текущем уровне: одна строка результата — и main просто её печатает.
fun parsePurchaseToMessage(line: String): String {
val parts = splitPurchase(line)
if (parts.size != 3) {
return "Ошибка: ожидаю формат '<item> <qty> <price>'"
}
val item = parts[0]
val qty = parts[1].toIntOrNull()
val price = parts[2].toIntOrNull()
if (qty == null || price == null) {
return "Ошибка: qty и price должны быть целыми числами"
}
val total = qty * price
return "Покупка: $item, qty=$qty, price=$price, total=$total"
}
fun main() {
println(parsePurchaseToMessage(" Milk, 2 30 "))
// Покупка: Milk, qty=2, price=30, total=60
}
Заметьте, насколько спокойнее стала жизнь: main() больше не занимается мелочами. Он не «пилит строку» и не ловит ошибки формы. Он делегирует это функции, а сам просто печатает результат.
Шаг 4: нормализация регистра там, где она нужна
Частый вопрос: «А можно сделать так, чтобы MILK, Milk и milk считались одинаковыми?». Можно. Но лучше делать это осознанно: мы нормализуем не «всё подряд», а только то, что мы собираемся сравнивать.
Например, заведём «каноническое имя товара» для внутренних расчётов (пока просто покажем идею в выводе):
fun main() {
val item = "Milk"
val canonical = item.lowercase()
println("item='$item', canonical='$canonical'") // item='Milk', canonical='milk'
}
Эта идея позже очень пригодится для команд, категорий, идентификаторов — всего, что должно совпадать независимо от ввода. Важно только помнить: не всегда нужно приводить пользовательский текст к нижнему регистру. Иногда это портит «смысловой вид» данных.
6. Типичные ошибки
Ошибка №1: вызвать trim() и забыть сохранить результат.
Очень распространённый сценарий: написали line.trim() и дальше продолжили работать с line. Но trim() возвращает новую строку, а исходная остаётся прежней. Поэтому либо пишем val cleaned = line.trim(), либо (если нужно менять переменную) var line = line.trim().
Ошибка №2: ожидать, что trim() убирает пробелы внутри строки.
trim() работает только по краям. Если у вас строка "milk 2 30", то после trim() пробелы внутри останутся. Для внутренних пробелов нужна отдельная логика (например, цикл while (s.contains(" ")) s = s.replace(" ", " ")), как мы делали выше.
Ошибка №3: использовать split(" ") на «грязной» строке и удивляться пустым частям.
Если в строке много пробелов подряд, разбиение по одному пробелу может дать неожиданные результаты (вплоть до «пустых» элементов), и ваш парсер начинает путаться. Самый простой выход на текущем уровне — сначала нормализовать пробелы, потом уже делать split().
Ошибка №4: брать parts[1] и parts[2] без проверки size.
Даже если «по заданию» пользователь должен ввести три части, в реальном вводе он может ввести одну или две. Без проверки size вы получаете падение из-за выхода за границы. Списки индексируются с нуля, и последний индекс — size - 1.
Ошибка №5: сравнивать команды без нормализации регистра.
Если вы где-то делаете проверку на "add", а пользователь вводит "Add", условие не сработает. Поэтому команды почти всегда стоит приводить к lowercase() (или uppercase()), а потом сравнивать уже нормализованную строку.
Ошибка №6: перепутать replace и replaceFirst.
Иногда нужно заменить только первое вхождение (например, поправить «один разделитель»), а иногда — все. Если вы используете replace(), думая, что он заменит только один раз, можно незаметно испортить данные: строка изменится «слишком сильно», и ошибка будет не очевидной.
Ошибка №7: смешивать «нормализацию» и «разбор» в один нечитаемый ком.
Технически можно написать монстра вида line.trim().lowercase().replace(...).split(...)..., но читать и отлаживать это больно. Гораздо спокойнее: отдельная функция нормализации, потом отдельный шаг split(), потом проверки и парсинг. Такой код проще поддерживать и проще расширять.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ