Как Rust обманывает процессор. Часть 2: niche сквозь крейты, dropck, Pin и провенанс указателей

В первой части мы коснулись niche-оптимизации, drop flags, MIR, Stacked Borrows и async-стейт-машин. Под статьёй справедливо заметили (спасибо, Mingun): про niche рассказано только в самой простой форме — Option<&T> и NonZeroU8. А что происходит, когда enum живёт в одном крейте, оборачивается в newtype в другом, и оба варианта внешнего enum хранят один и тот же внутренний? По факту у такого внешнего типа всего четыре состояния — байта должно хватить. Хватит ли? Ответ короткий: иногда да, иногда нет, и причина не в теории, а в том, как rustc вообще считает layout. С этого и начнём.
1. Niche сквозь границы крейтов
Возьмём ровно тот пример из комментария:
// crate inner
pub enum Inner { A, B }
// crate outer
pub enum Outer {
Variant1(Inner),
Variant2(Inner),
}
С точки зрения теории информации у Outer ровно четыре обитаемых состояния: V1(A), V1(B), V2(A), V2(B). Два бита. Один байт более чем достаточно. На практике size_of::<Outer>() сегодня вернёт 2 байта: один на дискриминант Outer, один на дискриминант Inner. Почему?
Niche-оптимизация в rustc — это не «найти все неиспользуемые битовые комбинации в типе». Это конкретный алгоритм в compiler/rustc_abi/src/layout.rs, который для каждого поля типа спрашивает: «есть ли у тебя Niche — диапазон значений, который ты гарантированно никогда не примешь?». У ссылки niche есть (нулевой адрес), у NonZeroU8 — ноль, у bool — значения 2..=255, у char — суррогаты и всё выше 0x10FFFF. У enum Inner { A, B } тоже формально есть niche: байт дискриминанта принимает только 0 и 1, остальные 254 значения свободны. И rustc это видит. Но использует только один уровень: дискриминант Outer укладывается в свободные значения внутри одного варианта Inner, а не «поверх» всех вариантов сразу. rustc на сегодняшний день не делает глобальный niche-search по всем enum-вариантам одновременно.
Это не теоретическое ограничение, а инженерное. Включите -Zprint-type-sizes на nightly и увидите буквально:
print-type-size type: `Outer`: 2 bytes, alignment: 1 bytes
print-type-size discriminant: 1 bytes
print-type-size variant `Variant1`: 1 bytes
print-type-size field `.0`: 1 bytes
print-type-size variant `Variant2`: 1 bytes
print-type-size field `.0`: 1 bytes
Теперь ключевой момент про крейты. Niche — это публичный контракт типа. Если Inner объявлен в чужом крейте без #[non_exhaustive], rustc обязан учитывать, что добавление варианта C — не ломающее семвер изменение. Иначе обновление патч-версии зависимости меняло бы размер вашего типа. Поэтому компилятор ведёт себя консервативно: использует niche ровно настолько, насколько это безопасно при будущих расширениях. Если Inner помечен как #[non_exhaustive], niche-диапазон сужается ещё сильнее.
Практический вывод, который редко формулируют явно: если вы пишете библиотеку и хотите, чтобы пользовательский Option<MyType> был того же размера, что и MyType, niche должен быть документированной частью API. Самый надёжный способ — выразить его через NonZero* или &T/Box<T>: у них niche гарантирован стабилизированной частью языка. Для собственных enum-ов таких гарантий нет, и unsafe-код не имеет права полагаться на конкретный layout, даже если -Zprint-type-sizes показывает желаемое.
Побочный эффект: Option<Option<NonZeroU8>> весит один байт, а Option<Option<bool>> — два. У bool niche — 254 значения, Option<bool> съедает одно из них под None, остаются 253 — внешний Option влезает. А Option<Option<Option<bool>>>? Тоже один байт. Niche-поиск рекурсивен ровно до тех пор, пока во внутреннем типе остаются свободные битовые паттерны.
2. Variance, или почему &mut T инвариантен
Перейдём к теме, которая в первой части не затрагивалась, но без неё нельзя понять половину сообщений borrow checker. У каждого типового параметра в Rust есть variance: ковариантность, контравариантность или инвариантность. Это не академическая абстракция, а вполне практическая вещь, определяющая, можно ли подставить &'long T туда, где ожидается &'short T.
fn coerce<'a>(x: &'static str) -> &'a str { x } // OK, &'a T ковариантен по 'a
А вот это уже не пройдёт:
fn coerce_mut<'a>(x: &'static mut String) -> &'a mut String { x }
Причина — &mut T инвариантен по T. Если бы он был ковариантен, можно было бы взять &mut Vec<&'static str>, скастовать его к &mut Vec<&'a str>, записать туда короткоживущую ссылку и через исходный &'static выдернуть висячий указатель. Инвариантность — единственное, что не даёт системе типов взорваться.
Проверить variance конкретного типа можно через rustc-driver или через простую таблицу:
&'a T — ковариантен по 'a и T
&'a mut T — ковариантен по 'a, инвариантен по T
Box<T>, Vec<T> — ковариантны по T
fn(T) -> U — контравариантен по T, ковариантен по U
Cell<T>, *mut T — инвариантны по T
Когда вы пишете свой unsafe-тип на основе указателя, по умолчанию вы получаете variance «как у первого поля». Если хотите явно зафиксировать инвариантность (а для большинства raw-обёрток это правильный выбор), используется PhantomData<*mut T> или PhantomData<fn(T) -> T>. Эта мелочь — причина того, что Pin<&mut T> ведёт себя именно так, как ведёт. И это плавный мост к следующей теме.
3. Pin, !Unpin и самоссылающиеся async-футуры
В первой части мы видели, как async fn превращается в анонимный enum-стейт-машину. Теперь главный вопрос: что произойдёт, если внутри async-блока есть локальная переменная, на которую указывает другая локальная переменная, и обе живут через .await?
async fn weird() {
let s = String::from("hello");
let r = &s; // ссылка внутрь того же стейта
yield_now().await;
println!("{}", r);
}
После .await компилятор сохраняет и s, и r в один enum. Поле r указывает на поле s того же объекта. Если этот объект переместить в памяти, r станет висячим. Это и есть классическая проблема самоссылающихся структур, на которой подорвалось не одно поколение C++-программистов.
Решение Rust — Pin. Pin<&mut T> — это обёртка, которая говорит: «значение, на которое я указываю, не будет перемещено до конца его жизни». Compile-time гарантии у Pin нет, но safe-API устроено так, что без unsafe из Pin<&mut T> нельзя достать &mut T для типов, реализующих !Unpin. А компилятор автоматически делает каждую async-футуру с полями, живущими через await, !Unpin.
Дальше начинается тонкость, о которой не пишут в туториалах. Pin — это не свойство значения, это свойство ссылки. Само значение в памяти ничем не отличается от обычного. Pin работает не магией, а социальным контрактом: чтобы создать Pin<&mut T> на !Unpin тип в safe-коде, нужно либо Box::pin, либо pin! макрос (стабилизированный в 1.68), который создаёт значение на стеке и сразу делает его непереставляемым через хитрый трюк с переменной-«призраком», которую нельзя адресовать пользовательским кодом.
Если посмотреть на это под микроскопом, pin! работает так: создаётся локальная переменная в текущем стек-фрейме, на неё берётся &mut, оборачивается в Pin::new_unchecked, а оригинальная переменная shadow-ится новым именем, чтобы пользователь не мог взять её &mut в обход. Это не unsafe-хак ради хака — это единственный способ выразить «закреплено на стеке» в системе типов, не вводя нового вида ссылок на уровне языка.
4. Dropck, #[may_dangle] и парадокс Vec<&'a T>
Ещё одна тема, продолжающая разговор про drop из первой части. Когда у вас есть Vec<T>, а T содержит ссылку с временем жизни 'a, что должно произойти при drop вектора? Формально — T::drop может прочитать эту ссылку, значит ссылка должна быть валидной в момент drop. Это правило называется dropck (drop check) и порождает кучу ограничений.
Но если бы оно применялось буквально, Vec<&'a T> нельзя было бы дропать, пока 'a не закончится строго после Vec. На практике это работает иначе:
fn main() {
let s = String::from("hi");
let v: Vec<&str> = vec![&s];
drop(s); // ?!
// v всё ещё дропается здесь
}
Компилятор разрешает такое благодаря атрибуту #[may_dangle] на параметре T в impl Drop for Vec<T>. Этот атрибут — нестабильный (живёт за #![feature(dropck_eyepatch)]) и говорит дроп-чекеру: «я обещаю, что мой Drop не будет читать поля типа T, поэтому T можно дропать с уже невалидными ссылками внутри». Vec, Box, BTreeMap — все стандартные коллекции пользуются этим трюком. Если вы пишете свою коллекцию на стабильном Rust, увы, такой возможности у вас нет, и пользователи получат менее эргономичный API на ровном месте.
Есть и обратная сторона: PhantomData<T> в коллекции должен быть ровно того типа, который реально дропается. Если вы напишете PhantomData<*const T> вместо PhantomData<T>, dropck решит, что вы не владеете T, и пропустит проверку, которая на самом деле нужна — получится use-after-free в safe-коде. Это одна из причин, по которой Vec в std реализован именно через RawVec с PhantomData<T>, а не через указатель напрямую.
5. Tree Borrows: что приходит на смену Stacked Borrows
В первой части мы говорили про Stacked Borrows. Эта модель работает, но у неё есть болезненные углы: например, она запрещает разумный паттерн «получили &mut, привели к *mut, поработали через указатель, потом вернулись к &mut» в случаях, когда формально стек разрешений не пушится, а должен бы.
Tree Borrows (Neven Villani, 2023) переписывает модель: вместо стека тегов на участке памяти строится дерево заимствований. Каждое перезаимствование становится дочерним узлом, и состояние каждого узла отслеживается отдельно: Reserved, Active, Frozen, Disabled. Доступ к памяти проверяется не по «лежит ли мой тег в стеке», а по «совместимо ли состояние моего узла и всех его предков с этим типом доступа».
Ключевое практическое отличие: Tree Borrows гораздо мягче к unsafe-коду, который работает с raw-указателями параллельно с ссылками, и при этом сохраняет почти все оптимизации, ради которых Stacked Borrows вообще придумывали. Запустить программу под Miri с обеими моделями можно одной командой:
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri run
Если ваш unsafe-код проходит Stacked Borrows, он почти наверняка пройдёт и Tree Borrows. Обратное неверно: Tree Borrows допускает строго больше программ.
6. Strict provenance и почему usize → *mut T — это не каст
И напоследок про модель указателей, которая медленно становится официальной. До недавнего времени программисты на Rust привычно делали так:
let p = &x as *const i32 as usize;
// ... хитрая арифметика ...
let q = magic as *const i32;
unsafe { *q }
С точки зрения C это нормально. С точки зрения LLVM и оптимизатора — катастрофа: указатель несёт с собой не только адрес, но и provenance, информацию о том, к какому аллокатору и какому объекту он привязан. Когда вы кастуете указатель в usize и обратно, provenance теряется, и оптимизатор имеет полное право переставить операции местами, потому что «это просто число».
Strict provenance API (стабилизировано частично в 1.84, доделывается) вводит явные методы:
let p: *const i32 = &x;
let addr: usize = p.addr(); // только число, без provenance
let q: *const i32 = p.with_addr(addr); // воссоздаёт указатель с provenance от p
let r: *const i32 = ptr::without_provenance(addr); // явно без provenance
Это не просто косметика. Это контракт с оптимизатором: «я знаю, что provenance важен, и я явно говорю, откуда он берётся». В долгосрочной перспективе lint missing_provenance_casts должен включаться по умолчанию, и привычные as-касты между usize и указателями станут предупреждением. Если вы пишете аллокатор, lock-free структуру или интероп с C, к этому стоит готовиться сейчас, а не когда сломается сборка.
Итог
Если в первой части мы смотрели на Rust как на язык с интересными внутренностями, то во второй стало видно, что эти внутренности не разрозненные трюки, а один большой согласованный механизм. Niche-оптимизация ограничена не теорией, а контрактами семвера. Pin существует, потому что variance и async вместе создают самоссылки. Dropck и #[may_dangle] — два полюса одного и того же баланса между безопасностью и удобством. Tree Borrows и strict provenance — это попытка наконец-то записать правила, по которым Rust живёт уже много лет, в виде, который может проверить машина.
Главный вывод тот же, что и в первой части, только громче: borrow checker — это верхушка айсберга, и чем глубже копать, тем интереснее оказываются компромиссы, на которых стоит язык.
