1. Вступ
Якщо чесно, пробіли й лапки — це головні диверсанти під час парсингу. Вони здаються невинними: «ну подумаєш, зайвий пробіл». Але саме через них виникають ситуації, коли ваш код «майже працює», а потім раптом перестає. Бо хтось увів два пробіли, залишив порожнє поле між комами або написав назву з двох слів.
У цій лекції ми не намагатимемося «вигадати ідеальний універсальний парсер». Натомість навчимося заздалегідь помічати типові проблеми формату й добирати прості прийоми, щоб застосунок принаймні коректно визначав, чи правильне введення.
Далі всі приклади розвиватимуть один міні‑застосунок: консольний список завдань. Ми зберігаємо завдання в std::vector<std::string> і читаємо команди построково через std::getline(std::cin, line).
2. Міні‑заготовка CLI ToDo
Щоб говорити предметно, нам потрібна відправна точка: цикл читання рядків і примітивний розбір команд. Тут ми навмисно почнемо з «наївної» версії, а далі поступово знайдемо місця, де вона ламається на пробілах, лапках і порожніх полях.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks;
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream iss(line);
std::string cmd;
iss >> cmd;
if (cmd == "add") {
std::string text;
std::getline(iss, text); // поки "як є"
tasks.push_back(text);
std::cout << "added\n"; // added
} else if (cmd == "list") {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << (i + 1) << ") " << tasks[i] << '\n';
}
} else if (!cmd.empty()) {
std::cout << "unknown command\n"; // unknown command
}
}
}
На цьому етапі здається, що все непогано. Але тут уже заховані дві майбутні проблеми: getline(iss, text) поверне рядок, який часто починається з пробілу, а команда "add" технічно додасть завдання, навіть якщо тексту немає або він складається лише з пробілів.
3. Пробіли: роздільники та частина даних
Пробіл може бути і роздільником токенів, і звичайним символом, що входить до значення. І тут починається філософія формату: в одному випадку пробіли неважливі (між числами), а в іншому — критично важливі (у назві завдання).
Ми розберемо три типові ситуації: зайві пробіли навколо токенів, пробіл як частину значення і дуже підступний випадок — коли ви змішуєте operator>> і getline, а на початку решти рядка зʼявляється «хвіст пробілу».
Зайві пробіли: operator>> їх «зʼїдає», getline — зберігає
Коли ви читаєте iss >> cmd, потоковий оператор operator>> пропускає початкові пробіли й символи табуляції. Це зручно: команда " list" однаково розпізнається як "list". Але getline працює інакше: він читає все «як є» до кінця рядка, зокрема й початкові пробіли.
Подивімося, як це працює на прикладі:
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add Buy milk";
std::istringstream iss(line);
std::string cmd;
iss >> cmd;
std::string rest;
std::getline(iss, rest);
std::cout << "cmd=[" << cmd << "]\n"; // cmd=[add]
std::cout << "rest=[" << rest << "]\n"; // rest=[ Buy milk]
}
rest починається з пробілу, тому що після читання cmd потік зупинився перед першим пробілом, а getline чесно зчитав «решту рядка», починаючи саме з нього.
Це не «помилка C++». Це нормальна логіка: ви просто маєте вирішити, чи є цей пробіл частиною формату, чи це сміття.
Міні‑трим: прибираємо пробіли на початку «хвоста рядка»
Створімо невелику функцію, яка видаляє пробіли й символи табуляції на початку рядка. Так, це не найшвидший трим у світі (ми використовуємо erase(0, 1) у циклі), але для навчальної консольної програми цього більш ніж достатньо.
#include <string>
void ltrimSpaces(std::string& s) {
while (!s.empty() && (s[0] == ' ' || s[0] == '\t')) {
s.erase(0, 1);
}
}
І використаємо її в "add":
if (cmd == "add") {
std::string text;
std::getline(iss, text);
ltrimSpaces(text);
tasks.push_back(text);
std::cout << "added: " << text << '\n';
}
Тепер "add Buy milk" збереже завдання "Buy milk", а не " Buy milk".
Але тут постає нове запитання про формат: чи можна додати порожнє завдання? Що робити з "add" без тексту? Що робити з "add "?
«Порожньо» і пробіли: чому їх важливо розрізняти
З погляду користувача команда "add" без тексту зазвичай означає «я помилився», а не «додай порожній рядок». Але компʼютер без проблем додасть порожній рядок і буде цілком задоволений — майже як компілятор, який знайшов, до чого причепитися.
Додамо перевірку: якщо після триму рядок порожній, вважатимемо формат некоректним.
if (cmd == "add") {
std::string text;
std::getline(iss, text);
ltrimSpaces(text);
if (text.empty()) {
std::cout << "usage: add <text>\n"; // usage: add <text>
continue;
}
tasks.push_back(text);
std::cout << "added\n"; // added
}
Це хороший стиль у парсингу: не намагатися «вгадати, що мав на увазі користувач», а чесно дотримуватися контракту й відмовляти, якщо його порушено.
4. Порожні поля: чому "a;;b" — це три поля
Порожні поля — друга класична пастка. Зазвичай вони зʼявляються у форматах на кшталт CSV (поля через кому), у логах, конфігураціях і «командах зі списком значень». Новачкам часто здається, що «якщо між комами нічого немає, значить, цього поля не існує». Але у форматах із позиційними стовпцями порожнє поле цілком існує й означає: «значення пропущено».
Щоб краще відчути різницю, введемо в нашому ToDo команду "bulk_add", яка додає кілька завдань одразу, розділених символом ';':
bulk_add buy milk;wash car;;call mom
Дві крапки з комою поспіль означають порожнє завдання між ними. Питання таке: ігноруємо порожнє завдання чи вважаємо це помилкою? Знову ж таки, це частина контракту формату — і ми маємо зробити вибір.
Роздільник не пробіл: чому operator>> тут не допоможе
Якщо спробувати читати такі значення через operator>>, нічого доброго не вийде: operator>> ділить за пробільними символами, а ';' для нього — лише частина токена. Тобто "buy milk;wash car" залишиться одним шматком, якщо там немає пробілів у «правильних місцях».
Нам потрібен інструмент із логікою «читай до роздільника». І це якраз std::getline(stream, token, delim).
Розбір "bulk_add" через getline(..., ';')
Ось мінімальний розбір полів:
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "buy milk;wash car;;call mom";
std::istringstream iss(line);
std::string item;
while (std::getline(iss, item, ';')) {
std::cout << "[" << item << "]\n";
}
}
Виведення буде таким (зверніть увагу на порожній елемент):
[buy milk]
[wash car]
[]
[call mom]
Отже, порожнє поле не «зникло». І це дуже корисно, бо ви можете чесно сказати: «у третій позиції порожньо».
Порожній рядок повністю: особливий випадок
Підступний момент: якщо вхідний рядок узагалі порожній (""), то цикл while (getline(...)) не виконається жодного разу. А інколи за контрактом формату порожній рядок означає «одна порожня колонка», а інколи — «немає даних».
Для нашої команди "bulk_add" логічно вважати так: якщо після команди немає нічого, це помилка формату — немає чого додавати.
Вбудовуємо "bulk_add" у застосунок
Додамо обробник:
if (cmd == "bulk_add") {
std::string rest;
std::getline(iss, rest);
ltrimSpaces(rest);
if (rest.empty()) {
std::cout << "usage: bulk_add a;b;c\n"; // usage: bulk_add a;b;c
continue;
}
std::istringstream items(rest);
std::string item;
while (std::getline(items, item, ';')) {
ltrimSpaces(item);
if (item.empty()) {
std::cout << "skip empty item\n"; // skip empty item
continue;
}
tasks.push_back(item);
}
std::cout << "bulk added\n"; // bulk added
}
Тут ми ухвалили таке правило формату: порожні елементи — не помилка, але ми їх пропускаємо. Це не єдиний варіант, зате він зрозумілий і передбачуваний.
5. Лапки: значення з пробілами
Якщо пробіли й порожні поля — це «технічні» проблеми формату, то лапки — це вже майже «мовний дизайн». Вони зʼявляються тоді, коли ви хочете зберегти потоковий стиль розбору ("cmd value number"), але водночас дозволити значення на кшталт "milk chocolate" як один токен.
І тут новачки зазвичай роблять так: читають рядок через operator>> і дивуються, що "milk chocolate" розпадається на два токени. Насправді все логічно: operator>> не вміє «вгадувати», що пробіл усередині лапок має бути частиною значення.
Демонстрація проблеми: operator>> ріже за пробілами
Подивімося, як це ламається на практиці:
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add \"milk chocolate\"";
std::istringstream iss(line);
std::string cmd;
std::string text;
iss >> cmd >> text;
std::cout << "cmd=" << cmd << '\n'; // cmd=add
std::cout << "text=" << text << '\n'; // text="milk
}
Ми отримали text="milk, бо другим токеном став текст до першого пробілу. Решта (chocolate") залишилася далі в потоці.
І тут важливо правильно сформулювати проблему: не «потоки погані», а «контракт формату не визначено». Якщо формат дозволяє пробіли всередині поля, він має дати правило, як відрізнити «пробіл‑роздільник» від «пробіл‑частини значення». Найпоширеніше правило — лапки.
Два рівні лапок: дані та літерали C++
У прикладі вище рядок записано так: "add \"milk chocolate\"". Це не тому, що формат такий страшний. Просто ми помістили дані всередину рядкового літерала C++, і там \" — це спосіб записати символ лапки.
Тут важливо розрізняти два рівні.
Рівень 1 — дані, які користувач уводить:
add "milk chocolate"
Рівень 2 — як ці дані записати в коді C++:
std::string line = "add \"milk chocolate\"";
Якщо змішати ці рівні в голові, починаються легенди на кшталт «користувач повинен вводити слеші перед лапками». Ні, не повинен. Слеші — це про літерали в C++.
Тимчасове рішення без підтримки лапок
У межах цієї лекції ми ще не використовуємо спеціальний механізм, який читає лапки як один токен. Але вже зараз можна ухвалити тимчасове рішення щодо формату, щоб застосунок залишався працездатним.
Або ми кажемо: «У "add" текст — це решта рядка після команди». Тоді пробіли дозволені, а лапки не потрібні:
add milk chocolate
Або ми кажемо: «"add" приймає один токен без пробілів». Тоді користувач має замінити пробіли, наприклад, на "_":
add milk_chocolate
Перший варіант зазвичай зручніший: у CLI для нотаток і завдань частіше хочеться вводити цілі фрази.
І ми його вже реалізували: "add" читає хвіст рядка через getline.
Але щойно ви захочете формат типу:
add <text> <priority>
де <text> може містити пробіли, а <priority> — число, вам знадобиться «справжня» підтримка лапок.
Чому лапки — це звична частина стандартної бібліотеки
Гарна новина: ідея «рядок у лапках як один токен» настільки поширена, що для неї є стандартні рішення. Ми скористаємося ними в наступній лекції, де розберемо std::quoted уже «по‑дорослому».
Шпаргалка: чим лікувати типові проблеми
Коли ви починаєте писати парсери, дуже легко занепасти духом і вирішити, що «формати завжди жахливі». На практиці все простіше: для кожного типу проблеми є свій типовий прийом. Нижче — невелика таблиця, щоб ви могли швидко зіставити симптом і спосіб лікування.
| Симптом формату | Приклад введення | Чому ламається | Типовий прийом |
|---|---|---|---|
| Зайві пробіли навколо даних | |
getline зберігає пробіли | трим початку хвоста рядка (принаймні зліва) |
| Пробіл усередині поля | |
якщо читати через operator>>, поле розбивається | читати решту рядка через getline |
| Поля через ,/; і порожні значення | |
operator>> не ділить за ;, а порожні поля губляться під час «наївного розбиття» | getline(stream, token, ';'), явно вирішувати долю порожніх полів |
| Поле в лапках містить пробіли | |
operator>> не сприймає лапки як межі поля | формат із лапками + std::quoted |
6. Типові помилки
Помилка № 1: вважати, що getline «сам прибере пробіли».
std::getline нічого не «нормалізує» і не «чистить». Він просто читає символи як є. Тому в сценарії iss >> cmd; getline(iss, rest); ви майже напевно отримаєте початковий пробіл у rest. Виправляється це простим тримом або свідомим правилом формату: «після команди допускається рівно один пробіл».
Помилка № 2: плутати «порожнє поле» і «відсутність поля».
Рядок "a;;b" містить три поля, де друге порожнє. А рядок "a;b" містить два поля. Якщо ви ігноруєте порожні поля, не подумавши, то зміщуєте позиції стовпців і далі неправильно інтерпретуєте дані. Навіть якщо ви вирішили пропускати порожні поля, робіть це свідомо й однаково скрізь.
Помилка № 3: намагатися розібрати формат із ; або , через operator>>.
operator>> за замовчуванням ділить за пробільними символами. Кома й крапка з комою для нього — звичайні символи всередині токена. У підсумку ви отримуєте шматок рядка, у якому всередині ще сидять роздільники, а далі починається виснажливий ручний розбір. Для роздільників використовуйте getline(..., delim).
Помилка № 4: не перевіряти порожню решту після "add".
Команда "add" без тексту виглядає як користувацька помилка. Якщо не перевіряти text.empty(), ви додасте порожнє завдання, потім виведете його в "list", і користувач вирішить, що програма «зʼїхала з глузду». Насправді проблема у форматі, а ви просто не поставили перевірку на вході.
Помилка № 5: змішувати в голові лапки в даних і лапки в C++‑літералах.
Користувач вводить add "milk chocolate". А ви в коді пишете "add \"milk chocolate\"". Якщо переплутати ці рівні, можна почати вимагати від користувача вводити \" — і це майже гарантовано зробить ваш інтерфейс непридатним для людей. Памʼятайте: зворотні слеші — це спосіб записати лапки у вихідному коді C++, а не правило користувацького введення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ