Разработчик сдвигает элементы std::vector вправо. Определите проблему в коде и назовите подходящий алгоритм для этой операции:
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
std::copy(v.begin(), v.end() - 1, v.begin() + 1);
v[0] = 0;
}
Код содержит неопределённое поведение: исходный и целевой диапазоны std::copy перекрываются, причём копирование выполняется слева направо. Для сдвига элементов вправо следует использовать std::copy_backward, который копирует элементы справа налево:
После операции вектор содержит {0, 1, 2, 3, 4}. std::copy_backward сохраняет исходные значения, потому что сначала обрабатывает элементы с конца диапазона.
Обобщённые алгоритмы STL работают с диапазонами итераторов, а не с конкретными контейнерами. Это позволяет применять один алгоритм к std::vector, массиву, std::deque и другим структурам с подходящими итераторами.
Однако направление копирования является частью контракта алгоритма. Обычный std::copy предназначен для неперекрывающихся диапазонов либо для случая, когда целевой диапазон расположен перед исходным. Для безопасного копирования при сдвиге вправо существует отдельный алгоритм std::copy_backward.
В исходном коде источник — диапазон [v.begin(), v.end() - 1), содержащий {1, 2, 3, 4}, а назначение начинается с v.begin() + 1. Диапазоны перекрываются: целевая область содержит элементы, которые ещё должны быть прочитаны как источник.
Если копировать слева направо, после записи 1 в позицию v[1] исходное значение 2 будет потеряно. Следующее копирование может прочитать уже изменённое значение вместо исходного. Стандарт не обязан определять результат такой операции, поэтому это не просто логическая ошибка с предсказуемым неправильным выводом.
std::copy концептуально выполняет операции в таком порядке:
При сдвиге вправо это опасно: destination[0] может находиться перед source[1] и перезаписать значение, которое понадобится на следующем шаге.
std::copy_backward принимает начало и конец исходного диапазона, а также конечную позицию назначения. Для приведённого примера он выполняет копирование в обратном направлении:
Вызов std::copy_backward(v.begin(), v.end() - 1, v.end()) копирует [begin, end - 1) в диапазон [begin + 1, end). Каждый элемент считывается до того, как его исходная позиция будет перезаписана.
Оба алгоритма имеют линейную сложность O(n) и требуют, чтобы итераторы поддерживали последовательное перемещение по диапазону. Они также присваивают значения в уже существующие объекты: сами алгоритмы не увеличивают размер контейнера и не создают дополнительный элемент для сдвига.
Если требуется именно вставка нового элемента в std::vector, обычно предпочтительнее vector::insert. Он сам проверяет ёмкость, сдвигает элементы с корректным направлением и обновляет размер контейнера. Ручной copy_backward уместен, когда размер уже подготовлен, например после resize, и требуется явно контролировать операцию.
В модуле обработки отсортированных записей требовалось вставить элемент в середину std::vector. Рассматривались три варианта.
std::copy для ручного сдвига был отвергнут: при перекрытии диапазонов он нарушает контракт алгоритма.std::copy_backward с предварительным увеличением размера работает и имеет линейную сложность, но требует вручную учитывать создание элемента, перемещение объектов и возможную реаллокацию.std::vector::insert скрывает эти детали, поддерживает инварианты контейнера и прямо выражает намерение операции.Выбран был insert, поскольку задача заключалась во вставке, а не только в копировании диапазона. Ручной copy_backward оставили для низкоуровневого участка, где размер вектора заранее контролировался и требовалось выполнить только сдвиг существующих объектов.
Достаточно ли заменить std::copy на std::copy_backward при любом перекрытии диапазонов?
Нет. Направление должно соответствовать расположению диапазонов. При сдвиге вправо, когда назначение начинается внутри исходного диапазона после его начала, используют copy_backward. При сдвиге влево обычно подходит обычный copy, поскольку элементы читаются до того, как будут перезаписаны.
Ни один из этих алгоритмов не является универсальной заменой для произвольного перекрытия без анализа диапазонов. Кроме того, нарушение требований алгоритма приводит к неопределённому поведению, а не к гарантированному исключению или диагностике компилятора.
Почему нельзя считать std::copy аналогом memmove?
std::copy работает через присваивание элементов и учитывает типы и операции, доступные через итераторы. Он не обещает безопасную обработку перекрывающихся диапазонов.
std::memmove безопасно обрабатывает перекрытие на уровне байтов, но применим только к данным, для которых такое побайтовое копирование допустимо, например к trivially copyable-типам. Для произвольных объектов C++ побайтовое перемещение может нарушить время жизни объектов и инварианты типа.
Что изменится, если вместо ручного копирования вызвать vector::insert?
insert увеличит размер контейнера, создаст или переместит нужные элементы и выполнит сдвиг с корректным направлением. Вставка в середину обычно имеет линейную сложность O(n), а при реаллокации дополнительно перемещаются элементы всего вектора.
После реаллокации ранее сохранённые итераторы, указатели и ссылки на элементы vector могут стать недействительными. Поэтому insert безопаснее с точки зрения логики операции, но не отменяет общие правила инвалидирования ссылок и итераторов контейнера.