JavaRush /Курси /C++ SELF /Стратегії парсингу: find/substr чи потоковий парсинг

Стратегії парсингу: find/substr чи потоковий парсинг

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

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: вважати нормалізацію необовʼязковою, а потім героїчно лагодити все умовами.
Якщо ви не прибрали пробіли на краях рядка і не визначили, чи допустимі множинні пробіли, код швидко обростає перевірками на кшталт «а якщо тут два пробіли, а якщо таб, а якщо пробіл перед =». Значно спокійніше зробити одну-дві прості дії з нормалізації й парсити вже «причесаний» текст.

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