std::array или std::vector в поле класса C++: где хранятся данные и как не сломать ABI | AdminWiki

std::array или std::vector в поле класса C++: где хранятся данные и как не сломать ABI

28 сентября 2026 14 мин. чтения

Почему выбор контейнера для поля класса влияет на дизайн класса

Для поля класса выбор между 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 overflowbad_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.

Как сверить расчётный объём памяти с фактическим и найти раздутые буферы, описано в статье Измерение памяти под массивы: профилирование и оценка объёма. Там учтены заголовки объектов, выравнивание и оверхед аллокатора, которые часто съедают больше, чем сами данные.

Чек-лист: как выбрать контейнер для вашего класса

Пройдите по вопросам и остановитесь на первом ответе "да":

  1. Размер массива известен на этапе компиляции и не меняется за время жизни объекта? Берите std::array.
  2. Массив больше нескольких килобайт и объект создаётся на стеке? Переносите данные в кучу: std::vector или std::unique_ptr<std::array<char, N>>.
  3. Объекты копируются в горячем цикле, а поле большое? Проверьте, не копируется ли 4 КиБ и больше на каждой итерации: переходите на ссылки, перемещение или shared_ptr.
  4. Тип объявлен в публичном заголовке разделяемой библиотеки? Считайте N частью ABI: выбирайте std::vector, PIMPL или пару (указатель, размер).
  5. Нужны вычисления и таблицы на этапе компиляции? std::array и static_assert.
  6. Нужен обход множества объектов с одинаковым массивом? Сравните std::array и структуру массивов, замерьте cache-misses.
  7. Нужен динамический рост? std::vector плюс reserve под ожидаемый размер, и помните про цену реаллокаций.

Дальше действие, которое займёт полчаса и даст конкретику: найдите в проекте класс с самым большим sizeof, добавьте static_assert на ожидаемый размер, прогоните горячий цикл под perf stat -e cache-references,cache-misses и сравните два варианта поля. Решение принимайте по цифрам на своей нагрузке, а не по привычке: std::array выигрывает там, где размер фиксирован, объект невелик и важна предсказуемость, а std::vector - там, где объём заранее неизвестен или тип обязан оставаться бинарно совместимым.

Поделиться: