Массивы в структурах Rust в 2026: [T; N], Vec<T>, repr(C) и MaybeUninit | AdminWiki

Массивы в структурах Rust в 2026: [T; N], Vec<T>, repr(C) и MaybeUninit

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

Размер структуры в Rust складывается из размеров полей и padding, который компилятор добавляет для выравнивания. Массив [T; N] лежит внутри структуры и добавляет к ней N * size_of::<T>() байт. Поле Vec<T> ведёт себя иначе: структура получает только дескриптор из указателя, длины и capacity, обычно 24 байта на 64-битной платформе, а сами элементы живут в куче.

Отсюда рабочее правило. Нужен фиксированный размер, предсказуемый layout и отсутствие аллокаций - берите [T; N]. Нужны данные переменной длины, буферы из сети, растущие коллекции - берите Vec<T>. Если структура уходит в C через FFI, добавьте repr(C), иначе порядок полей останется на усмотрение компилятора.

Дальше разбираем конкретику: как посчитать размер структуры с массивом, что меняет repr(C), как безопасно инициализировать большой буфер через MaybeUninit и какие проверки стоит добавить в тесты.

Что происходит с массивом внутри структуры Rust

Компилятор размещает поля структуры в непрерывном блоке памяти. Массив [T; N] входит в этот блок целиком. Поле Vec<T> в блоке элементы не хранит: там лежат указатель на кучу, длина и capacity.

Разница видна на первом же примере. Структура с полем [u8; 1024] занимает больше килобайта, структура с полем Vec<u8> занимает 24 байта независимо от того, сколько в ней данных.

Как [T; N] влияет на размер структуры

Формула простая: размер структуры равен сумме размеров полей плюс padding, а результат округляется вверх до кратности выравниванию структуры. Padding появляется там, где следующее поле должно начинаться с адреса, кратного своему выравниванию.

use std::mem::{align_of, size_of};

struct S {
    a: u8,
    b: [u32; 4],
}

fn main() {
    println!("size = {}", size_of::<S>());   // 20
    println!("align = {}", align_of::<S>());  // 4
}

Считаем вручную. Поле a занимает 1 байт, но следующее поле b требует выравнивания 4, поэтому после a компилятор добавляет 3 байта padding. Массив b занимает 4 * 4 = 16 байт. Итого 4 + 16 = 20 байт при выравнивании 4. Если поменять поля местами, размер в этом примере не изменится, но при repr(Rust) компилятор вправе переставлять поля сам, и рассчитывать на конкретный порядок не стоит.

Массив внутри структуры не отделяется от неё: копирование структуры копирует и массив. Для [T; N] с большим N это заметная нагрузка на стек и на memcpy. Как устроен непрерывный блок и индексная адресация, разбираем в отдельном материале про хранение массивов в памяти.

Проверять размер стоит тестом, а не глазами:

#[test]
fn layout_is_stable() {
    assert_eq!(std::mem::size_of::<S>(), 20);
    assert_eq!(std::mem::align_of::<S>(), 4);
}

Такой тест ловит случайный рост структуры после рефакторинга. Для repr(Rust) он может упасть после смены версии компилятора, для repr(C) остаётся стабильным.

Отдельный риск связан с величиной N. Структура с полем [u8; 2_000_000] создаётся на стеке целиком, и при типичном лимите стека это заканчивается падением. Про причины и про перенос данных в кучу подробно говорим в гайде про массивы на стеке и в куче.

Когда Vec<T> внутри структуры выгоднее

Vec<T> хранит три машинных слова: указатель на буфер, длину и capacity. На 64-битной платформе это 24 байта, и размер структуры не зависит от количества элементов. Это подтверждает документация и обсуждения на Stack Overflow: при копировании Vec копируются именно эти 24 байта, а данные в куче не копируются (Stack Overflow).

struct Packet {
    header: [u8; 16],
    payload: Vec<u8>,
}

// На 64-битной платформе дескриптор Vec занимает 24 байта,
// поэтому ожидаемый размер Packet - 40 байт. Проверяйте size_of на своей цели.

Заголовок живёт внутри структуры, payload - в куче. Так удобно разбирать пакеты переменной длины, накапливать ответы от сокетов, держать буферы, размер которых известен только в рантайме.

Цена решения: аллокация при росте, косвенное обращение к элементам, потеря непрерывности с остальными полями. Если данные читает горячий цикл и их длина известна на этапе компиляции, фиксированный массив быстрее и предсказуемее. Гибридный вариант: структура с маленьким inline-массивом под типичный случай и Vec под редкий большой. Так устроена small buffer optimization, если собрать её вручную.

Оба подхода работают с одним буфером без копирования, когда вы передаёте срез. Механику разделяемых представлений и алиасинга разбираем в статье про срезы и представления массивов.

Зачем нужен repr(C) и как он меняет layout

repr(C) vs repr(Rust): что меняется на практике

По умолчанию структура получает repr(Rust). Это представление даёт только те гарантии layout, которые необходимы для soundness: смещение поля делится на его выравнивание, а выравнивание типа не меньше максимального выравнивания его полей. Порядок полей не гарантируется, и layout типа может меняться при каждой компиляции - Rust документирует только то, что гарантируется на сегодняшний день (The Rust Reference: Type layout). Внутри одного крейта это удобно, на границе с C - опасно.

repr(C) предназначен для создания типов, совместимых с языком C, и типов, над которыми можно безопасно выполнять операции, полагающиеся на layout данных, например реинтерпретацию значений как другого типа. Padding не исчезает, но становится предсказуемым: его можно посчитать вручную и сверить с заголовком на C.

#[repr(C)]
struct Header {
    magic: u32,
    len: u16,
    flags: [u8; 4],
}

Раскладка: magic занимает байты 0-3, len - 4-5, flags - 6-9. Итоговый размер 12 байт при выравнивании 4. Без repr(C) компилятор мог бы, например, поместить flags раньше len, и чтение структуры из C сломается.

#[test]
fn header_layout() {
    assert_eq!(std::mem::size_of::<Header>(), 12);
    assert_eq!(std::mem::offset_of!(Header, len), 4);
    assert_eq!(std::mem::offset_of!(Header, flags), 6);
}

Макрос std::mem::offset_of! раскрывается в смещение поля в байтах от начала типа (std::mem::offset_of!). Rust гарантирует, что смещение заданного поля внутри заданного типа не изменится в течение времени жизни программы, однако две разные компиляции одной и той же программы могут дать разные layout'ы. Макрос появился в Rust в 2023 году и на тот момент ещё должен был быть стабилизирован, поэтому перед использованием сверьтесь с документацией std для вашего тулчейна. Если offset_of! недоступен, посчитайте смещение через указатель на поле и разницу адресов, но тогда тест придётся запускать в рантайме, а не на этапе компиляции.

Когда repr(C) обязателен: FFI и бинарные протоколы

Три сценария, где без repr(C) вы получите мусор в памяти:

  • Передача структуры в C-библиотеку по указателю или по значению.
  • Разделяемая память между Rust и C, где обе стороны читают одни поля по фиксированным смещениям.
  • Разбор бинарного протокола, когда layout должен совпадать с описанием формата.

Для пакетов из сети repr(C) сочетают с repr(packed) только при осознанной необходимости. packed убирает padding, но ломает выравнивание полей: обращение к u32 по нечётному адресу может идти медленнее или требовать копирования. Важно и то, что repr(align) и repr(packed) сами по себе не дают гарантий о порядке полей в layout структуры или варианта перечисления, хотя могут комбинироваться с представлениями вроде repr(C), которые такие гарантии дают (The Rust Reference). Часто дешевле объявить заголовок как [u8; N] и разбирать поля через u32::from_be_bytes, чем бороться с выравниванием.

Ещё одна деталь для протоколов: repr(C) фиксирует порядок в памяти, но не порядок байт. Сетевые форматы обычно используют big-endian, и преобразование остаётся на вас.

Безопасная инициализация больших массивов через MaybeUninit

Почему [0u8; 1_000_000] в структуре - плохая идея

Локальные значения живут на стеке потока, а его лимит задаёт окружение. На Linux проверьте ulimit -s, типичное значение 8 МБ; у потоков, созданных вручную, размер стека может быть меньше. Структура с полем [u8; 2_000_000] при создании локальной переменной требует около 2 МБ непрерывного пространства; несколько таких объектов в одной функции или рекурсия легко упирают в лимит, и поток падает с stack overflow.

struct Big {
    data: [u8; 2_000_000],
}

fn main() {
    let big = Big { data: [0u8; 2_000_000] }; // риск stack overflow
    println!("{}", big.data[0]);
}

Варианты решения: Box<[u8; N]>, Vec<u8> или Box<[u8]>. Все три переносят данные в кучу и оставляют на стеке только указатель. Если нужен именно inline-массив и заполнять его придётся по частям, помогает MaybeUninit.

Паттерн инициализации через MaybeUninit без UB

MaybeUninit<T> описывает память, которая может быть не инициализирована. Union был стабилизирован в Rust 1.36, а mem::uninitialized объявлен устаревшим начиная с Rust 1.38 (Methods for Array Initialization in Rust). Для массивов порядок такой: создать массив неинициализированных слотов, записать значение в каждый слот, затем преобразовать массив в обычный. Именно такой пример поэлементной инициализации большого массива приводит документация MaybeUninit: создаётся неинициализированный массив из MaybeUninit, элементы инициализируются в цикле, а затем массив трансмутируется в инициализированный (MaybeUninit in std::mem).

use std::mem::MaybeUninit;

const N: usize = 64 * 1024;

let mut buf = [MaybeUninit::<u8>::uninit(); N];
for (i, slot) in buf.iter_mut().enumerate() {
    slot.write((i & 0xff) as u8);
}
// все слоты записаны, поэтому чтение массива целиком корректно
let buf: [u8; N] = unsafe { std::mem::transmute(buf) };

Ключевое условие: к моменту transmute или assume_init записаны все элементы. Ответственность лежит на вызывающем: он должен гарантировать, что все элементы массива находятся в инициализированном состоянии (MaybeUninit). Иначе получаете UB, и компилятор вправе посчитать недостижимым код, который читает невозможные значения.

Документация MaybeUninit демонстрирует и корректность transmute между T и массивом MaybeUninit<u8> того же размера: в примере функция identity трансмутирует значение T в MaybeUninit<[u8; size_of::<T>()]>. Это тот же приём, что и обратное преобразование массива MaybeUninit в готовый массив.

Для нулевого заполнения есть безопасный путь: MaybeUninit::zeroed() и [MaybeUninit::<u8>::zeroed(); N]. Если нужен именно [0u8; N], помните, что литерал создаёт значение на стеке до перемещения в Box.

Массив из MaybeUninit для типов без Drop можно создать и через MaybeUninit::uninit().assume_init(): вызов безопасен, потому что тип, который мы объявляем инициализированным, - это набор MaybeUninit, которые не требуют инициализации (MaybeUninit). Для элементов с Drop такой приём не подходит: assume_init объявит инициализированными значения, которых там нет, и дальнейшая работа с ними приведёт к UB.

Отдельно про панику в цикле инициализации: дропинг MaybeUninit ничего не делает, поэтому при панике возможна утечка памяти, но проблемы безопасности памяти нет (MaybeUninit in std::mem).

Функция MaybeUninit::array_assume_init меняла статус стабилизации, поэтому перед использованием сверьтесь с документацией std для вашего тулчейна. Универсальная замена - transmute для конкретного N или цикл по ptr::read.

Как измерить размер и выравнивание структуры

size_of, size_of_val и align_of: что использовать

  • size_of::<T>() возвращает размер типа, известный на этапе компиляции, с учётом padding.
  • size_of_val(&value) возвращает размер значения по ссылке; нужен для срезов и trait objects, где размер известен только в рантайме.
  • align_of::<T>() возвращает требуемое выравнивание.
  • offset_of!(T, field) даёт смещение поля, что удобно сверять с C-заголовком.
let v: Vec<u32> = vec![0; 1024];
println!("{}", std::mem::size_of::<Vec<u32>>());  // 24, дескриптор
println!("{}", std::mem::size_of_val(&v[..]));    // 4096, данные

Для Vec<T> size_of показывает размер дескриптора, а не объём данных в куче. Чтобы оценить реальное потребление, складывайте size_of::<T>() * capacity с накладными расходами аллокатора. Методику сравнения расчётного и фактического объёма разбираем в статье про измерение памяти под массивы.

Практический пример: расчёт layout для сетевого пакета

#[repr(C)]
struct Packet {
    magic: u32,
    version: u8,
    flags: u8,
    length: u16,
    payload: [u8; 32],
}

Считаем: magic 4 байта, version 1, flags 1, length 2, payload 32. Сумма 40, выравнивание структуры 4, дополнительный padding не нужен. Проверяем тестом: assert_eq!(std::mem::size_of::<Packet>(), 40). Если C-заголовок на другой стороне даёт 44, значит там другое выравнивание или лишний padding, и структуру нельзя читать напрямую.

fn parse(bytes: &[u8; 40]) -> (u32, u16) {
    let magic = u32::from_be_bytes(bytes[0..4].try_into().unwrap());
    let length = u16::from_be_bytes(bytes[6..8].try_into().unwrap());
    (magic, length)
}

Разбор через срезы не требует repr(C) вовсе и снимает вопрос с padding и порядком байт. repr(C) нужен, когда вы передаёте структуру по указателю в C или накладываете её на готовый буфер.

Типичные ошибки и подводные камни

UB при assume_init: как не выстрелить в ногу

assume_init сообщает компилятору, что значение инициализировано. Если это не так, поведение не определено, и симптомом становится не падение, а молчаливая выдача мусора или вырезанный компилятором код.

  • Заполнили половину массива MaybeUninit и вызвали assume_init на всём массиве. Правильно: assume_init только для заполненного префикса, по элементам.
  • Прочитали поле через assume_init_ref после записи лишь части структуры.
  • Передали неинициализированную память в функцию, которая делает копию через memcpy.

Ловите такие ошибки в тестах: запустите набор под Miri командой cargo +nightly miri test. Интерпретатор отслеживает инициализацию памяти и покажет место, где прочитано неинициализированное значение.

Проблемы с drop при частичной инициализации

MaybeUninit<T> не вызывает деструктор T. Для элементов с Drop это даёт две крайности: утечку при выходе из области видимости и двойное освобождение, если частично инициализированные данные смешать с обычным контейнером.

let mut slots: Vec<MaybeUninit<String>> = Vec::with_capacity(4);
slots.push(MaybeUninit::new(String::from("a")));
// при выходе из области видимости String не будет освобождён

// корректное завершение:
unsafe {
    for slot in slots.iter_mut() {
        slot.assume_init_drop();
    }
}

Правило: если у типов есть Drop, ведите учёт инициализированных элементов и вызывайте drop_in_place или assume_init_drop ровно для них. Для Copy-типов вроде u8 проблема не возникает.

Другие грабли: забытый repr(C) при FFI, большой массив на стеке, ожидание одинакового padding у repr(Rust) между версиями компилятора, выравнивание packed-структур, из-за которого обращение к u32 идёт по нечётному адресу.

Что выбрать в 2026 году: практические рекомендации

Сводка по инструментам:

ИнструментКогда применятьЧто даёт
[T; N]Длина известна на этапе компиляции, данные нужны inlineНет аллокаций, размер структуры предсказуем
Vec<T>Длина известна в рантайме, буфер растёт24 байта дескриптора, элементы в куче
Box<[u8; N]>Фиксированный большой буферДанные в куче, структура остаётся маленькой
repr(C)FFI, C ABI, бинарные протоколы, разделяемая памятьФиксированный порядок полей и padding
MaybeUninitБольшой буфер, который заполняется по частямИнициализация без лишних копий и нулей

Чек-лист перед мержем:

  1. Добавьте assert_eq с size_of и align_of для структур, которые уходят в C, и сверьте значения с заголовком на другой стороне.
  2. Проверьте offset_of! для каждого поля структуры с repr(C).
  3. Убедитесь, что массивов размером в мегабайты нет среди локальных переменных; перенесите их в Box или Vec.
  4. Проверьте, что каждый assume_init вызывается только после записи всех элементов.
  5. Для типов с Drop завершайте инициализацию явно через assume_init_drop или drop_in_place.
  6. Прогоните тесты под Miri, если в коде есть unsafe.

Константные дженерики и стабильный std::mem позволяют обойтись без сторонних крейтов. Выбор между [T; N] и Vec<T> сводится к одному вопросу: известна ли длина на этапе компиляции и нужен ли вам стабильный layout в памяти. Если да, берите массив и добавляйте repr(C) на границе с C. Если нет, берите Vec и не считайте недостатком лишние 24 байта в структуре.

Источники

Поделиться: