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 << "поганий формат\n"; // поганий формат
    }
}

Тут важлива проста думка: ми використовуємо 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 << "поганий формат\n";
        return 0;
    }

    std::string extra;
    if (ss >> extra) {
        std::cout << "забагато аргументів: " << extra << '\n'; // забагато аргументів: 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]
    B -->|помилка формату| D[стан fail]
    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 << "додано товар id=" << id << " qty=" << qty;

    std::string msg = ss.str();
    std::cout << msg << '\n'; // додано товар 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 << "ви ввели: " << 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 << "додано\n"; // додано
            } else {
                std::cout << "формат: 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: плутати «пробіли в даних» і «пробіли як розділювач».
Коли ви проєктуєте формат команди, треба заздалегідь вирішити: пробіл — це частина значення чи розділювач токенів. У пробільному форматі пробіл майже завжди є розділювачем, і це потрібно прийняти. Якщо ж значення може містити пробіли, формат має явно це позначати — лапками або іншим розділювачем. Інакше парсер матиме рацію, а користувач залишиться незадоволеним.

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