При расширении std::vector почему элементы иногда копируются вместо перемещения, даже если у типа есть перемещающий конструктор?
std::vector предпочитает перемещение элементов при перераспределении памяти только когда оно гарантированно не выбрасывает исключений либо когда копирование недоступно. Если перемещающий конструктор может выбросить, а копирование доступно, контейнер обычно выбирает копирование, чтобы сохранить сильную гарантию исключений.
Это поведение определяется свойством noexcept перемещающего конструктора, а не одним фактом его наличия.
До появления перемещающей семантики в C++11 контейнеры при расширении хранилища копировали элементы. Перемещение сделало такие операции дешевле, но перемещающий конструктор может изменить исходный объект до того, как выбросит исключение.
Для контейнера это создаёт проблему: после неудачного перераспределения он может не суметь восстановить исходное состояние элементов. Поэтому C++ использует noexcept как контракт, позволяющий безопасно предпочесть перемещение копированию.
При расширении std::vector обычно выделяется новый непрерывный буфер, после чего элементы переносятся из старого буфера. Если перенос потенциально опасен, выбор операции влияет одновременно на производительность и гарантию исключений.
Перемещение обычно быстрее для типов, владеющих ресурсами, например динамической памятью. Однако потенциально выбрасывающее перемещение может оставить часть старых элементов в изменённом состоянии, если обработка следующего элемента завершится исключением.
Если тип является nothrow-перемещаемым, std::vector может перемещать его элементы при перераспределении. Для этого перемещающий конструктор должен быть объявлен с noexcept или эквивалентно определяться как не выбрасывающий.
Если перемещение может выбросить, но тип можно копировать, std::vector обычно выбирает копирование. Копирование не изменяет исходные элементы, поэтому при исключении старый буфер можно сохранить в исходном состоянии.
Если копирование недоступно, контейнер вынужден использовать потенциально выбрасывающее перемещение. В таком случае стандартная гарантия исключений при перераспределении может быть ослаблена: при исключении из перемещающего конструктора состояние элементов может оказаться не таким, как до операции.
Минимальный пример:
При добавлении второго элемента произойдёт перераспределение. Поскольку перемещение Item объявлено как noexcept, контейнер может переместить уже существующий элемент. Если убрать noexcept, но оставить копирующий конструктор, при таком перераспределении обычно будет выбрано копирование.
noexcept не означает, что функция физически не способна завершиться аварийно или что внутри неё не возникнет ошибка. Это обещание функции не завершаться исключением; нарушение обещания приводит к вызову std::terminate. Поэтому добавлять noexcept следует только тогда, когда это действительно гарантировано.
Для проверки свойства типа применяются признаки вроде std::is_nothrow_move_constructible_v<T>. Похожую логику предоставляет std::move_if_noexcept: она возвращает ссылку для перемещения, если оно безопасно, и выбирает константную ссылку для копирования в обратном случае.
Допустим, вектор хранит объекты-обёртки над большими буферами. Вариант без noexcept сохраняет возможность копирования и обычно обеспечивает более надёжную гарантию исключений, но расширение вектора может многократно копировать большие буферы.
Вариант с корректно объявленным noexcept делает перераспределение существенно дешевле: перемещаются указатели и служебные поля, а не сами буферы. Риск возникает, если реализация перемещения на самом деле может выбросить исключение: тогда объявление noexcept будет ложным и при исключении завершит программу через std::terminate.
Практически выбирают второй вариант только после проверки инвариантов класса. Для класса, который корректно передаёт владение ресурсом и не выполняет бросающих операций при перемещении, noexcept обычно является правильным решением. Результат — более дешёвое расширение контейнера без потери ожидаемой гарантии исключений.
Достаточно ли наличия перемещающего конструктора, чтобы std::vector всегда его использовал?
Нет. Наличие перемещающего конструктора само по себе не гарантирует его выбора. Если он не помечен noexcept, а копирование доступно, контейнер предпочитает копирование ради безопасности при исключениях.
Кроме того, фактическая доступность операций зависит от свойств типа: конструктор может быть удалён, недоступен или не удовлетворять требованиям конкретной операции контейнера.
Что произойдёт, если тип нельзя копировать, а его перемещение потенциально выбрасывает?
Вектор всё равно должен попытаться переместить элементы, поскольку альтернативы нет. При исключении из такого перемещения сильная гарантия может не предоставляться, и часть элементов может оказаться перемещённой из исходного буфера.
Поэтому для некопируемых типов особенно важно корректно объявлять перемещение как noexcept, если это соответствует реальному поведению класса.
Почему ложный noexcept опаснее, чем отсутствие noexcept?
Без noexcept контейнер может выбрать более медленное копирование, но сохранить корректную стратегию обработки исключений. При ложном noexcept исключение из функции не передаётся вызывающему коду — вызывается std::terminate.
Поэтому noexcept должен быть частью доказанного контракта класса, а не средством принудительно заставить контейнер перемещать элементы.