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, …) — це вже спроба вирізати текст із неіснуючої позиції.

Простий приклад з електронною поштою:

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
}

Річ не в електронній пошті (вона живе за своїми правилами), а в загальному шаблоні: 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, повертаєте значення за замовчуванням або друкуєте діагностичне повідомлення й просите повторити. Головне — щоб поведінка була явною, а не «якось само вийшло».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ