|
|
|
Professor Seleznov
|
В первой части мы обсуждали 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::() обязан быть 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-е 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 через FFI окажется не таким, каким был. Практический вывод: niche - мощный, но локальный приём. На него можно опираться внутри одного крейта (в духе NonZero*, Box, &T), а строить ABI или сериализацию поверх niche чужого типа - плохая идея. Побочный эффект: Option> занимает 1 байт за счёт того, что у bool есть niche 2..=255, внутренний Option забирает значение 2, внешний - значение 3. Уберите этот контракт у bool, и вся башня рассыплется. 2. Variance, или почему &mut T инвариантен Вторая тема, без которой жить с unsafe тяжело - variance. Если коротко: при наличии параметра T тип F может быть ковариантен (подтип T даёт подтип F), контравариантен (наоборот) или инвариантен (никаких отношений). У ссылок 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 { ptr: *mut T }, компилятор не знает, что вы там делаете внутри, и по умолчанию выводит variance из полей. У сырого указателя *mut T инвариантность по T, у *const T ковариантность. Если нужен явный контроль, используют PhantomData:
use std::marker::PhantomData; // Ковариантен по T, как &T struct Covariant(*const T, PhantomData); // Инвариантен по T, как &mut T или Cell struct Invariant(*mut T, PhantomData<*mut T>); // Контравариантен, нужен fn(T) struct Contravariant(PhantomData);
Эти трюки видны во всём std: у Cell и RefCell поле UnsafeCell делает их инвариантными, иначе можно было бы через ковариантность подделать тип. Отдельная тонкость - variance и 'static. Многие думают, что &'static T всегда сильнее любого &'a T, и это правда, но только до момента, когда тип попадает в инвариантную позицию. Простейший пример: Box инвариантен по 'a (внутри fat-pointer), и попытка передать Box туда, где ждут Box, не пройдёт без явного приведения. 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: 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> { 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 или в слот аренного аллокатора, чтобы адрес стабилизировался. Если в исполнителе или в ручной комбинаторной обвязке нарушить этот контракт, словите 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 есть unsafe impl
|
|
|
|