JavaRush /Курси /C++ SELF /Правило часу життя: view

Правило часу життя: view

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

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 — це «погляд».

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