1. Парсинг як перетворення за правилами
Коли ми говоримо «парсинг рядка», то маємо на увазі не просто «нарізати текст на шматки», а перетворити рядок на значення за правилами формату. Це як отримати посилку: важливо не лише відкрити коробку, а й перевірити, що всередині справді ваше замовлення, а не три цеглини й записка «так і задумано». У програмуванні «формат рядка» — це контракт, а наше завдання — обрати підхід, який робить цей контракт зрозумілим і придатним до перевірки.
Парсинг майже завжди складається з трьох етапів. Навіть якщо ви їх так не називаєте, послідовність зазвичай однакова: спочатку ми приводимо вхід до передбачуванішого вигляду, тобто нормалізуємо його, потім витягаємо фрагменти даних — токени або поля, а далі перевіряємо, чи справді ці фрагменти відповідають змісту: числа — це числа, ключ — це ключ тощо. Сьогодні ми зосередимося на виборі між двома базовими стратегіями: позиційною (find()/substr()) і потоковою (у стилі >>).
Контракт формату: спочатку домовитися про правила
Перш ніж писати бодай один рядок коду, корисно — і, несподівано, цілком по-дорослому — словами відповісти: як саме влаштований рядок. Не «приблизно», а так, щоб інша людина могла написати парсер за вашим описом. Це і є «контракт формату». Якщо контракту немає, код перетворюється на вгадування: сьогодні він працює на прикладі, завтра ламається через пробіл, післязавтра — на порожньому полі, а потім ви починаєте підозрювати, що у світі існує змова проти вашої програми.
Контракт зазвичай охоплює кілька простих запитань: які поля ми очікуємо, чим вони розділені, чи бувають зайві пробіли, чи можливі порожні поля, чи може значення містити пробіли, чи використовується знак = або : як роздільник «ключ–значення», і що саме вважати помилкою формату. Відповіді на ці запитання майже автоматично підказують, що для вас зручніше: позиційно шукати роздільники чи читати рядок як потік токенів.
Нормалізація вхідного рядка
Перед парсингом рядок часто варто привести до нормального вигляду. Це не «краса заради краси», а спосіб різко зменшити кількість випадків, які доводиться тримати в голові. Наприклад, якщо ви заздалегідь прибрали пробіли на початку й у кінці рядка та перетворили «багато пробілів поспіль» на «один пробіл», то потоковий парсинг починає працювати значно стабільніше, а позиційний менше страждає від «раптових пробілів навколо =».
Нормалізація не обовʼязково має бути ідеальною. У навчальних прикладах достатньо двох простих дій: прибрати пробіли зліва і справа та, за потреби, «стиснути» послідовності пробілів. Головне — домовитися всередині програми, що після нормалізації рядок підпорядковується суворішим правилам, і вже під ці правила писати парсер.
2. Дві базові стратегії парсингу
Позиційний парсинг: find() + substr()
Позиційний парсинг — це підхід, за якого ви дивитеся на рядок як на послідовність символів, знаходите ключові місця, наприклад позицію =, і вирізаєте потрібні фрагменти. Він особливо добре працює, коли формат «локальний»: один-два роздільники, усе передбачувано, і для вас важливі конкретні символи. Типовий приклад — key=value, name:Bob, id=42, x=10;y=20 та інші записи, схожі на конфігураційні.
Тут важливо памʼятати дві речі. По-перше, find() уміє сказати «не знайдено» через спеціальне значення std::string::npos. По-друге, substr() вирізає за індексами, і якщо ви не перевірили npos, то легко отримаєте дивні межі та несподівані результати. Сам метод substr() — цілком стандартна частина роботи з рядками.
Мініприклад: розбір key=value.
#include <iostream>
#include <string>
int main() {
std::string s = "id=42";
std::size_t eq = s.find('=');
if (eq == std::string::npos) {
std::cout << "хибний формат\n"; // хибний формат
return 0;
}
std::string key = s.substr(0, eq);
std::string val = s.substr(eq + 1);
std::cout << key << " -> " << val << '\n'; // id -> 42
}
Зверніть увагу на eq + 1. Це дрібниця, але саме з таких дрібниць виростають баги: якщо зробити substr(eq), ви отримаєте значення на кшталт "=42" і потім дивуватиметеся, чому «число не парситься».
Потоковий парсинг: коли формат «пробільний»
Потоковий підхід — це коли ви читаєте дані послідовно, ніби користувач вводить їх у консоль: слово, потім число, потім ще число. Логіка тут цілком людська: «дай мені команду, потім аргументи». Цей підхід найкраще розкривається тоді, коли роздільники — пробіли або будь-які інші пробільні символи, і вам не потрібно вручну шукати позиції.
Ключова ідея потокового парсингу — перевіряти успіх читання. Не «прочитали й пішли далі», а «якщо прочиталося — використовуємо, інакше — формат хибний». У стандартній бібліотеці навіть є окремі рядкові потоки, тобто сама модель «рядок можна читати як потік» для C++ цілком природна.
Поки що тримаймо в голові просте правило: якщо формат схожий на «консольне введення», то потокова стратегія часто простіша й безпечніша, тому що >> сам пропускає зайві пробіли й уміє зупинятися на помилці.
Мініприклад на ідею «слово + два числа» (як команда калькулятора):
#include <iostream>
#include <string>
int main() {
std::string cmd = "sum";
int a = 10;
int b = 20;
std::cout << cmd << " " << (a + b) << '\n'; // sum 30
}
Цей приклад поки що не «парсить рядок», але добре показує форму даних, для якої потоковий підхід зазвичай підходить ідеально: коротка команда, а потім фіксована кількість аргументів.
Як обрати стратегію
Вибір між find()/substr() і потоковим читанням — не про те, «що крутіше», а про те, який інструмент краще відповідає контракту формату. Якщо ви обрали стратегію, що справді пасує формату, код стає коротшим і чеснішим: менше ручної математики з індексами, менше «а що як тут два пробіли», менше прихованих припущень.
Нижче — невелика таблиця, яка часто рятує від зайвих страждань:
| Ситуація у форматі | Що зручніше | Чому |
|---|---|---|
| key=value (явний символ-роздільник) | find()/substr() | Легко знайти = і вирізати дві частини |
| cmd 10 20 (слово + числа через пробіли) | потоковий підхід | >> сам пропустить пробіли й перевірить типи |
| «Роздільник не пробіл» (наприклад, x,y,z) | позиційний підхід / окрема техніка | Потоковий >> за замовчуванням ділить за пробілами |
| Потрібні хороші перевірки формату | обидва підходи доречні | Головне — перевіряти результат (npos, успіх читання) |
І ще одна невелика схема-підказка. Вона не ідеальна, але як «швидкий фільтр» працює добре:
flowchart TD
A["Є рядок, його треба розібрати"] --> B["Формат описано? (контракт)"]
B -->|ні| C["Спочатку описуємо: поля, роздільники, пробіли, порожні значення"]
B -->|так| D["Основні роздільники — пробіли?"]
D -->|так| E["Потокова стратегія: читаємо токени по черзі"]
D -->|ні| F["Є явний символ-роздільник на кшталт '=' ':' ';'?"]
F -->|так| G["Позиційна стратегія: find + substr + перевірка npos"]
F -->|ні| H["Потрібне окреме рішення для такого роздільника (це буде далі за планом дня)"]
Головна думка проста: якщо ви не можете впевнено відповісти, «який тут формат», то ви не обираєте стратегію — ви вгадуєте.
3. Практичний приклад: мініінтерпретатор команд
Щоб не залишатися у світі абстракцій, почнімо з невеликого застосунку, який стане у пригоді й на наступних лекціях цього дня: консольного мініінтерпретатора команд. Він читає рядок, розпізнає команду й виконує дію. Команди зробимо двох типів: одна добре підходитиме для потокового формату (sum 10 20), інша — для позиційного (set name=Alice).
Каркас: читаємо рядки до exit
Зараз нам потрібен простий цикл: читаємо рядок, якщо exit — виходимо, інакше передаємо його до обробника. Уже на цьому етапі видно, чому ми надаємо перевагу getline(): команда може містити пробіли, і ми не хочемо втратити половину рядка.
#include <iostream>
#include <string>
int main() {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "exit") break;
std::cout << "отримано: " << line << '\n';
}
}
Поки що це програма-«ехо». Але саме так починається багато корисних утиліт: спочатку ви навчилися стабільно отримувати рядок, а потім уже ускладнюєте обробку.
Полегшена нормалізація: trim()
Зробімо маленьку функцію trim(), щоб не залежати від випадкових пробілів зліва і справа. Ідеальної нормалізації сьогодні не будуємо: наша мета — показати саму ідею «спочатку привести до норми, а вже потім парсити».
#include <cctype>
#include <string>
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(0, 1);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
Так, erase(0, 1) — не найшвидший спосіб у світі, але для навчального застосунку це нормально: зараз нам важливіша зрозумілість, ніж мікросекунди. А оптимізацію залишимо тим, хто спершу пише «швидко», а потім зʼясовує, чому це «швидко» падає.
Позиційний парсинг: команда set key=value
Зробімо функцію, яка намагається розібрати key=value. Якщо не вийшло, вона повертає false. Якщо вийшло — передає key і value через посилання. Це не єдиний можливий дизайн, але він зрозумілий і не потребує «просунутих» типів результату.
#include <string>
bool parseKeyValue(const std::string& s, std::string& key, std::string& value) {
std::size_t eq = s.find('=');
if (eq == std::string::npos) return false;
key = s.substr(0, eq);
value = s.substr(eq + 1);
return !key.empty();
}
Тут ми вже робимо дві перевірки формату: чи є = і чи не порожній ключ. За бажання можна додати ще й перевірку на непорожнє значення, але це залежить від контракту: іноді порожнє значення допустиме й означає «стерти налаштування».
Обробник рядка: розпізнаємо команду й обираємо стратегію
Тепер напишемо просту логіку: якщо рядок починається з set (і пробілу), застосовуємо позиційний розбір; якщо починається з sum (і пробілу), то поки просто виведемо, що це «пробільна команда». Потокову обробку ми повноцінно розглянемо далі за планом дня, але сам вибір стратегії покажемо вже зараз.
#include <iostream>
#include <string>
void processLine(const std::string& raw) {
std::string line = trim(raw);
if (line.rfind("set ", 0) == 0) {
std::string key, value;
if (parseKeyValue(line.substr(4), key, value)) {
std::cout << "SET [" << key << "] = [" << value << "]\n";
} else {
std::cout << "хибний формат команди set\n";
}
return;
}
if (line.rfind("sum ", 0) == 0) {
std::cout << "команда sum (аргументи через пробіл)\n";
return;
}
std::cout << "невідома команда\n";
}
Зверніть увагу на line.rfind("set ", 0) == 0. Це простий спосіб перевірити, чи починається рядок із підрядка. Ми навмисно не ускладнюємо приклад: наша мета — побачити архітектуру «розпізнав команду → обрав стратегію парсингу».
Підключаємо обробник у main()
Залишилося звʼязати все разом: читаємо рядок і передаємо його в processLine().
#include <iostream>
#include <string>
int main() {
std::string line;
while (std::getline(std::cin, line)) {
if (trim(line) == "exit") break;
processLine(line);
}
}
Тепер у нас є невелика, але важлива структура: застосунок, у якому різні команди використовують різні стратегії розбору. Саме так це й буває в реальних програмах: десь key=value, десь «слово + числа», десь щось «CSV-подібне», і ви не зобовʼязані парсити все одним способом.
4. Типові помилки під час вибору та реалізації стратегії парсингу
Помилка №1: парсити без контракту формату.
Коли ви не описали, які поля очікуєте і чим вони розділені, код стає набором випадкових find() і substr(). На тестовому рядку все «ніби працює», але будь-яке відхилення — зайвий пробіл, порожнє поле або інший порядок — робить поведінку непередбачуваною. Лікується це просто: спочатку домовитися, що саме є помилкою формату, і лише потім писати код.
Помилка №2: не перевіряти std::string::npos після find().
find() не гарантує, що щось знайде. Якщо роздільника немає, він повертає npos, і далі будь-яка арифметика з цим значенням призводить до «цікавих» індексів і хибних substr(). Просте правило дисципліни: знайшли позицію — одразу перевірили npos — і лише потім ріжемо рядок.
Помилка №3: неправильні межі substr() (особливо pos vs pos + 1).
Це класика: ви знайшли =, але вирізали значення, починаючи із самого =. Потім дивуєтеся, чому в значенні зʼявився зайвий символ і чому число не читається як число. Корисна звичка — проговорювати вголос: «роздільник належить до формату, а не до даних», отже його треба пропускати.
Помилка №4: намагатися потоковим стилем розібрати формат, де роздільник — не пробіл.
>> за замовчуванням ділить дані за пробільними символами. Якщо у вас x,y,z або a=b, то «потоковий підхід» у чистому вигляді не врятує: потрібно або позиційно шукати символи-роздільники, або використовувати окремі прийоми для форматів із власними роздільниками. Помилка не в тому, що потоковий підхід поганий, а в тому, що його обрали не під той контракт.
Помилка №5: вважати нормалізацію необовʼязковою, а потім героїчно лагодити все умовами.
Якщо ви не прибрали пробіли на краях рядка і не визначили, чи допустимі множинні пробіли, код швидко обростає перевірками на кшталт «а якщо тут два пробіли, а якщо таб, а якщо пробіл перед =». Значно спокійніше зробити одну-дві прості дії з нормалізації й парсити вже «причесаний» текст.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ