Перейти к содержанию

Перечисления - Rust объекты необычного размера

Posted on:5 июля 2023 г. at 08:45

Example Dynamic OG Image link Как оптимизация компилятора для перечисления обеспечивает производительность наших программ.

Недавно, во время путешествия по исходному коду std::io::Result, я нашел что-то, что бросило вызов моему пониманию типов перечисления Rust.

В 64-разрядных системах std::io::Error - это обертка вокруг внутреннего представления в битовой упаковке Repr:

pub struct Error {
    repr: Repr,
}

struct Repr(NonNull<()>, PhantomData<ErrorData<Box<Custom>>>)

Определение Repr выглядит пугающим, но детали не важны. Все, что вам нужно знать, это то, что, несмотря на представление нескольких возможных видов ошибок IO, хитрая упаковка битов означает, что Repr (и, следовательно io::Error) укладывается в одно, 64-битное машинное слово.

Из документации по битовой упаковке Repr мне на глаза попался следующий комментарий об io::Result:

“Эта оптимизация не только позволяет io::Error занимать один указатель, но и улучшает io::Result, особенно для таких ситуаций, как io::Result<()> (который теперь 64 бита) …”

Напомним, что io::Result<()> является псевдонимом для:

std::result::Result<(), std::io::Error>

И этот result::Result - перечисление с двумя вариантами:

enum Result<T, E> {
  Ok(T),
  Err(E),
}

Мы узнали, что io::Error составляет ровно 64 бита. Итак, как io::Result<()>, тип который кажется передает значительно больше информации, чем один io::Error , все же только 64 бита?

Основные сведения о перечислении

Чтобы ответить на этот вопрос, давайте сначала резюмируем, как перечисления располагаются в памяти.

Если вы хотите все детали, то Amos at fasterthanli.me покажет основы перечислений в своем классическом исследовании размера небольших типов. На данный момент все, что нам нужно знать, это то, что значение перечисления обычно состоит из двух вещей:

  1. Значение поля, связанно с вариантом. Для io::Result значение связанное с вариантом Err, является экземпляром io::Error.
  2. Дискриминант, целое значение, которое Rust использует для идентификации варианта, которому соответствует значение перечисления.

Таким образом, общий размер перечисления является размером дискриминанта плюс размер наибольшего возможного ассоциированного поля. Rust не знает, какой вариант появится во время выполнения, поэтому он всегда выделяет достаточно места для самого большого варианта.

По умолчанию дискриминантом является значение isize - восемь байт в 64-разрядных системах. Однако компилятору разрешается использовать меньший тип, если он выбирает. Точные обстоятельства, при которых это происходит, не уточняются. Размер может даже изменяться между компиляциями на одном компьютере!

Во избежание путаницы в следующих примерах при определении типов перечислений используется директива #[repr(u64)]. Это подсказывает компилятору использовать макет, который будет использовать для типа, выбирая u64 для дискриминантов перечисления.

Вот перечисление, представляющее множество входных событий:

#[repr(u64)]
enum InputEvent {
    KeyPress(char),       // дискриминант = 0
    MouseClick(u64, u64), // дискриминант = 1
}

Размер KeyPress сам по себе будет 4 байта для символа плюс 8 байтов для дискриминанта. Всего 12 байт. Но KeyPress не существует в изоляции. Rust выделяет достаточно места для хранения самого большого поля - MouseClick(u64, u64) - и помещает любое незаполненное пространство в варианты с меньшими полями. Поэтому размер InputEvent составляет 24 байта: три u64.

При базовом поведении установленных перечислений позвольте спросить вас: каков размер Result<T, E>? Result<T> имеет два варианта: Ok(T) и Err(E).

Следовательно, его размер обычно равен размеру дискриминанта плюс больший размер T и E. Рассмотрим пример:

use std::error::Error;
use std::mem::size_of;

#[repr(u64)]
enum Result<T, E> {
    Ok(T),
    Result(E),
}

println!("{}", size_of::<Result<u128, Box<dyn Error>>>());
// => 24
println!("{}", size_of::<Result<u64, Box<dyn Error>>>());
// => 24
println!("{}", size_of::<Result<u32, Box<dyn Error>>>());
// => 24
println!("{}", size_of::<Result<(), Box<dyn Error>>>());
// => 24

Упакованный объект трейта Box<dyn Error>, имеет ширину в два указателя - 16 байт на 64-разрядных платформах. Во всех этих примерах T равен или меньше по размеру по сравнению с Box, но размер Result остается постоянным в 24 байта для того, чтобы сохранить бокс варианта Err (если он имеет место), плюс восьми-байтовый дискриминант.

Это удивляет

Вот тот же пример с директивой repr, удаленной из определения Result, что означает, что компилятор Rust может выбрать собственное представление дискриминанта.

enum Result<T, E> {
    Ok(T),
    Result(E),
}

println!("{}", size_of::<Result<u128, Box<dyn Error>>>());
// => 24
println!("{}", mem::size_of::<Result<u64, Box<dyn Error>>>());
// => 24
println!("{}", mem::size_of::<Result<u32, Box<dyn Error>>>());
// => 24
println!("{}", :mem::size_of::<Result<(), Box<dyn Error>>>());
// => 16

В первых трех случаях мы видим, что компилятор использовал представление по умолчанию для дискриминанта: значение isize, которое составляет 64 бита на моей машине. Помните, что компилятор может изменить свое мнение об этом, так что нет никаких гарантий, что вы увидите те же результаты.

Четвертое раскрывает особый случай! Подобно io::Result<()>, result::Result<(), Box<dyn Error>> - это именно размер этого варианта ошибки. Когда вы даете компилятору свободный выбор, дискриминант, кажется исчезает в воздухе.

Поскольку это черная магия, только Рустономикон может рассказать нам, что происходит. В разделе Data Layout: repr(Rust) мы находим:

Перечисление, такое как:

enum Foo {
    A(u32),
    B(u64),
    C(u8),
}

может быть выложен как:

struct FooRepr {
    data: u64, // это либо u64, u32, либо u8, основанный на `tag`
    tag: u8,   // 0 = A, 1 = B, 2 = C
}

Однако есть несколько случаев, когда такое представление неэффективно. Классическим случаем этого является «оптимизация нулевого указателя» Rust: перечисление, состоящее из одного варианта внешней единицы (например, None) и (потенциально вложенного) варианта указателя, не допускающего значения NULL (например, Some(&T)), делает тег ненужным. Нулевой указатель можно безопасно интерпретировать как вариант юнита (None). В результате получается, например, size_of::<Option<&T>>() == size_of::<&T>().

Каждый раз, когда имеется перечисление с двумя вариантами, например Option или Result, где один вариант не имеет поля или поля типа единицы измерения, (), а другой имеет поле без единицы измерения. Rust оптимизирует потребность в дискриминанте, рассматривая вариант единицы как нулевой указатель.

Эта оптимизация полностью прозрачна. Вы никогда не будете обрабатывать этот нулевой указатель напрямую.

В перечислениях, как и во всем остальном, Rust дает нам наилучшую производительность, защищая нас от небезопасного кода.