JavaRush /Курси /C++ SELF /Правила безпеки часу життя: owner і view

Правила безпеки часу життя: owner і view

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

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

Зараз буде важливий момент: правила безпеки — це не про те, щоб «заборонити все цікаве». Вони про те, щоб показати контракт через тип, а не змушувати читача здогадуватися.

Нижче — робоча таблиця. Вона не є єдино правильною, але для початківців дуже корисна:

Ситуація Хороший вибір Чому це зручно з погляду часу життя
Функція читає рядок «прямо зараз» і не зберігає його
std::string_view
Немає копій, контракт короткий і зрозумілий
Функція читає контейнер «прямо зараз» і не зберігає його
std::span<const T>
Неважливо, vector, array чи масив — інтерфейс один
Функція має зберегти текст усередині структури або вектора
std::string
Власник очевидний, dangling не виникне
Функція повертає дані, створені всередині повернути за значенням (std::string, std::vector) Повертаємо володіння, тож це безпечно
Функція повертає view на чужі дані
string_view / span
Потрібно гарантувати, що джерело живе довше

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

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