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[reallocation]
    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 уже освободил. Это ровно та же логика, что у dangling‑указателя: «адрес есть, объекта нет».

Самое коварное: если reallocation не случился, программа может “выглядеть рабочей”, и вы получите ощущение, что всё нормально. А потом добавите ещё один push_back, поменяете компилятор, включите оптимизации — и начнётся магия тёмной стороны.

Это следствие смены владельца: owner поменял буфер, view не заметил

Важно поймать идею именно архитектурно, а не как “частный баг”. std::vectorвладелец памяти. Он имеет право:

  • увеличить capacity;
  • переместить элементы в новый буфер;
  • уничтожить старые элементы (в старом буфере) и освободить память.

std::spanневладеющий наблюдатель. Он не подписан на новости, не получает SMS «я переехал», не умеет “обновить адрес”. Поэтому вся ответственность за корректность времени жизни и “непереезда буфера” лежит на вас.

Эта мысль очень похожа на правило дня: owner живёт дольше view и не ломает память, на которую смотрит view. Для 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 — не единственный способ «сломать view». Достаточно разрушить элементы или изменить смысл “что сейчас лежит в этом диапазоне”.

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, даже если байтики в памяти выглядят «похожими на старые». Устранение простое по идее и сложное по привычкам: после операций, которые уничтожают элементы, старые view‑типы считаем недействительными и создаём новые.

Ошибка №5: возвращать span из функции, где владелец данных — локальный vector.
По типам всё красиво, по времени жизни — катастрофа: владелец уничтожается при выходе из функции, span остаётся, и вы получаете dangling view. Правильный подход: возвращать владеющий контейнер по значению, а span использовать либо как параметр “прямо сейчас”, либо строить его у вызывающего кода на долгоживущем владельце.

1
Задача
C++ SELF, 47 уровень, 1 лекция
Недоступна
Живой span
Живой span
1
Задача
C++ SELF, 47 уровень, 1 лекция
Недоступна
Старый размер
Старый размер
1
Задача
C++ SELF, 47 уровень, 1 лекция
Недоступна
Короткий span
Короткий span
1
Задача
C++ SELF, 47 уровень, 1 лекция
Недоступна
Команды суммы
Команды суммы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ