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
}
Здесь всё безопасно, потому что:
- v живёт дольше, чем s;
- мы не меняем 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 использовать либо как параметр “прямо сейчас”, либо строить его у вызывающего кода на долгоживущем владельце.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ