Ответ по умолчанию
Используйте std::vector. Это правильный контейнер для подавляющего большинства
случаев. Выбирайте что-то другое только при наличии конкретной причины.
Это не отговорка — это следствие того, как работают кэши CPU. Вектор хранит элементы последовательно в памяти. Итерация по вектору из N элементов касается N кэш-линий по порядку. Все остальные стандартные контейнеры разбрасывают элементы по памяти, увеличивая количество промахов кэша.
1std::vector<float> vec(1000); // 1000 float, одно выделение, последовательный доступ
2std::list<float> lst(1000); // 1000 узлов, 1000 выделений, случайный паттерн доступа
На современном CPU итерация по lst может быть в 10–50 раз медленнее, чем по vec
при том же количестве элементов — исключительно из-за эффектов кэша.
std::array — фиксированный размер, на стеке
std::array<T, N> — обёртка с нулевыми накладными расходами над массивом C. Размер
фиксирован на этапе компиляции; нет выделения на куче.
1#include <array>
2
3std::array<uint8_t, 64> buf{}; // 64 байта на стеке
4buf[0] = 0xAA;
5buf.fill(0);
6
7// range-for, итераторы, size() — всё работает как в vector
8for (auto b : buf) process(b);
Используйте когда:
- Размер известен на этапе компиляции и не будет меняться
- Нужно избежать выделения на куче (встраиваемые, ISR-контексты)
- Нужен API std::vector без накладных расходов
Не используйте когда:
- Размер меняется во время выполнения
std::array — встраиваемый стандарт для буферов. Предпочитайте его сырому T arr[N],
так как он несёт информацию о размере, поддерживает копирование/перемещение/сравнение
и работает со span’ами.
1void process(std::span<const uint8_t> data);
2
3std::array<uint8_t, 8> frame = {0x01, 0x02, 0x03};
4process(frame); // неявное преобразование в span — без копирования
std::vector — динамический, непрерывный
std::vector<T> — рабочая лошадка. Непрерывная память, амортизированный O(1)
push_back, O(1) случайный доступ.
1std::vector<SensorSample> samples;
2samples.reserve(1000); // предварительное выделение — избегает перераспределений
3
4samples.push_back({tick, voltage});
5samples.emplace_back(tick, voltage); // конструирование на месте — без лишней копии
6
7float third = samples[2].voltage; // O(1)
Ключевые операции и стоимость:
| Операция | Стоимость |
|---|---|
operator[], at() |
O(1) |
push_back / emplace_back |
O(1) амортизированный |
| Вставка в середину | O(n) — сдвигает элементы |
| Удаление из середины | O(n) — сдвигает элементы |
| Поиск (не отсортирован) | O(n) |
Поиск (отсортирован + lower_bound) |
O(log n) |
reserve обязателен для кода, чувствительного к производительности. Без него
вектор удваивает ёмкость при каждом переполнении — корректно, но вызывает повторные
выделения на куче и копирования.
1// Построить вектор из данных известного размера — всегда reserve
2std::vector<Frame> frames;
3frames.reserve(messageCount);
4for (auto& msg : messages)
5 frames.push_back(parseFrame(msg));
Идиома erase-remove — удаление элементов по значению без второго выделения:
1// Удалить все устаревшие сэмплы
2auto now = HAL_GetTick();
3auto it = std::remove_if(samples.begin(), samples.end(),
4 [now](const SensorSample& s) { return now - s.tick > 5000; });
5samples.erase(it, samples.end());
std::deque — двусторонняя очередь
std::deque<T> поддерживает O(1) push/pop с обоих концов и O(1) случайный доступ.
Внутри это сегментированный массив — не полностью непрерывный, но более кэш-дружественный,
чем список.
1std::deque<Command> queue;
2queue.push_back(cmd1); // добавить в конец
3queue.push_front(cmd2); // добавить в начало
4auto c = queue.front();
5queue.pop_front(); // O(1)
Используйте когда:
- Нужен O(1) доступ с обоих концов
- Нужен случайный доступ (в отличие от
list) - Рабочий набор достаточно мал, чтобы промахи кэша от сегментации не имели значения
Не используйте когда:
- Нужна непрерывная память (span, DMA, C API)
- Интенсивная итерация — для последовательного доступа vector быстрее
На практике std::deque используется для очередей BFS, стеков отмены с доступом
к началу и очередей задач с элементами с обоих концов. Для простого FIFO
это чище, чем std::queue (который по умолчанию использует deque).
std::list — двусвязный список
std::list<T> — двусвязный список. Каждый элемент — отдельное выделение на куче.
O(1) вставка/удаление в любом месте при наличии итератора на позицию.
1std::list<Task> tasks;
2tasks.push_back(t1);
3tasks.push_front(t2);
4
5// Splice — O(1) перенос между списками без копирования
6std::list<Task> pending;
7auto it = tasks.begin();
8pending.splice(pending.end(), tasks, it); // перемещает *it в pending, O(1)
9
10// Удаление по итератору — O(1)
11auto pos = std::find(tasks.begin(), tasks.end(), target);
12if (pos != tasks.end()) tasks.erase(pos); // O(1) удаление, O(n) поиск
Используйте когда:
- Требуется стабильность итераторов при вставке/удалении (итераторы на другие элементы остаются действительными)
- Нужен O(1) splice (перемещение диапазонов между списками без копирования)
- Реализуете структуру данных, которая изначально требует связанных узлов (например, колесо таймеров)
Не используйте для: чего угодно, где вы итерируете весь контейнер. Промах кэша
на каждый элемент делает итерацию std::list значительно медленнее, чем std::vector.
Вектор с erase-remove почти всегда быстрее, чем список для типичных случаев, где вы думали, что нужен список.
std::set / std::map — отсортированное дерево
std::set<T> и std::map<K, V> — красно-чёрные деревья. O(log n) вставка, удаление,
поиск. Отсортированный порядок. Каждый узел — выделение на куче, такой же штраф кэша,
как у списка.
1std::map<std::string, Config> settings;
2settings["baud"] = Config{115200};
3auto it = settings.find("baud"); // O(log n)
Используйте когда:
- Нужен отсортированный порядок и O(log n) операции
- Нужна стабильность итераторов (итераторы переживают вставку/удаление других элементов)
Предпочитайте std::unordered_map, когда нужен только O(1) средний поиск
и порядок не важен:
1std::unordered_map<std::string, Config> settings;
2settings["baud"] = Config{115200};
3auto it = settings.find("baud"); // O(1) в среднем
Для небольших хранилищ ключ-значение фиксированного размера (до ~20 записей)
отсортированный vector пар с std::lower_bound часто быстрее обоих — нет накладных
расходов на узлы кучи, кэш-дружественный, бинарный поиск за O(log n).
Руководство по выбору
Размер известен на этапе компиляции?
Да → std::array<T, N>
Размер динамический?
Нужен O(1) с обоих концов?
Да → std::deque
Нет → std::vector ← по умолчанию
Нужен O(1) вставка/удаление в середине со стабильными итераторами?
Да → std::list (и измерьте — редко оправдано)
Нужен поиск по отсортированному ключу?
O(log n), упорядоченный, стабильный → std::map / std::set
O(1) в среднем, неупорядоченный → std::unordered_map / std::unordered_set
Встраиваемые контейнеры
На МК без кучи или с жёсткими ограничениями памяти стандартные контейнеры с выделением часто запрещены. Альтернативы:
| Стандартный | Встраиваемая альтернатива |
|---|---|
std::vector |
std::array + ручной счётчик размера, или etl::vector |
std::list |
Интрузивный связный список (узел встроен в объект) |
std::map |
Отсортированный std::array + бинарный поиск |
std::queue |
Кольцевой буфер (RingBuffer<T, N>) |
Embedded Template Library (ETL) предоставляет версии большинства STL-контейнеров без кучи с фиксированной ёмкостью времени компиляции.
Итоги
std::array— размер на этапе компиляции, стек, нулевые накладные расходы — встраиваемый стандарт для фиксированных буферовstd::vector— динамический, непрерывный, кэш-дружественный — стандарт для всего остальногоstd::deque— O(1) с обоих концов, случайный доступ — очереди задач, BFSstd::list— O(1) вставка/удаление везде, стабильные итераторы, splice — редко оправдывает потери кэшаstd::map/set— отсортированное дерево, O(log n) — когда важен порядок или стабильность итераторовstd::unordered_map— хэш-таблица, O(1) в среднем — когда важна скорость поиска, порядок нет
Что дальше
- std::variant и std::optional — типобезопасные альтернативы void* и сторожевым значениям
- std::span и string_view — невладеющие представления любого непрерывного контейнера
- Умные указатели и владение — владеющие контейнеры динамически типизированных объектов