1. Навіщо потрібні правила й модель owner/view
Приємно думати, що компілятор — це суворий дорослий у кімнаті, який не дасть вам наробити дурниць. Але коли йдеться про час життя в C++, компілятор часто може лише знизати плечима: «Типи ж збігаються… а чи живий ще обʼєкт — це вже ваша сюжетна лінія». Через це dangling-помилки особливо неприємні: програма може працювати, потім перестати, потім знову «майже працювати», а ви підозрюватимете погоду, фазу Місяця й сусідній Wi‑Fi.
Проблема в тому, що посилання, вказівник, std::string_view і std::span — це не володіння, а лише «адреса + обіцянка, що там є дані». Якщо цю обіцянку порушено, мова не зобовʼязана вас рятувати. Тому нам потрібні прості й людяні правила — такі, щоб ви могли подивитися на API й одразу зрозуміти: «Ага, ось власник, а ось той, хто лише дивиться».
Owner і view: хто за що відповідає
Щоб далі не потонути в термінах, домовмося про просту картину. Власник — це обʼєкт, який справді зберігає дані й відповідає за їхнє життя. View — це обʼєкт, який «дивиться» на чужі дані, але не керує ними. Якщо власник помер або переїхав, view лишається з адресою на памʼять, яка вже не зобовʼязана містити потрібні байти.
Схема (дуже спрощена, але корисна):
flowchart LR
Owner["Owner: std::string / std::vector / масив"] --> Buffer["Буфер у памʼяті"]
View["View: string_view / span / T* / T&"] --> Buffer
Важлива думка: view не продовжує життя власника. Він не «приклеюється» до обʼєкта магією. Це радше закладка в книжці: якщо книжку спалили, закладка не перетвориться на нову книжку.
2. Правило № 1: owner живе довше за view
Звучить банально, але саме це правило найчастіше рятує проєкт від «містичних» падінь. Якщо у вас є std::string_view sv, то десь має бути std::string s (або літерал, або інше довговічне джерело), яке гарантовано живе, поки використовується sv. І так само для std::span: має бути масив/std::vector/std::array, чия памʼять не зникне й не переїде.
Подивімося на «добре» й «погано» в максимально прикладному вигляді.
View як вхідний параметр — зазвичай безпечно
Коли функція приймає view і не зберігає його, вона користується цим «вікном» лише під час виклику. Це найвдаліший контракт.
#include <iostream>
#include <string_view>
bool is_command(std::string_view s) {
return !s.empty() && s[0] == '/';
}
int main() {
std::cout << is_command("/add") << '\n'; // 1
std::cout << is_command("hello") << '\n'; // 0
}
Тут std::string_view живе лише всередині is_command. Ми не намагаємося повернути його назовні й не кладемо в поле структури. Це буквально «здорове харчування» для безпеки часу життя.
View, побудований від тимчасового власника, — класична пастка
Ця помилка виглядає невинно: «Ну це ж один рядок, що може піти не так?». А потім — UB.
#include <string_view>
#include <string>
int main() {
std::string_view sv = std::string("hello"); // dangling після ';'
(void)sv;
}
Чому? Тому що std::string("hello") — тимчасовий обʼєкт, і він знищується наприкінці повного виразу, зазвичай наприкінці рядка. А sv лишається й указує на памʼять, якою більше ніхто не володіє.
Якщо дуже хочеться «створити рядок і дивитися на нього», потрібно зробити власника іменованим:
#include <string>
#include <string_view>
int main() {
std::string s = "hello";
std::string_view sv = s; // OK: s живе довше за sv у цій області видимості
}
Повернення view назовні: безпечно лише за чіткого контракту
Повернути std::string_view або std::span можна, але тільки тоді, коли ви точно знаєте, що джерело переживе повернений view. Новачкові легше запамʼятати таке правило: якщо всередині функції ви створюєте дані — повертайте володіння, тобто значення.
Поганий приклад: локальний рядок зникне під час виходу з функції.
#include <string>
#include <string_view>
std::string_view bad_title() {
std::string s = "Do homework";
return s; // dangling
}
Хороший варіант: повернути std::string за значенням.
#include <string>
std::string good_title() {
std::string s = "Do homework";
return s; // OK: повертаємо володіння
}
Так, формально ми повертаємо «копію». Але в сучасному C++ це нормальна практика: повернення за значенням оптимізується (copy elision і оптимізації переміщення), а головне — контракт стає безпечним.
3. Правило № 2: не зберігаємо view у полях без гарантій
Ось тут зазвичай починається справжній біль. View-типи спокусливі: «вони маленькі, швидкі, без копій». І рука так і тягнеться зробити ось що:
- у структурі Task зберігати std::string_view title;
- у структурі парсера зберігати std::span<const char> data;
- у моделі зберігати посилання const std::string& name;
Проблема в тому, що поле структури живе стільки, скільки живе сама структура. А дані, на які дивиться view, можуть жити менше. Саме так найчастіше й трапляється.
Антиприклад: модель зберігає string_view, і все ламається «потім»
Уявімо, що ми робимо простий консольний застосунок TaskTracker: додаємо задачі, друкуємо список. Наївне рішення — зберігати std::string_view у задачі.
#include <string_view>
struct TaskBad {
std::string_view title; // НЕ володіємо
};
А тепер подивіться, як легко випадково покласти туди view на тимчасовий обʼєкт:
#include <iostream>
#include <string>
#include <string_view>
struct TaskBad {
std::string_view title;
};
TaskBad make_bad() {
return TaskBad{std::string("Read C++ book")}; // dangling після ';' усередині make_bad
}
int main() {
TaskBad t = make_bad();
std::cout << t.title << '\n'; // UB
}
Виглядає як звичайний код. Він компілюється. Іноді навіть щось друкує. А потім — привіт, непередбачуваність.
Правильний підхід: модель володіє даними
Якщо обʼєкт «за змістом» зберігає текст задачі, нехай він зберігає std::string.
#include <string>
struct Task {
std::string title; // володіємо
};
А std::string_view використовуйте там, де він справді сильний: на межі функцій, як спосіб «прочитати вхід».
4. Дизайн функцій: value, const&, view
Зараз буде важливий момент: правила безпеки — це не про те, щоб «заборонити все цікаве». Вони про те, щоб показати контракт через тип, а не змушувати читача здогадуватися.
Нижче — робоча таблиця. Вона не є єдино правильною, але для початківців дуже корисна:
| Ситуація | Хороший вибір | Чому це зручно з погляду часу життя |
|---|---|---|
| Функція читає рядок «прямо зараз» і не зберігає його | |
Немає копій, контракт короткий і зрозумілий |
| Функція читає контейнер «прямо зараз» і не зберігає його | |
Неважливо, vector, array чи масив — інтерфейс один |
| Функція має зберегти текст усередині структури або вектора | |
Власник очевидний, dangling не виникне |
| Функція повертає дані, створені всередині | повернути за значенням (std::string, std::vector) | Повертаємо володіння, тож це безпечно |
| Функція повертає view на чужі дані | |
Потрібно гарантувати, що джерело живе довше |
Найчастіша помилка новачка — повертати посилання або view «заради оптимізації», не розуміючи, що це ламає контракт часу життя. У сучасному C++ безпечніше спочатку зробити правильно, а вже потім оптимізувати там, де це справді потрібно і де є дані профілювання, а не діяти «за відчуттям».
5. TaskTracker: view на вході, володіння всередині
Тепер зберемо ці правила в невеликий приклад. Ми читатимемо команди построково, розбиратимемо їх і зберігатимемо задачі в std::vector<Task>. Парсер активно використовуватиме std::string_view, але задачі зберігатимуться у std::string.
Модель задачі: володіємо рядком
#include <string>
struct Task {
std::string title;
bool done = false;
};
Тут усе нудно — і це комплімент. Нудний код із погляду часу життя зазвичай означає «надійний».
Додавання задачі: приймаємо string_view, зберігаємо string
#include <string>
#include <string_view>
#include <vector>
struct Task {
std::string title;
bool done = false;
};
void add_task(std::vector<Task>& tasks, std::string_view title) {
tasks.push_back(Task{std::string(title), false}); // створюємо копію з володінням
}
Контракт простий: у функцію можна передати хоч літерал, хоч std::string, хоч шматок рядка. Усередині ми робимо копію й більше ні від кого не залежимо.
Друк задач: працюємо з володінням
#include <iostream>
#include <vector>
struct Task {
std::string title;
bool done = false;
};
void print_tasks(const std::vector<Task>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ". [" << (tasks[i].done ? 'x' : ' ') << "] "
<< tasks[i].title << '\n';
}
}
Розбір команди: string_view живе лише всередині обробки рядка
Припустімо, у нас є такі команди:
- /add Buy milk
- /done 0
- /list
Ми читаємо рядок цілком як std::string line, а потім усередині обробки створюємо std::string_view на нього.
#include <iostream>
#include <string>
#include <string_view>
std::string_view trim_left(std::string_view s) {
while (!s.empty() && s.front() == ' ') {
s.remove_prefix(1);
}
return s;
}
int main() {
std::string line = " /add Buy milk";
std::string_view v = trim_left(line);
std::cout << v << '\n'; // /add Buy milk
}
Важливо: v валідний лише доти, доки живий line і доки line не зміниться так, що його буфер переїде. Але тут line живе в поточній області видимості, а ми використовуємо v одразу — це нормальний сценарій.
6. Owner живе й не ламає буфер під view
З std::string і std::vector є додаткова хитрість: навіть якщо обʼєкт-власник живий, він може змінити внутрішній буфер, наприклад під час push_back, +=, reserve або insert. А view зберігає адресу старого буфера. Підсумок: власник живий, а view уже вказує «в минуле».
Небезпека: тримаємо string_view і змінюємо std::string
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string s = "abc";
std::string_view sv = s;
s += "defghijklmnopqrstuvwxyz"; // може перевиділити буфер
std::cout << sv << '\n'; // UB, якщо буфер переїхав
}
Це особливо підступно, бо інколи рядок не переїде, наприклад через small string optimization, і вам здасться, що «все нормально». А потім ви зміните довжину тексту — і раптом усе зламається.
Аналогічно зі span і vector
#include <iostream>
#include <span>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
std::span<const int> sp{v.data(), v.size()};
v.push_back(4); // може перевиділити пам’ять
std::cout << sp[0] << '\n'; // UB, якщо перевиділення відбулося
}
Із цього випливає практична звичка: якщо вам потрібен std::span на вектор, зазвичай логічно спочатку «дозібрати» вектор, тобто виконати всі push_back/insert/reserve, а вже потім брати span і передавати його далі.
7. Корисні нюанси: зберігання view і мініпамʼятка
Що робити, якщо все ж хочеться зберігати view
Іноді хочеться, щоб структура зберігала «зріз» даних без копій. У межах нашого курсу базове правило просте: не зберігайте view у полях, доки не навчитеся гарантувати власника на рівні архітектури. А це вже окреме мистецтво.
У прикладному коді для новачка є три здорові альтернативи.
- Замість зберігання std::string_view зберігайте std::string. Так, це копія. Зате модель даних стає чесною: якщо задача зберігає заголовок, вона його справді зберігає.
- Замість зберігання std::span зберігайте std::vector або std::array, залежно від того, чи потрібен динамічний розмір. Це володіння, і воно не зникає з-під ніг.
- Якщо дуже хочеться уникнути копії, найчастіше правильний шлях — зберігати індекс/ID або інший «ключ», а дані тримати в одному місці, яке ними володіє. Але це вже той момент, де легко все переускладнити. Сьогодні наша мета — безпека й очевидність.
Мініпамʼятка для TaskTracker: де view доречний, а де ні
Щоб повʼязати тему безпосередньо з нашим застосунком, зафіксуймо: у TaskTracker std::string_view корисний для обробки вхідних рядків, команд і токенів, бо там ми часто хочемо «подивитися на шматок рядка» без зайвих копій. Але щойно ми додаємо задачу до списку, то зобовʼязані перетворити цей шматок на std::string і вже його зберегти як копію з володінням.
Нижче — маленький «правильний» фрагмент обробки команди /add, який дотримується обох правил сьогоднішньої лекції.
#include <string>
#include <string_view>
#include <vector>
struct Task {
std::string title;
bool done = false;
};
void add_task(std::vector<Task>& tasks, std::string_view title) {
tasks.push_back(Task{std::string(title), false});
}
void handle_add(std::vector<Task>& tasks, std::string_view line_after_cmd) {
// line_after_cmd — view на вихідний рядок введення, живе лише в межах обробки
add_task(tasks, line_after_cmd); // усередину tasks потрапить копія з володінням
}
8. Типові помилки
Помилка № 1: «view як оптимізація за замовчуванням» у полях структур.
Дуже часта історія: студент дізнається про std::string_view, бачить «о, без копій!» і починає зберігати string_view у моделях (Task, User, Product). Це майже гарантовано призводить до dangling, бо поля живуть довго, а джерела рядків часто виявляються тимчасовими або локальними. Лікується просто: модель має володіти тим, що вона зберігає, а view треба використовувати для читання «на вході».
Помилка № 2: створення view від тимчасового обʼєкта, особливо «в один рядок».
Запис на кшталт std::string_view sv = std::string("hi"); виглядає компактно й «красиво», але це пастка: тимчасовий власник знищується наприкінці рядка. У підсумку sv вказує на памʼять, яка вже не зобовʼязана містити текст. Якщо потрібен власник, робіть std::string s = "hi"; і лише потім std::string_view sv = s;.
Помилка № 3: «власник живий, отже view безпечний» — без урахування перевиділення.
З рядками й векторами небезпека полягає не лише в знищенні обʼєкта, а й у зміні буфера. s += ... і v.push_back(...) можуть перевиділити памʼять, і тоді старі string_view/span стають висячими навіть за живого власника. Тому view зазвичай беруть на короткий час і не тримають під час модифікації власника.
Помилка № 4: повернення view або посилання на дані, створені всередині функції.
Повернення std::string_view на локальний std::string або std::span на локальний std::vector — майже гарантований dangling. Новачків рятує просте правило: якщо дані створено всередині, повертайте тип із володінням за значенням. Це робить контракт безпечним і очевидним.
Помилка № 5: «зараз же використаю» як виправдання.
Іноді здається: «Ну я ж одразу розіменую вказівник, посилання або прочитаю view». Але якщо dangling зʼявляється одразу після return, це «одразу» вже не рятує: обʼєкт знищено в сам момент виходу з функції. Час життя потрібно аналізувати окремо від того, наскільки швидко ви збираєтеся використати значення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ