JavaRush /Курси /C++ SELF /std::span і std::vector: reallocation

std::span і std::vector: reallocation

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

1. Вступ

Коли ви вперше бачите std::span, легко подумати: «О, круто! Це ж як vector, тільки легше». І саме тут span починає зловтішно потирати руки — якби, звісно, вони в нього були. std::span — не контейнер і не власник. Це невласницьке представлення (view) неперервного фрагмента памʼяті: він зберігає адресу початку та кількість елементів.

Якщо мислити в термінах «робочої моделі» (без занурення у стандарт), span схожий на пару (T* data, size). Тому він дуже зручний як параметр функції. Але саме через це стає й небезпечним, якщо ви починаєте ставитися до нього як до «самостійних даних».

Мініприклад: створюємо span на vector і читаємо елементи:


#include <iostream>
#include <span>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};
    std::span<const int> s{v};          // view на дані v

    std::cout << s[1] << '\n';          // 20
}

Тут усе безпечно, тому що:

  1. v живе довше, ніж s;
  2. ми не змінюємо v, поки використовуємо s.

У цій лекції ми порушимо принаймні один із цих пунктів — винятково з навчальною метою і трохи — зі шкідливості.

2. Чому std::vector «переїжджає»: size, capacity, reallocation

Якщо span — це «вікно» в памʼять, то std::vector — «власник квартири», який іноді раптом вирішує переїхати. І попереджати ваші вікна він не зобовʼязаний. Вектор зберігає елементи в неперервному буфері, але розмір буфера (capacity) зазвичай більший, ніж кількість елементів (size). Коли місця бракує, vector виділяє новий буфер, переносить туди елементи й звільняє старий.

Це і є reallocation: адреса v.data() може змінитися. А оскільки std::span запамʼятовує адресу, він починає дивитися на стару памʼять, якої вже немає, або на памʼять, яка вже належить комусь іншому. Це типовий шлях до UB, тобто до ситуації «може спрацювати, а може влаштувати вам квест».

Схема переїзду виглядає приблизно так:

flowchart LR
    A[vector: data = 0x1000<br/>size=3 cap=3] -- push_back --> B[перевиділення памʼяті]
    B --> C[vector: data = 0x9000<br/>size=4 cap=6]
    D[span: data = 0x1000<br/>size=3] -. залишився зі старою адресою .-> E[UB під час читання/запису]

І так, стандартна бібліотека прямо обговорює питання, що саме стає недійсним під час push_back/emplace_back. Навколо цього є окремі зауваження й обговорення.

Подивімося на один із «симптомів» у коді: виведемо адресу буфера до і після push_back.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    std::cout << "before: " << static_cast<const void*>(v.data()) << '\n'; // наприклад: 0x12345000
    v.push_back(4);
    std::cout << "after : " << static_cast<const void*>(v.data()) << '\n'; // наприклад: 0xABCDEF00
}

Якщо адреса змінилася — відбулося перевиділення. Якщо не змінилася — вам пощастило… але покладатися на удачу в C++ — це як покладатися на «ще пʼять хвилин сну»: іноді працює, але зазвичай закінчується погано.

3. Основні граблі span + vector

Dangling після push_back: класичний UB

Зараз буде типовий баг, який компілюється, може «час від часу друкувати правильний результат», і саме тому він небезпечний.

Щоб повʼязати приклади в невеликий застосунок, уявімо, що маємо найпростіший «менеджер задач» (TaskTracker). Ми зберігаємо задачі у std::vector, а для деяких операцій хочемо без копіювання дати доступ до масиву задач — через std::span.

Опишемо модель задачі:

#include <string>

struct Task {
    std::string title;
    bool done{};
};

Напишемо функцію для виведення задач через span (це якраз добрий стиль):

#include <iostream>
#include <span>

void print_tasks(std::span<const Task> tasks) {
    for (const Task& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.title << '\n';
    }
}

А тепер — пастка. Ми створюємо span для поточних задач, потім додаємо ще одну задачу, тобто робимо push_back, а після цього намагаємося друкувати через старий span.

#include <span>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back({"Buy milk", false});
    tasks.push_back({"Learn C++", false});

    std::span<const Task> view{tasks};   // view запамʼятав address+size

    tasks.push_back({"Sleep", true});    // може статися reallocation

    print_tasks(view);                   // UB: view може стати dangling
}

Чому це UB? Тому що view зберігає адресу старого буфера. Якщо vector «переїхав», то view.data() вказує на памʼять, яку vector уже звільнив. Це та сама логіка, що й у висячого вказівника: «адреса є, а обʼєкта вже немає».

Найпідступніше те, що якщо reallocation не відбувся, програма може здаватися робочою, і ви отримаєте відчуття, ніби все гаразд. А потім додасте ще один push_back, зміните компілятор, увімкнете оптимізації — і почнеться магія темного боку.

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

Важливо вловити цю ідею саме на рівні архітектури, а не сприймати її як «окремий баг». std::vectorвласник памʼяті. Він має право:

  • збільшити capacity;
  • перемістити елементи в новий буфер;
  • знищити старі елементи (у старому буфері) і звільнити памʼять.

std::spanневласницький спостерігач. Він не підписаний на новини, не отримує повідомлення «я переїхав» і не вміє «оновити адресу». Тому вся відповідальність за коректний час життя й за те, щоб буфер не «переїхав», лежить на вас.

Це можна звести до простого правила: власник живе довше за представлення і не ламає памʼять, на яку дивиться представлення. Для vector «зламати памʼять» — це якраз виконати reallocation. І це не «погана поведінка vector», а нормальна робота контейнера.

reserve знижує ризик, але не скасовує правила часу життя

Коли студенти дізнаються про reserve, часто виникає думка: «О! Тоді я зроблю reserve(1000) і можу тримати span скільки завгодно». Це майже як «куплю величезний холодильник і перестану думати про їжу». Проблема в тому, що reserve знижує частоту перевиділень під час зростання, але не перетворює span на вічну істину.

Якщо ви заздалегідь знаєте приблизну кількість задач, reserve справді корисний: push_back рідше призводить до переїзду, а отже рідше робить недійсними вказівники, посилання й span на елементи.

#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.reserve(10);                    // просимо місце заздалегідь

    tasks.push_back({"T1", false});
    tasks.push_back({"T2", false});

    std::span<const Task> view{tasks};

    // Поки ми не перевищили capacity, view зазвичай валідний.
    // Але щойно почнеться реальне зростання — знову ризик.
}

Ключове слово тут — «зазвичай». Контракт безпеки все одно той самий: поки живе span, власник не повинен виконувати операцій, які можуть змінити буфер або знищити елементи. reserve — не контракт, а оптимізаційний прийом і «пара подушок безпеки», але не ремінь.

Пастки без reallocation: розмір і знищення елементів

Є пастка, яку часто пропускають, бо вона не завжди виглядає як «висячий вказівник». span зберігає розмір і сам його не оновлює. Якщо ви взяли span на 2 елементи, а потім вектор збільшився до 3, span усе ще вважає, що елементів 2. Він не «розшириться», бо не є контейнером.

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

#include <iostream>
#include <span>
#include <vector>

int main() {
    std::vector<int> v{1, 2};
    std::span<const int> s{v};

    v.push_back(3);                        // reallocation може і не бути

    std::cout << s.size() << '\n';         // 2 (а не 3) — логічна "неактуальність"
}

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

#include <span>
#include <vector>

int main() {
    std::vector<Task> tasks{{"A", false}, {"B", true}};
    std::span<const Task> s{tasks};

    tasks.pop_back();                       // обʼєкт "B" знищено

    // s[1] тепер звертається до знищеного обʼєкта => UB
}

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

4. Як писати безпечно: span як параметр, а не стан

Правило: span повинен жити менше, ніж зміни vector

Головна ідея проста: беремо span лише тоді, коли вже не плануємо змінювати vector. Тобто спочатку збираємо дані, а потім дивимося на них через це вікно.

Виправімо приклад найпрямішим способом: спочатку додамо задачу, потім створимо span, а вже після цього друкуємо.

#include <span>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back({"Buy milk", false});
    tasks.push_back({"Learn C++", false});
    tasks.push_back({"Sleep", true});     // усі зміни власника — ДО span

    std::span<const Task> view{tasks};
    print_tasks(view);                    // OK
}

Це не «обхідний шлях», а правильний контракт: span — тимчасове представлення даних в момент читання. Щойно ви хочете й далі модифікувати vector, span краще відпустити, тобто не зберігати його як змінну, а створювати ближче до місця використання.

Якщо хочеться зробити код ще акуратнішим, часто використовують стиль «одразу у виклику», щоб span був зовсім короткоживучим:

#include <vector>

int main() {
    std::vector<Task> tasks{{"A", false}, {"B", true}};
    print_tasks(tasks); // неявно будується span<const Task> (у більшості реалізацій)
}

Так, виглядає надто просто — і це якраз чудовий знак: простий код легше зробити безпечним.

span чудово працює як API для функцій

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

Додамо в наш умовний TaskTracker пару функцій, які працюють із задачами як із діапазоном.

Наприклад, порахувати виконані задачі:

#include <span>

int count_done(std::span<const Task> tasks) {
    int cnt = 0;
    for (const Task& t : tasks) {
        if (t.done) ++cnt;
    }
    return cnt;
}

І показати перші n задач:

#include <algorithm>
#include <span>

void print_first(std::span<const Task> tasks, std::size_t n) {
    const std::size_t limit = std::min(tasks.size(), n);
    for (std::size_t i = 0; i < limit; ++i) {
        std::cout << tasks[i].title << '\n';
    }
}

Тепер використаймо це акуратно в main: власник (vector) живе, span створюється просто перед викликом і ніде не «кешується».

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{"A", false}, {"B", true}, {"C", true}};

    std::cout << count_done(tasks) << '\n';  // 2
    print_first(tasks, 2);                   // A \n B
}

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

Не можна повертати span на локальний vector

Це граблі з тієї самої серії, що й «повернути вказівник на локальну змінну», тільки замасковані моднішим типом. Дуже легко написати «гарно» — і так само легко отримати dangling.

Ось так робити не можна:

#include <span>
#include <vector>

std::span<const int> bad_make_view() {
    std::vector<int> v{1, 2, 3};
    return std::span<const int>{v};         // dangling: v помре під час виходу з функції
}

Причина та сама: span не володіє даними. Він повертається назовні, а власник (v) знищується під час виходу з функції. У результаті ви отримаєте представлення для вже неіснуючих елементів.

Правильні варіанти залежать від задачі, але базова безпечна стратегія майже завжди така: якщо дані створюються всередині, повертайте власницький обʼєкт, тобто std::vector, std::array або інший owner.

#include <vector>

std::vector<int> make_numbers() {
    return {1, 2, 3};                       // повертаємо власника
}

А span вже створюється в коді, який викликає функцію, — тоді, коли йому справді потрібно «подивитися»:

#include <span>
#include <vector>

int main() {
    std::vector<int> nums = make_numbers();
    std::span<const int> s{nums};           // OK: nums живе тут
}

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

Помилка № 1: створити span на vector, а потім зробити push_back і продовжити користуватися старим span.
Ця помилка виглядає невинно: «ну я ж просто додав елемент». Але додавання може спричинити перевиділення буфера, і span залишиться з адресою старої памʼяті. У найкращому разі ви побачите сміття, у найгіршому — отримаєте UB із непередбачуваними наслідками. Вирішується це простою дисципліною: спочатку всі модифікації власника, потім створюємо span і використовуємо його лише доти, доки власник не змінюється.

Помилка № 2: думати, що reserve «гарантує» безпеку span.
reserve знижує ймовірність перевиділення під час зростання, але не перетворює vector на нерухомий масив. Ви можете перевищити зарезервований обсяг, можете викликати reserve ще раз, можете виконати операції, які змінюють елементи або знищують їх. Правильна модель така: reserve — це оптимізація, а не контракт часу життя.

Помилка № 3: зберігати std::span як поле структури або класу «заради швидкості».
Такий код часто працює рівно до першого рефакторингу: десь змінили порядок операцій, десь почали додавати елементи в vector, і старий span став dangling. Якщо обʼєкту потрібно зберігати дані, він має зберігати власника (std::vector, std::array) або мати жорстко зафіксований контракт часу життя власника. У навчальних проєктах краще вважати, що такого контракту немає.

Помилка № 4: робити pop_back або erase у vector, а потім читати елементи через старий span, бо «памʼять же ще там».
Це особливо підступно зі складними типами, наприклад із Task та std::string. Після видалення елемента обʼєкт уже знищено: для нього викликано деструктор. Читати знищений обʼєкт — це UB, навіть якщо байтики в памʼяті виглядають «схожими на старі». Рішення просте за ідеєю, але складне за звичкою: після операцій, що знищують елементи, старі представлення вважаємо недійсними й створюємо нові.

Помилка № 5: повертати span із функції, де власник даних — локальний vector.
За типами все виглядає гарно, а з погляду часу життя — катастрофа: власник знищується під час виходу з функції, span лишається, і ви отримуєте dangling-представлення. Правильний підхід такий: повертати власницький контейнер за значенням, а span використовувати або як параметр «просто зараз», або будувати його в коді, що викликає, на довгоживучому власнику.

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