Как выглядит std :: vector в памяти?
Я читал, что std::vector
должен быть смежным. Я понимаю, что его элементы должны храниться вместе, а не распространяться по памяти. Я просто принял этот факт и использовал эти знания, например, используя метод data()
чтобы получить базовую непрерывную часть памяти.
Однако я столкнулся с ситуацией, когда векторная память ведет себя странным образом:
std::vector<int> numbers;
std::vector<int*> ptr_numbers;
for (int i = 0; i < 8; i++) {
numbers.push_back(i);
ptr_numbers.push_back(&numbers.back());
}
Я ожидал, что это даст мне вектор некоторых чисел и вектор указателей на эти числа. Однако при перечислении содержимого указателей ptr_numbers
существуют разные и, казалось бы, случайные числа, как будто я обращаюсь к неправильным частям памяти.
Я пытался проверять содержимое на каждом шагу:
for (int i = 0; i < 8; i++) {
numbers.push_back(i);
ptr_numbers.push_back(&numbers.back());
for (auto ptr_number : ptr_numbers)
std::cout << *ptr_number << std::endl;
std::cout << std::endl;
}
Результат выглядит примерно так:
1
some random number
2
some random number
some random number
3
Так кажется, что когда я push_back()
в векторе numbers
, его старые элементы меняют свое местоположение.
Итак, что же это означает, что std::vector
является непрерывным контейнером и почему его элементы перемещаются? Может быть, они хранят их вместе, но перемещают их все вместе, когда требуется больше места?
Edit: Является ли std::vector
смежным только с С++ 17? (Просто чтобы оставить комментарии к моему предыдущему утверждению актуальными для будущих читателей.)
Ответы
Ответ 1
Это примерно похоже на это (извините мой шедевр MS Paint):
Стандартный экземпляр std::vector
который у вас есть в стеке, представляет собой небольшой объект, содержащий указатель на буфер, выделенный для кучи, плюс некоторые дополнительные переменные для отслеживания размера и емкости вектора.
Так кажется, что когда я push_back()
в векторе numbers
, его старые элементы меняют свое местоположение.
Буфер, выделенный для кучи, имеет фиксированную емкость. Когда вы дойдете до конца буфера, новый буфер будет выделен где-то еще в куче, и все предыдущие элементы будут перемещены в новый. Поэтому их адреса будут меняться.
Может быть, они хранят их вместе, но перемещают их все вместе, когда требуется больше места?
Грубо, да. Итератор и стабильность адресов элементов гарантируются с помощью std::vector
только если перераспределение не происходит.
Я знаю, что std::vector
является смежным контейнером только с С++ 17
Макет памяти std::vector
не изменился с момента его первого появления в стандарте. ContiguousContainer
- это всего лишь "концепция", которая была добавлена для дифференциации смежных контейнеров от других во время компиляции.
Ответ 2
Ответ
Это единое непрерывное хранилище (1d-массив). Каждый раз, когда у него заканчивается пропускная способность, он перераспределяется, и сохраненные объекты перемещаются в новое более крупное место - вот почему вы наблюдаете адреса сохраненных объектов.
Это всегда было так, не с C++17
.
TL; DR
Хранилище растет геометрически, чтобы обеспечить требование амортизированного O(1)
push_back()
. Фактор роста равен 2 ( Cap n + 1= Cap n + Cap n) в большинстве реализаций стандартной библиотеки C++ (GCC, Clang, STLPort) и 1.5 ( Cap n + 1= Cap n + Cap n/2) в варианте MSVC.
Если вы предварительно выделите его vector::reserve(N)
и достаточно большим N
, тогда адреса сохраненных объектов не будут меняться при добавлении новых.
В большинстве практических приложений обычно стоит предварительно выделить его как минимум 32 элементам, чтобы пропустить первые несколько перераспределений вскоре после другого (0 → 1 → 2 → 4 → 8 → 16).
Также иногда бывает целесообразно замедлить его, перейти к политике арифметического роста ( Cap n + 1= Cap n + Const) или полностью прекратить работу после достаточно большого размера, чтобы приложение не тратилось или не выросло из памяти.
Наконец, в некоторых практических приложениях, таких как хранилища объектов на основе столбцов, возможно, стоит отказаться от идеи непрерывного хранения полностью в пользу сегментированной (то же, что и std::deque
, но с гораздо большими кусками). Таким образом, данные могут храниться достаточно хорошо локализовано как для запросов на столбец, так и для каждой строки (хотя для этого может потребоваться некоторая помощь и от распределителя памяти).
Ответ 3
std::vector
являющийся непрерывным контейнером, означает именно то, что вы думаете.
Тем не менее, многие операции над вектором могут переустановить весь фрагмент памяти.
Один общий случай - когда вы добавляете к нему элемент, вектор должен расти, он может перераспределять и копировать все элементы в другую непрерывную часть памяти.
Ответ 4
Итак, что же это означает, что std :: vector является непрерывным контейнером и почему его элементы перемещаются? Может быть, они хранят их вместе, но перемещают их все вместе, когда требуется больше места?
Это точно, как это работает и почему добавление элементов действительно делает недействительными все итераторы, а также места памяти при перераспределении. Это не только актуально с С++ 17, с тех пор это было так.
Существует несколько преимуществ такого подхода:
- Он очень удобен для кэширования и, следовательно, эффективен.
- Метод
data()
может использоваться для передачи базовой необработанной памяти в API, которые работают с необработанными указателями. - Стоимость выделения новой памяти при
push_back
, reserve
или resize
размеров сводится к постоянному времени, так как геометрический рост амортизируется с течением времени (каждый раз, когда push_back
называется удвоением мощности в libc++ и libstdc++, и прибл. фактор 1,5 в MSVC). - Он позволяет использовать самую ограниченную категорию итераторов, то есть, итераторы произвольного доступа, поскольку классическая арифметика указателя хорошо работает, когда данные смежно сохраняются.
- Перемещение конструкции векторного экземпляра из другого очень дешево.
Эти последствия можно считать недостатком такого макета памяти:
- Все итераторы и указатели на элементы недействительны при модификациях вектора, которые предполагают перераспределение. Это может привести к тонким ошибкам, например, стирание элементов при итерации по элементам вектора.
- Такие операции, как
push_front
(в виде std::list
или std::deque
), не предоставляются (insert(vec.begin(), element)
работает, но, возможно, дорогостоящая¹), а также эффективное слияние/сплайсинг нескольких векторных экземпляров,
¹ Спасибо @FrançoisAndrieux за указание на это.
Ответ 5
С точки зрения фактической структуры std::vector
выглядит примерно так:
struct vector { // Simple C struct as example (T is the type supplied by the template)
T *begin; // vector::begin() probably returns this value
T *end; // vector::end() probably returns this value
T *end_capacity; // First non-valid address
// Allocator state might be stored here (most allocators are stateless)
};
Соответствующий фрагмент кода из реализации libc++
используемый LLVM
Печать содержимого необработанной памяти std::vector
:
(Не делайте этого, если вы не знаете, что делаете!)
#include <iostream>
#include <vector>
struct vector {
int *begin;
int *end;
int *end_capacity;
};
int main() {
union vecunion {
std::vector<int> stdvec;
vector myvec;
~vecunion() { /* do nothing */ }
} vec = { std::vector<int>() };
union veciterator {
std::vector<int>::iterator stditer;
int *myiter;
~veciterator() { /* do nothing */ }
};
vec.stdvec.push_back(1); // Add something so we don't have an empty vector
std::cout
<< "vec.begin = " << vec.myvec.begin << "\n"
<< "vec.end = " << vec.myvec.end << "\n"
<< "vec.end_capacity = " << vec.myvec.end_capacity << "\n"
<< "vec size = " << vec.myvec.end - vec.myvec.begin << "\n"
<< "vec capacity = " << vec.myvec.end_capacity - vec.myvec.begin << "\n"
<< "vector::begin() = " << (veciterator { vec.stdvec.begin() }).myiter << "\n"
<< "vector::end() = " << (veciterator { vec.stdvec.end() }).myiter << "\n"
<< "vector::size() = " << vec.stdvec.size() << "\n"
<< "vector::capacity() = " << vec.stdvec.capacity() << "\n"
;
}