JavaRush /Курсы /C++ SELF /Проблемы форматов: пробелы, кавычки, пустые поля

Проблемы форматов: пробелы, кавычки, пустые поля

C++ SELF
17 уровень , 2 лекция
Открыта

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 уже «по‑взрослому».

Шпаргалка: чем лечить типовые проблемы

Когда начинаешь писать парсеры, очень легко впасть в отчаяние и решить, что «форматы всегда ужасны». На практике всё проще: под каждый тип проблемы есть свой типичный приём. Ниже — маленькая таблица, чтобы вы могли быстро соотнести симптом и метод лечения.

Симптом формата Пример входа Почему ломается Типичный приём
Лишние пробелы вокруг данных
add   Buy milk
getline сохраняет пробелы трим хвоста строки (как минимум слева)
Пробел внутри поля
add Buy milk
если читать через operator>>, поле режется читать остаток строки через getline
Поля через ,/; и пустые значения
a;;b
operator>> не делит по ;, пустые поля теряются в «наивных сплитах» getline(stream, token, ';'), явно решать судьбу пустых полей
Поле в кавычках содержит пробелы
add "milk chocolate"
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++, а не правило пользовательского ввода.

1
Задача
C++ SELF, 17 уровень, 2 лекция
Недоступна
Чистый ввод
Чистый ввод
1
Задача
C++ SELF, 17 уровень, 2 лекция
Недоступна
Команда и хвост
Команда и хвост
1
Задача
C++ SELF, 17 уровень, 2 лекция
Недоступна
Поля анкеты
Поля анкеты
1
Задача
C++ SELF, 17 уровень, 2 лекция
Недоступна
Заметки в терминале
Заметки в терминале
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ