Найти тему
Ржавый код

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

Оглавление

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

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

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

-2

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

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

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

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

-3

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

-4

Мы узнали, что 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` для дискриминантов перечисления.

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

-5

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

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

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

-6

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

Это удивляет

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

-7

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

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

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

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

-8

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

-9

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

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

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

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

Статья на rusty-code.ru