В первой части мы обсуждали niche-оптимизацию, drop flags, MIR, Stacked Borrows и async-стейт-машины. В комментариях справедливо заметили (спасибо, Mingun): про niche рассказано в простой форме — Option<&T> и NonZeroU8. А что происходит, когда enum живёт в одном крейте, оборачивается в newtype в другом, и оба варианта внешнего enum хранят один и тот же внутренний? У такого внешнего типа всего четыре состояния, байта должно хватить. Хватит ли? Зависит от того, как rustc считает layout. Об этом и поговорим.
Во второй части идём глубже: niche сквозь границы крейтов, variance, Pin и самоссылающиеся футуры, dropck с #[may_dangle], Tree Borrows вместо Stacked Borrows и strict provenance. Без этого половина unsafe-кода в экосистеме держится на честном слове.
1. Niche сквозь границы крейтов
Возьмём пример из комментария:
// crate innerpub enum Inner { A, B }// crate outer (зависит от inner)pub enum Outer { Variant1(Inner), Variant2(Inner),}
У Outer ровно четыре состояния: (V1, A), (V1, B), (V2, A), (V2, B). Кажется, size_of::<Outer>() обязан быть 1 байт: два бита под дискриминант плюс один бит под Inner. На практике компилятор выдаёт 1 байт, и -Zprint-type-sizes это подтверждает:
print-type-size type: `Outer`: 2 bytes, alignment: 1 bytesprint-type-size discriminant: 1 bytesprint-type-size variant `Variant1`: 1 bytesprint-type-size field `.0`: 1 bytesprint-type-size variant `Variant2`: 1 bytesprint-type-size field `.0`: 1 bytes
Стоп, тут 2 байта, а не 1. Первая ловушка: niche-алгоритм rustc не умеет «сжимать» дискриминант внешнего enum в неиспользуемые битовые паттерны внутреннего, если тип пришёл из другого крейта и не помечен как #[repr]-стабильный. Алгоритм консервативен: он смотрит на «дырки» в layout-е Inner (у Inner { A, B } это значения 2…=255) и может туда положить дискриминант, но только в одном направлении — когда внешний enum имеет вариант без полей. Здесь оба варианта несут Inner целиком, и компилятор не складывает их в один байт.
Если переписать в enum Outer2 { V1, V2(Inner) }, layout схлопнется до 1 байта: V1 представлен значением 2 в байте Inner, а V2(_) — значениями 0 и 1. Если нужно держать обе формы и упаковать вручную, есть способ через #[repr(u8)] и явные дискриминанты, и этот способ работает на уровне ABI-контракта.
Контракт между крейтами устроен так: niche-разметка типа — часть его layout, и она нестабильна. Когда вы публикуете pub enum Inner без #[repr], вы не обещаете соседнему крейту, что у Inner останется текущий набор niche-значений. Завтра добавите вариант C, niche у внешнего Outer поедет, размер может вырасти, и какой-нибудь Vec<Outer> через FFI окажется не таким, каким был.
Практический вывод: niche — мощный, но локальный приём. На него можно опираться внутри одного крейта (в духе NonZero*, Box<T>, &T), а строить ABI или сериализацию поверх niche чужого типа — плохая идея. Побочный эффект: Option<Option<bool>> занимает 1 байт за счёт того, что у bool есть niche 2..=255, внутренний Option забирает значение 2, внешний — значение 3. Уберите этот контракт у bool, и вся башня рассыплется.
2. Variance, или почему &mut T инвариантен
Вторая тема, без которой жить с unsafe тяжело — variance. Если коротко: при наличии параметра T тип F<T> может быть ковариантен (подтип T даёт подтип F<T>), контравариантен (наоборот) или инвариантен (никаких отношений). У ссылок Rust это распределяется так: &'a T ковариантен по 'a и по T, &'a mut T ковариантен по 'a, но инвариантен по T, fn(T) -> U контравариантен по T и ковариантен по U.
Почему &mut T инвариантен — классический вопрос на собеседовании. Ответ: потому что через &mut T можно записать. Если бы &mut T был ковариантен, можно было бы взять &mut Vec<&'static str>, привести к &mut Vec<&'short str> (формально подтип, ведь 'static: 'short) и записать туда короткоживущую ссылку. После выхода из области видимости остался бы Vec<&'static str> с висячей ссылкой внутри. Компилятор этого не разрешает, отсюда инвариантность.
fn extend_lt<'a>(v: &mut Vec<&'static str>, s: &'a str) { // если бы &mut был ковариантен, тут бы прокатило приведение // и v получил бы ссылку с временем жизни 'a < 'static v.push(s); // не скомпилируется}
Когда вы пишете обёртку вроде struct MyCell<T> { ptr: *mut T }, компилятор не знает, что вы там делаете внутри, и по умолчанию выводит variance из полей. У сырого указателя *mut T инвариантность по T, у *const T ковариантность. Если нужен явный контроль, используют PhantomData:
use std::marker::PhantomData;// Ковариантен по T, как &Tstruct Covariant<T>(*const T, PhantomData<T>);// Инвариантен по T, как &mut T или Cell<T>struct Invariant<T>(*mut T, PhantomData<*mut T>);// Контравариантен, нужен fn(T)struct Contravariant<T>(PhantomData<fn(T)>);
Эти трюки видны во всём std: у Cell<T> и RefCell<T> поле UnsafeCell<T> делает их инвариантными, иначе можно было бы через ковариантность подделать тип.
Отдельная тонкость — variance и 'static. Многие думают, что &'static T всегда сильнее любого &'a T, и это правда, но только до момента, когда тип попадает в инвариантную позицию. Простейший пример: Box<dyn Trait + 'a> инвариантен по 'a (внутри fat-pointer), и попытка передать Box<dyn Trait + 'static> туда, где ждут Box<dyn Trait + 'short>, не пройдёт без явного приведения.
3. Pin, !Unpin и самоссылающиеся async-футуры
Pin появился, чтобы решить одну задачу: разрешить типу хранить ссылки на самого себя. Без Pin это невозможно безопасно — при перемещении объекта внутренние ссылки превратятся в висячие. Async-блоки в Rust — главный потребитель Pin, потому что компилятор разворачивает async fn в стейт-машину, поля которой могут ссылаться на другие поля той же структуры.
Простейший пример самоссылочной футуры:
async fn read_buf() -> usize { let buf = [0u8; 1024]; let slice = &buf[..]; // ссылка на стек some_async_io(slice).await; // .await может вернуть управление slice.len()}
После .await стейт-машина должна сохранить и buf, и slice, причём slice указывает внутрь buf. Если такой объект сдвинуть в памяти, slice станет недействительным. Pin запрещает сдвигать.
Контракт Pin: Pin<P>, где P: Deref, гарантирует, что значение, на которое указывает P, не будет перемещено до конца своего жизненного цикла (точнее, до момента drop). Исключение — типы, реализующие Unpin, для них Pin семантически прозрачен. По умолчанию Unpin реализован для всех типов через автотрейт, но компилятор снимает реализацию у async-стейт-машин и у структур, явно содержащих PhantomPinned.
use std::pin::Pin;use std::marker::PhantomPinned;struct SelfRef { data: String, ptr: *const u8, // указывает в data _pin: PhantomPinned, // снимает Unpin}impl SelfRef { fn new(data: String) -> Pin<Box<Self>> { let mut boxed = Box::pin(Self { data, ptr: std::ptr::null(), _pin: PhantomPinned, }); let ptr = boxed.data.as_ptr(); // SAFETY: не двигаем self, только записываем поле unsafe { let mut_ref: Pin<&mut Self> = boxed.as_mut(); Pin::get_unchecked_mut(mut_ref).ptr = ptr; } boxed }}
Тонкое место — Pin::get_unchecked_mut. Это unsafe-метод, дающий &mut T из Pin<&mut T>. Контракт: вы обещаете, что не используете этот &mut T для перемещения значения (например, через mem::replace или mem::swap). Любая такая операция — UB, причём UB немедленный: библиотеки полагаются на этот инвариант и могут хранить наружу указатели на внутренности pin-нутого объекта.
Связь с async: когда Future::poll принимает Pin<&mut Self>, исполнитель обязан гарантировать, что после первого poll футура не двигается. Поэтому tokio::spawn принимает Future + Send + 'static и сразу прячет её в Box<dyn Future> или в слот аренного аллокатора, чтобы адрес стабилизировался. Если в исполнителе или в ручной комбинаторной обвязке нарушить этот контракт, словите UB на следующем .await.
4. Dropck, #[may_dangle] и парадокс Vec<&’a T>
Dropck — третий сложный механизм Rust после borrow checker и trait resolution. Его задача: убедиться, что в момент вызова Drop::drop для значения T все ссылки внутри T ещё валидны. По умолчанию компилятор требует, чтобы любой 'a внутри T пережил сам T. Политика разумная, но она ломает популярный паттерн с коллекциями короткоживущих ссылок.
struct Foo<'a>(&'a str);impl<'a> Drop for Foo<'a> { fn drop(&mut self) { println!("{}", self.0); }}fn main() { let s = String::from("hi"); let f = Foo(&s); drop(s); // ошибка: s используется в f.drop()}
Здесь dropck корректно ругается: Foo::drop читает self.0, ссылку на s. Тот же механизм по умолчанию запрещает компилироваться даже коду, где Drop ссылку не трогает, потому что компилятор не верит на слово.
fn main() { let mut v: Vec<&str> = Vec::new(); let s = String::from("hi"); v.push(&s); // v должен дропнуться после s, но Vec не читает &str в drop}
Чтобы такое работало, у Vec<T> есть unsafe impl<#[may_dangle] T> Drop for Vec<T>. Атрибут #[may_dangle] — обещание компилятору: «в моём Drop я не буду использовать значение типа T, поэтому T может быть уже невалидным к моменту вызова». Это часть нестабильного механизма dropck_eyepatch, и он живёт за unsafe, потому что нарушение обещания — UB.
Проверить, что нарушит контракт #[may_dangle], легко: добавьте в свой тип реализацию Drop, читающую дженерик-параметр, и пометьте параметр #[may_dangle]. Получите доступ к освобождённой памяти. Поэтому в std такой атрибут стоит у Vec, Box, BTreeMap, LinkedList — там, где Drop действительно не трогает T, а только освобождает аллокацию.
Параллельно работает PhantomData<T>. Если MyVec<T> хранит *mut T и PhantomData<T>, dropck считает, что MyVec<T> владеет T и в дропе может его читать. Если же хранить PhantomData<*const T>, dropck считает, что владения нет, и ослабляет требование. Эти нюансы напрямую влияют на то, какой код примет компилятор, и на них держатся почти все ручные коллекции в экосистеме (smallvec, hashbrown, slotmap).
5. Tree Borrows: что приходит на смену Stacked Borrows
Stacked Borrows (SB) — модель алиасинга, на которую долгое время ориентировался Miri. Идея: каждому указателю присваивается тег, теги складываются в стек, операции с указателями толкают и снимают теги, а Miri ловит UB, когда программа использует тег, которого больше нет на стеке. Модель красивая, но строгая: целый ряд паттернов, которые Rust компилирует и реально работают, в SB считаются UB. Самый болезненный случай — два &mut через сырые указатели и аккуратное чередование доступов.
Tree Borrows (TB) — следующая модель, Ralf Jung и команда rustc разрабатывают её как преемника SB. Идея: вместо стека используется дерево «происхождений» (provenance), и вместо «снять тег» работает мягкое «ребёнок ещё активен». В TB разрешены типичные паттерны smart-pointer-ов, в частности интрузивные двусвязные списки и self-referential структуры через Pin, которые SB пускал только частично.
Что меняется на практике. В SB конструкция let x = &mut *raw; let y = &mut *raw; ловит UB, потому что второй &mut сбрасывает первый; в TB это допустимо при условии, что первый &mut не используется до возврата управления вторым. В SB шаринг через UnsafeCell требовал точечных Reborrow в правильных местах; в TB взаимодействие с UnsafeCell описано регулярными правилами и меньше зависит от порядка операций. В TB появилась явная фаза protected: указатель защищён, пока живёт фрейм функции. Это закрывает класс багов с возвратом висячих ссылок и заодно даёт оптимизатору гарантии вокруг параметров.
В Miri уже есть флаг -Zmiri-tree-borrows. Если у вас есть unsafe-крейт, который Miri-стек ругает, прогон под TB покажет, действительно ли там UB или это ограничение SB. Финальная модель Rust ещё не зафиксирована — спецификация моделей памяти и провенанса в активной фазе разработки.
6. Strict provenance и почему usize → *mut T опаснее, чем кажется
Provenance — это «откуда взялся указатель». В классической C-семантике указатель — просто число. В Rust и в современном LLVM это число плюс невидимая метка происхождения: какой объект он адресует, где был получен, какие операции с ним легальны. Strict provenance — API в core::ptr, который явно работает с этой меткой.
Старый код выглядит так:
let p = &x as *const i32;let n = p as usize;let q = (n + 4) as *const i32; // UB по strict provenance
Каст as usize сохраняет адрес, но теряет provenance. Каст обратно as *const T создаёт указатель «без происхождения», и любая разыменовка через него в strict-модели считается UB. На практике LLVM иногда «прощает», иногда нет, и от версии к версии поведение меняется.
Новый API:
use core::ptr;let p = &x as *const i32;let addr: usize = p.addr(); // адрес без provenancelet q: *const i32 = p.with_addr(addr + 4); // тот же provenance, новый адресlet r: *const i32 = ptr::without_provenance(addr); // явно без provenance
Метод addr() возвращает голое число и теряет provenance честно. with_addr(new) берёт provenance исходного указателя и приделывает к нему новый адрес. without_provenance(addr) создаёт указатель, который заведомо нельзя разыменовать (его можно только сравнивать и приводить обратно к адресу).
Зачем это нужно. В LLVM модель памяти основана на provenance, и оптимизации (alias analysis, GVN, LICM) полагаются на то, что разные provenance не пересекаются. Если ваш код через usize-каст «склеивает» два разных объекта, LLVM может переупорядочить чтения и записи, исходя из ложного предположения, что вы обращаетесь к разным объектам. Результат — спорадический UB на оптимизациях, который не воспроизводится в debug.
Strict provenance пока не обязательная модель, но lint fuzzy_provenance_casts уже доступен на nightly, а core::ptr::without_provenance стабилизировался. Авторы низкоуровневых библиотек (allocator, intrusive collections, GC) переходят на strict-API, потому что иначе любая будущая оптимизация LLVM может тихо сломать их код.
Итог
Niche, variance, Pin, dropck, Tree Borrows и provenance — слои одной системы: компилятор хочет агрессивно оптимизировать, программа должна быть безопасной, unsafe-код должен явно проговорить контракт между ними. Каждый слой решает свою задачу: niche отвечает за упаковку, variance за подтипы, Pin за стабильный адрес, dropck за порядок drop, Tree Borrows за алиасинг, provenance за связь между числом и объектом.
Когда пишете unsafe, читайте контракты, прогоняйте Miri (лучше под -Zmiri-tree-borrows), смотрите -Zprint-type-sizes для критичного layout. Это сэкономит недели поиска UB, который компилятор уже знает, как обнаружить.
ссылка на оригинал статьи https://habr.com/ru/articles/1032214/