Маркерные трейты
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Маркерные трейты
В прошлом уроке ты встретил
Sizedи заметил странность: трейт без единого метода, который компилятор никогда не вызывает, но который тем не менее решает, можно ли положить значение на стек. Это не исключение, это целое семейство.Send,Sync,Copy,Unpin, тот жеSized, все они трейты без методов. Компилятор их не вызывает, он их доказывает и проверяет ещё до запуска программы, и из этой проверки вырастают самые сильные гарантии Rust: что код без гонок данных просто не скомпилируется, что строку не скопируют, когда копировать нельзя, что мьютекс не уедет в чужой поток. В этом уроке мы соберём всё семейство в одну картину: что каждый трейт гарантирует, как они выводятся структурно по полям, как тип от них отказывается и при чём тутPhantomData, поле, которого нет в памяти, но которое договаривается с компилятором за тебя.
Что такое маркерный трейт
Обычный трейт это набор методов: реализовал Display, значит, у тебя есть fmt. Маркерный трейт устроен иначе: методов в нём нет вообще, реализовать его значит не получить новое поведение, а получить ярлык. Тип либо помечен, либо нет, и компилятор читает этот ярлык, принимая решения.
Определения в стандартной библиотеке пустые:
pub trait Copy: Clone {} // ни одного метода
pub unsafe auto trait Send {} // тоже пусто
pub unsafe auto trait Sync {} // и тут
Раз вызывать нечего, вся ценность в самом факте реализации. T: Copy разрешает компилятору копировать значение побитово вместо перемещения. T: Send разрешает передать значение в другой поток. T: Sized сообщает, что размер известен. Метод бы тут ничего не добавил: гарантия не в коде, а в наличии ярлыка. Поэтому такие трейты называют маркерами, метками.
С одним из них ты уже подружился. Sized это маркерный трейт, и весь прошлый урок был про то, что значит его наличие (размер известен) и отсутствие (DST, только за указателем). Теперь поставим его в один ряд с остальными.
Copy: побитовое копирование вместо перемещения
Начнём с самого знакомого. В уроке про владение ты видел два разных поведения при присваивании. Число копируется, а String перемещается, и после перемещения исходная переменная мертва. Разницу решает один маркерный трейт.
Copy помечает типы, значение которых можно продублировать тупым копированием байтов, без всякой особой логики. У таких типов присваивание это не перемещение, а копия: исходник остаётся жив.
fn main() {
let a = 5; // i32, это Copy
let b = a; // побитовая копия, a жив
println!("{a} {b}"); // 5 5, оба валидны
let s = String::from("привет"); // String это НЕ Copy
let t = s; // перемещение, s больше нельзя трогать
// println!("{s}"); // ошибка: borrow of moved value: `s`
println!("{t}");
}
Почему i32 можно копировать, а String нет? Потому что String владеет буфером в куче и в его байтах лежит указатель на этот буфер. Скопируй байты, и два String будут указывать на один буфер, а при выходе из области видимости оба попытаются его освободить: double free. Поэтому Rust для владеющих ресурсом типов перемещает, а не копирует, и Copy им запрещён.
Отсюда жёсткое правило: тип с impl Drop не может быть Copy. Drop означает «при уничтожении надо что-то прибрать» (закрыть файл, освободить буфер), а Copy означает «дублирование это просто копия байтов, ничего прибирать не нужно». Это взаимоисключающие заявления, и компилятор их вместе не пропустит.
Copy это к тому же подтрейт Clone (запись Copy: Clone), поэтому помечают всегда пару:
#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32, // оба поля Copy, значит и Point может быть Copy
}
#[derive(Clone)] // только Clone: String внутри не Copy
struct Person {
name: String,
age: u32,
}
Разница между Clone и Copy в том, кто и когда дублирует. Copy срабатывает неявно и всегда дёшев, это всегда memcpy. Clone ты вызываешь руками через .clone(), и он может делать сколько угодно работы (та же String в clone идёт в аллокатор за новым буфером). Copy это обещание «копия настолько дешёвая и тупая, что я разрешаю делать её молча». Получить его можно только через derive и только если все поля сами Copy и нет Drop.
Auto-трейты: вывод по полям
Copy ты просишь явным derive. Но три самых важных маркера, Send, Sync и Sized, компилятор раздаёт сам, без твоего слова. Это auto-трейты: тип реализует их тогда и только тогда, когда все его поля их реализуют. Никакого derive, чистая структурная индукция по составу типа.
struct Wrapper {
id: u64, // Send + Sync
name: String, // Send + Sync
}
// Wrapper автоматически Send + Sync: оба поля Send + Sync.
struct Tricky {
id: u64, // Send + Sync
shared: std::rc::Rc<u64>, // НЕ Send, НЕ Sync
}
// Tricky НЕ Send и НЕ Sync: одного «плохого» поля достаточно.
Главное здесь правило: одно поле без трейта лишает трейта всю структуру. Это не недостаток, а ровно то, что нужно. Если внутри спрятан тип, который нельзя безопасно отправить в другой поток, то и контейнер вокруг него отправлять нельзя, иначе ты пронёс бы опасное значение через границу потока контрабандой в кармане структуры. Компилятор закрывает эту лазейку автоматически, проверяя состав до самого дна.
Этим auto-трейты и отличаются от Copy: Copy надо попросить (derive), а Send, Sync, Sized приходят сами и так же сами исчезают, стоит подмешать в поля что-то непригодное.
Send и Sync: безопасность на границе потока
Теперь два главных маркера всего многопоточного Rust. Они отвечают на два разных вопроса о границе между потоками.
Send отвечает на вопрос «можно ли переместить значение в другой поток». Если тип Send, владение его значением безопасно уезжает в thread::spawn. Почти все типы Send.
Sync отвечает на другой вопрос: «можно ли поделить значение между потоками по ссылке». Точное определение красиво и его стоит запомнить дословно: T: Sync тогда и только тогда, когда &T: Send. То есть «тип можно делить ссылкой» это ровно «ссылку на тип можно передать в поток». Send про передачу владения, Sync про разделяемый доступ.
Вот граница в коде. Замыкание, которое ты отдаёшь в thread::spawn, обязано быть Send, а значит, и всё, что оно захватывает:
use std::thread;
fn main() {
let data = vec![1, 2, 3]; // Vec<i32> это Send
let handle = thread::spawn(move || {
// владение data переехало в новый поток, это законно: Vec<i32>: Send
println!("{}", data.iter().sum::<i32>());
});
handle.join().unwrap();
}
А вот где компилятор бьёт по рукам. Rc это счётчик ссылок для одного потока, и его счётчик неатомарный: два потока, одновременно клонирующие один Rc, гонкой испортят счётчик и устроят double free. Поэтому Rc не Send, и попытка унести его в поток не компилируется:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(42);
thread::spawn(move || {
println!("{}", shared); // ошибка ещё до запуска
});
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
|
= help: within `{closure@...}`, the trait `Send`
is not implemented for `Rc<i32>`
note: required because it's used within this closure
note: required by a bound in `spawn`
Прочитай сообщение медленно, оно говорит именно то, что мы обсудили: Rc<i32> «нельзя безопасно отправить между потоками», потому что для него «не реализован Send». Это не загадка, это маркерный трейт, который компилятор проверил и не нашёл. Лечение тоже известно: заменить Rc на Arc, у которого счётчик атомарный и который потому Send и Sync.
Покрути типы в стенде ниже и проследи, как Send и Sync выводятся слой за слоем. Обрати внимание на два разных теста справа: перенос в поток требует Send, разделение ссылки между потоками требует Sync. Это видно на Cell: перенести можно, поделить нельзя.
Негативная реализация: как тип отказывается от трейта
Auto-трейт приходит сам, если все поля подходят. Но иногда тип подходит структурно, а по смыслу всё равно небезопасен, и его нужно вычеркнуть вручную. Так и устроен Rc: его поля (пара чисел и указатель) по составу не мешали бы Send, но логически он однопоточный. Стандартная библиотека вычёркивает его негативной реализацией:
// примерно так это сделано в std (синтаксис !Trait доступен только на nightly)
impl<T: ?Sized> !Send for Rc<T> {}
impl<T: ?Sized> !Sync for Rc<T> {}
Запись !Send буквально означает «этот тип не Send, и точка», перекрывая структурный вывод. Бывает и обратное движение: тип структурно !Send (например, держит сырой указатель), но автор знает, что обёртка безопасна, и возвращает трейт через unsafe impl:
// так Arc возвращает себе Send и Sync, несмотря на сырой указатель внутри
unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {}
unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {}
unsafe здесь не про опасный код, а про доверие: компилятор не способен сам доказать, что обёртка над сырым указателем потокобезопасна, поэтому ты подписываешься под контрактом своей рукой. Если соврёшь, получишь гонку данных, которую язык обычно не допускает. Это прямой мостик к уроку про unsafe: unsafe impl Send это обещание, которое держишь ты, а не компилятор.
И auto trait, и impl !Trait на стабильном Rust пользователю недоступны (это nightly-фичи auto_traits и negative_impls), ими пользуется только стандартная библиотека. Но отказаться от Send и Sync в своём типе на стабильном Rust всё равно можно, и делается это полем-маркером, к которому мы придём в конце урока: PhantomData<*const ()> внутри структуры убирает оба трейта, потому что сырой указатель их не имеет.
Тонкие случаи: каталог исключений
Большинство типов Send + Sync, и думать о них не приходится. Запоминать стоит исключения, их немного, и за каждым стоит понятная причина. Вот они одной таблицей:
| Тип | Send | Sync | Почему |
|---|---|---|---|
Rc<T>, Weak<T> | нет | нет | неатомарный счётчик ссылок, гонка при клоне |
Arc<T> | если T: Send + Sync | если T: Send + Sync | атомарный счётчик безопасен |
Cell<T>, RefCell<T> | если T: Send | нет | внутренняя изменяемость без блокировки |
Mutex<T> | если T: Send | если T: Send | блокировка даёт эксклюзивный доступ |
MutexGuard<T> | нет | если T: Sync | ОС требует разблокировки в захватившем потоке |
*const T, *mut T | нет | нет | сырой указатель без гарантий |
Три строки заслуживают отдельного слова, потому что ломают наивную интуицию «Send и Sync ходят парой».
Cell и RefCell: Send, но не Sync. Их можно перенести в другой поток целиком (владелец один, гонки нет), но нельзя дать &Cell сразу двум потокам: внутренняя изменяемость без блокировки устроит гонку записи. Это и есть смысл Send без Sync, и определение Sync через &T: Send тут видно вживую: Cell не Sync именно потому, что &Cell нельзя отправить в поток.
Mutex<T> это Sync, когда T всего лишь Send. Заметь, требуется только T: Send, а не T: Sync. Почему слабее: мьютекс выдаёт доступ к T строго по одному потоку за раз, поэтому T достаточно уметь переезжать между потоками, делить его одновременно никто не будет. Именно Mutex превращает не-Sync тип в Sync-обёртку, и поэтому Arc<Mutex<T>> это рабочая лошадка разделяемого состояния.
MutexGuard<T>: не Send, но Sync. Самый коварный. Охранник, который ты получаешь из mutex.lock(), нельзя унести в другой поток: на уровне операционной системы разблокировать мьютекс обязан тот же поток, что его захватил. При этом &MutexGuard отдаёт &T, и делить его ссылкой безопасно, так что Sync он сохраняет. Практическое следствие ты встретишь в async: держать MutexGuard через .await нельзя, потому что футура с не-Send значением внутри сама перестаёт быть Send, и компилятор говорит ровно это:
error: future cannot be sent between threads safely
note: future is not `Send` as this value is used across an await
| has type `MutexGuard<'_, i32>` which is not `Send`
Лечится либо тем, что охранника освобождают (через drop) до .await, либо тем, что берут tokio::sync::Mutex, чей охранник как раз Send. Подробнее это разберём в блоке про async, здесь важно увидеть корень: всё упирается в один маркерный трейт.
Sized снова и Unpin впервые
Два оставшихся auto-трейта закроем коротко, оба уже знакомы по соседним темам.
Sized ты целиком разобрал в прошлом уроке: тип Sized, если его размер известен компилятору, и эта граница неявно висит на каждом параметре типа. Теперь видно, что он точно такой же auto-трейт, как Send и Sync: выводится структурно (структура Sized, если все её поля Sized), снимается через ?Sized. Единственная особенность, что границу по нему компилятор добавляет молча, а не по запросу.
Unpin ты встретишь в блоке про async по-настоящему, здесь только назовём. Unpin помечает типы, которые безопасно перемещать в памяти даже когда они закреплены через Pin. Почти всё на свете Unpin: числа, ссылки, Box, Vec. Исключение одно и важное: автогенерируемая футура async-функции, которая держит заимствование через .await, становится самоссылающейся, и перемещать её нельзя, иначе внутренние указатели повиснут в пустоте. Такая футура !Unpin, и ради этого случая весь механизм Pin и существует. Отказаться от Unpin в своём типе помогает поле-маркер PhantomPinned. Сейчас достаточно запомнить: Unpin это ещё один auto-трейт того же семейства, гарантирующий «меня можно двигать».
PhantomData: поле, которого нет, но которое говорит
Остался инструмент, который мелькал в уроке про вариантность и который связывает весь урок воедино. Иногда тип должен сообщить компилятору о связи с типом-параметром, не храня значение этого параметра. Звучит странно, но это сплошь и рядом.
Классический случай: ты пишешь свою коллекцию и держишь данные за сырым указателем *mut T. Указатель сам по себе не несёт компилятору никакой информации: он не говорит «я владею T», не задаёт вариантность по T, не участвует в drop-проверке. Компилятор будет считать, что T тебе вообще не нужен, и сделает неверные выводы про время жизни и потокобезопасность. Нужно поле типа T, но настоящего значения у тебя нет, есть только указатель.
Решение это PhantomData<T>, нулевой по размеру тип-маркер. Поле PhantomData<T> занимает ноль байт (в рантайме его просто нет), но на этапе компиляции оно говорит ровно то, что нужно: «структура логически связана с T». Из этой связи компилятор выводит три вещи: вариантность по T, участие в drop-проверке (владеет ли структура значением T) и наследование Send/Sync.
use std::marker::PhantomData;
struct MyVec<T> {
ptr: *mut T, // сырой указатель: компилятору про T ничего не говорит
len: usize,
cap: usize,
_owns: PhantomData<T>, // нулевой размер, но сообщает: «я владею T»
}
Без _owns компилятор считал бы MyVec<String> типом, который String не касается, и пропустил бы ошибки времени жизни и drop-порядка. С PhantomData<T> он понимает: эта коллекция владеет T, ведёт себя как Vec, и drop-проверка работает правильно. Именно так устроены внутренности настоящего Vec и Box.
Тонкость в том, что разные формы PhantomData дают разные комбинации свойств. PhantomData<T> говорит «владею T» и включает drop-проверку. PhantomData<fn() -> T> даёт ту же ковариантность по T, но без владения и без потери потокобезопасности, и потому это лучший выбор для чистого типового тега. PhantomData<*const T> убирает Send и Sync. Покрути формы и проследи, как при нулевом размере меняются вариантность, потокобезопасность и drop-проверка:
Самое частое практичное применение это типизированные идентификаторы. Хочешь, чтобы Id<User> и Id<Post> были разными типами и их нельзя было перепутать, хотя внутри оба это просто u64:
use std::marker::PhantomData;
struct Id<T> {
raw: u64,
_marker: PhantomData<fn() -> T>, // тег: ковариантность по T, остаётся Send + Sync
}
struct User;
struct Post;
fn delete_user(id: Id<User>) { /* ... */ }
fn main() {
let user_id: Id<User> = Id { raw: 1, _marker: PhantomData };
let post_id: Id<Post> = Id { raw: 1, _marker: PhantomData };
delete_user(user_id); // ок
// delete_user(post_id); // ошибка типов: Id<Post> это не Id<User>
}
Числа внутри одинаковые, а типы разные, и компилятор не даст передать идентификатор поста туда, где ждут идентификатор пользователя. Целый класс ошибок «перепутал id» исчезает, и всё это бесплатно: PhantomData не занимает ни байта. Заметь выбор формы: здесь взят PhantomData<fn() -> T>, а не PhantomData<T>, потому что тег ничем не владеет, и тянуть за собой drop-проверку и зависимость Send от T не нужно.
Правило урока
Свернём всё семейство в одну мысль.
Маркерный трейт это трейт без методов: ценен не вызов, а сам факт реализации.
Copy(его просят черезderive, и он запрещён приDrop) разрешает побитовую копию вместо перемещения.Send,Sync,Sized,Unpinэто auto-трейты: компилятор выводит их структурно, тип получает трейт, только если все его поля им обладают, и одно плохое поле лишает трейта всю структуру.Sendзначит «можно переместить в другой поток»,Syncзначит «можно поделить ссылкой между потоками», причёмT: Syncэто в точности&T: Send. Когда структурный вывод врёт, тип правят руками:impl !Sendвычёркивает трейт,unsafe impl Sendвозвращает под твою ответственность. АPhantomDataэто нулевое по размеру поле, которым тип сообщает компилятору о связи с типом-параметром (владение, вариантность, потокобезопасность), не храня его значения.
Типичная ошибка, которую снимает урок: упереться в the trait Send is not implemented for ... и начать наугад оборачивать значение во что попало или городить unsafe. На деле сообщение точное: внутри сидит однопоточный тип (чаще всего Rc, RefCell, Cell или сырой указатель), и компилятор не пускает его через границу потока. Лечится это не случайной обёрткой, а заменой на потокобезопасную ровню (Rc на Arc, RefCell под Mutex), и только если ты сам доказал безопасность, осознанным unsafe impl Send.
ДЗ
Дальше
Ты собрал всё семейство маркерных трейтов в одну систему: Copy про дешёвое дублирование, Send и Sync про границу потока, Sized и Unpin про размер и перемещение, и PhantomData как способ договориться с компилятором о связи с типом, не платя ни байтом памяти. За каждым стоит гарантия, которую язык проверяет до запуска: код с гонкой данных просто не скомпилируется. Один пункт мы оставили висеть: unsafe impl Send это обещание, которое держит уже не компилятор, а ты. В следующем уроке про unsafe мы разберём, что именно ты подписываешь, когда снимаешь страховку: сырые указатели, неопределённое поведение и контракт безопасной обёртки, внутри которой unsafe есть, а снаружи виден только безопасный API.