% Типы экзотического размера
Большую часть времени мы думаем в терминах типов фиксированного положительного размера. Однако, это не всегда так.
Типы динамического размера
Rust на самом деле поддерживает типы динамического размера (ТДР; англ. dynamically sized type, DST): типы без статически известного размера или выравнивания. На поверхности, все это кажется бессмысленным: Rust должен знать размер и выравнивание чего-либо, чтобы корректно с этим работать! В этом отношении, ТДР - это не нормальные типы. Из-за отсутствия статически известного размера, они могут скрываться только за указателем особого типа. Любой указатель на ТДР вследствие этого становится толстым, состоящим из указателя и информации, которая "дополняет" его (подробнее об этом ниже).
Язык предлагает два главных ТДР: типажи-объекты и срезы.
Типаж-объект представляет тип, реализующий указанный типаж. Точный исходный тип затирается и заменяется таблицей виртуальных методов (vtable), содержащей всю информацию, необходимую для использования типа. Вот эта информация и дополняет типаж-объект: указатель на его таблицу виртуальных методов.
Срез - это просто отображение в какое-то подходящее хранилище - обычно в массив
или Vec
. Информация, которая дополняет срез - это просто количество элементов,
на которые он указывает.
Вообще, структуры могут хранить один ТДР прямо в своем последнем поле, но это делает их также ТДР:
#![allow(unused)] fn main() { // Невозможно хранить напрямую в стеке struct Foo { info: u32, data: [u8], } }
Внимание: В Rust 1.0 ТДР, являющиеся структурами, работают неправильно, если последнее поле имеет переменную позицию, зависящую от выравнивания.
Типы с нулевым размером
Rust на самом деле позволяет объявлять типы, которые не занимают места:
#![allow(unused)] fn main() { struct Foo; // Нет полей = нет размера // У каждого поля нет размера = нет размера struct Baz { foo: Foo, qux: (), // у пустого кортежа нет размера baz: [u8; 0], // у пустого массива нет размера } }
Сами по себе типы с нулевым размером (ТНР; англ. zero-sized types, ZST), по очевидным причинам, довольно бесполезны. Но, как и с другими любопытными решениями по выделению памяти в Rust, их потенциал выражается в контексте обобщений: Rust очень хорошо понимает, что любые операции, которые создают или хранят ТНР, могут быть заменены на пустую операцию. Прежде всего хранение их вообще не имеет смысла - они не занимают никакого места. Также, есть только одно значение этого типа, поэтому все, что загружает их, может создать их из эфира - что тоже является пустой операцией из-за того, что они опять же не занимают никакого места.
Одним из самых важных примеров являются множества (sets) и словари (maps). Имея
Map<Key, Value>
, часто реализуют Set<Key>
в качестве тонкой обёртки вокруг
Map<Key, БесполезныйМусор>
. Многие языки заставляют выделять место под
БесполезныйМусор и вынуждают обрабатывать его хранение и загрузку только для
того, чтобы потом выкинуть его. Их компиляторам сложно доказать, что эти действия
не нужны.
Однако в Rust мы можем просто сказать Set<Key> = Map<Key, ()>
. Так Rust
понимает, что любая загрузка и хранение бесполезны, и выделение памяти не нужно.
В результате получаем, что мономорфный код - это обычная частая реализация
HashSet без каких бы накладных расходов по поддержке значений.
Безопасному коду не надо волноваться о ТНР, но небезопасный должен быть очень
аккуратен с последствиями, которые влекут типы без размера. В частности,
смещение указателей это no-op, и стандартный распределитель памяти (включая
jemalloc, который использует по умолчанию Rust) может вернуть nullptr
если
запрашивается выделение памяти под тип с нулевым размером, и это будет
неотличимо от ситуации нехватки памяти.
Пустые типы
Rust также позволяет объявлять типы, экземпляр которых нельзя создать. О них можно говорить только на уровне типов, но никогда на уровне значений. Пустые типы можно объявить, указав перечисление без вариантов:
#![allow(unused)] fn main() { enum Void {} // Вариантов нет = Пусто }
Пустые типы еще более маргинальны чем ТНР. Единственное назначение типа Void из
примера выше - недостижимость на уровне типов. Предположим, API в общем случае
должно возвращать Result, но какой-то частный случай никогда не возвращает
ошибку. Здесь можно сообщить об этом на уровне типа, возвращая Result<T, Void>
. Пользователи этого API могут спокойно делать unwrap такого Result, зная
что статически невозможно получить Err
в значении, так как пришлось бы
предоставлять значение типа Void
.
В принципе, Rust мог бы выполнять некоторый интересный анализ и оптимизацию,
зная все это. Например, Result<T, Void>
мог бы быть представлен просто как
T
, потому что случая Err
на самом деле не существует. Код ниже мог бы
компилироваться:
enum Void {}
let res: Result<u32, Void> = Ok(0);
// Err не существует, поэтому Ok полностью безошибочен.
let Ok(num) = res;
Но на данный момент ни один из этих трюков не работает, поэтому все, что дает вам тип void - это возможность быть уверенным в том, что некоторые ситуации статически невозможны.
И последний тонкий момент о пустых типах - сырые указатели на них создавать
можно и это считается правильным, но разыменование таких указателей приведет к
Неопределенному поведению, потому что никакого смысла в этом нет. Таким образом,
вы можете смоделировать void *
из Си с помощью *const Void
, но это не
обязательно даст выигрыш по сравнению с использованием, например, *const ()
,
который безопасен по отношению к случайному разыменованию.