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. Це беззнаковий цілочисельний тип, який підходить для розмірів та індексів. Тому тут краще одразу звикати писати індекси так само.
Невелика підказка:
| Що це | Найуживаніший тип |
|---|---|
| індекс символу в рядку | |
| довжина рядка | |
| «кількість чогось» | |
Мініприклад, щоб просто відчути тип:
#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(), отже формат неправильний, і це треба явно обробити.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ