Размер структуры в 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 | Большой буфер, который заполняется по частям | Инициализация без лишних копий и нулей |
Чек-лист перед мержем:
- Добавьте assert_eq с size_of и align_of для структур, которые уходят в C, и сверьте значения с заголовком на другой стороне.
- Проверьте offset_of! для каждого поля структуры с repr(C).
- Убедитесь, что массивов размером в мегабайты нет среди локальных переменных; перенесите их в Box или Vec.
- Проверьте, что каждый assume_init вызывается только после записи всех элементов.
- Для типов с Drop завершайте инициализацию явно через assume_init_drop или drop_in_place.
- Прогоните тесты под Miri, если в коде есть unsafe.
Константные дженерики и стабильный std::mem позволяют обойтись без сторонних крейтов. Выбор между [T; N] и Vec<T> сводится к одному вопросу: известна ли длина на этапе компиляции и нужен ли вам стабильный layout в памяти. Если да, берите массив и добавляйте repr(C) на границе с C. Если нет, берите Vec и не считайте недостатком лишние 24 байта в структуре.
Источники
- MaybeUninit in std::mem - Rust
- offset_of in std::mem - Rust
- Type layout - The Rust Reference
- reference/src/type-layout.md - rust-lang/reference
- Methods for Array Initialization in Rust
- How do I convert a Vec<T> to a Vec<u8> without copying the vector - Stack Overflow
- maybe_uninit::MaybeUninit - Rust