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 << "bad format\n"; // bad format
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["Нужно отдельное решение под delimiter (будет дальше по плану дня)"]
Главная мысль: если вы не можете уверенно ответить «какой формат», вы не выбираете стратегию — вы гадаете.
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 << "got: " << 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 << "bad set format\n";
}
return;
}
if (line.rfind("sum ", 0) == 0) {
std::cout << "SUM command (space-separated args)\n";
return;
}
std::cout << "unknown command\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-подобное», и вы не обязаны парсить всё одним способом.
5. Типичные ошибки при выборе и реализации стратегии парсинга
Ошибка №1: парсить без контракта формата.
Когда вы не описали, какие поля ожидаете и чем они разделены, код становится набором случайных find() и substr(). На тестовой строке оно «вроде работает», но любое отклонение (лишний пробел, пустое поле, другой порядок) превращает поведение в непредсказуемое. Лечится просто: сначала договориться, что является ошибкой формата, и только потом писать код.
Ошибка №2: не проверять std::string::npos после find().
find() не гарантирует, что что-то найдёт. Если разделителя нет, он возвращает npos, и дальше любая арифметика с этим значением приводит к «интересным» индексам и кривым substr(). Правило дисциплины: нашли позицию — сразу проверили npos — только потом режем строку.
Ошибка №3: неправильные границы substr() (особенно pos vs pos + 1).
Это классика: вы нашли =, но вырезали значение начиная с =. Потом удивляетесь, почему в значении лишний символ, и почему число не читается как число. Полезная привычка: проговаривать вслух — «разделитель относится к формату, а не к данным», значит его надо пропускать.
Ошибка №4: пытаться потоковым стилем разобрать формат, где разделитель не пробел.
>> по умолчанию режет по пробельным символам. Если у вас x,y,z или a=b, то «потоковый подход» в чистом виде не спасёт: нужно либо позиционно искать символы-разделители, либо использовать отдельные приёмы для delimiter-форматов (они идут дальше по плану дня). Ошибка не в том, что потоковый подход плох, а в том, что он выбран не под тот контракт.
Ошибка №5: считать «нормализацию» необязательной и потом героически чинить всё условиями.
Если вы не убрали пробелы по краям и не определили, допустимы ли множественные пробелы, ваш код обрастает проверками вида «а если здесь два пробела, а если таб, а если пробел перед =». Гораздо спокойнее сделать 1–2 простых шага нормализации и парсить уже «причесанный» текст.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ