JavaRush /Курси /C++ SELF /Розбиття рядка на токени циклами

Розбиття рядка на токени циклами

C++ SELF
Рівень 9 , Лекція 3
Відкрита

1. Вступ

Коли ви лише починаєте програмувати, рядок здається чимось цілісним: «ось текст, ось я його вивів — і все чудово». Але дуже швидко зʼясовується, що рядок — це контейнер символів, а сенс зʼявляється лише тоді, коли ми вміємо виділяти його частини: слова, команди, параметри. Токенізація схожа на нарізання овочів перед супом: якщо кинути моркву цілком, технічно вона теж «у супі», але їсти її буде незручно. Приблизно так само незручно парсити введення без розбиття на шматки.

Є й практична причина, чому ми робимо це вручну. У стандартній бібліотеці C++ немає «однієї чарівної функції split()», яка завжди робить саме те, що вам потрібно, і водночас лишається зручною для навчального курсу. Були окремі обговорення та пропозиції поліпшити «розділення рядків» у стандарті, але все це не перетворилося на «просту кнопку» для новачка. Тому сьогодні ми вивчаємо базову механіку: беремо рядок і виділяємо токени самостійно, використовуючи цикли та substr().

Терміни: токен, розділювач і межі

Перш ніж писати код, корисно домовитися про терміни. Інакше ми сперечатимемося не про програму, а про значення слів. А це особливо прикро, коли сперечатися можна ще й про саму програму. Токенізація — це процес виділення токенів. Токен — це фрагмент рядка, а розділювач — символ або набір символів, що відокремлює токени один від одного. І так: найчастіша причина багів у токенізації — не «складність алгоритму», а звичайний вихід за межі рядка.

Токен — безперервний шматок рядка між розділювачами. Наприклад, у рядку add milk 2 токенами будуть add, milk, 2.

Розділювач — те, що відділяє токени. Сьогодні найчастіше це пробіл ' ', а інколи — символ на кшталт '=' у форматі key=value.

Межа рядка — умова i < s.size(). Це ваша «лінія життя». Порушите її — і програма почне поводитися як кіт, який «просто хотів понюхати свічку».

Індекси в рядку і чому майже завжди це std::size_t

Коли ви проходите рядок циклом, у вас є індекс. І дуже хочеться написати int i = 0; — бо так «простіше». Але методи рядка (size(), find()) працюють із типом std::size_t. Це беззнаковий цілочисельний тип, який підходить для розмірів та індексів. Тому тут краще одразу звикати писати індекси так само.

Невелика підказка:

Що це Найуживаніший тип
індекс символу в рядку
std::size_t
довжина рядка
std::size_t
«кількість чогось»
std::size_t

Мініприклад, щоб просто відчути тип:

#include <iostream>
#include <string>

int main() {
    std::string s = "abc";
    std::size_t n = s.size();
    std::cout << n << '\n'; // 3
}

substr(pos, count): другий параметр — це довжина

Саме тут студенти регулярно натрапляють на «тихий баг»: дуже хочеться думати, що substr(i, j) означає «від i до j». Але в C++ це не так.

substr(pos, count) означає: взяти count символів, починаючи з pos.

Тобто якщо в нас є межі токена [i, j) — як у математиці й майже в усіх звичних алгоритмах, — то довжина токена дорівнює j - i, і правильний запис буде таким:

token = s.substr(i, j - i);

Мініприклад:

#include <iostream>
#include <string>

int main() {
    std::string s = "hello world";
    std::cout << s.substr(0, 5) << '\n'; // hello
}

2. Базовий алгоритм токенізації: «пропусти → захопи → повтори»

У ручній токенізації є дуже приємна властивість: вона майже завжди зводиться до одного й того самого каркаса. Ми не намагаємося «зрозуміти весь текст одразу». Натомість рухаємося по рядку зліва направо й на кожному кроці розвʼязуємо два завдання. Перше: пропустити розділювачі. Друге: знайти межу токена. Наприкінці ми вирізаємо токен через substr() і йдемо далі. Це схоже на читання тексту очима: ви не намагаєтеся охопити всю сторінку одним поглядом, а переходите від слова до слова.

Ключова ідея — працювати з межами й не читати s[i], доки не перевірили i < s.size().

Два індекси: i і j

Для новачка найзручніша модель — два індекси.

i — де ми зараз перебуваємо.
j — куди «дотягнувся» токен, тобто перша позиція після токена.

Тоді токен — це діапазон [i, j). А отже:

  • початок токена = i
  • кінець токена (не включно) = j
  • довжина токена = j - i
  • сам токен = s.substr(i, j - i)

Ось мініскелет без деталей:

std::size_t i = 0;
while (i < s.size()) {
    // 1) пропустити розділювачі
    // 2) знайти j
    // 3) token = substr(i, j - i)
    // 4) i = j
}

Мініблок-схема алгоритму

Іноді корисно хоча б раз побачити це як процес, а не як набір рядків коду.

flowchart TD
    A[Початок: i = 0] --> B{"i < s.size()?" }
    B -- ні --> Z[Кінець]
    B -- так --> C["Пропускаємо розділювачі: доки s[i] == ' ' -> i++"]
    C --> D{"i >= s.size()?" }
    D -- так --> Z
    D -- ні --> E["Шукаємо кінець токена: j = i; доки j < s.size() && s[j] != ' ' -> j++"]
    E --> F["Вирізаємо токен: substr(i, j - i)"]
    F --> G[Зсуваємо i = j]
    G --> B

3. Розбиття за пробілами: друкуємо всі токени

Почнемо з найпоширенішого випадку: токени розділені пробілами. І одразу домовимося про важливу річ: ми не вимагатимемо «ідеального введення». Користувач може поставити три пробіли, може почати рядок із пробілу, може закінчити його пробілом — і програма не повинна перетворювати це на трагедію. Наша токенізація спочатку пропускатиме пробіли, потім читатиме токен, і так до кінця.

Наведений нижче приклад друкує кожен токен окремо. Це корисно не лише як «задача», а й як інструмент налагодження: якщо ви сумніваєтеся, чи правильно розумієте введення, спершу просто виведіть токени.

#include <iostream>
#include <string>

int main() {
    std::string s = "  add   milk   2  ";

    std::size_t i = 0;
    while (i < s.size()) {
        while (i < s.size() && s[i] == ' ') ++i; // пропуск пробілів
        if (i >= s.size()) break;

        std::size_t j = i;
        while (j < s.size() && s[j] != ' ') ++j; // кінець токена

        std::string token = s.substr(i, j - i);
        std::cout << "token=[" << token << "]\n";
        i = j;
    }
}

Очікуваний вивід:

token=[add]
token=[milk]
token=[2]

Зверніть увагу на «подвійну перевірку меж». Ми перевіряємо i < s.size() і в циклі пропуску, і в циклі пошуку кінця токена. Це виглядає трохи занудно, зате програма не падає через порожній рядок або рядок, що складається лише з пробілів.

4. Кілька розділювачів: пробіли та '='

Тепер трохи ускладнімо ситуацію, але все ще без «потокового парсингу». Уявімо команду на кшталт: set user=alice.

Тут пробіл ділить рядок на «частини команди», а '=' розділяє один токен на ключ і значення. У реальному житті це трапляється постійно: у конфігураціях, параметрах, простих DSL-форматах і навіть URL-рядках. Там усе трохи складніше, але ідея та сама.

Спочатку виділимо токени за пробілом, а потім один із токенів розріжемо за '='. Почнімо з другого кроку — розрізання user=alice, але зробімо це теж «вручну», без find(), щоб закріпити навички.

#include <iostream>
#include <string>

int main() {
    std::string pair = "user=alice";

    std::size_t k = 0;
    while (k < pair.size() && pair[k] != '=') ++k;

    if (k == pair.size()) {
        std::cout << "bad pair\n";
    } else {
        std::string key = pair.substr(0, k);
        std::string value = pair.substr(k + 1);
        std::cout << "key=[" << key << "], value=[" << value << "]\n";
        // key=[user], value=[alice]
    }
}

У цьому прикладі ми явно обробили ситуацію, коли розділювача немає. Це важлива навичка: навіть якщо формат «має бути правильним», код однаково повинен мати план Б. Інакше користувачі неодмінно знайдуть спосіб увести щось неочікуване. Користувачі — як вода: тільки замість щілин вони шукають баги.

5. Мінізастосунок TaskShell: розуміємо команди

Тепер поєднаємо все в одну зрозумілу історію. Почнемо створювати консольний застосунок TaskShell, який прийматиме команди в одному рядку. Поки що він не зберігає завдань (контейнери на кшталт std::vector будуть пізніше), але вже вміє «зрозуміти», чого від нього хочуть, і акуратно розбирати параметри. Це дуже чесний етап розроблення: спочатку ви вчите програму розуміти мову команд, а вже потім додаєте їй «памʼять».

Підтримуватимемо такі команди:

  • echo ... — друкує токени-аргументи
  • set key=value — повідомляє, що ключ і значення розпізнано
  • exit — вихід

Читаємо команду рядком

Щоб токенізувати команду, її треба прочитати повністю. Зазвичай для цього використовують std::getline.

#include <iostream>
#include <string>

int main() {
    std::string line;
    std::getline(std::cin, line);

    std::cout << "line=[" << line << "]\n";
}

Якщо користувач уведе:

echo hello world

то програма надрукує:

line=[echo hello world]

Перший токен — команда, решта — аргументи

Тепер додамо розбір. Важливо: ми не зберігатимемо аргументи в масиві, бо контейнери розглянемо пізніше. Натомість просто пройдемося по них і виведемо їх на екран.

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = (i < line.size()) ? line.substr(i, j - i) : "";
    std::cout << "cmd=[" << cmd << "]\n"; // cmd=[echo]
}

Зараз ми дістали лише команду. Далі — аргументи. Логіка така: після того як ми прочитали cmd, продовжуємо токенізацію з позиції i = j.

Нижче — версія, яка друкує команду та всі наступні токени:

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    int token_index = 0;

    while (i < line.size()) {
        while (i < line.size() && line[i] == ' ') ++i;
        if (i >= line.size()) break;

        std::size_t j = i;
        while (j < line.size() && line[j] != ' ') ++j;

        std::string token = line.substr(i, j - i);
        std::cout << token_index << ": [" << token << "]\n";
        // 0: [echo], 1: [hello], 2: [world]
        ++token_index;
        i = j;
    }
}

Це вже майже «універсальний друкар токенів». Такий шаблон зручно тримати в голові як мінімальну перевірку: якщо токени друкуються правильно, значить, ви вже наполовину написали парсер.

Розбір set key=value за один прохід

Тепер зробимо фрагмент логіки TaskShell: розпізнаємо set і обробимо наступний токен як key=value. Ми зробимо це без зберігання аргументів: просто прочитаємо перший токен і другий токен.

#include <iostream>
#include <string>

int main() {
    std::string line = "set user=alice";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = line.substr(i, j - i);
    i = j;

    while (i < line.size() && line[i] == ' ') ++i;
    j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string arg = (i < line.size()) ? line.substr(i, j - i) : "";

    std::cout << "cmd=[" << cmd << "], arg=[" << arg << "]\n";
    // cmd=[set], arg=[user=alice]
}

А тепер другий крок: розібрати arg за '=':

#include <iostream>
#include <string>

int main() {
    std::string arg = "user=alice";

    std::size_t eq = 0;
    while (eq < arg.size() && arg[eq] != '=') ++eq;

    if (eq == arg.size()) {
        std::cout << "bad format\n";
    } else {
        std::string key = arg.substr(0, eq);
        std::string value = arg.substr(eq + 1);
        std::cout << "set " << key << " to " << value << '\n';
        // set user to alice
    }
}

Так, коду вийшло більше, ніж хотілося б. Але це чесна ціна за розуміння: ви буквально власноруч робите те, що пізніше виконуватимуть більш просунуті інструменти.

Як зробити токенізацію стійкою

Коли люди вперше пишуть токенізацію, вони часто орієнтуються на «ідеальне введення». Мовляв: «там же рівно один пробіл, навіщо ці перевірки». А потім введення виявляється таким: " set user=alice ", або взагалі порожнім рядком, і все починає ламатися.

Стійкість токенізації зазвичай досягається не магією, а дисципліною: ви завжди пропускаєте розділювачі окремим циклом, завжди перевіряєте i < size, і ніколи не звертаєтеся до line[i], якщо не впевнені, що i всередині рядка. Навіть якщо потім ви зробите trim і «стискання» пробілів окремим кроком, «безпечна токенізація» все одно лишатиметься корисною: вона захищає вас від неврахованих випадків і полегшує налагодження.

Ще один важливий момент: токенізація не обовʼязково означає «зробити список токенів». Сьогодні ми часто токенізуємо у процесі, як конвеєр: отримали токен → одразу обробили → пішли далі. Це особливо зручно на ранніх етапах курсу, поки ми ще не використовуємо контейнери для зберігання.

6. Типові помилки під час ручної токенізації

Помилка №1: доступ до s[i] без перевірки i < s.size().
Це найпоширеніший спосіб отримати невизначену поведінку й дуже дивні симптоми. У токенізації ви майже завжди рухаєте i, і на останній ітерації легко «виїхати» за межу. Правило просте: будь-яке звернення до s[i] має відбуватися лише після перевірки межі, навіть якщо вам здається, що «там точно є символ».

Помилка №2: забути пропустити розділювачі перед читанням токена.
Якщо ви пропускаєте крок «пропускаємо пробіли», то в рядку з кількома пробілами або отримаєте порожні токени, або зациклите програму, або вирізатимете substr(i, 0) знову і знову. У нормальному алгоритмі завжди є окремий цикл, який рухає i через пробіли.

Помилка №3: плутанина в substr(i, j - i) і спроба написати substr(i, j).
Це логічна пастка: j у токенізації зазвичай означає «позицію після останнього символу токена». Але substr() чекає не «позицію кінця», а «довжину». Тому правильний вираз майже завжди має вигляд j - i. Якщо ви написали substr(i, j), рядок може виглядати «майже правильно» на коротких тестах, але ламатися на реальних даних.

Помилка №4: робити i = j + 1 і сподіватися, що пробіл завжди один.
Такий код працює рівно до першого введення з двома пробілами підряд або пробілом наприкінці рядка. Набагато надійніше робити i = j, а пробіли пропускати окремим циклом. Тоді кількість пробілів узагалі перестає бути проблемою.

Помилка №5: не обробляти випадок «розділювача немає» у key=value.
Якщо ви розбираєте key=value, але не перевіряєте, що '=' справді знайдено, то substr(eq + 1) може перетворитися на спробу «відрізати після кінця рядка». Іноді це дасть порожній рядок і тиху помилку, а іноді — неприємніші наслідки. Правильний сценарій такий: якщо eq == arg.size(), отже формат неправильний, і це треба явно обробити.

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