JavaRush /Курси /C++ SELF /std::quoted: читання та запис рядків у лапках

std::quoted: читання та запис рядків у лапках

C++ SELF
Рівень 17 , Лекція 3
Відкрита

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
std::string cmd; iss >> cmd;
Без пробілів, одне слово
Назва
"milk chocolate"
iss >> std::quoted(name);
Обовʼязково в лапках, пробіли всередині дозволені
Кількість
3
iss >> count;
Ціле число

Із цього автоматично випливає, що рядок 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 тихо проігнорується. Іноді це допустимо, але частіше призводить до «дивних» сценаріїв, де користувач упевнений, що ввів ще один параметр, а програма робить вигляд, ніби його не було.

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