В первой части мы обсуждали 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 inner
pub 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 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

Стоп, тут 2 байта, а не 1. Первая ловушка: niche-алгоритм rustc не умеет «сжимать» дискриминант внешнего enum в неиспользуемые битовые паттерны внутреннего, если тип пришёл из другого крейта и не помечен как #[repr]-стабильный. Алгоритм консервативен: он смотрит на «дырки» в layout-е InnerInner { 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, как &T
struct 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();           // адрес без provenance
let 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, который компилятор уже знает, как обнаружить.

[Пишу про Rust в тг] (https://t.me/+pU1foWNRAwQyNjM6), если интересно залетайте!

Жду замечаний и предложений в комментариях, спасибо за прочтение статьи!