Почему выбор контейнера для поля класса влияет на дизайн класса
Для поля класса выбор между std::array и std::vector решает, где физически лежат элементы. std::array<int, 100> держит 100 чисел внутри самого объекта: объект создан на стеке, значит и данные на стеке, объект создан через new, значит данные лежат в том же блоке кучи. std::vector<int> хранит в объекте три машинных слова (указатель на буфер, size, capacity), а элементы живут в отдельном блоке, который аллокатор выдаёт из кучи.
Из этого расходятся все практические свойства: размер объекта, цена создания и копирования, кэш-локальность обхода и стабильность ABI. Сравните два поля:
struct A {
std::array<int, 100> data; // sizeof(A) == 400
};
struct B {
std::vector<int> data; // sizeof(B) обычно 24 байта на x86-64
};
Объект с std::array весит 400 байт независимо от того, сколько элементов вы реально заполнили. Объект с std::vector весит около 24 байт (в libstdc++ и libc++ это три указателя, в MSVC размер зависит от конфигурации, а в debug-сборках растёт из-за проверок итераторов), зато каждый экземпляр тянет за собой отдельное выделение памяти. Точный размер std::vector зависит от реализации и конфигурации сборки, поэтому проверяйте его статическим assert или выводом sizeof на своей платформе, а не полагайтесь на «типовые» 24 байта.
Для утилит, которые разбирают сетевые пакеты, собирают метрики или обрабатывают логи в реальном времени, разница между 24 и 400 байтами превращается в задержки, потребление памяти и предсказуемость поведения. Дальше разберём критерии выбора на конкретных примерах и покажем, где каждый контейнер отрабатывает свою задачу.
Где хранятся данные: стек против кучи в контексте класса
std::array - агрегат с сырым массивом внутри, по сути struct { T elems[N]; }. Собственного заголовка у него нет, поэтому sizeof(std::array<int, 100>) равен ровно 400 байтам. Размер известен компилятору, и он может разложить объект по регистрам, заинлайнить обход и посчитать смещения полей константами.
std::vector всегда работает через аллокатор: буфер появляется при конструировании с ненулевым size, при первом push_back или при reserve. Данные лежат по адресу, который вернул operator new, а объект хранит только указатель, size и capacity. Обращение к элементу добавляет одну загрузку указателя, и в горячем цикле компилятор обычно держит его в регистре. Подробное описание интерфейса и гарантий контейнера приведено в документации std::vector на cppreference.com.
Размер объекта зависит не только от типа поля, но и от порядка полей. Выравнивание добавляет padding:
struct Mixed {
std::array<char, 3> tag; // 3 байта, выравнивание 1
int value; // требует выравнивания 4
};
// sizeof(Mixed) == 8: 3 байта tag, 1 байт padding, 4 байта value
Поменяйте поля местами, и размер уменьшится до 8 всё равно, но в более сложных структурах перестановка полей экономит десятки байт на каждом экземпляре. Для структур, которые создаются миллионами, это заметная экономия.
Как размер объекта влияет на производительность и безопасность
Большое std::array в поле переносит риск переполнения стека прямо в ваш код:
struct Packet {
std::array<char, 65536> buffer; // 64 КиБ внутри объекта
};
void handle() {
Packet p; // 64 КиБ на стеке вызывающего потока
}
На Linux главный поток обычно получает 8 МиБ стека (значение по умолчанию задаёт ulimit -s), но у потоков, созданных с маленьким стеком, лимит бывает в разы меньше. Для musl libc в разных обсуждениях и баг-репортах упоминается размер стека потока по умолчанию порядка 80-128 КиБ, причём точное значение зависит от версии и конфигурации, поэтому перед деплоем его стоит проверить на целевой платформе. Так, в баг-репорте rsyslog сообщается о переполнении стека потока при использовании модуля imtcp и указывается значение по умолчанию 128 КиБ (rsyslog issue #6357), а в обсуждении патча OVS для того же musl libc фигурирует 80 КиБ (ovs-dev, февраль 2016). Функция с несколькими такими объектами в потоке с лимитом в десятки килобайт упадёт с SIGSEGV, причём без внятного сообщения об ошибке: диагностировать падение в проде придётся по кордампу.
std::array не выделяет память динамически, поэтому не может выбросить std::bad_alloc, зато может уронить процесс переполнением стека. std::vector на 64 КиБ при нехватке памяти выбросит std::bad_alloc, который можно перехватить и вернуть понятную ошибку вместо падения.
Практическое правило: буферы больше нескольких килобайт держите в куче. Подойдут std::vector, std::unique_ptr<std::array<char, N>> или объект, целиком созданный через new. Если размер фиксирован и объект должен жить на стеке, ограничьте N и сверьтесь с лимитом стека через ulimit -s перед деплоем.
Критерии выбора между стеком и кучей, причины переполнения стека и работу аллокаторов мы разбирали отдельно в статье Массивы на стеке и в куче: критерии выбора, переполнение стека и примеры на C, C++ и Go.
Копирование и перемещение: что происходит с данными
Копирование std::array поэлементное и глубокое: копия занимает N * sizeof(T), а для тривиально копируемых типов компилятор заменяет цикл на memcpy. Перемещение std::array тоже поэлементное, и для int, char, double оно не отличается от копирования: переместить тривиальный объект нельзя, можно только скопировать байты. Класс с полем std::array<int, 1000> при передаче по значению каждый раз копирует 4000 байт.
std::vector ведёт себя иначе. Копирование выделяет новый буфер и переносит в него все элементы, поэтому стоит столько же, сколько копирование массива, плюс обращение к аллокатору. Перемещение переносит владение буфером: указатель передаётся новому объекту, источник остаётся валидным, но пустым. Операция занимает константное время независимо от количества элементов.
Отсюда простая тактика: если класс с большим массивом часто передаётся между функциями, либо передавайте его по ссылке, либо храните данные в std::vector и используйте перемещение, либо заверните массив в std::shared_ptr и получите дешёвое копирование с разделением владения.
Правило пяти и noexcept: практические следствия
Класс, собранный из полей std::array, std::vector и других RAII-типов, не требует рукописных конструкторов: компилятор сгенерирует копирование, перемещение и деструктор корректно. Это правило нуля, и его хватает в большинстве случаев.
Проблемы начинаются, когда вы объявляете деструктор или конструктор копирования руками. Тогда конструктор перемещения не генерируется автоматически, и вместо переноса указателя начинается глубокое копирование. Второй эффект касается контейнеров от ваших типов: при росте буфера std::vector использует move_if_noexcept и, если перемещение элемента не помечено noexcept, а копирование доступно, предпочтёт копировать все элементы. Один отсутствующий noexcept превращает рост вектора в N глубоких копий. По этой же причине пользовательские конструкторы перемещения не должны генерировать исключения — это прямо указано в справочнике по конструкторам перемещения (cppreference.com, конструкторы перемещения).
class SampleBuffer {
public:
SampleBuffer() = default;
SampleBuffer(const SampleBuffer&) = default;
SampleBuffer& operator=(const SampleBuffer&) = default;
SampleBuffer(SampleBuffer&&) noexcept = default;
SampleBuffer& operator=(SampleBuffer&&) noexcept = default;
~SampleBuffer() { /* например, сброс счётчиков */ }
void add(double v) { samples_.push_back(v); }
private:
std::vector<double> samples_;
};
Здесь перемещение помечено noexcept не для красоты: перемещение std::vector<double> само noexcept с C++17, поэтому сгенерированный оператор тоже не выбрасывает исключений, и class корректно работает внутри других контейнеров.
Как устроена реаллокация, какой коэффициент роста используют разные реализации и сколько реально стоит копирование при росте буфера, разобрано в статье Динамические массивы: рост, реаллокация и цена копирования. Там же есть чек-лист по reserve и shrink_to_fit.
Кэш-локальность и скорость итерации: почему std::array может быть быстрее
Если перебирать элементы одного вектора, данные лежат непрерывно, и разницы со std::array почти нет: оба варианта стримят память последовательно, префетчер отрабатывает одинаково. Узким местом становится пропускная способность памяти, а не контейнер.
Разница проявляется в другом сценарии, когда есть массив объектов, у каждого из которых поле-массив:
struct WithArray { std::array<int, 256> v; };
struct WithVector { std::vector<int> v; };
std::array<WithArray, 1024> a; // все данные подряд в одном блоке
std::array<WithVector, 1024> b; // 1024 буфера разбросаны по куче
Обход a читает подряд примерно 1 МиБ памяти: одна область, идеальная локальность. Обход b требует 1024 разыменования указателей на буферы, которые аллокатор разложил так, как ему удобно; каждое промахивание по кэшу стоит десятки и сотни тактов. В горячем пути обработки метрик или пакетов первый вариант предсказуемее по времени отклика, особенно когда аллокатор фрагментировал кучу за время работы процесса.
Практический пример: сравнение итерации по std::array и std::vector
long long sum_array(const std::array<int, 1024>& a) {
long long s = 0;
for (int x : a) s += x;
return s;
}
long long sum_vector(const std::vector<int>& v) {
long long s = 0;
for (int x : v) s += x;
return s;
}
Замер удобно делать через Google Benchmark с приёмником результата:
static void BM_array(benchmark::State& state) {
std::array<int, 1024> a{};
a[0] = 1;
for (auto _ : state) {
benchmark::DoNotOptimize(sum_array(a));
}
}
BENCHMARK(BM_array);
Без DoNotOptimize или volatile-приёмника компилятор выбросит суммирование, и вы получите ноль наносекунд на операцию. Второй момент: для одиночного вектора результаты часто совпадают в пределах шума, потому что ограничение задаёт память, а не контейнер. Устойчивые различия видны на маленьких массивах в десятки элементов, где данные помещаются в L1, а лишняя загрузка указателя и промах по нему заметны на фоне полезной работы.
Проверить поведение на своей машине можно без сторонних библиотек:
perf stat -e cache-references,cache-misses ./bench_array
Высокий процент cache-misses на варианте с вектором обычно означает, что указатели на буферы разбросаны по памяти, и структуру стоит переработать: заменить массив объектов на структуру массивов или перейти на std::array. Подробнее про локальность и сравнение с несвязными структурами читайте в статье Связные списки или массивы: что выбрать для хранения данных в 2026 году.
constexpr и шаблоны фиксированного размера: когда std::array незаменим
std::array подходит для вычислений на этапе компиляции, потому что его размер известен, а данные не требуют выделения памяти:
constexpr std::array<int, 5> kCodes = {10, 20, 30, 40, 50};
constexpr int kThird = kCodes[2]; // 30
static_assert(kCodes.size() == 5);
static_assert(kThird == 30);
Шаблон с размером как параметром даёт типобезопасные буферы и проверки прямо при сборке:
template <std::size_t N>
struct FrameBuffer {
std::array<std::uint8_t, N> bytes{};
std::size_t length = 0;
};
static_assert(sizeof(FrameBuffer<1500>) >= 1500); // MTU Ethernet
static_assert(sizeof(FrameBuffer<9000>) >= 9000); // jumbo frame
Такой static_assert ловит ошибку в момент компиляции, а не падением в проде: никто не уменьшит буфер ниже MTU и не заметит этого только на нагрузочном тесте. Для DevOps-кода это даёт проверяемые таблицы кодов ответов, заранее посчитанные маски и размеры буферов под конкретный протокол, записанные в одном месте.
С C++20 std::vector формально допускается в constexpr-выражениях, но на практике поддержка зависит от компилятора и версии стандартной библиотеки: сообщения об ошибках компиляции constexpr std::vector встречаются до сих пор, что подтверждается обсуждением на Stack Overflow (C++20 constexpr vector and string not working). Для справочных таблиц, кодов ошибок и разобранных конфигов std::array остаётся проще и надёжнее: аллокатор не нужен, а значит, меньше поводов для неожиданностей при переходе между компиляторами.
Почему изменение размера массива в публичной структуре ломает ABI
N в std::array<T, N> входит в тип, а sizeof зависит от N. Если структура объявлена в публичном заголовке, её размер становится частью ABI:
// v1.0, library.h
struct Config {
std::array<int, 10> values; // 40 байт
};
// v2.0, тот же заголовок
struct Config {
std::array<int, 20> values; // 80 байт
};
Код, собранный с версией 1.0, разместит Config размером 40 байт там, где библиотека версии 2.0 пишет 80 байт. Результат - перезапись соседней памяти и неопределённое поведение. Проблема затрагивает и смещения последующих полей, если поле-массив стоит не последним: меняется layout всей структуры, включая расположение по границам кэш-линий.
std::vector в этом смысле устойчивее: sizeof объекта не меняется при добавлении элементов, поэтому новая версия библиотеки может вернуть больше данных без пересборки потребителя. Ограничение тоже есть: количество элементов должно передаваться отдельно, а само содержимое вектора остаётся в куче вызывающей стороны.
С C++-символами ошибку часто поймает линковщик: имя функции или класса включает аргумент шаблона, и манглированное имя меняется. Для extern "C" API, opaque-структур и функций, принимающих указатель на буфер, ошибка проходит молча и проявляется порчей памяти на проде.
Что делать с публичными API: держите std::vector, применяйте PIMPL, передавайте пару (указатель, размер) или закладывайте запас через поля с версией и выравниванием. Отдельный риск - смешивание реализаций стандартной библиотеки и разных режимов сборки: libstdc++ имеет dual ABI через макрос _GLIBCXX_USE_CXX11_ABI, из-за которого объекты, собранные с разными значениями этого макроса (в частности, вокруг GCC 4.8 и новее), не стыкуются между собой и дают ошибки линковки на символах с abi::cxx11 — это описано в обсуждении на Stack Overflow (Linking problems due to symbols with abi::cxx11). Совместимость libstdc++ и libc++ на уровне ABI в предоставленных источниках не подтверждена, поэтому при смешивании реализаций проверяйте её отдельно для вашего тулчейна.
Если структура уходит на диск или в сеть, её layout превращается в формат данных, и менять его ещё дороже: старые файлы перестанут читаться, а разбор по сети сломается на смешанных версиях. Приёмы сохранения массивов, порядок байтов и выравнивание разобраны в статье Сериализация и персистентность массивов на диске: форматы, порядок байтов и выравнивание.
Практические рекомендации: матрица выбора контейнера
Соберём критерии в одну таблицу, чтобы решение занимало минуту, а не вечер обсуждений.
| Критерий | std::array<T, N> | std::vector<T> |
|---|---|---|
| Размер объекта | N * sizeof(T) внутри объекта | обычно 24 байта на x86-64, зависит от реализации |
| Где лежат данные | там же, где объект: стек или куча | буфер в куче, в объекте указатель |
| Размер известен компилятору | да | нет |
| Изменение количества элементов | требует смены типа | push_back, resize, reserve |
| Копирование | поэлементно, N * sizeof(T) | выделение буфера плюс копирование элементов |
| Перемещение | поэлементно, для тривиальных типов равно копированию | перенос указателя за O(1), noexcept с C++17 |
| constexpr | да | формально с C++20, на практике зависит от компилятора |
| ABI при смене размера | ломается | не меняется |
| Работа с памятью | нет bad_alloc, но возможен stack overflow | bad_alloc возможен и обрабатываем |
| Локальность в массиве объектов | высокая, данные лежат рядом | зависит от аллокатора, указатели разбросаны |
Короткие выводы по сценариям:
- Размер известен и невелик, до нескольких сотен элементов: std::array.
- Размер меняется или неизвестен заранее: std::vector плюс reserve под ожидаемый объём.
- Публичный заголовок разделяемой библиотеки: std::vector, PIMPL или opaque-дескриптор вместо std::array с фиксированным N.
- Вычисления и таблицы на этапе компиляции: std::array.
- Буфер больше нескольких килобайт: куча, то есть std::vector или std::unique_ptr<std::array<char, N>>.
- Важна локальность при обходе множества объектов: std::array в поле либо переход к структуре массивов.
Пример: класс для хранения метрик с std::vector
class MetricsCollector {
public:
explicit MetricsCollector(std::size_t expected) {
samples_.reserve(expected);
}
void add(double v) { samples_.push_back(v); }
double average() const {
if (samples_.empty()) return 0.0;
double s = 0.0;
for (double v : samples_) s += v;
return s / static_cast<double>(samples_.size());
}
const std::vector<double>& samples() const { return samples_; }
private:
std::vector<double> samples_;
};
Здесь выбран вектор, потому что число метрик заранее неизвестно. reserve(expected) убирает реаллокации на типичном объёме, а отдача данных через const& или std::span не копирует буфер. Если бы протокол ограничивал число метрик жёстко, например 64 значениями на пакет, поле можно было бы заменить на std::array<double, 64> и получить объект без обращений к куче.
Пример: буфер фиксированного размера с std::array
class EthernetFrame {
public:
static constexpr std::size_t kMtu = 1500;
std::array<std::uint8_t, kMtu> data{};
std::size_t length = 0;
};
static_assert(sizeof(EthernetFrame) >= EthernetFrame::kMtu);
Размер кадра известен заранее и не меняется, поэтому std::array даёт объект без динамического выделения и с проверкой размера на этапе компиляции. Для jumbo-фреймов на 9000 байт такой объект уже стоит создавать в куче, а если число байт приходит извне и может быть любым, берите std::vector и контролируйте верхнюю границу самостоятельно, чтобы не получить переполнение buffer overflow.
Как сверить расчётный объём памяти с фактическим и найти раздутые буферы, описано в статье Измерение памяти под массивы: профилирование и оценка объёма. Там учтены заголовки объектов, выравнивание и оверхед аллокатора, которые часто съедают больше, чем сами данные.
Чек-лист: как выбрать контейнер для вашего класса
Пройдите по вопросам и остановитесь на первом ответе "да":
- Размер массива известен на этапе компиляции и не меняется за время жизни объекта? Берите std::array.
- Массив больше нескольких килобайт и объект создаётся на стеке? Переносите данные в кучу: std::vector или std::unique_ptr<std::array<char, N>>.
- Объекты копируются в горячем цикле, а поле большое? Проверьте, не копируется ли 4 КиБ и больше на каждой итерации: переходите на ссылки, перемещение или shared_ptr.
- Тип объявлен в публичном заголовке разделяемой библиотеки? Считайте N частью ABI: выбирайте std::vector, PIMPL или пару (указатель, размер).
- Нужны вычисления и таблицы на этапе компиляции? std::array и static_assert.
- Нужен обход множества объектов с одинаковым массивом? Сравните std::array и структуру массивов, замерьте cache-misses.
- Нужен динамический рост? std::vector плюс reserve под ожидаемый размер, и помните про цену реаллокаций.
Дальше действие, которое займёт полчаса и даст конкретику: найдите в проекте класс с самым большим sizeof, добавьте static_assert на ожидаемый размер, прогоните горячий цикл под perf stat -e cache-references,cache-misses и сравните два варианта поля. Решение принимайте по цифрам на своей нагрузке, а не по привычке: std::array выигрывает там, где размер фиксирован, объект невелик и важна предсказуемость, а std::vector - там, где объём заранее неизвестен или тип обязан оставаться бинарно совместимым.