1. Вступ
Коли ви лише починаєте писати програми, легко подумати, що дані «живуть завжди», доки ви про них памʼятаєте. Та, на жаль, C++ не читає ваших думок. Він живе за правилами областей видимості: увійшли в блок { ... } — змінні зʼявилися, вийшли — зникли. Для звичайних типів це зазвичай очевидно. Але view-типи, особливо std::string_view, можуть виглядати «як рядок», хоча насправді це лише посилання на чужу памʼять. Саме тому тема часу життя в лекції про view-типи — не філософія, а техніка безпеки.
Уявіть, що view — це адреса на мапі, а власник — реальний будинок. Адресу можна переписати в блокнот, але якщо будинок знесли, сама адреса нікого не врятує. І найпідступніше ось що: ви все ще можете прийти за цією адресою, постояти й… іноді навіть побачити «шматок стіни» — випадкові дані в памʼяті. А іноді — отримати збій програми.
Головне правило: view не подовжує життя даних
Правило, яке сьогодні варто вивчити як таблицю множення:
std::string_view і std::span не володіють даними. Вони не подовжують і не захищають час життя. Тому view слід використовувати лише доти, доки живий власник даних.
Це правило має дві практичні складові.
Перша стосується часу життя власника: якщо власника знищено, view втрачає сенс. Друга — стабільності памʼяті власника: іноді власник залишається живим, але «переїжджає» в інше місце, наприклад коли std::vector збільшується й перевиділяє памʼять. Тоді view і далі зберігає «стару адресу», а це теж проблема. У цій лекції ми зосередимося на першій частині — «власник помер».
Для наочності корисна така схема:
flowchart LR
A[Owner: std::string / std::vector] -->|зберігає дані| D[(Дані в памʼяті)]
B[View: std::string_view / std::span] -->|вказує на| D
A -->|вийшли з блоку -> знищено| X[Дані зникли / стали недоступні]
B -->|лишився жити| Y[View вказує «в нікуди»]
View — як ярлик на робочому столі: якщо програму видалили, ярлик не перетворюється на програму. Він перетворюється на сумну іконку «файл не знайдено».
2. Де закінчується життя обʼєкта: мінімальна модель
Щоб застосовувати це правило на практиці, потрібно розуміти, де саме C++ «зачиняє двері й вимикає світло».
Найпростіша й цілком достатня для нас модель така: локальні змінні живуть до кінця блоку. Блоком може бути тіло if, for, while або просто { ... }. Коли виконання виходить із блоку, локальні змінні всередині нього знищуються.
Друга важлива річ: локальні змінні всередині функції знищуються під час виходу з неї. Це звучить очевидно, але саме тут найчастіше й зʼявляються «погані view».
Подивіться на приклад: він компілюється, але логічно небезпечний.
#include <string>
#include <string_view>
std::string_view bad() {
std::string s = "hello";
return std::string_view{s}; // s буде знищено під час виходу з функції
}
Ззовні bad() повертає «майже рядок». Насправді ж усередині функція повертає «віконце», яке дивилося на s. Але s уже знищено, тож це віконце дивиться в порожнечу.
Далі ми постійно ставитимемо собі одне запитання: «хто власник даних і де він живе?». Якщо відповіді немає, це тривожний сигнал.
3. Сценарій: повертаємо std::string_view на локальний рядок
Дуже хочеться написати «зручну» функцію, яка повертає частину рядка як string_view, адже це швидко й без копій. Але якщо вихідний рядок створюється всередині функції, повертати view не можна: разом із виходом із функції вихідний рядок помре.
Поганий приклад
Зараз ми навмисно напишемо код, який виглядає як оптимізація: «я не копіюю рядок, я повертаю view». Насправді це оптимізація в стилі «прискорив падіння програми». Такий код інколи навіть працює на маленьких тестах, бо памʼять ще «не затерлася». Але це саме той випадок, коли «інколи» — не успіх, а пастка.
#include <string>
#include <string_view>
std::string_view get_prefix_bad() {
std::string s = "cmd:run";
return std::string_view{s}.substr(0, 3); // "cmd"
}
Проблема не в substr(). Проблема в тому, що s — локальна змінна.
Як зробити правильно
А тепер — як правильно: або нехай власник створюється ззовні й передається всередину, або функція має повертати тип, що володіє результатом (std::string). Вибір залежить від контракту: чи повинен результат жити незалежно, чи ми просто хочемо «подивитися» на частину вже наявного рядка.
Варіант A: власник приходить параметром, повертаємо view
#include <string_view>
std::string_view get_prefix_ok(std::string_view s) {
if (s.size() < 3) return s;
return s.substr(0, 3);
}
Тут функція чесно каже: «я не володію рядком, я лише дивлюся». Отже, код, що викликає функцію, зобовʼязаний передати рядок, який живе достатньо довго.
Варіант B: потрібен незалежний результат — повертаємо std::string
#include <string>
std::string get_prefix_copy(std::string_view s) {
std::size_t n = (s.size() < 3) ? s.size() : 3;
return std::string{s.substr(0, n)}; // копія: результат живе сам
}
Так, тут є копіювання. Але воно чесне й безпечне: результат не залежить від часу життя вихідного рядка.
4. Сценарії: тимчасові обʼєкти та зберігання view «на потім»
View на тимчасовий std::string
Навіть якщо ви не створюєте std::string як локальну змінну, можна ненароком отримати тимчасовий обʼєкт. Якщо спростити, тимчасові обʼєкти — це такі «одноразові склянки»: вони існують лише до кінця виразу, а потім зникають. std::string_view може легко «прилипнути» до тимчасового std::string, і тоді ви отримаєте view на вже знищений рядок. Це особливо підступно, бо синтаксис виглядає охайно й сучасно.
Поганий приклад: створюємо тимчасовий рядок через +, а потім беремо view.
#include <string>
#include <string_view>
std::string_view bad_temp() {
std::string_view v = std::string("hi") + "!!!"; // тимчасовий рядок
return v; // v уже вказує на знищені дані
}
Чому? Тому що std::string("hi") + "!!!" створює тимчасовий std::string. Після завершення цього рядка коду тимчасовий обʼєкт знищується. А v лишається.
Правильна ідея така: якщо ви створюєте новий рядок — конкатенацією, через append або форматуванням, — отже, ви створили нового власника. Нехай цей власник живе у змінній:
#include <string>
#include <string_view>
int main() {
std::string owner = std::string("hi") + "!!!";
std::string_view v = owner;
// v безпечний, доки живе owner
}
«Давайте збережемо string_view, раптом знадобиться»
У цьому місці в новачків часто вмикається режим «ощадливості»: «я не хочу копіювати рядок, давайте зберігатимемо string_view у структурі, у векторі, у глобальній змінній». Ідея звучить економно, але на практиці перетворюється на кредит під 300 % річних: економія сьогодні, баги завтра. Зберігати view можна, але лише якщо ви справді жорстко контролюєте, що власник живе довше. У навчальних проєктах такого контролю найчастіше немає.
Розгляньмо типовий шаблон помилки: читаємо рядки построково, ріжемо їх на шматки string_view і зберігаємо ці шматки в контейнері.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
int main() {
std::vector<std::string_view> tokens;
std::string line;
while (std::getline(std::cin, line)) {
tokens.push_back(std::string_view{line}); // token вказує на line
}
// Після циклу line містить лише останній рядок,
// а старі tokens вказують у минуле (яке вже перезаписано).
}
Зверніть увагу: line живий, але його вміст змінювався. Усі std::string_view, які вказували на попередні значення line, тепер указують невідомо на що. Іноді це буде останній рядок, іноді — сміття, а іноді здаватиметься, що все «працює».
Що робити? Якщо вам потрібно зберігати токени надовго, зберігайте власників, тобто std::string. Наприклад, копіюйте токени в std::vector<std::string>. Так, це копіювання. Але воно відповідає самій задачі: «зберігати».
5. std::span і час життя: ті самі правила
Зі std::span ситуація концептуально така сама, тільки «рядок» замінюється на «масив елементів». std::span<T> зазвичай зберігає вказівник на перший елемент і довжину. Він зручніший, ніж пара (T*, size), але щодо часу життя так само суворий: якщо масив зник, span зникає разом із надією на коректність. До того ж span особливо люблять використовувати у функціях, що працюють із числами, тому помилка може проявитися не одразу, а як дивні результати обчислень.
Поганий приклад: повертаємо span на локальний масив.
#include <span>
std::span<const int> bad_span() {
int a[3] = {1, 2, 3};
return std::span<const int>(a); // a буде знищено під час виходу з функції
}
Правильний підхід схожий на випадок із рядками: або власник приходить ззовні, або ви повертаєте контейнер, що володіє даними, — std::vector<int> чи std::array<int, N>, — а не view.
Наприклад, якщо дані створюються всередині, можна повернути std::vector<int>:
#include <vector>
std::vector<int> make_data() {
return {1, 2, 3};
}
А span створити вже в коді, що викликає функцію:
#include <span>
#include <vector>
int sum(std::span<const int> xs) {
int total = 0;
for (int x : xs) total += x;
return total;
}
int main() {
std::vector<int> data = {1, 2, 3};
std::span<const int> view(data.data(), data.size());
int s = sum(view);
}
І знову: view живе рівно стільки, скільки живе data.
6. Приклад у застосунку: view живе до кінця обробки команди
Щоб правило часу життя не лишилося «теорією на плакаті», привʼяжемо його до нашої навчальної програми. Уявімо невеликий консольний застосунок, який читає команду рядком і виконує прості дії, наприклад "add", "list" або "help". Після теми про парсинг хочеться розбирати команду ефективно, без зайвих копій. І тут string_view ідеально підходить — але лише якщо ми не зберігатимемо його довше за одну ітерацію циклу.
Розбір команди без зберігання view
Зробімо просту функцію: відокремимо команду від аргументу пробілом. Функція повертає два string_view, і це нормально, бо код, що викликає функцію, володіє рядком та використовує результат одразу.
#include <string_view>
#include <utility>
std::pair<std::string_view, std::string_view> split_cmd(std::string_view line) {
std::size_t pos = line.find(' ');
if (pos == std::string_view::npos) return {line, std::string_view{}};
return {line.substr(0, pos), line.substr(pos + 1)};
}
Ключовий контракт: split_cmd не зберігає view, не кладе його в глобальні змінні й не повертає посилання на локальні дані. Вона просто розрізає те, що їй дали.
Безпечний цикл читання команд
Тепер у main читаємо рядок-власник, будуємо view, розбираємо, виконуємо — і забуваємо.
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string line;
while (std::getline(std::cin, line)) {
std::string_view v = line; // view живе в межах ітерації
auto [cmd, arg] = split_cmd(v);
if (cmd == "help") {
std::cout << "Команди: help, echo\n";
} else if (cmd == "echo") {
std::cout << arg << '\n';
}
}
}
Тут усе чесно: line — власник, v, cmd, arg — view. Ітерація закінчилася — view більше не використовується. Ми не намагаємося «зберегти arg назавжди».
Якщо вам хочеться десь «запамʼятати аргумент», наприклад зберегти нотатку в список, це вже інший контракт. Отже, потрібно копіювати arg у std::string і зберігати саме її.
7. Швидка самоперевірка: чи можна тут view
Коли ви пишете код із string_view/span, спершу майже завжди виникає сумнів: «а це безпечно?». Це нормально. Щоб не ворожити на кавовій гущі, корисно тримати поруч маленьку таблицю рішень. Вона не замінює розуміння, але допомагає в типових випадках. Якщо ваш випадок не потрапляє ні в один рядок, значить, треба зупинитися й зʼясувати, хто власник і який у нього час життя.
| Ситуація | Чи можна використовувати view? | Чому |
|---|---|---|
| Функція приймає std::string_view і одразу читає | Так | View живе всередині виклику, дані належать коду, що викликає функцію |
| Функція повертає std::string_view на рядок, переданий параметром | Іноді так | Лише якщо код, що викликає функцію, гарантує час життя рядка після повернення |
| Функція повертає std::string_view на локальний std::string | Ні | Локальний рядок буде знищено під час виходу з функції |
| Зберігаємо std::string_view у vector «на майбутнє» | Зазвичай ні | Надто легко втратити власника або перезаписати дані |
| Функція приймає std::span<const int> і рахує суму | Так | Немає копій, а час життя даних контролює код, що викликає функцію |
| Повертаємо std::span<int> на локальний масив | Ні | Масив буде знищено під час виходу з функції |
8. Типові помилки
Помилка № 1: повернення string_view/span, який дивиться на локальні дані функції.
Це найчастіший і найкласичніший провал. Код компілюється, іноді навіть видає «правильні» значення на маленьких тестах, а потім починає поводитися дивно. Ліки прості: або повертайте тип, що володіє результатом, — std::string, std::vector, — або приймайте власника параметром і повертайте view як частину його даних.
Помилка № 2: зберігання view довше, ніж живе вихідний рядок або масив.
Новачки часто зберігають std::string_view у полі структури або в контейнері, не зберігши власника поруч. За кілька кроків виконання програми власник змінюється або знищується, а view лишається. Виправлення просте: зберігати власника, наприклад std::string, або зберігати ідентифікатор чи позиції, а view будувати «на льоту» вже під час використання.
Помилка № 3: view на дані, які «тихо змінюються».
Навіть якщо власник формально живий, його вміст може змінюватися, наприклад якщо ви використовуєте одну змінну std::string line у циклі й перезаписуєте її через getline. View, збережений на попередній ітерації, тепер указує на нові дані. Ліки: view має бути короткоживучим і використовуватися в межах одного «такту» обробки, або ж ви маєте робити копію даних для довготривалого зберігання.
Помилка № 4: «раз не копіює, отже безпечніше».
Це психологічна пастка. string_view/span дають швидкість і зручність інтерфейсу, але не додають безпеки часу життя — навпаки, вимоги до уважності стають вищими. Добра звичка: щоразу, коли створюєте view, подумки проговорюйте: «хто власник і коли він помре».
Помилка № 5: спроба зробити view «універсальним сховищем результату».
Іноді хочеться повернути з функції string_view як «результат пошуку» або «обрізаний рядок», а потім десь зберігати цей результат. Якщо результат справді потрібно зберігати незалежно, це робота типів, що володіють даними. View — це не «результат», view — це «погляд».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ