1. Пробіли, токени та лапки
Якщо ви раніше писали інтерактивні програми, у вас уже, ймовірно, траплялася така ситуація: «Я ввів назву товару milk chocolate, а програма прочитала лише milk». Це не баг компілятора й не якась магія. Це звичайне правило operator>>: за замовчуванням він ділить потік за пробільними символами (пробіл, табуляція, переведення рядка). Для чисел це зазвичай чудово, а от для «назв», «повідомлень» і «адрес» це вже проблема.
Уявіть, що ми пишемо міні‑CLI (консольне меню), де користувач вводить команди:
- add milk 2
- add milk chocolate 2
У другому випадку формат стає двозначним: milk — це назва? Чи лише перша частина назви? А може, взагалі інша команда? Потоковий парсер не вміє «здогадуватися» — він просто читає токени.
Класичне рішення в текстових форматах просте: якщо поле може містити пробіли, ми беремо його в лапки. Тоді контракт стає чітким:
- команда: слово без пробілів
- назва: рядок у лапках, усередині можуть бути пробіли
- кількість: число
Тобто: add "milk chocolate" 2.
Залишилося навчитися зручно це читати. Можна вручну розбивати рядок, шукати першу й останню лапку, витягувати підрядок… Але сьогодні зробимо простіше: попросимо стандартну бібліотеку виконати цю роботу за нас.
2. std::quoted: що це таке і як підключити
Коли ви бачите в C++ щось на кшталт std::fixed, std::setprecision, std::setw, це маніпулятори потоку: невеликі обʼєкти, які змінюють поведінку введення та виведення. std::quoted належить до цієї самої родини.
Технічно std::quoted — це маніпулятор, який можна вставляти в ланцюжки << і >>.
- Під час виведення він друкує рядок у лапках і екранує спеціальні символи (зазвичай лапки та зворотний слеш).
- Під час введення він уміє прочитати рядок у лапках як один логічний токен, знімаючи зовнішні лапки й коректно обробляючи екранування.
Щоб користуватися std::quoted, потрібно підключити заголовок:
#include <iomanip> // std::quoted
І, звісно, потоки:
#include <iostream>
#include <sstream> // якщо читаємо з рядка, через istringstream
#include <string>
Швидка перевірка, що цей маніпулятор справді існує:
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string s = "hello world";
std::cout << std::quoted(s) << '\n'; // "hello world"
}
Зверніть увагу: навіть якщо в рядку немає пробілів, std::quoted усе одно додає лапки. Це нормально: ми явно просимо «виведи у quoted‑форматі».
3. Виведення: робимо рядок безпечним
Коли ми проєктуємо текстовий формат, нам потрібно вміти не лише читати, а й генерувати рядки в тому самому форматі. Тут std::quoted особливо приємний: він дає симетрію «запис ↔ читання». Ви записали рядок у лапках, потім прочитали його назад — і отримали те саме значення, якщо дотримано розумних обмежень.
Почнемо з простого: рядок із пробілами.
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string item = "milk chocolate";
std::cout << std::quoted(item) << '\n'; // "milk chocolate"
}
Тепер важливіший випадок: рядок із лапками всередині. Якщо ви використовуєте назви на кшталт Bob said "hi", без екранування формат зламається: де лапки відкривають поле, а де це просто символ усередині поля?
std::quoted під час виведення екранує лапки зворотним слешем.
#include <iomanip>
#include <iostream>
#include <string>
int main() {
std::string s = "Bob said \"hi\"";
std::cout << std::quoted(s) << '\n'; // "Bob said \"hi\""
}
І тут зʼявляється важлива думка, до якої ми ще повернемося: у цьому прикладі є два різні рівні екранування. Усередині C++‑коду ви написали \", тому що так улаштовані рядкові літерали мови. А std::quoted друкує \", тому що так улаштований текстовий формат даних. Символи схожі, але сенс у них різний.
Якщо ви складаєте рядок «як команду» для логів або щоб десь його зберегти (поки що — просто в памʼяті), зручно використовувати std::ostringstream:
#include <iomanip>
#include <sstream>
#include <string>
int main() {
std::string name = "milk chocolate";
int count = 3;
std::ostringstream oss;
oss << "add " << std::quoted(name) << ' ' << count;
// oss.str() == add "milk chocolate" 3
}
Ми поки що нічого не записуємо у файл (це тема іншого дня курсу), але вже вчимося будувати коректні рядки формату.
4. Введення: читаємо quoted‑рядок як один токен
Тепер — найпрактичніше: як зробити так, щоб рядок "milk chocolate" вважався одним полем, а не двома словами milk і chocolate.
Схема проста: ви читаєте з потоку як зазвичай, але для рядка використовуєте std::quoted(str).
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add \"milk chocolate\" 3";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name) >> count) {
std::cout << cmd << " | " << name << " | " << count << '\n';
// add | milk chocolate | 3
} else {
std::cout << "помилковий формат\n";
}
}
Тут важливо звикнути до дисципліни: не використовуйте значення, доки не перевірили, що читання було успішним. У новачків часто буває так: вони пишуть iss >> cmd >> std::quoted(name) >> count;, а потім друкують name, навіть якщо введення було неправильним. У разі помилки потік просто перейде в стан fail, а змінні можуть залишитися старими або частково заповненими — і починається серіал «чому це інколи працює».
Ще один важливий момент: std::quoted(name) очікує, що в потоці справді буде рядок у лапках (за замовчуванням — у подвійних "). Якщо лапок немає, читання зазвичай не спрацює так, як передбачено контрактом. І це добре: формат порушено, отже ми маємо чесно сказати "помилковий формат", а не намагатися вгадувати.
5. Два рівні екранування: дані й C++‑код
Зараз буде фрагмент лекції, після якого в половини групи зʼявиться вираз обличчя «я все зрозумів… але нічого не зрозумів». Це нормально: тут справді є два шари, і мозок спочатку намагається змішати їх в один.
Перший шар — екранування в рядковому літералі C++. Це те, що ви пишете у вихідному коді:
- "\n" — один символ переведення рядка
- "\"" — одна лапка
- "\\ " — один зворотний слеш (і пробіл)
Другий шар — екранування всередині формату даних, який ми парсимо. Наприклад, у quoted‑форматі лапка всередині рядка має бути записана як \", інакше вона закриє рядок завчасно.
Давайте подивимося на приклад, де в даних є лапки.
Дані (так користувач міг би ввести в CLI):
add "milk \"dark\"" 1
Тобто назва товару: milk "dark".
Але якщо ми хочемо записати цей рядок у C++‑літерал (для тесту), доведеться екранувати його вже за правилами C++:
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add \"milk \\\"dark\\\"\" 1";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name) >> count) {
std::cout << name << '\n'; // milk "dark"
}
}
Подивіться уважно: у C++‑коді написано \\\". Це означає: «у рядку даних буде \"». А \" уже означає «лапка всередині quoted‑рядка».
Хороша новина: у реальному житті ці рядки частіше надходять не з вихідного коду, а від користувача, із файла чи з мережі. Тоді залишається лише один рівень екранування — рівень формату даних.
Також корисно розуміти, що std::quoted під час читання «знімає» зовнішні лапки та розекрановує внутрішні послідовності. Тобто в name ви отримаєте вже «чистий» рядок для роботи.
6. Міні‑CLI «Список покупок»
Щоб std::quoted не залишився лише «цікавою штукою з лекції», давайте вбудуємо його в маленький консольний застосунок. Ми не використовуємо struct (його в нас ще немає за планом), тому зробимо просте «паралельне» сховище: vector<string> для назв і vector<int> для кількості. Так, це не ідеальна архітектура, але зараз наша мета — парсинг, а не дизайн моделі даних.
Почнемо зі зберігання й виведення списку:
#include <iostream>
#include <string>
#include <vector>
void printList(const std::vector<std::string>& names,
const std::vector<int>& counts) {
for (std::size_t i = 0; i < names.size(); ++i) {
std::cout << i << ": " << names[i] << " x" << counts[i] << '\n';
}
}
Тепер зробимо парсер команди add. Він приймає рядок цілком і намагається витягти команду, quoted‑назву та кількість.
#include <iomanip>
#include <sstream>
#include <string>
bool parseAdd(const std::string& line, std::string& name, int& count) {
std::istringstream iss(line);
std::string cmd;
if (!(iss >> cmd >> std::quoted(name) >> count)) return false;
if (cmd != "add") return false;
std::string extra;
return !(iss >> extra); // жодних зайвих токенів
}
Зверніть увагу на останню перевірку. Вона робить наш контракт суворішим: якщо користувач увів add "milk" 2 blah, ми вважаємо це помилкою формату й не ігноруємо хвіст мовчки. Іноді поблажливіший підхід корисний, але для навчального парсингу суворий режим простіше налагоджувати.
Тепер — обробник команди. Якщо товару ще немає, додамо нову позицію. Якщо вже є, збільшимо кількість. Для пошуку зробимо невелику функцію:
#include <string>
#include <vector>
int findIndex(const std::vector<std::string>& names, const std::string& name) {
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == name) return static_cast<int>(i);
}
return -1;
}
І застосуємо її:
#include <string>
#include <vector>
void addItem(std::vector<std::string>& names, std::vector<int>& counts,
const std::string& name, int count) {
int idx = findIndex(names, name);
if (idx == -1) {
names.push_back(name);
counts.push_back(count);
} else {
counts[static_cast<std::size_t>(idx)] += count;
}
}
Тепер зберемо цикл читання команд. Ми читаємо рядок цілком через std::getline, бо команди можуть містити пробіли всередині лапок — і це якраз наша тема.
#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::string name;
int count{};
if (parseAdd(line, name, count)) {
addItem(names, counts, name, count);
std::cout << "ok\n";
} else if (line == "list") {
printList(names, counts);
} else {
std::cout << "невідома команда або помилковий формат\n";
}
}
}
Тепер цей формат справді працює:
- add "milk chocolate" 3
- add "milk \"dark\"" 1 (якщо користувач уміє екранувати лапки в даних)
- list
- exit
І головне: ми парсимо назву як одне поле, навіть якщо в ній є пробіли.
7. Формат як контракт
На цьому етапі важливо не «вгадувати», що мав на увазі користувач, а чітко визначити, що саме ми вважаємо валідним. Тому що std::quoted — доволі суворий інструмент: він не «лікує» хаос, а допомагає підтримувати домовленість про формат.
Давайте запишемо контракт нашої команди add у вигляді невеликої таблиці:
| Поле | Приклад | Як читаємо | Коментар |
|---|---|---|---|
| Команда | |
|
Без пробілів, одне слово |
| Назва | |
|
Обовʼязково в лапках, пробіли всередині дозволені |
| Кількість | |
|
Ціле число |
Із цього автоматично випливає, що рядок add milk chocolate 3 для нас некоректний, навіть якщо «за змістом усе зрозуміло». Адже за контрактом «назва — у лапках». Це хороше правило: програма не повинна гратися в телепата.
Для наочності можна уявити розбір як маленьку блок‑схему:
flowchart TD
A[Прочитали рядок line] --> B[iss >> cmd]
B --> C{cmd == 'add'?}
C -- ні --> X[Це не add: інша команда]
C -- так --> D["iss >> quoted(name)"]
D --> E[iss >> count]
E --> F{читання успішне?}
F -- ні --> Y[помилковий формат]
F -- так --> G{є зайві токени?}
G -- так --> Y
G -- ні --> H["addItem(name, count)"]
Якщо ви вмієте так «малювати» процес у себе в голові, то починаєте налагоджувати парсери в рази швидше.
Інші лапки та символ екранування
Іноді формат даних диктує не подвійні лапки, а, наприклад, одинарні. Або з певної причини ви хочете екранувати не \, а іншим символом. std::quoted це дозволяє: у нього є варіант із параметрами «лапка» та «символ екранування».
Виглядає так:
std::quoted(str, quote_char, escape_char)
Приклад: читаємо рядок в одинарних лапках '...':
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string line = "add 'milk chocolate' 2";
std::istringstream iss(line);
std::string cmd;
std::string name;
int count{};
if (iss >> cmd >> std::quoted(name, '\'', '\\') >> count) {
std::cout << name << '\n'; // milk chocolate
}
}
Чому це може бути корисно? Іноді дані вже існують у такому форматі (наприклад, ви копіюєте введення з якоїсь системи), і вам простіше прийняти їхні правила, ніж змушувати користувача перевчатися. Але тут важливо не переборщити: що більше варіантів формату ви підтримуєте, то більше нетипових ситуацій доведеться налагоджувати.
Ще одна тонкість: якщо ви змінюєте символ екранування, то маєте розуміти, який вигляд тепер матимуть екрановані лапки всередині даних. Тобто ви буквально змінюєте «мову» міні‑формату.
8. Типові помилки під час роботи зі std::quoted
Помилка № 1: забули підключити <iomanip>.
Найпростіша й найприкріша ситуація: ви пишете std::quoted(name), а компілятор каже, що такого не знає. Інтуїтивно хочеться звинуватити <sstream> або <iostream>, але quoted живе саме в <iomanip>, тому що це маніпулятор введення/виведення.
Помилка № 2: очікують, що std::quoted «сам здогадається» і без лапок.
Якщо за контрактом поле може містити пробіли, лапки — не прикраса, а частина формату. std::quoted не має вгадувати, де закінчується рядок, якщо лапок немає. Тому введення add milk chocolate 3 — це не «майже правильно», а просто інший формат.
Помилка № 3: плутають екранування в даних та екранування в C++‑літералі.
Коли ви тестуєте парсер через std::string line = "...";, вам потрібно екранувати зворотні слеші та лапки за правилами C++ — і через це здається, що std::quoted «вимагає дивних послідовностей». Насправді в реальному введенні від користувача буде лише один рівень екранування — формат даних.
Помилка № 4: читають без перевірки й використовують змінні так, ніби все вийшло.
Дуже поширений сценарій: «ну я ж увів правильно, отже й програма прочитає». Але щойно зʼявиться зайвий пробіл, порожній рядок, забута лапка або літера замість числа, потік перейде в fail, і ваші змінні виявляться частково заповненими. Правило просте: спочатку if (iss >> ...), потім робота з результатом.
Помилка № 5: не перевіряють хвіст рядка й випадково приймають сміття.
Якщо ви не перевіряєте, що після читання всіх полів потік закінчився, то add "milk" 2 blah вважатиметься коректною командою, а blah тихо проігнорується. Іноді це допустимо, але частіше призводить до «дивних» сценаріїв, де користувач упевнений, що ввів ще один параметр, а програма робить вигляд, ніби його не було.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ