В практической ситуации функция возвращает ссылку на элемент std::vector. Как увеличение размера контейнера может сделать эту ссылку недействительной?
При увеличении размера std::vector может потребоваться перераспределение памяти. Контейнер выделяет новый непрерывный буфер, перемещает или копирует туда элементы и уничтожает объекты в старом буфере. Все ранее полученные указатели, ссылки и итераторы на элементы становятся недействительными.
Если новая ёмкость уже достаточна и перераспределения не произошло, само добавление элемента в конец обычно не инвалидирует ссылки на существующие элементы. Однако операции вставки и удаления имеют дополнительные правила: они могут сделать недействительными итераторы и ссылки на изменённые элементы и элементы после точки изменения.
std::vector создан как типобезопасный динамический массив с непрерывным размещением элементов. Непрерывность памяти обеспечивает быстрый индексированный доступ и совместимость с алгоритмами, работающими с диапазонами памяти.
Цена этого свойства — необходимость иногда перемещать весь массив в более крупный буфер. Поэтому адрес конкретного элемента не является стабильным идентификатором элемента на всём времени жизни контейнера.
Ссылка или указатель на элемент обычно воспринимаются как способ обратиться к тому же объекту позже. Но после перераспределения старый объект уничтожается вместе со старым буфером, а в новом буфере существует уже другой объект с тем же логическим значением.
Использование прежней ссылки после этого приводит к неопределённому поведению. Возможные проявления включают чтение устаревших данных, повреждение памяти и падение программы; конкретное проявление стандартом не гарантируется.
Минимальный пример:
Проблема возникает не из-за самого факта изменения размера, а из-за возможной смены адреса буфера. Поэтому полагаться на отсутствие перераспределения без соответствующей гарантии нельзя.
При добавлении элемента std::vector сравнивает новый размер с текущей ёмкостью. Если свободного места нет, он выделяет новый буфер, переносит или копирует элементы, добавляет новый элемент, уничтожает старые объекты и освобождает старую память.
После такой операции недействительны:
end().Метод reserve позволяет заранее запросить ёмкость. Пока размер не превышает зарезервированную ёмкость, добавление в конец не вызывает перераспределения, поэтому ссылки на уже существующие элементы сохраняются. Это не делает ссылки бессрочными: последующая операция, превысившая ёмкость, снова может их инвалидировать.
При вставке элемента в середину без перераспределения элементы начиная с позиции вставки обычно перемещаются, поэтому ссылки и итераторы на них становятся недействительными. При удалении недействительной становится ссылка на удалённый элемент, а также могут измениться адреса или значения элементов после него.
Надёжные стратегии зависят от задачи:
std::unique_ptr, если адреса объектов должны оставаться стабильными при росте вектора;std::list, принимая более высокие накладные расходы и худшую локальность памяти.std::unique_ptr в std::vector защищает адрес самого объекта, но не адрес элемента-контейнера: при перераспределении переместятся объекты unique_ptr, а управляемые ими объекты останутся по прежним адресам. Это полезно, когда внешние пользователи должны ссылаться именно на управляемые объекты.
В системе обработки задач вектор хранит записи, а подсистема журналирования сохраняет указатель на конкретную запись. Позже очередь растёт, вектор перераспределяет память, и журналирование использует старый указатель. Ошибка проявляется нерегулярно, потому что зависит от текущей ёмкости и расположения освобождённой памяти.
Рассматривались три варианта. Хранение индекса дешёво и хорошо использует память, но индекс может перестать обозначать ту же запись после удаления или сортировки. Предварительный reserve прост, но требует достаточно точной оценки максимального размера и не защищает от превышения оценки. Использование вектора std::unique_ptr сохраняет адрес самих записей, но добавляет отдельные выделения памяти и ухудшает локальность доступа.
Выбрано хранение std::unique_ptr<Record> при действительно стабильных адресах записей, а для временных обращений используются короткоживущие указатели. Это решение оправдано тем, что стабильность адреса важнее минимального числа выделений; владение при этом остаётся явным и безопасным благодаря RAII.
reserve гарантирует сохранность ссылок после добавления элементов?Нет. Он предотвращает перераспределение только до тех пор, пока новая ёмкость достаточна. Если размер превысит зарезервированную ёмкость, контейнер снова может выделить новый буфер, и все старые ссылки, указатели и итераторы станут недействительными.
Кроме того, reserve не отменяет инвалидирование, связанное со вставкой или удалением элементов внутри диапазона. Он управляет ёмкостью, но не делает позиции элементов неизменными.
std::unique_ptr на объект дают разные гарантии?Указатель на элемент указывает на объект, находящийся непосредственно внутри буфера std::vector. При перераспределении этот объект уничтожается в старом буфере, поэтому указатель становится недействительным.
std::unique_ptr<Record> сам является элементом вектора, но объект Record находится в отдельной динамической памяти. При перемещении unique_ptr меняется расположение владельца, а управляемый Record остаётся по тому же адресу. Поэтому указатель на Record сохраняется, пока сам Record не будет уничтожен.
std::vector из функции?Да, если вызывающая сторона гарантирует, что контейнер и нужный элемент будут существовать, а операции, инвалидирующие ссылку, не выполнятся до последнего использования. Сама функция, возвращающая ссылку, не продлевает время жизни ни контейнера, ни элемента и не запрещает его перераспределение.
Если такие гарантии трудно поддерживать, лучше вернуть копию, индекс с явно оговорённым временем действия или объект-владелец. Выбор зависит от стоимости копирования и от того, требуется ли наблюдать изменения исходного элемента.