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++, а не правило пользовательского ввода.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ