Avatar ·

Насколько велики накладные расходы Option<T> в Rust по сравнению с нулевыми указателями?

Всем привет! Я только начал изучать Rust, перешёл с C++. Там я привык использовать nullptr или NULL для обозначения отсутствия значения. В Rust вместо этого используют Option, и меня мучает вопрос: насколько это дорого? Ведь в C++ нулевой указатель — это просто 0, и никаких дополнительных затрат нет. Например, у меня есть такая структура: ```rust struct Node { value: i32, left: Option>, right: Option>, } ``` Если я сделаю `left: None`, то Rust должен хранить где-то флаг, который говорит, что это None. Получается, каждый Option занимает больше памяти, чем просто сырой указатель? Или компилятор как-то оптимизирует это? Я попробовал проверить размер: ```rust use std::mem::size_of; fn main() { println!("{}", size_of::>>()); // 8? println!("{}", size_of::>()); // 8? } ``` На 64-битной системе оба показали 8 байт. Но как так? Если Option — это enum, он должен хранить дискриминант (метку варианта). Неужели Rust как-то использует недопустимые значения указателя (например, нулевой адрес) для представления None? Это безопасно? Или это работает только для Box, а для других типов Option всё-таки добавляет накладные расходы? Объясните, пожалуйста, механику: когда Option занимает столько же, сколько T, а когда больше? И есть ли разница в производительности во время выполнения? Спасибо! <<>>

Как Rust умудряется сделать Option> бесплатным?

Отличный вопрос! Вы абсолютно правы в своих догадках. Rust действительно использует оптимизацию представления нулевого указателя (null pointer optimization, NPO). Это одна из ключевых фич, которая позволяет Option не иметь накладных расходов в большинстве практических случаев.

Суть оптимизации: если тип T имеет недопустимое битовое представление (например, нулевой указатель для Box, &T, &mut T, NonNull), то компилятор использует это значение для представления None. Дискриминант (флаг) не хранится отдельно — он вшит в само значение.

Вот как это работает для вашего примера с Box:

// Box - это просто указатель на кучу. 
// По определению, Box никогда не может быть нулевым (null).
// Поэтому:
// - Some(box_val) -> любой ненулевой указатель
// - None -> нулевой указатель (0x0)

// Размер: 8 байт (как обычный указатель на 64-битной системе)
println!("{}", size_of::>>()); // 8

То же самое работает для ссылок и умных указателей:

let x: Option<&i32> = None; // занимает 8 байт (как и &i32)
let y: Option> = None; // занимает 8 байт
let z: Option> = None; // занимает 8 байт

Когда Option занимает больше места?

Оптимизация работает только для типов, у которых есть "дырка" в представлении. Для большинства простых типов (i32, bool, struct из полей) все битовые комбинации валидны, поэтому дискриминант добавляется явно:

// i32 может быть любым 32-битным числом — нет недопустимых значений
println!("{}", size_of::>()); // 8 (4 байта i32 + 4 байта дискриминанта)
println!("{}", size_of::());          // 4

// bool тоже не имеет "дырок" (0 и 1 — оба валидны)
println!("{}", size_of::>()); // 2 (1 байт bool + 1 байт выравнивания/дискриминанта)
println!("{}", size_of::());         // 1

Проверка на практике

Вы можете убедиться в этом, используя std::mem::size_of и std::mem::align_of:

use std::mem;

// Сырые указатели тоже имеют нулевую дырку
println!("Option<*const i32>: {}", mem::size_of::>()); // 8 (оптимизировано!)
println!("*const i32: {}", mem::size_of::<*const i32>());                // 8

// А вот Option уже не оптимизируется
println!("Option: {}", mem::size_of::>()); // 2
println!("u8: {}", mem::size_of::());                 // 1

Производительность во время выполнения

Накладные расходы на проверку Option в рантайме минимальны:

  • Для оптимизированных типов (Box, ссылки): проверка — это просто сравнение указателя с нулём. Одна инструкция процессора.
  • Для неоптимизированных типов (i32): проверка — это сравнение дискриминанта с константой. Тоже одна инструкция.
  • В обоих случаях ветвление (if/else) предсказывается процессором, и современные CPU справляются с этим за 1–2 такта.

Более того, Rust часто устраняет проверки во время компиляции с помощью анализа потока данных (NLL — Non-Lexical Lifetimes). Если компилятор может доказать, что Option гарантированно Some, он просто уберёт проверку.

Итог

Option — это абстракция с нулевой стоимостью (zero-cost abstraction) для всех типов, которые имеют недопустимое представление (указатели, ссылки, NonNull, NonZero*). Для остальных типов накладные расходы составляют 1–8 байт на дискриминант, но проверка занимает одну инструкцию CPU. В 99% случаев это быстрее и безопаснее, чем ручная проверка на null в C++.

<<>>

Альтернативный взгляд: когда Option всё-таки может быть дорогим

Хотя в большинстве случаев Option бесплатен, есть ситуации, где оптимизация не работает и накладные расходы становятся заметными:

// 1. Option в массивах примитивов
// Массив из 1000 Option занимает 8000 байт (вместо 4000 для i32)
let arr: [Option; 1000] = [None; 1000];
println!("{}", mem::size_of_val(&arr)); // 8000

// 2. Option для пользовательских типов без "дырок"
struct MyType(u8, u8); // все 65536 комбинаций валидны
println!("{}", mem::size_of::>()); // 3 (2 байта данных + 1 байт дискриминанта)

// 3. Option в контейнерах с большим количеством вложенности
struct Deep {
    a: Option>> // 3 уровня - размер 8 байт (только один дискриминант)
}

Рекомендация: Если вы храните миллионы маленьких значений в массиве и каждый байт на счету, рассмотрите альтернативы:

  • Используйте Vec> — он компактнее массива фиксированного размера.
  • Для целых чисел — NonZeroI32 (если 0 недопустим) — тогда Option будет размером с обычный i32.
  • Для пользовательских типов — добавьте "дырку" вручную, используя #[repr(transparent)] с NonNull.

Но в 99% случаев просто используйте Option — компилятор сделает всё за вас оптимально.