Введение: о чём молчат документации
Представьте: вы пишете высоконагруженный сервер или алгоритм обработки данных. У вас есть два std::vector<int>, и вам нужно объединить их в один. Казалось бы, что может быть проще? Берёшь и добавляешь. Но C++ не прощает поверхностного подхода. Один неправильный способ — и вы получаете лишние копии, падение производительности или даже неопределённое поведение.
Вопрос «Appending a vector to a vector» на Stack Overflow собрал тысячи просмотров не случайно. Даже опытные разработчики иногда путают push_back с insert или забывают про move-семантику. В этой статье мы заглянем под капот компилятора и разберёмся, как на самом деле работает добавление одного вектора в другой. Вы узнаете, почему std::copy может быть быстрее insert, и когда move-семантика спасёт ваш проект.
Самый очевидный (и опасный) способ: push_back в цикле
Начинающие часто пишут так:
std::vector<int> dest = {1, 2, 3};
std::vector<int> src = {4, 5, 6};
for (size_t i = 0; i < src.size(); ++i) {
dest.push_back(src[i]);
}
Что здесь происходит? На каждой итерации push_back проверяет, достаточно ли выделено памяти. Если нет — происходит реаллокация: старый массив копируется в новую область памяти, старый удаляется. При трёх элементах реаллокаций может быть 2–3. Для маленьких векторов это незаметно, но при тысячах элементов — катастрофа.
Проблема: O(n) проверок и потенциальных копирований всего массива. В C++17 и новее компилятор может оптимизировать, но полагаться на это — риск.
Что происходит под капотом push_back?
- Проверка
size() < capacity(). - Если нет — вызов
reallocate(new_capacity). - Копирование (или перемещение) существующих элементов.
- Удаление старого буфера.
- Копирование нового элемента.
Вывод: никогда не используйте цикл для добавления целого вектора, если только вы не делаете это осознанно для небольшого количества элементов.
Правильный путь: std::vector::insert
Стандартная библиотека предоставляет элегантное решение:
std::vector<int> dest = {1, 2, 3};
std::vector<int> src = {4, 5, 6};
dest.insert(dest.end(), src.begin(), src.end());
Однострочник! Но что скрывается за этим вызовом? insert делает следующее:
- Вычисляет необходимое количество памяти:
dest.size() + src.size(). - Если текущая ёмкость недостаточна — выделяет ровно столько, сколько нужно (или с запасом, зависит от реализации).
- Сдвигает существующие элементы (если вставляем не в конец).
- Копирует (или перемещает) новые элементы из
src.
Важно: insert делает одну реаллокацию (или ни одной, если места хватает). Это уже лучше цикла, но всё ещё есть копирование элементов из src.
Производительность insert
Если src больше не нужен после вставки, вы можете переместить его элементы, используя std::make_move_iterator:
dest.insert(dest.end(),
std::make_move_iterator(src.begin()),
std::make_move_iterator(src.end()));
Это превращает копирование в перемещение. Для std::vector<int> разницы нет (int — тривиальный тип), но для std::vector<std::string> или сложных объектов перемещение — это копейки по сравнению с копированием.
Альтернатива: std::copy с back_inserter
Некоторые разработчики предпочитают алгоритмы STL:
std::copy(src.begin(), src.end(), std::back_inserter(dest));
Под капотом std::back_inserter вызывает push_back. То есть мы возвращаемся к проблеме множественных реаллокаций. Однако, если заранее выделить память, это становится эффективно:
dest.reserve(dest.size() + src.size());
std::copy(src.begin(), src.end(), std::back_inserter(dest));
Теперь copy работает без реаллокаций. Но всё равно каждый элемент копируется. insert делает то же самое, но в одном вызове — и часто оптимизирован лучше.
Итог: insert читаемее и надёжнее, чем copy + reserve.
Move-семантика: когда один вектор становится донором
А что если вам нужно не скопировать, а переместить содержимое одного вектора в другой? Например, вы собрали временные данные в src и хотите добавить их в dest, а src после этого уничтожить. В C++11 и новее есть элегантный способ:
std::vector<std::string> dest;
std::vector<std::string> src = {"hello", "world"};
dest.insert(dest.end(),
std::make_move_iterator(src.begin()),
std::make_move_iterator(src.end()));
// src теперь в корректном, но неопределённом состоянии
Что происходит под капотом? make_move_iterator превращает обычный итератор в move-итератор. При разыменовании он возвращает std::move(*it). Таким образом, строки не копируются, а перемещаются: их внутренние указатели на буфер просто переходят в dest, а src остаётся с пустыми строками.
Когда это критично?
- Работа с большими строками (
std::string). - Контейнеры с динамическими ресурсами (
std::vector,std::deque). - Пользовательские типы с нетривиальными конструкторами копирования.
Сравнение производительности: бенчмарк
Давайте представим, что мы объединяем два вектора по 100 000 элементов типа std::string (длина строк ~50 символов). Результаты (в условных единицах):
| Метод | Время (отн.) | Количество реаллокаций |
|---|