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 методів», залишимо лише те, що справді знадобиться в задачах:
| Що хочемо зробити | Що писати | Сенс |
|---|---|---|
| Створити потік з рядка | |
Читатимемо токени з line |
| Прочитати токен/число | |
Читає, пропускаючи пробіли |
| Перевірити, що прочиталося | |
Захист від поганого формату |
| Перевірити, чи є «зайве» | |
Формат виявився «задовгим» |
| Отримати зібраний рядок | |
Повернути буфер як std::string |
| Замінити буфер | |
Покласти всередину новий рядок |
| Скинути стан | |
Після 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: плутати «пробіли в даних» і «пробіли як розділювач».
Коли ви проєктуєте формат команди, треба заздалегідь вирішити: пробіл — це частина значення чи розділювач токенів. У пробільному форматі пробіл майже завжди є розділювачем, і це потрібно прийняти. Якщо ж значення може містити пробіли, формат має явно це позначати — лапками або іншим розділювачем. Інакше парсер матиме рацію, а користувач залишиться незадоволеним.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ