JavaRush /Курсы /C++ SELF /std::stringstream: извлечение токенов

std::stringstream: извлечение токенов

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

1. Введение

Если вы когда-нибудь пытались «разобрать строку вручную» (то есть гуляли по символам, ловили пробелы, считали индексы и аккуратно не падали лицом в npos), то вы уже знаете: это полезно для тренировки внимательности… но программистом вы себя чувствуете как сапёр без схемы минного поля.

В реальной жизни часто приходит строка вида:

  • sum 10 20
  • add milk 3
  • move 2 5

И вы хотите прочитать это ровно так же, как читали бы из консоли: слово-команда, затем пара чисел, и всё. Вот тут и появляется гениальная идея стандартной библиотеки: «А давайте мы сделаем поток, который читает не из клавиатуры, а из строки».

std::stringstream — это поток поверх строки. Он умеет:

  • читать из строки оператором >> так же, как std::cin;
  • писать в строку оператором << так же, как std::cout;
  • хранить внутри буфер-строку, которую можно получить через .str().

В стандарте C++ это отдельная большая часть библиотеки (в черновиках стандарта вы даже увидите упоминания о разделе [stringstream]).

Строка как консоль

Давайте сделаем важную остановку и представим простую аналогию. std::cin — это как «лента с данными», которая идёт от пользователя. Мы читаем с неё токены: слова, числа и т.д. std::stringstream — это та же лента, только данные на ней уже записаны в виде std::string.

То есть мы сначала читаем целую строку (обычно через std::getline), а потом запускаем «мини‑cin» поверх этой строки и достаём из неё кусочки.

Ключевое правило потокового чтения: оператор >> по умолчанию делит по пробельным символам (пробел, табуляция, перевод строки). Поэтому формат «слово + число + число» читается очень естественно.

2. Читаем команду и аргументы

Подключение и первый пример

Когда вы видите stringstream, почти всегда нужны:

#include <sstream>
#include <string>
#include <iostream>

Сделаем маленький пример: в строке команда "sum 10 20".

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "sum 10 20";
    std::stringstream ss(line);

    std::string cmd;
    int a{}, b{};

    ss >> cmd >> a >> b;

    std::cout << cmd << ": " << (a + b) << '\n'; // sum: 30
}

Выглядит почти как cin, и это именно то, ради чего мы здесь собрались.

Но прямо сейчас в этом коде спрятана классическая проблема новичка: мы не проверили, что чтение вообще удалось. Если строка будет "sum ten 20", чтение a не получится, и программа продолжит жить «на мифических данных». Давайте исправляться.

Проверка успеха чтения: if (ss >> ...)

Когда поток не смог прочитать значение нужного типа (например, ожидали int, а встретили "ten"), он переходит в состояние ошибки. И самое приятное: результат операции чтения можно использовать как условие.

Сделаем «щит от плохого формата»:

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "sum ten 20";
    std::stringstream ss(line);

    std::string cmd;
    int a{}, b{};

    if (ss >> cmd >> a >> b) {
        std::cout << cmd << ": " << (a + b) << '\n';
    } else {
        std::cout << "bad format\n"; // bad format
    }
}

Здесь важна мысль: мы используем a и b только если чтение прошло успешно. Это не «красивый стиль», это способ не строить дом на зыбучем песке.

3. Токены: слова, числа и смешанные типы

Поток умеет читать разные типы подряд, и это супер-удобно, когда формат фиксирован. Но нужно понимать, что поток не «угадывает», что вы имели в виду: он честно пытается преобразовать текст в нужный тип.

Посмотрим на типичные случаи.

Читаем слова

Представим команду "hello Alice".

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "hello Alice";
    std::stringstream ss(line);

    std::string cmd, name;
    if (ss >> cmd >> name) {
        std::cout << "cmd=" << cmd << ", name=" << name << '\n'; // cmd=hello, name=Alice
    }
}

Если имя будет из двух слов ("Alice Cooper"), обычный >> вам не поможет — он отрежет по пробелу. Это нормально: значит, в контракте формата надо предусмотреть кавычки или другой способ. Но это будет следующая лекция (std::quoted). Сегодня мы честно живём в мире токенов, разделённых пробелами.

Читаем числа

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "mul 7 8";
    std::stringstream ss(line);

    std::string cmd;
    int x{}, y{};

    if (ss >> cmd >> x >> y) {
        std::cout << cmd << ": " << (x * y) << '\n'; // mul: 56
    }
}

Смешиваем типы: слово + int + double

Такой формат часто встречается в «мини-командах»:

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "buy 3 19.90";
    std::stringstream ss(line);

    std::string cmd;
    int count{};
    double price{};

    if (ss >> cmd >> count >> price) {
        std::cout << cmd << ": total=" << (count * price) << '\n'; // buy: total=59.7
    }
}

Опять же: если где-то в середине формат сломался, if это поймает.

4. Контроль формата и состояние потока

Проверяем, что лишнего ничего нет

Очень частая боль: строка вроде бы подходит, но пользователь написал лишний параметр, и вы не заметили.

Например, формат команды "sum A B", а пользователь ввёл "sum 10 20 30". Если вы прочитали только первые три токена, то команда «как бы сработала», но это уже не тот контракт, который вы хотели.

Типичный приём: после ожидаемых токенов попробовать прочитать «что-нибудь ещё».

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "sum 10 20 30";
    std::stringstream ss(line);

    std::string cmd;
    int a{}, b{};

    if (!(ss >> cmd >> a >> b)) {
        std::cout << "bad format\n";
        return 0;
    }

    std::string extra;
    if (ss >> extra) {
        std::cout << "too many arguments: " << extra << '\n'; // too many arguments: 30
        return 0;
    }

    std::cout << a + b << '\n';
}

Это внезапно делает ваш парсер в разы «взрослее», потому что он перестаёт молча принимать мусор.

Почему после ошибки «ничего не читается»

Когда чтение не удалось, поток обычно становится в состояние fail. Это означает: «я дальше читать не буду, пока меня не приведут в чувство».

Давайте увидим это поведение вживую:

#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::stringstream ss("10 xx 30");

    int a{}, b{}, c{};

    ss >> a;        // ok: a=10
    ss >> b;        // fail: "xx" не int
    ss >> c;        // уже не читается

    std::cout << "a=" << a << '\n'; // a=10
    std::cout << "b=" << b << '\n'; // b=0 (остался дефолт)
    std::cout << "c=" << c << '\n'; // c=0
}

Важный вывод: если вы игнорируете проверку, то переменные могут остаться со старыми/дефолтными значениями, и дальше вы делаете математику над тем, что не читалось.

5. Переиспользование и сборка строки

Переиспользование std::stringstream: str(...) и clear()

Иногда хочется взять один std::stringstream и кормить ему разные строки. И это нормально, но есть ловушка: если поток однажды ушёл в fail, то смена строки не вернёт его в норму автоматически.

Надо сделать две операции:

  • ss.str("новая строка") — заменить внутренний буфер,
  • ss.clear() — сбросить флаги состояния (например, fail).

Посмотрим на правильный шаблон:

#include <iostream>
#include <sstream>

int main() {
    std::stringstream ss;

    ss.str("10 20");
    int a{}, b{};
    ss >> a >> b;
    std::cout << a << "+" << b << '\n'; // 10+20

    ss.str("30 40");
    ss.clear(); // важно!
    ss >> a >> b;
    std::cout << a << "+" << b << '\n'; // 30+40
}

Если убрать clear(), то в некоторых сценариях (особенно после неудачного чтения) вы внезапно получите «поток молчит». Это один из тех багов, которые выглядят мистически, пока вы не знаете про состояние потока.

Для наглядности — маленькая блок-схема жизни потока:

flowchart TD
    A[Создали stringstream] --> B[Пытаемся читать через >>]
    B -->|успех| C[good state]
    B -->|ошибка формата| D[fail state]
    D --> E[Чтение дальше не работает]
    E --> F["ss.clear()"]
    F --> G[Можно читать снова]

std::stringstream как сборщик строки: << и .str()

Пока мы читали из строки. Но stringstream также умеет собирать строку (как cout собирает вывод). Это удобно, когда строка состоит из многих частей, и вы не хотите вручную клеить + и следить за пробелами.

Пример: сделать сообщение лога (или просто красивую строку).

#include <iostream>
#include <sstream>
#include <string>

int main() {
    int id = 7;
    int qty = 3;

    std::stringstream ss;
    ss << "added item id=" << id << " qty=" << qty;

    std::string msg = ss.str();
    std::cout << msg << '\n'; // added item id=7 qty=3
}

Почему это иногда удобнее, чем std::string + + +? Потому что << «ест» числа без ваших ручных to_string, и итог читается почти как обычный вывод.

Памятка: что реально используем

Чтобы не превращать лекцию в «а теперь запомните 200 методов», оставим только то, что реально пригодится в задачах:

Что хотим сделать Что писать Смысл
Создать поток из строки
std::stringstream ss(line);
Будем читать токены из line
Прочитать токен/число
ss >> x;
Читает, пропуская пробелы
Проверить, что прочиталось
if (ss >> a >> b)
Защита от плохого формата
Узнать, есть ли “лишнее”
if (ss >> extra)
Формат “слишком длинный”
Получить собранную строку
ss.str()
Вернуть буфер как std::string
Заменить буфер
ss.str("...")
Положить новую строку внутрь
Сбросить состояние
ss.clear()
После fail снова можно читать

6. Мини-CLI «Список покупок»

Сейчас мы сделаем небольшой шаг в сторону практики и начнём собирать «единое приложение курса». Пусть это будет консольная программа “Список покупок”, которая принимает команды строками (через getline) и парсит их через stringstream.

Мы пока не используем struct (это будет позже), поэтому сделаем простую модель: два vector одинаковой длины — names[i] и counts[i]. Да, это выглядит слегка как «я несу два пакета и делаю вид, что это один». Но для текущего этапа — нормально.

Каркас: читаем строки и выходим по "exit"

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;
    std::vector<int> counts;

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "exit") break;

        std::cout << "you typed: " << line << '\n'; // отладка
    }
}

Пока это «эхо-программа». Теперь добавим парсинг команды.

Парсим add <name> <count> через stringstream

Здесь мы впервые почувствуем, что парсинг командами становится почти механическим:

#include <iostream>
#include <sstream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;
    std::vector<int> counts;

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "exit") break;

        std::stringstream ss(line);

        std::string cmd;
        ss >> cmd;

        if (cmd == "add") {
            std::string name;
            int count{};

            std::string extra;
            if ((ss >> name >> count) && !(ss >> extra)) {
                names.push_back(name);
                counts.push_back(count);
                std::cout << "added\n"; // added
            } else {
                std::cout << "format: add <name> <count>\n";
            }
        }
    }
}

Обратите внимание на логику проверки: мы требуем ровно два аргумента, и отдельно убеждаемся, что третьего нет.

Да, команда "add milk 3" работает. Команда "add milk" — не работает. Команда "add milk 3 now" — тоже не работает. И это прекрасно.

Команда list: выводим текущие покупки

Добавим самую приятную команду — показать, что мы вообще накопили.

#include <iostream>
#include <sstream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;
    std::vector<int> counts;

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "exit") break;

        std::stringstream ss(line);
        std::string cmd;
        ss >> cmd;

        if (cmd == "list") {
            for (std::size_t i = 0; i < names.size(); ++i) {
                std::cout << i << ": " << names[i] << " x" << counts[i] << '\n';
            }
        }
        // add будет здесь же (опущено, чтобы пример был коротким)
    }
}

И тут видно главное преимущество строкового потока: мы отделили “читать строку целиком” от “разбирать строку на токены”. Именно так строятся многие консольные утилиты (и да, когда-нибудь вы напишете свою мини-версию git и будете этим гордиться).

Почему не всегда подходит stringstream

Когда формат пробельный, stringstream почти всегда выигрывает у ручного find/substr по одной простой причине: >> уже умеет пропускать лишние пробелы. То есть строка "add milk 3" будет работать без дополнительных плясок.

А вот если у вас формат с запятыми ("a,b,c") или с кавычками ("\"milk chocolate\""), то stringstream в одиночку не спасает — там нужны другие приёмы. Но это как раз логика дня: сначала учимся пробельным форматам, затем берём явные разделители через getline(..., delim), потом учимся кавычкам через std::quoted.

7. Типичные ошибки при работе со std::stringstream

Ошибка №1: читать без проверки и сразу использовать переменные.
Очень хочется написать ss >> cmd >> a >> b; и дальше считать результат, потому что «ну строка же нормальная». Но как только формат ломается, поток ставит fail, чтение не происходит, а переменные остаются со старыми/дефолтными значениями. Правильная привычка: либо if (ss >> ...), либо ранний выход из обработки команды.

Ошибка №2: не ловить “лишние аргументы”.
Новички часто проверяют только, что первые нужные поля считались, но не проверяют, что строка закончилась. В результате команды с мусором в конце начинают “случайно работать”, а пользователь привыкает к странному поведению. Надёжный приём: после чтения ожидаемых аргументов попытаться прочитать extra и, если получилось, считать формат некорректным.

Ошибка №3: переиспользовать один stringstream, забыв про clear().
Если поток хотя бы раз сломался на чтении (например, пытались прочитать int из "abc"), он остаётся в состоянии ошибки. Даже если вы сменили буфер через str("..."), поток всё равно “помнит”, что он в fail. Поэтому при переиспользовании правило железное: сначала ss.str(...), потом ss.clear(), и только потом читаем.

Ошибка №4: ожидать, что >> умеет читать “фразу” целиком.
>> читает токен до пробела. Если вы хотите параметр с пробелами (например, название товара "milk chocolate"), то stringstream без дополнительных инструментов не справится. Это не недостаток, это просто контракт: либо меняем формат (например, кавычки), либо читаем остаток строки отдельно, либо используем std::quoted (но это будет следующая лекция).

Ошибка №5: путать “пробелы в данных” и “пробелы как разделитель”.
Когда вы проектируете формат команды, нужно заранее решить: пробел — это часть значения или разделитель токенов. В пробельном формате пробел почти всегда разделитель, и это надо принять. Если значение может содержать пробелы, формат должен явно это обозначать (кавычками или другим разделителем), иначе парсер будет прав, а пользователь — недоволен.

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