JavaRush /Курсы /Kotlin SELF /Безопасный разбор строк — indexOf, substring, split и защ...

Безопасный разбор строк — indexOf, substring, split и защита от ошибок на границах

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

1. Введение

Когда вы начинаете разбирать строку «по частям», вы внезапно обнаруживаете, что строка — не дружелюбный пакетик с печеньем, а скорее коробка с печеньем, у которой иногда не хватает крышки. Пользователь может ввести пустую строку, забыть разделитель, поставить два пробела подряд или вообще написать что-то творческое. В этой лекции мы учимся писать код так, чтобы он не падал, а предсказуемо выбирал стратегию поведения.

Представьте, что ваша программа — это кассир, а пользователь — покупатель, который иногда говорит невнятно. Кассир не должен падать в обморок от фразы «эээ… ну… хлеб… наверно». Он должен переспросить, либо принять решение по умолчанию. С разбором строк то же самое: мы заранее проектируем, что делать, если «не нашли», «не влезли», «пусто».

2. Индексы и границы в строке

Строка в Kotlin — это последовательность символов, у которых есть индексы. Индексация начинается с нуля. Это звучит привычно, но именно тут рождаются классические ошибки: «почему нельзя взять символ по индексу length?» и «почему lastIndex — это не length?».

Важно привыкнуть к идее, что length — это количество символов, а последний допустимый индекс — length - 1 (если строка не пустая).

Чтобы не держать это в голове как заклинание, удобно мыслить так: length — это «позиция после последнего символа». Она полезна для операций вроде «вырезать до конца», но не годится как индекс символа.

Вот маленькая схема, которую стоит мысленно представлять, когда вы режете строку:

Строка:  "Kotlin"
Индексы:  012345
length = 6
lastIndex = 5

Мини-проверка в коде (чтобы мозг поверил глазам):

fun main() {
    val s = "Kotlin"
    println(s.length)          // 6
    println(s.lastIndex)       // 5
    println(s[0])              // K
    println(s[s.lastIndex])    // n
}

Если строка пустая (""), то length == 0, а lastIndex == -1. Это не баг, а честное предупреждение: «последнего элемента нет».

3. indexOf(...): -1 — это ответ «не найдено»

Когда мы ищем в строке разделитель (например, '=', '@', ':'), первым делом часто используем indexOf. И здесь важно принять философию Kotlin (и вообще большинства языков): если элемент не найден, вам возвращают -1. Программа не падает. Это просто способ сказать: «я искал, но не нашёл».

Именно поэтому типичная ошибка новичка звучит так: «я нашёл индекс, сразу сделал substring, и всё упало». Упало оно потому, что индекс был -1, а substring(-1, …) — это уже попытка вырезать текст из несуществующей позиции.

Простой пример с email:

fun main() {
    val email = "alice@example.com"
    val at = email.indexOf('@')

    val domain = if (at >= 0) email.substring(at + 1) else "INVALID"
    println(domain) // example.com
}

Смысл не в email (почта живёт своей жизнью), а в паттерне: indexOf → проверка → потом уже резать.

4. substring(...): границы и «end не включается»

substring — это мощный нож. И как любой нож, он хорошо работает, пока вы не машете им наугад.

Самое важное правило:

  • substring(start, end) берёт символы с индекса start включительно до индекса end не включительно.

То есть end — это «позиция остановки», а не «последний символ».

Давайте на коротком примере, чтобы закрепить:

fun main() {
    val s = "Kotlin"
    println(s.substring(0, 1)) // K
    println(s.substring(0, 2)) // Ko
    println(s.substring(1, 4)) // otl
}

Почему это удобно? Потому что end можно без страха ставить равным length. Это «позиция после последнего символа», она корректна для границы вырезания.

То есть вот так — нормально:

fun main() {
    val s = "Kotlin"
    println(s.substring(0, s.length)) // Kotlin
}

А вот так — уже ошибка границ:

// s.substring(0, s.length + 1)  // так делать нельзя: выйдем за пределы

substring(start) как способ взять «хвост»

Иногда вам не нужно указывать end, потому что «до конца строки» — как раз то, что надо. Для этого есть форма substring(start).

Её удобно использовать, когда вы уже нашли разделитель, и хотите взять всё после него:

fun main() {
    val path = "/users/alice"
    val slash = path.indexOf('/')

    val tail = if (slash >= 0) path.substring(slash + 1) else path
    println(tail) // users/alice
}

Да, пример специально «странный» (потому что '/' тут в начале). Но он показывает мысль: substring(start) снимает необходимость думать про length и «end не включается». Вы просто говорите: «дай мне всё отсюда и до конца».

5. Паттерн: нашли разделитель, проверили, вырезали

Сейчас мы соберём основной рабочий паттерн лекции. Он нужен везде: конфиги вида key=value, команды вида add:100:food, строки логов, «имя: значение», да что угодно.

Алгоритм человеческим языком:

  1. Находим разделитель (indexOf).
  2. Если не найден — выбираем поведение (вернуть null, вернуть дефолт, считать всю строку «ключом», и т.д.).
  3. Если найден — аккуратно режем левую и правую часть.

Вот пример «ключ=значение»:

fun main() {
    val line = "key=value"
    val eq = line.indexOf('=')

    val key = if (eq >= 0) line.substring(0, eq) else line
    val value = if (eq >= 0) line.substring(eq + 1) else ""

    println("key=$key, value=$value") // key=key, value=value
}

Обратите внимание: мы одним найденным индексом (eq) достаём обе части. Это не только короче, но и безопаснее: не получится, что «ключ» мы режем по одному правилу, а «значение» — по другому.

6. Подготовка ввода: trim() и безопасный split

Пользовательский ввод почти всегда «грязный»: лишние пробелы, табы, случайные переносы, пробел в конце, пробел в начале. И если вы начнёте разбирать строку без подготовки, вы получите странные ошибки «вроде всё правильно, но почему-то ключ пустой».

Поэтому базовая дисциплина выглядит так: сначала trim(), потом анализ.

fun main() {
    val raw = "   key=value   "
    val line = raw.trim()

    val eq = line.indexOf('=')
    val key = if (eq >= 0) line.substring(0, eq) else line
    val value = if (eq >= 0) line.substring(eq + 1) else ""

    println("key='$key', value='$value'") // key='key', value='value'
}

Здесь кавычки в выводе — чисто педагогический трюк: они помогают глазами увидеть пробелы, если бы они вдруг остались.

split(" ") и множественные пробелы

Когда мы хотим разбить строку на «слова», чаще всего делаем split(" "). И на аккуратном вводе оно работает. Но на вводе типа "one two three" вы внезапно получаете пустые строки среди результатов. Потому что split(" ") буквально режет по одиночным пробелам, а если пробелов несколько подряд — между ними «пустое слово».

Поэтому практически всегда при разборе команд и ввода полезно делать три шага: trim()split(" ")filter { it.isNotEmpty() }.

fun main() {
    val raw = "   one  two   three  "
    val parts = raw.trim()
        .split(" ")
        .filter { it.isNotEmpty() }

    println(parts) // [one, two, three]
}

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

7. Стратегии при ошибке: маркер vs null

Сейчас важный момент проектирования: что делать, если строка «не подходит»?

Есть два очень живых подхода.

  • Первый подход — вернуть маркер (например, "INVALID" или пустую строку) и продолжать. Он бывает удобен, если вы делаете отчёт и хотите «хоть что-то показать». Но он опасен тем, что маркер легко забыть обработать, и тогда "INVALID" внезапно уезжает дальше как будто это нормальное значение.
  • Второй подход — вернуть null и заставить вызывающий код принять решение. Этот стиль обычно надёжнее: вы явно видите, что результат может отсутствовать. Мы уже умеем работать с nullable-типами, так что сегодня это будет нашим основным инструментом.

Давайте добавим в наше учебное консольное приложение (условный трекер расходов) маленькую функцию-парсер: «разбери токен key=value».

Функция parseKeyValue(token: String): Pair<String, String>?

Эта функция не печатает ничего, не читает ничего, она просто аккуратно разбирает строку и возвращает либо пару, либо null. Такой стиль легко тестировать и переиспользовать.

fun parseKeyValue(token: String): Pair<String, String>? {
    val eq = token.indexOf('=')
    if (eq < 0) return null

    val key = token.substring(0, eq).trim()
    val value = token.substring(eq + 1).trim()

    if (key.isEmpty()) return null
    return key to value
}

Обратите внимание на «точки безопасности»:

  • eq < 0 → сразу null, не пытаемся резать.
  • substring(eq + 1) безопасно только потому, что eq >= 0. При eq == token.lastIndex значение будет пустым, но это не падение.
  • key.isEmpty() — защита от случаев вроде "=123".

Мини-проверка:

fun main() {
    println(parseKeyValue("amount=120")) // (amount, 120)
    println(parseKeyValue("=120"))       // null
    println(parseKeyValue("amount="))    // (amount, )
    println(parseKeyValue("amount"))     // null
}

Разбор команды: «прочитал → подготовил → распарсил»

Теперь давайте соберём маленький (но реалистичный) кусок консольного интерфейса для нашего приложения учёта расходов.

Мы хотим, чтобы пользователь вводил команды в таком стиле:

  • add amount=120 category=food note=coffee
  • add amount=999 category=tech note=keyboard

Мы не будем сегодня усложнять форматирование, хранение, отчёты и т.д. Наша цель — не падать на кривом вводе и ясно понимать, почему команда не распарсилась.

Сначала чистим ввод и режем на токены:

fun splitTokens(line: String): List<String> {
    return line.trim()
        .split(" ")
        .filter { it.isNotEmpty() }
}

Проверка:

fun main() {
    val tokens = splitTokens("   add   amount=120   category=food   ")
    println(tokens) // [add, amount=120, category=food]
}

Дальше безопасно получаем команду и аргументы. Важно: tokens[0] опасно, если список пуст. Поэтому мы сперва проверяем isEmpty().

fun main() {
    val line = readln().trim()
    val tokens = splitTokens(line)

    if (tokens.isEmpty()) {
        println("Пустая команда. Введите что-нибудь.")
        return
    }

    val command = tokens[0].lowercase()
    val args = tokens.drop(1)
    println("command=$command, args=$args")
}

readln() мы используем как основной ввод в консольных программах.

Разбираем аргументы key=value в Map

Сейчас мы сделаем следующий шаг: обработаем список токенов после команды add, и попытаемся достать amount, category, note.

Сделаем функцию, которая проходит по токенам и собирает Map<String, String> из валидных key=value. Мы уже знакомы с Map и читаем, что map[key] может вернуть null, так что это тоже вписывается в наш стиль безопасного программирования.

fun parseArgs(tokens: List<String>): Map<String, String> {
    val result = mutableMapOf<String, String>()

    for (t in tokens) {
        val kv = parseKeyValue(t) ?: continue
        val (k, v) = kv
        result[k] = v
    }

    return result
}

Проверка:

fun main() {
    val args = parseArgs(listOf("amount=120", "category=food", "oops", "note=coffee"))
    println(args) // {amount=120, category=food, note=coffee}
}

Заметьте, что мы выбрали стратегию: если токен «плохой» ("oops"), мы его пропускаем. Можно было бы вернуть ошибку — но это уже другая стратегия. Сегодня нам важна устойчивость и простота.

Финальный мини-сценарий: команда add и диагностические сообщения

Теперь соберём всё вместе: читаем строку, достаём команду, парсим аргументы, проверяем наличие обязательных полей.

Мы не будем сейчас делать полноценное хранение расходов (это уже было/будет в других лекциях). Мы просто покажем, что разбор работает и не падает.

fun main() {
    val tokens = splitTokens(readln())

    if (tokens.isEmpty()) {
        println("Введите команду, например: add amount=120 category=food note=coffee")
        return
    }

    val command = tokens[0].lowercase()
    val args = parseArgs(tokens.drop(1))

    if (command != "add") {
        println("Неизвестная команда: $command")
        return
    }

    val amountRaw = args["amount"]
    val category = args["category"]
    val note = args["note"] ?: ""

    val amount = amountRaw?.toIntOrNull()
    if (amount == null || category == null || category.isBlank()) {
        println("Неверный ввод. Нужно: amount=<Int> и category=<text>")
        return
    }

    println("Добавляем расход: amount=$amount, category=$category, note=$note")
}

Здесь сразу несколько важных вещей «в духе безопасности»:

  • Мы не предполагаем, что команда есть.
  • Мы не предполагаем, что amount и category переданы.
  • Мы не предполагаем, что amount — это число (используем toIntOrNull()).
  • Мы не режем строку по индексам без проверок, потому что вынесли это в parseKeyValue.

8. Типичные ошибки при разборе строк

Ошибка №1: использовать результат indexOf как будто он всегда валидный.
Чаще всего это выглядит так: val i = s.indexOf('='); val left = s.substring(0, i). На «правильных» строках всё работает, а на строке без '=' вы получаете исключение. Лечится одной привычкой: indexOf всегда проверяем, потому что -1 — штатный ответ «не найдено».

Ошибка №2: путать границы substring(start, end) и ожидать включённый end.
Это классика, потому что мозг любит «включительно». В Kotlin end не включается. Если вы режете «до разделителя», то substring(0, eq) уже исключает сам символ '=', и это как раз удобно. А вот попытка «включить последний символ» часто приводит к end = length + 1 и падению.

Ошибка №3: резать пустую строку и надеяться, что там есть хотя бы один символ.
Встречается в коде вроде substring(0, 1) без проверок. На пустом вводе это авария. В устойчивом коде вы либо проверяете isNotEmpty(), либо строите логику так, чтобы пустая строка обрабатывалась отдельной веткой (например, «попросить повторить ввод»).

Ошибка №4: делать split(" ") и верить, что «лишние пробелы не помешают».
Мешают. Множественные пробелы дают пустые токены, которые потом неожиданно ломают разбор аргументов. Паттерн «trim()split(" ")filter { it.isNotEmpty() }» делает поведение предсказуемым и избавляет от странных багов, которые выглядят как «иногда работает, иногда нет».

Ошибка №5: пытаться «резать любой ценой» вместо выбора стратегии при ошибке.
Иногда хочется написать: «ну пользователь же должен вводить правильно». Но практика показывает обратное: пользователь вводит как получится. Поэтому нужно заранее решить: при ошибке мы возвращаем null, возвращаем дефолт, или печатаем диагностическое сообщение и просим повторить. Главное — чтобы поведение было явным, а не «как-то само получилось».

1
Задача
Kotlin SELF, 27 уровень, 0 лекция
Недоступна
Почта саппорта
Почта саппорта
1
Задача
Kotlin SELF, 27 уровень, 0 лекция
Недоступна
Настройка параметра
Настройка параметра
1
Задача
Kotlin SELF, 27 уровень, 0 лекция
Недоступна
Таймер смены
Таймер смены
1
Задача
Kotlin SELF, 27 уровень, 0 лекция
Недоступна
Разбор команды
Разбор команды
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ