JavaRush /Курси /C++ SELF /string_view‑граблі: п...

string_view‑граблі: поглиблення правил часу життя

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

1. std::string_view одночасно корисний і небезпечний

Коли ви вперше бачите std::string_view, він часто сприймається як «легкий рядок без копіювання»: передали у функцію — і все швидко, зручно, сучасно. І це правда, доки ви памʼятаєте один важливий нюанс: string_view не володіє текстом. Він радше схожий на «віконце» в чужі дані. Віконце — річ зручна: подивитися можна, забрати додому — ні.

Практична цінність string_view зазвичай у двох речах. По‑перше, ми можемо приймати текст у функцію без копіювання: хоч std::string, хоч рядковий літерал, хоч фрагмент рядка. По‑друге, можна брати підрядки теж без копіювання: просто «вказати на діапазон символів».

Але з цих самих переваг випливає і ризик: якщо власник тексту зник або перемістив свої дані, view і далі зберігатиме стару адресу й довжину — і ви читатимете вже не текст, а випадкові дані.

Щоб чітко розрізняти ролі, корисно тримати в голові таку таблицю:

Сутність Володіє памʼяттю? Зберігає «де текст»? Час життя даних
std::string
так так доки живий обʼєкт
std::string
рядковий літерал "hi" «так» (статично) так до кінця програми
std::string_view
ні так (адреса + довжина) не керує часом життя

Що насправді всередині std::string_view

Дуже легко помилитися, якщо думати, що string_view — це «майже string». Насправді це зовсім інша сутність: просто пара «вказівник на символи + кількість символів». Жодних гарантій щодо нульового термінатора, жодних гарантій, що «дані житимуть», жодних гарантій, що «дані не зміняться». Тому string_view часто поводиться як акуратний «зріз» масиву символів.

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


#include <iostream>
#include <string_view>

int main() {
    std::string_view sv = "hello";

    std::cout << sv.size() << '\n'; // 5
    std::cout << sv << '\n';        // hello
}

Чому це працює безпечно? Тому що "hello" — рядковий літерал. Він живе дуже довго (практично «завжди»), тож view на нього не стає висячим.

Важлива деталь: std::string_view може вказувати на текст, який узагалі не нуль-термінований. Наприклад, на середину рядка. Це нормально. Але це означає, що будь-яка логіка на кшталт «передам data() кудись, де очікують C‑рядок» може раптом зламатися. І зазвичай ламається саме того дня, коли ви вже впевнені, що «все протестовано».

2. Закон дня: view не має пережити власника

Центральна думка всієї лекції звучить майже як правило техніки безпеки: view не повинен жити довше за власника даних. Якщо ви запамʼятаєте лише одне речення, нехай це буде воно.

Зручна аналогія: std::string — це книга, std::string_view — це закладка з номером сторінки та рядком. Закладка не зберігає текст. Вона зберігає «де читати». Якщо книгу викинули, закладка лишилася, але читати тепер нічого. А якщо книгу передрукували і сторінки «переїхали», закладка вказує в нікуди, хоча виглядає цілком солідно.

Ще одна важлива деталь: час життя view (тобто змінної std::string_view) і час життя даних, на які він дивиться, — це різні речі. View може жити довго, а дані — уже ні. Саме це і є ситуація «dangling string_view».

3. Часті граблі з часом життя std::string_view

Грабля № 1: string_view із тимчасового std::string

Найчастіша й найприкріша помилка: створити тимчасовий рядок і одразу зробити з нього string_view. Код компілюється ідеально. На ревʼю виглядає «сучасно». А потім починаються містичні падіння «лише на сервері і лише по пʼятницях».

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string_view sv = std::string("hi");
    std::cout << sv << '\n'; // UB: sv дивиться на вже знищений рядок
}

Чому так? Тому що std::string("hi") — тимчасовий обʼєкт. Він живе до кінця повного виразу, тобто приблизно до ;. Після ; тимчасовий рядок знищується, а sv лишається з адресою з «минулого».

Приховані тимчасові рядки: substr у std::string

Є ще хитріший варіант. Ви можете навіть не створювати std::string("...") вручну. Достатньо викликати метод, який повертає std::string, а не string_view. Наприклад, std::string::substr.

#include <string>
#include <string_view>

int main() {
    std::string s = "command: add milk";

    std::string_view v = s.substr(0, 7); // UB: substr повернув тимчасовий std::string
    (void)v;
}

Тут проблема не в s — він живий. Проблема в тому, що s.substr(...) створив новий рядок — тимчасовий, і view побудувався вже на ньому.

Як зробити правильно? Якщо ви хочете саме view без копіювання, то створіть view із початкового рядка, а вже потім беріть substr у view:

#include <string>
#include <string_view>

int main() {
    std::string s = "command: add milk";

    std::string_view v = std::string_view{s}.substr(0, 7); // OK
    (void)v;
}

Тепер substr робить зріз усередині view, а view дивиться на памʼять s, яка живе.

Нюанс із практики: чому тут легко помилитися

Навіть у комітеті C++ обговорювали деталі конструкторів string_view для range‑сценаріїв і суворість перетворень — тобто тема «як легко випадково побудувати view не звідти» справді болюча. Наприклад, порушувалося питання, що range‑конструктор string_view має бути explicit.

Грабля № 2: функція повертає string_view, а всередині створює рядок

Після першої граблі легко наступити на другу: «гаразд, не робитиму тимчасовий у main, напишу функцію, яка повертає view». І тут dangling майже гарантований, бо рядок усередині функції локальний, а отже знищується під час виходу з функції.

#include <string>
#include <string_view>

std::string_view bad_prefix() {
    std::string p = "cmd:";
    return p; // UB: p буде знищено під час виходу з функції
}

Це прямий аналог «повернути посилання на локальну змінну», тільки у світі рядків.

Повертати string_view можна, але тільки якщо ви повертаєте view на дані, які гарантовано живуть довше. Наприклад, на рядковий літерал:

#include <string_view>

std::string_view ok_prefix() {
    return "cmd:"; // OK: літерал живе до кінця програми
}

Або на рядок, який вам передали ззовні (але тут зʼявляється інший ризик: код, що викликає функцію, може передати тимчасовий рядок).

Грабля № 3: повертаємо view на параметр, але передають тимчасовий обʼєкт

Ця грабля виглядає особливо елегантно. Ви пишете функцію «без копій», приймаєте std::string_view, повертаєте std::string_view — краса. Проблема в тому, що контракт функції починає залежати від того, що саме їй передали.

#include <string_view>

std::string_view first_word(std::string_view line) {
    std::size_t pos = line.find(' ');
    return (pos == std::string_view::npos) ? line : line.substr(0, pos);
}

Тепер небезпечний виклик:

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string_view w = first_word(std::string("add milk"));
    std::cout << w << '\n'; // UB: тимчасовий рядок помер після ';'
}

Сама функція first_word «чесна»: вона працює з view і повертає view на той самий буфер. Але контракт функції виходить крихким: «повернене значення валідне, доки живе джерело тексту».

Як зробити надійніше в коді початківця? Дуже часто відповідь проста: повертати std::string за значенням, якщо результат передбачається зберігати.

#include <string>
#include <string_view>

std::string first_word_copy(std::string_view line) {
    std::size_t pos = line.find(' ');
    std::string_view w = (pos == std::string_view::npos) ? line : line.substr(0, pos);
    return std::string(w); // копіюємо рівно потрібний шматок
}

Так, це копія. Зате контракт прозорий: результат живе сам по собі.

Грабля № 4: зберігати std::string_view як поле структури

Коли ви починаєте моделювати дані, зʼявляється спокуса: «а давайте зберігатимемо string_view замість string, щоб було швидше». Це один із найдорожчих мікровиграшів у вашому житті, бо він часто перетворюється на мінне поле часу життя.

Дуже поганий варіант моделі виглядає так:

#include <string_view>

struct Task {
    int id = 0;
    std::string_view title; // небезпечно: хто власник тексту?
    bool done = false;
};

Чому небезпечно? Тому що Task тепер зберігає «чужу закладку». А де книга? Хто гарантує, що вона не зникне? Найчастіше — ніхто.

Правильний, хоч і нудний, варіант — володіти рядком:

#include <string>

struct Task {
    int id = 0;
    std::string title; // володіємо
    bool done = false;
};

А string_view використовувати там, де він справді корисний: на вході функцій, під час парсингу, порівняння й пошуку, але не як довготривале сховище.

Грабля № 5: view на std::string, а потім рядок змінюють

Навіть якщо власник тексту живе достатньо довго, лишається ще одна причина, чому view може «зламатися»: власник може змінити буфер. Для std::string це означає таке: під час модифікації рядок може перевиділити памʼять, переїхати в інше місце — і стара адреса вже не вказуватиме на актуальні дані.

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string s = "abc";
    std::string_view v = s;

    s += "defghijklmnopqrstuvwxyz"; // може перевиділити буфер
    std::cout << v << '\n';         // UB, якщо буфер переїхав
}

Підступність у тому, що іноді буфер не переїжджає (наприклад, через small string optimization або тому, що capacity() уже достатня). Тому помилка може «не проявлятися» на маленьких рядках і проявитися на великих, на іншому компіляторі або просто в іншій фазі місяця.

Практичний висновок для початкового коду простий: якщо ви створили string_view для рядка, не модифікуйте цей рядок, доки view вам потрібен. А якщо рядок потрібно змінити — створіть view заново після модифікацій, як «одноразове посилання на поточну версію».

4. Безпечний стиль для TaskBoard: приймаємо view, зберігаємо string

Хочеться не просто налякатися, а й скласти робочий стиль, який справді можна застосовувати в проєкті. Добрий базовий підхід такий: усередині моделей і сховища ми володіємо даними, а string_view використовуємо на межі функцій — як вхідний параметр «прочитати просто зараз».

Нехай у нас є додавання задачі. Зручна сигнатура має такий вигляд: ми приймаємо std::string_view title, але в модель кладемо копію, якою володіємо.

#include <string>
#include <string_view>
#include <vector>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

void add_task(std::vector<Task>& tasks, int id, std::string_view title) {
    tasks.push_back(Task{id, std::string(title), false});
}

Зверніть увагу на ідею: view живе лише в межах виклику add_task, а всередині Task ми одразу створюємо рядок, яким володіємо. Це саме той випадок, коли копіювання виправдане: ми перетворюємо зовнішній «запозичений» текст на наш власний стан.

Схожий стиль корисний і для пошуку. Наприклад, перевірити, чи починається заголовок із "#":

#include <string_view>

bool starts_with_hash(std::string_view s) {
    return !s.empty() && s[0] == '#';
}

Ця функція добре пасує для string_view, тому що вона нічого не зберігає і не повертає назовні посилання на чужі дані. Вона просто читає й відповідає.

Міні‑набір парсингу на string_view

Щоб побачити string_view у природному середовищі, розгляньмо невеликий шматочок парсингу команд для TaskBoard. Користувач вводить рядки на кшталт "add Buy milk" або "done 3". Ми читаємо рядок у std::string line, а потім парсимо його, уникаючи зайвих копій на проміжних кроках.

Почнімо з простого — «обрізання» пробілів ліворуч. Функція повертає string_view, і це нормально, бо повернений view усе ще дивиться на ті самі початкові дані.

#include <string_view>

std::string_view ltrim(std::string_view s) {
    while (!s.empty() && s.front() == ' ') {
        s.remove_prefix(1);
    }
    return s;
}

Тепер — «розділити один раз» за пробілом: команда і решта рядка.

#include <string_view>
#include <utility>

std::pair<std::string_view, std::string_view> split_once(std::string_view s) {
    s = ltrim(s);
    std::size_t pos = s.find(' ');
    if (pos == std::string_view::npos) return {s, std::string_view{}};
    return {s.substr(0, pos), ltrim(s.substr(pos + 1))};
}

Ці функції безпечні за однієї умови: вхідний view має бути коректним. У нашому стилі це забезпечується просто: ми читаємо рядок у std::string line, передаємо std::string_view{line} у парсер, а той працює лише в межах поточної ітерації обробки команди.

Тепер зробімо результат парсингу таким, що володіє даними. Це ключовий момент: парсер може всередині використовувати view, але назовні повертає рядки, якими володіє.

#include <string>

struct ParsedAdd {
    std::string title;
};

І функція парсингу команди add:

#include <optional>
#include <string>
#include <string_view>

std::optional<ParsedAdd> parse_add(std::string_view line) {
    auto [cmd, rest] = split_once(line);
    if (cmd != "add" || rest.empty()) return std::nullopt;
    return ParsedAdd{std::string(rest)};
}

Тут усе правильно з погляду часу життя: rest — це view на line, але ми одразу копіюємо його в std::string. Тому ParsedAdd можна безпечно зберігати й використовувати де завгодно.

І от тут починається доросла дисципліна: якщо ви повертаєте назовні обʼєкт, який житиме далі, цей обʼєкт має володіти всім, що йому потрібно. View добрий як проміжний інструмент усередині функції, але погано пасує як частина результату, якщо ви не готові вбудовувати в контракт умову «а джерело точно живе?».

5. Шпаргалка: де string_view доречний, а де небезпечний

Коли ви пишете код, мозок дуже любить усе спрощувати: раз тип підходить, значить усе гаразд. Але з string_view так не працює — тут потрібна швидка перевірка часу життя. Добра новина: якщо ставити собі правильні запитання, вона справді займає секунди.

Місце в коді
std::string_view
зазвичай…
Чому
параметр функції «прочитати текст» доречний живе лише під час виклику
локальна змінна всередині функції доречний ви контролюєте порядок дій
значення, яке повертає функція ризикований код, що викликає функцію, може зберігати його довше, ніж живе джерело
поле
struct/class
майже завжди ризикований поле живе довго, а власник тексту може стільки не прожити
view на рядок, який ви потім змінюєте ризикований буфер може переїхати

А тепер та сама перевірка у вигляді невеликої блок‑схеми:

flowchart TD
    A["Хочу використати string_view"] --> B{"Де він житиме?"}
    B -->|Лише всередині виклику / функції| C["Зазвичай безпечно"]
    B -->|Зберігатиму в полі / глобально / у контейнері| D{"Є залізна гарантія, що власник живе довше?"}
    D -->|Так, власник живе довше і не змінює буфер| E["Можна, але контракт має бути явним"]
    D -->|Ні / не впевнений| F["Зберігайте std::string (володійте даними)"]

Якщо ви ловите себе на думці: «ну, наче власник житиме…» — це майже завжди сигнал, що краще володіти рядком. std::string дешевший за тиждень налагодження.

6. Типові помилки під час роботи з std::string_view

Помилка № 1: std::string_view sv = std::string("..."); і впевненість, що «ну я ж одразу використовую».
Це одна з найчастіших пасток, бо вона виглядає як «оптимізація»: жодних копій, усе сучасно. На практиці тимчасовий std::string знищується наприкінці повного виразу, а sv лишається. Правильна звичка: якщо джерело тимчасове — або створіть рядок із володінням std::string, або не зберігайте view поза межами виразу.

Помилка № 2: std::string_view v = s.substr(...) у std::string.
Інтуїтивно здається, що «substr — це підрядок, отже view». Але у std::string substr повертає новий std::string, тобто створює тимчасовий обʼєкт, і view починає дивитися на нього. Набагато безпечніше брати substr у std::string_view{s} — тоді ви справді отримуєте «зріз без копії».

Помилка № 3: повертати std::string_view із функції, де рядок створюється всередині.
Це точний аналог «повернення посилання на локальну змінну», тільки в текстовому вигляді. Якщо рядок створено всередині, він знищиться під час виходу, а view стане висячим. Лікується чесно: повертайте std::string за значенням, або повертайте view лише на дані, чий час життя гарантовано довший (наприклад, літерали).

Помилка № 4: зберігати std::string_view у моделі даних (наприклад, struct Task { std::string_view title; }).
Такий дизайн змушує всю систему залежати від зовнішнього власника тексту. У реальному застосунку це майже завжди призводить до ситуацій «воно вчора працювало». Надійніший стиль: зберігати std::string у моделі, а string_view використовувати в параметрах і під час розбору вхідного рядка.

Помилка № 5: тримати view на std::string, а потім модифікувати цей рядок.
Навіть якщо рядок усе ще «живий», його буфер може перевиділитися під час +=, append(), insert() та інших операцій. Старий view не оновиться і продовжить вказувати на колишню адресу. У безпечному стилі або не змінюють рядок, доки view використовується, або створюють view заново після модифікацій, або одразу копіюють потрібний фрагмент у std::string.

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