Вариантность
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Вариантность
Прошлый урок закончился на подтипизации: долгий лайфтайм законно подставляется туда, где ждут короткий. Это работает для голой ссылки. Но что происходит, когда ссылка спрятана внутри
&mut, внутриBox, внутри аргумента функции? Можно ли протащить подстановку сквозь конструктор типа? Иногда да, иногда наоборот, иногда нельзя вовсе, и от этого ответа напрямую зависит, пролезет ли в программу use-after-free. Вариантность это правило, по которому подтипизация проходит (или не проходит) внутрь составного типа. Самая абстрактная тема фундамента, но именно на ней держится безопасность ссылок.
Код, который обязан не скомпилироваться
Начнём с примера, который выглядит почти безобидно, а компилятор его отвергает, и правильно делает. Маленькая вспомогательная функция: записать новое значение по &mut.
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "hello"; // живёт всю программу
{
let world = String::from("world");
assign(&mut hello, &world); // вот здесь компилятор скажет нет
}
println!("{hello}"); // иначе тут читали бы освобождённую память
}
Прочитай глазами, что было бы без проверки. hello это ссылка с лайфтаймом 'static, самым долгим. assign берёт &mut hello и кладёт туда &world, ссылку на строку, которая живёт только до конца блока. Блок кончается, world дропается, её память освобождается, а hello продолжает на неё указывать. На println мы читаем висячий указатель. Классический use-after-free.
Компилятор это пресекает, причём не на println, а раньше, прямо на строке с assign. Ошибка будет про несовпадение лайфтаймов: &world не живёт достаточно долго. Вопрос урока: что именно в системе типов закрывает эту дыру? Почему голую ссылку подставлять можно, а ссылку под &mut нельзя? Ответ это вариантность, и сейчас мы её соберём по кусочкам.
Вариантность это поведение подтипизации под конструктором
Вспомним отношение подтипа из прошлого урока. Если 'long переживает 'short (запись 'long: 'short), то 'long это подтип 'short, и ссылку &'long str законно отдать туда, где ждут &'short str. Долгое годится вместо короткого.
Теперь главный вопрос. Подтипизация определена для голых ссылок. Но типы вкладываются друг в друга: ссылка лежит внутри Vec, внутри Box, под &mut, в аргументе функции. Вариантность отвечает на вопрос: если A это подтип B, что можно сказать про F<A> и F<B>? Есть ровно три ответа, и у каждого своё имя.
- Ковариантность: направление сохраняется.
F<'long>подходит туда, где ждутF<'short>. Так ведёт себя голая ссылка и всё, что только читает или производит значение. - Контравариантность: направление переворачивается. Теперь наоборот,
F<'short>подходит туда, где ждутF<'long>. Так ведёт себя позиция аргумента функции, и только она. - Инвариантность: никакой подстановки.
F<'long>иF<'short>несовместимы, хотя сами'longи'shortв подтипизации. Так ведёт себя&mutи всё с внутренней мутабельностью.
Эти три случая стоит не заучивать, а вывести из одной мысли: можно ли через конструктор испортить то, что лежит внутри. Если только читаешь, подстановка безопасна (ковариантность). Если только пишешь снаружи внутрь, безопасно обратное (контравариантность). Если и читаешь, и пишешь, безопасна лишь точная совместимость (инвариантность). Покрутим это на живом примере.
Открой вкладку «подстановка через конструктор» и пощёлкай по конструкторам. Каждый параметризован одним лайфтаймом 'a, а виджет проверяет обе подстановки: «дать долгий туда, где ждут короткий» и наоборот. Две галочки для ковариантного, две перевёрнутые для контравариантного, два креста для инвариантного. Дальше разберём каждую тройку отдельно.
Ковариантность: читателю долгое не повредит
Голая ссылка &'a T ковариантна по 'a. Это прямое продолжение прошлого урока: долгоживущая ссылка годится везде, где хватило бы короткоживущей.
fn print_it(s: &str) { // ждёт ссылку с коротким лайфтаймом
println!("{s}");
}
fn main() {
let forever: &'static str = "литерал"; // самый долгий лайфтайм
print_it(forever); // ок: 'static сужается до короткого региона вызова
}
Почему это безопасно, видно из того, что через &T нельзя ничего записать, только прочитать. Читателю всё равно, живёт источник ровно столько, сколько он ждёт, или дольше. За свой короткий срок он успеет лишь посмотреть на валидные данные. Дать ему более долгоживущее значение это как дать годовой пропуск туда, где хватило бы дневного: ограничение только усиливается, ничего не ломается.
Тем же свойством обладают Box<T>, Vec<T>, Rc<T>, выход функции fn() -> T. Все они ковариантны по T, потому что относятся к содержимому как читатель или владелец: отдают значение целиком по перемещению, но не дают писать чужую короткую ссылку внутрь через общий доступ. В виджете конструкторы &'a str, Box<&'a str> и fn() -> &'a str дают одинаковую картинку: долгое подставляется вместо короткого, обратное запрещено.
Запомни ориентир: позиция, откуда значение только уходит наружу (его читают, производят, отдают по move), ковариантна. Это позиция-производитель.
Контравариантность: потребитель, который согласен на большее
Теперь перевернём. Возьмём не значение, а функцию, которая его принимает. Пусть где-то ждут «функцию, умеющую обработать &'static str»:
fn wants(callback: fn(&'static str)) {
callback("вечная строка");
}
fn handle_any<'a>(s: &'a str) { // умеет обработать ссылку с любым лайфтаймом
println!("{}", s.len());
}
fn main() {
wants(handle_any); // ок, хотя handle_any принимает не только 'static
}
wants обещает звать колбэк только со 'static-ссылкой. Можно ли подсунуть туда handle_any, который готов принять ссылку с любым лайфтаймом? Да, и спокойно: тот, кто согласен на любую ссылку, тем более согласен на 'static. Функция, принимающая короткий (то есть любой) лайфтайм, подходит туда, где ждут функцию для долгого. Направление подтипизации перевернулось: fn(&'short) оказался подтипом fn(&'long).
А наоборот нельзя. Функция, которая требует именно &'static str, привередлива: она не умеет работать с короткой ссылкой. Подставить её туда, где могут позвать с короткоживущей ссылкой, опасно, и компилятор это запретит. Это и есть контравариантность аргумента.
Интуиция стандартная для подстановки чего угодно вызываемого: тот, кто требует от входа меньше, заменяет того, кто требует больше. Принимающая сторона безопасна, когда она менее привередлива, чем обещано. В виджете это конструктор fn(&'a str): единственный, где галочка стоит на «дать короткий туда, где ждут долгий».
Важная тонкость: контравариантна только входная позиция. У типа fn(A) -> B аргумент A контравариантен, а результат B ковариантен, ровно как у fn() -> T из прошлого раздела. Функция целиком смешанная: по входу одно правило, по выходу другое.
Инвариантность: и читает, и пишет, значит подстановки нет
Возвращаемся к коду из начала урока. &mut T инвариантен по T, и теперь понятно почему. Через &mut можно и прочитать, и записать. Чтение тянет к ковариантности (как у &T), запись тянет к контравариантности (как у аргумента функции). Оба требования разом выполнить нельзя, остаётся единственный безопасный вариант: не пускать подстановку никуда. Точное совпадение или ничего.
Покажем, что любое послабление ломает память. Открой в виджете вкладку «дыра в памяти». По умолчанию там реальность: &mut инвариантен. Жми «вперёд», и на шаге с assign симуляция упирается в отказ компилятора, до дропа world дело не доходит. Теперь нажми тумблер «представляем: &mut ковариантен» и пройди шаги до конца: запись &world проходит, блок закрывается, world умирает, а hello указывает на освобождённую память. Тот самый use-after-free из начала урока, во плоти.
Логика дыры дословно такая. Будь &mut T ковариантным по T, тип &mut &'static str принял бы &mut &'short str (ведь 'static подтип 'short). Тогда assign получил бы право записать короткую ссылку &world на место, где обещана 'static. После выхода из блока hello держит висячий указатель. Инвариантность это и есть запрет на такую запись: лайфтайм под &mut нельзя ни удлинить, ни укоротить, он зафиксирован.
Тем же заражено всё с внутренней мутабельностью. Cell<T>, RefCell<T>, UnsafeCell<T>, Mutex<T> инвариантны по T, потому что позволяют записать значение, имея на руках лишь общую ссылку &. Это ровно та же опасность записи короткой ссылки на место долгой, поэтому ковариантность они теряют. В виджете &'a mut &'b str и Cell<&'a str> дают два креста: подстановки нет ни туда, ни обратно.
Ориентир: позиция, в которую можно записать значение, не может быть ковариантной; если в неё ещё и читают, остаётся только инвариантность. Это позиция-хранилище для чтения-записи.
Это та же вариантность функторов, что и в ФП
Если кажется, что три слова с латинскими корнями придумали ради Rust, то нет. Это базовое понятие теории типов, и в функциональных языках оно ровно то же, просто часто записано руками, а не выведено компилятором.
Возьми тип функции A -> B. По выходу B он ковариантен, по входу A контравариантен, в точности как fn(A) -> B выше. Это универсальное правило: производитель (выход) ковариантен, потребитель (вход) контравариантен. Из него растёт всё остальное.
- В TypeScript массив и
Promise<T>ковариантны (контейнеры-производители), а тип функции контравариантен по аргументу приstrictFunctionTypes. Те жеout/inпозиции. - В Scala вариантность объявляют явно в сигнатуре:
class Box[+T]это ковариантный контейнер,trait Function1[-A, +B]контравариантен по входу и ковариантен по выходу. Знак+и-это буквально «ковариантно» и «контравариантно». - В Kotlin те же роли называются
out T(производитель, ковариантно) иin T(потребитель, контравариантно). Словаoutиinподобраны под позицию: откуда значение выходит и куда входит.
Разница Rust в одном: он выводит вариантность сам, по структуре типа, и нигде её не пишут в сигнатуре. Зато к лайфтаймам она применяется так же строго, как к обычным типам. А под капотом это всё тот же закон подстановки из теории категорий: ковариантный функтор сохраняет стрелки, контравариантный их разворачивает. Понимаешь Function1[-A, +B] в Scala, понимаешь и fn(A) -> B в Rust.
Практический вывод для проектирования, не только для Rust: где в типе значение читается, там подтип льётся свободно; где пишется, там свобода теряется. Поэтому неизменяемые структуры данных дружелюбнее к подтипизации, чем изменяемые. Ковариантный иммутабельный список безопасен, а ковариантный изменяемый массив это классическая дыра (та самая, из-за которой в Java массивы ковариантны и кидают ArrayStoreException в рантайме, потому что статически проверить уже нельзя). Rust ту же проблему ловит на этапе компиляции через инвариантность &mut и Cell.
Вариантность типа собирается из вариантности полей
Откуда компилятор берёт вариантность твоей собственной структуры? Он её выводит из полей. Правило простое и механическое:
- если параметр во всех полях используется только в ковариантных позициях, структура ковариантна по нему;
- если только в контравариантных, контравариантна;
- если в разных полях по-разному, или хоть раз в инвариантной позиции, структура инвариантна.
Последний пункт ключевой: инвариантность побеждает любой конфликт. Стоит параметру попасть хоть в одно &mut, хоть в один Cell, хоть одновременно в ковариантную и контравариантную позицию, и весь тип становится инвариантным по нему. Вот канонический разбор из «Расномикона», все случаи в одной структуре:
use std::cell::Cell;
struct MyType<'a, 'b, A: 'a, B: 'b, C, D, E, In, Out, Mixed> {
a: &'a A, // ковариантна по 'a и по A
b: &'b mut B, // ковариантна по 'b, ИНвариантна по B
c: *const C, // ковариантна по C
d: *mut D, // инвариантна по D
e: E, // ковариантна по E
f: Vec<E>, // тоже ковариантна по E
g: Cell<Out>, // инвариантна по Out
i: fn(In) -> Out, // контравариантна по In, ковариантна по Out
k1: fn(Mixed) -> usize, // была бы контравариантна по Mixed,
k2: Mixed, // но это поле ковариантно, конфликт -> инвариантна по Mixed
}
Разбери глазами Mixed: в k1 он во входе функции (контравариантно), в k2 голым полем (ковариантно). Два разных требования, компилятор не может удовлетворить оба, выбирает самое строгое и делает тип инвариантным по Mixed. С Out похоже: в g он под Cell (инвариантно), и этого уже хватает.
Есть и композиция вложенных конструкторов: вариантности перемножаются. Ковариантное под ковариантным остаётся ковариантным. Контравариантное под контравариантным даёт ковариантное (минус на минус). Любое под инвариантным даёт инвариантное. Поэтому fn(fn(T)) ковариантна по T: два переворота подряд возвращают исходное направление. На практике это редкость, но знать механику полезно: она ровно как умножение знаков.
О чём думает компилятор: вывод вариантности
Стоит заглянуть на уровень ниже, потому что это «о чём думает компилятор» в буквальном смысле. Вариантность в Rust нигде не пишется руками (в отличие от Scala и Kotlin), её выводит отдельный проход компилятора.
Работает он так. Каждому параметру каждого типа компилятор поначалу присваивает неизвестную вариантность и собирает по всем полям набор ограничений: это поле требует ковариантности по такому-то параметру, то поле инвариантности по такому-то. Дальше он ищет решение методом неподвижной точки: повторяет проход, пока вариантности не перестанут меняться. Внутри он помечает их короткими значками: + ковариантно, - контравариантно, o (буква, не ноль) инвариантно, и * для особого случая.
Этот особый случай важен на практике. Если параметр в полях вообще не встречается, его вариантность остаётся бивариантной: раз параметр ни на что не влияет, его можно менять как угодно. Звучит безобидно, но почти всегда это ошибка проектирования: ты объявил параметр и забыл связать его с данными. Поэтому Rust на неиспользуемый тип или лайфтайм выдаёт ошибку E0392, parameter is never used, и требует его куда-то пристроить. Этим «куда-то» и оказывается PhantomData, к которому мы наконец пришли.
Практический смысл: ошибка лайфтайма про вариантность это не каприз компилятора, а вывод его решателя. «Этот тип инвариантен по 'a, поэтому твою подстановку я не пропущу». Чинится она не расстановкой 'a наугад, а пониманием, какое поле сделало тип инвариантным, и нужно ли это поле такому, какое оно есть.
PhantomData: задать вариантность и владение руками
Когда пишешь небезопасные абстракции (свой Vec, свой итератор, обёртку над сырым указателем), параметр типа часто логически есть, а физического поля под него нет: данные лежат за сырым указателем, который сам по себе про T компилятору ничего честного не говорит. Тогда параметр повисает, и нужен PhantomData, маркер нулевого размера, который для компилятора выглядит как поле, но в рантайме ничего не весит.
PhantomData решает разом три задачи, и важно держать их раздельно:
- Задаёт вариантность. Какой
PhantomDataвпишешь, такую вариантность и получишь по своему параметру. - Сообщает drop-чекеру про владение.
PhantomData<T>говорит компилятору «я владею значениямиT», и тот при проверке дропа учитывает лайфтаймы внутриT. Без этого небезопасный тип может уронить порядок дропов. - Влияет на
Send/Sync. Маркер наследует авто-трейты от того, что в него вписано.
Подбираешь нужное поведение, выбирая, что положить в PhantomData. Таблица-шпаргалка по самым частым:
| Что пишут | Вариантность по T (и 'a) | Send/Sync | Когда брать |
|---|---|---|---|
PhantomData<T> | ковариантна, владеет T | как у T | владеющая обёртка над *const T |
PhantomData<&'a T> | ковариантна по 'a и T | Sync если T: Sync | заёмное представление, итератор по &'a [T] |
PhantomData<&'a mut T> | ковариантна по 'a, инвариантна по T | как у T | заём на чтение-запись |
PhantomData<fn(T)> | контравариантна по T | Send + Sync | маркер-потребитель, типобезопасные id |
PhantomData<fn() -> T> | ковариантна по T, не «владеет» | Send + Sync | маркер состояния без drop-влияния |
PhantomData<*const T> | ковариантна, не Send/Sync | !Send + !Sync | сырой указатель, который не делится между потоками |
PhantomData<Cell<T>> | инвариантна по T | как нужно | когда инвариантность обязательна |
Самое частое применение это правильная раскладка собственной коллекции. Посмотри, как устроен настоящий Vec внутри (упрощённо):
use std::marker::PhantomData;
use std::ptr::NonNull;
struct MyVec<T> {
ptr: NonNull<T>, // НЕ *mut T: NonNull<T> ковариантен по T
cap: usize,
len: usize,
_owns: PhantomData<T>, // «я владею T»: для drop-чека и вариантности
}
Две тонкости, ради которых это и показано. Первая: поле указателя это NonNull<T>, а не *mut T. Сырой *mut T инвариантен по T, и если бы Vec хранил его напрямую, он стал бы инвариантным, а пользователи Vec ожидают ковариантность (долгое подставляется вместо короткого). NonNull<T> это обёртка, специально сделанная ковариантной, плюс она ненулевая, что включает niche-оптимизацию. Вторая: PhantomData<T> нужен, чтобы drop-чекер знал, что Vec владеет значениями T и обязан учесть их лайфтаймы при дропе. Без него небезопасный код мог бы проскочить мимо проверки порядка освобождения.
Это прямая связь вариантности с архитектурой: выбор *mut T против NonNull<T> против &mut T это не косметика, а решение, ковариантной или инвариантной будет твоя абстракция, а значит, насколько гибко её смогут использовать с лайфтаймами.
Где вариантность задевает архитектуру API
Тема кажется внутренней, но протекает наружу, в эргономику чужого кода. Несколько практических следствий.
Инвариантность заразна и сужает вызывающего. Стоит положить в публичный тип Cell<&'a T> или &'a mut T, и весь тип становится инвариантным по 'a. Пользователь больше не сможет «сузить» долгую ссылку до короткой при передаче, лайфтаймы у него застынут, и появятся загадочные ошибки подстановки. Если инвариантность тебе не нужна, не тащи в тип внутреннюю мутабельность по заёмному параметру.
Owned против borrowed это и выбор вариантности. String (владеет) даёт типу больше свободы, чем &'a str (заём), и не только по сроку жизни: владеющее поле не привязывает тип к чужому лайфтайму вовсе. Это тот же выбор из урока про структуры со ссылками, теперь с добавкой: заём ещё и навязывает типу вариантность по 'a.
Типобезопасные маркеры на PhantomData. Паттерн типового состояния кодирует состояние объекта прямо в типе: Connection<Open> и Connection<Closed> это разные типы, и метод send существует только для Open. Состояние хранят в PhantomData<S>, физических данных за ним нет. И вот тут вариантность маркера важна: обычно берут PhantomData<fn() -> S>, чтобы маркер был ковариантным, но не «владел» состоянием для drop-чека и не портил Send/Sync. Выбор разновидности PhantomData это сознательное проектное решение, а не ритуал.
Линзы и обёртки в ФП-стиле. Если строишь типобезопасные id (Id<User> против Id<Order> поверх одного u64), маркер PhantomData<fn(User)> делает их контравариантными и не-владеющими, что чаще всего и нужно для фантомного тега: он не должен ни тянуть лайфтаймы, ни влиять на дроп. Понимание вариантности здесь напрямую определяет, какой PhantomData правильный.
Правило урока
Свернём в одну мысль.
Вариантность это правило, по которому подтипизация проходит сквозь конструктор типа. Позиция, откуда значение только читают или производят, ковариантна (долгое вместо короткого). Позиция аргумента функции контравариантна (короткое вместо долгого). Позиция, куда можно записать (
&mut,Cellи любая внутренняя мутабельность), инвариантна, и именно эта инвариантность закрывает use-after-free. Вариантность типа компилятор выводит сам из полей, инвариантность побеждает в любом конфликте, аPhantomDataпозволяет задать её и владение руками, когда поля под параметр нет.
Типичная ошибка, которую снимает урок: считать ошибку вариантности случайной придиркой и расставлять лайфтаймы наугад. На деле компилятор сообщает точный факт о твоём типе: «он инвариантен по 'a, потому что вот это поле пишет внутрь». Чинят это, меняя поле (NonNull вместо *mut, владение вместо заёма, нужный PhantomData), а не заклинаниями.
ДЗ
Дальше
Ты разобрал самый абстрактный слой фундамента: вариантность это поведение подтипизации под конструктором, и из неё прямо следует, почему &mut и внутренняя мутабельность инвариантны, а голые ссылки и владельцы ковариантны. Заодно встретил PhantomData как инструмент задать вариантность и владение там, где поля под параметр нет. Дальше идём в DST и толстые указатели: почему str и [T] не имеют размера, как устроен указатель из двух слов и что такое ?Sized. А PhantomData встретится снова уже как полноценный инструмент моделирования в уроке про маркерные трейты, где Send, Sync и Sized встанут рядом с вариантностью в один ряд гарантий, которые компилятор выводит, а не проверяет в рантайме.