JavaRush /Курсы /Kotlin SELF /Нормализация и разбор строк: trim*, lowercase/uppercase, ...

Нормализация и разбор строк: trim*, lowercase/uppercase, replace, split

Kotlin SELF
9 уровень , 4 лекция
Открыта

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(), потом проверки и парсинг. Такой код проще поддерживать и проще расширять.

1
Задача
Kotlin SELF, 9 уровень, 4 лекция
Недоступна
Рамки пробелов
Рамки пробелов
1
Задача
Kotlin SELF, 9 уровень, 4 лекция
Недоступна
Ответ да нет
Ответ да нет
1
Задача
Kotlin SELF, 9 уровень, 4 лекция
Недоступна
Чек из строки
Чек из строки
1
Задача
Kotlin SELF, 9 уровень, 4 лекция
Недоступна
Анкета в строке
Анкета в строке
Комментарии (2)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Logominov Viktor Уровень 57
30 марта 2026
Ошибка №2: ожидать, что trim() убирает пробелы внутри строки. trim() работает только по краям. Если у вас строка "milk 2 30", то после trim() пробелы внутри останутся. Для внутренних пробелов нужна отдельная логика (например, цикл while (s.contains(" ")) s = s.replace(" ", " ")), как мы делали выше. Тут, на мой взгляд, ошибка. Будет бесконечньій цикл. Пробел нужно менять на пустую строку.
Michael Уровень 28
4 апреля 2026
Здесь имеется ввиду, что contains(" ") содержит два пробела. А replace заменяет два пробела на один.