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, строки логов, «имя: значение», да что угодно.
Алгоритм человеческим языком:
- Находим разделитель (indexOf).
- Если не найден — выбираем поведение (вернуть null, вернуть дефолт, считать всю строку «ключом», и т.д.).
- Если найден — аккуратно режем левую и правую часть.
Вот пример «ключ=значение»:
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, возвращаем дефолт, или печатаем диагностическое сообщение и просим повторить. Главное — чтобы поведение было явным, а не «как-то само получилось».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ