1. Строка как последовательность символов
Когда вы начинаете «разбирать» строку, мозг автоматически хочет относиться к ней как к тексту из книги. Но компьютеру книга не нужна — ему нужна последовательность символов, у которой каждый символ лежит на своём месте и имеет номер. Этот номер называется индексом, и в Kotlin (как и почти везде) индексация начинается с нуля.
Индексы с нуля: почему первый символ — это 0
Идея «первый элемент имеет индекс 0» сначала кажется издевательством, но быстро становится удобной: индекс = «сколько символов стоит до текущего». До первого символа стоит ноль символов — значит, индекс 0. До второго стоит один — значит, индекс 1. Это ещё пригодится, когда будем вырезать substring().
Посмотрим на строку "Kotlin":
| Символ | |
|
|
|
|
|
|---|---|---|---|---|---|---|
| Индекс | |
|
|
|
|
|
Количество символов — 6, а последний индекс — 5.
length и lastIndex: не путать «размер» и «последний индекс»
Свойство length говорит, сколько символов в строке. А lastIndex — это индекс последнего символа.
Важно почувствовать разницу: length — это «количество», lastIndex — это «последняя позиция». В коллекциях и массивах используется ровно та же логика: последний индекс равен size - 1.
Мини‑пример:
fun main() {
val s = "Kotlin"
println(s.length) // 6
println(s.lastIndex) // 5
}
Если строка пустая (""), то length будет 0, а lastIndex станет -1. Это звучит странно, но логично: «последнего символа нет», поэтому «последний индекс» уходит в отрицательную область. Именно из-за этого мы так много говорим про проверки границ.
2. Доступ к символу по индексу: s[i] и аккуратная проверка границ
Теперь, когда индексы есть, руки тянутся сделать так: «дай мне символ номер 0». И Kotlin действительно позволяет обращаться к символу по индексу через квадратные скобки. Но есть подвох: если индекс неверный, программа упадёт с исключением (а вам нужно писать код, который не падает от одного пробела не там).
s[i] возвращает Char: текст — это не всегда строка
Оператор s[i] возвращает один символ типа Char. Символ — это не строка. Строка — это много символов.
fun main() {
val s = "Kotlin"
val first: Char = s[0]
val last: Char = s[s.lastIndex]
println(first) // K
println(last) // n
}
Если вы попробуете сделать s[6] для "Kotlin", программа упадёт: индексы там только 0..5.
Проверка границ: «перед тем как взять — убедись, что можно»
Здесь полезно держать в голове короткое правило: допустимые индексы — это все числа, которые не меньше 0 и меньше length.
Мини‑хелпер, который безопасно возвращает символ или '?', если индекс плохой:
fun charOrQuestion(text: String, index: Int): Char {
if (index < 0 || index >= text.length) return '?'
return text[index]
}
fun main() {
println(charOrQuestion("Kotlin", 2)) // t
println(charOrQuestion("Kotlin", 99)) // ?
}
Пустая строка: самый маленький, но очень коварный кейс
Пустая строка — это строка длины 0. У неё нет ни первого, ни последнего символа. Это значит, что даже s[0] опасен.
fun main() {
val s = ""
println(s.length) // 0
println(s.lastIndex) // -1
println(s[0]) // так делать нельзя — будет ошибка
}
Хорошая привычка, которая спасает нервы: перед доступом к s[0] спрашивать себя: «а строка точно не пустая?»
3. Поиск в строке: indexOf() и проверки contains(), startsWith(), endsWith()
Обычно мы разбираем строки не от скуки, а потому что в строке есть разделитель: пробел, @, =, двоеточие, точка и так далее. Сценарий часто такой: «найди, где стоит разделитель, и разрежь строку на две части».
indexOf(...): договорённость про -1
Метод indexOf(...) ищет подстроку (или символ) и возвращает индекс первого вхождения. А если не нашёл — возвращает -1. Это очень важная договорённость: -1 означает «не найдено».
fun main() {
val email = "user@example.com"
val at = email.indexOf('@')
println(at) // 4
val slash = email.indexOf('/')
println(slash) // -1
}
Если вы забудете проверить -1 и начнёте резать строку по этому индексу, получится либо ошибка границ, либо очень странный результат.
contains(), startsWith(), endsWith(): когда не нужны индексы
Иногда вам не нужен индекс — вам нужно просто «да/нет». Тогда удобнее использовать методы‑проверки.
fun main() {
val filename = "report.txt"
println(filename.contains(".")) // true
println(filename.startsWith("rep")) // true
println(filename.endsWith(".txt")) // true
}
Эти методы отвечают на простые вопросы:
- contains() — содержится ли в строке переданная подстрока?
- startsWith() — строка начинается с переданной подстроки?
- endsWith() — строка заканчивается на переданную подстроку?
Эти проверки часто делают код безопаснее: вы меньше вручную лезете в индексы, меньше шансов промахнуться «на 1».
4. substring(): режем строку на части, не наступая на грабли
Если индексы — это «координаты» в строке, то substring() — это «ножницы». Почти любой разбор строки сводится к одному шаблону: найти индекс разделителя и вырезать левую/правую часть.
Главное правило substring(start, end): end не входит
Метод substring(start, end) берёт символы с позиции start включительно, а вот end — не включается. Это классический стиль «полуинтервала», и он невероятно удобен, но в начале путает.
Схема:
"Kotlin"
012345
substring(0, 3) -> берём индексы 0,1,2 -> "Kot"
Пример:
fun main() {
val s = "Kotlin"
println(s.substring(0, 3)) // Kot
println(s.substring(3, 6)) // lin
}
Заметьте: end может быть равен length, и это нормально. «До конца строки» — это как раз end = length.
substring(start) — от позиции до конца
Есть удобная форма substring(start) — она берёт от start до конца строки.
fun main() {
val s = "Kotlin"
println(s.substring(2)) // tlin
}
Это очень удобно для правой части после разделителя: «после @ до конца», «после = до конца».
Границы substring(): где программа может упасть
substring() требует корректных границ. Упрощённо: start и end должны быть в диапазоне 0..length, и start <= end. Если нарушить — будет ошибка.
Поэтому опытный программист почти никогда не пишет substring(...) «в лоб»: он сначала проверяет, что индексы нормальные.
5. Паттерн «нашёл → проверил → вырезал»
Сейчас мы соберём всё в один устойчивый паттерн и добавим его в наше учебное консольное приложение. Представим, что мы делаем простую программу «Профиль пользователя»: пользователь вводит полное имя одной строкой и email, а мы выводим аккуратную карточку. Сегодня мы разберёмся именно с тем, как безопасно получить части этих строк через индексы.
Наша реальность сурова: люди вводят имя как попало. Да, они ставят пробелы странно. Да, они иногда забывают @. Это не «плохие пользователи», это «реальный мир».
Разбираем полное имя "Имя Фамилия" через indexOf(' ')
Самая простая версия полного имени — это два слова, разделённые пробелом. Мы пока специально не используем split() (это будет в следующей лекции), поэтому сделаем «ручной разрез»: найдём индекс пробела и вырежем левую и правую части.
fun main() {
val fullName = "Иван Петров"
val space = fullName.indexOf(' ')
val firstName = if (space == -1) fullName else fullName.substring(0, space)
val lastName = if (space == -1) "" else fullName.substring(space + 1)
println(firstName) // Иван
println(lastName) // Петров
}
Обратите внимание на space + 1: мы не хотим, чтобы пробел попал в фамилию. Если пробел — последний символ строки, substring(space + 1) превратится в substring(length), а это просто пустая строка (и это нормально).
Достаём домен из email: indexOf('@') и проверки
Email — классический пример строки с разделителем. Мы хотим взять домен: всё, что стоит после @. Но нужно защититься от случаев "@example.com" и "user@".
fun extractDomain(email: String): String {
val at = email.indexOf('@')
if (at == -1) return "INVALID"
if (at == 0) return "INVALID"
if (at == email.lastIndex) return "INVALID"
return email.substring(at + 1)
}
fun main() {
println(extractDomain("user@example.com")) // example.com
println(extractDomain("user@")) // INVALID
}
Здесь мы используем email.lastIndex, чтобы проверить: «@ стоит последним символом или нет». И это как раз тот случай, когда lastIndex читается лучше, чем length - 1.
Разбираем строку формата "key=value"
Очень популярный формат ввода — ключ=значение. Сегодня мы сделаем простую версию: найдём = и разрежем строку.
fun main() {
val line = "city=Tokio"
val eq = line.indexOf('=')
val key = if (eq == -1) "" else line.substring(0, eq)
val value = if (eq == -1) "" else line.substring(eq + 1)
println("key='$key', value='$value'") // key='city', value='Tokio'
}
Паттерн тот же: нашли индекс → проверили -1 → вырезали левую и правую части.
Собираем «карточку профиля» и не путаем разбор с выводом
Теперь соберём маленький фрагмент нашего приложения: читаем fullName и email, вычисляем firstName, lastName, domain и печатаем карточку.
fun extractDomain(email: String): String {
val at = email.indexOf('@')
if (at <= 0 || at == email.lastIndex) return "INVALID"
return email.substring(at + 1)
}
fun main() {
print("Введите имя и фамилию: ")
val fullName = readln()
print("Введите email: ")
val email = readln()
val space = fullName.indexOf(' ')
val firstName = if (space == -1) fullName else fullName.substring(0, space)
val lastName = if (space == -1) "" else fullName.substring(space + 1)
val domain = extractDomain(email)
val card = """
|Профиль
|-------
|Имя: $firstName
|Фамилия: $lastName
|Домен email: $domain
""".trimMargin()
println(card)
}
Здесь логика разбора (индексы, indexOf(), substring()) находится рядом, но не перемешивается в одну гигантскую строку. Такой стиль проще отлаживать: если карточка странная, вы можете распечатать промежуточные значения space, firstName, lastName.
Шпаргалка по индексам и проблемным местам
Когда вы работаете со строками, полезно иметь в голове маленькую табличку‑шпаргалку: что возвращается и что обязательно проверять.
| Операция | Что возвращает | На что смотреть, чтобы не упасть |
|---|---|---|
|
количество символов | если 0, строка пустая |
|
индекс последнего символа | будет -1, если строка пустая |
|
по индексу |
должен быть |
|
индекс или -1 | -1 обязательно обработать |
|
кусок строки | и в , и |
|
от до конца |
должен быть в |
И ещё один важный «мостик» к пониманию: индексы, начинающиеся с нуля, — это общий принцип Kotlin для индексируемых структур. Он одинаково проявляется и в массивах, и в списках, и в циклах по indices.
6. Типичные ошибки при работе с индексами и подстроками
Ошибка №1: путать length и «последний индекс».
Очень частый промах — думать, что раз длина строки 6, то последний индекс 6. На самом деле при индексации с нуля последний индекс всегда на 1 меньше. Из-за этого появляются ошибки «на 1»: вы либо берёте не тот символ, либо падаете на выходе за границы.
Ошибка №2: делать s[0] без проверки на пустую строку.
Пустая строка встречается чаще, чем кажется: пользователь нажал Enter, пришёл пустой ввод, где-то склеилось в "". Если после этого вы делаете text[0], программа падает. Правильная привычка — сначала проверять text.isEmpty() или хотя бы text.length == 0, и только потом лезть в индексы.
Ошибка №3: игнорировать indexOf(...) == -1.
indexOf не «ругается», он честно возвращает -1. Но если вы тут же используете этот -1 как границу в substring(), начинается хаос: в лучшем случае будет исключение, в худшем — вы отрежете что-то не то и получите «тихо неправильный» результат. Поэтому связка «нашёл → проверил → вырезал» должна стать рефлексом.
Ошибка №4: забыть, что substring(start, end) не включает end.
Это источник ошибок, которые особенно раздражают: программа вроде работает, но выдаёт «почти правильный» ответ — на один символ короче или длиннее. Лучшее лекарство — всегда проговаривать: «start включительно, end не включается», и делать маленькую схему на бумаге, когда сомневаетесь.
Ошибка №5: вырезать правую часть, не убрав разделитель (substring(at) вместо substring(at + 1)).
Если вы делите "user@example.com" по @, то правую часть вы хотите без @. Это значит substring(at + 1). Если написать substring(at), домен начнётся с @, и дальше логика «валидности» и сравнения строк может начать вести себя странно. Это особенно неприятно в программах, где потом домен сравнивают с "gmail.com".
Ошибка №6: смешивать разбор строки и форматирование результата в одну огромную строку.
Технически можно написать один гигантский println("Имя: ${...} ..."), но это почти гарантированно приведёт к ошибке и плохой читаемости. Надёжнее сначала получить val firstName, val lastName, val domain, а уже потом собрать финальный вывод. Такой код проще читать, проще тестировать и проще чинить, когда пользователь снова введёт что-то «творческое».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ