DST и толстые указатели
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
DST и толстые указатели
В уроке про трейт-объекты ты уже видел, что
&dyn Reportзанимает два машинных слова, а не одно, и что&strустроен так же. Тогда мы взяли этот факт как данность и поехали дальше. Теперь разберём его до конца. Почемуstrнельзя положить в переменную, а&strможно? Откуда вообще берётся второе слово в указателе и что именно в нём лежит? Что за скрытая границаSizedвисит на каждом дженерике и зачем её иногда снимают через?Sized? Этот урок собирает в одну картину три разрозненных факта: срезы, трейт-объекты иstr. Все они про одно, про типы, размер которых компилятор не знает заранее.
Код, который не компилируется, и рядом тот, что компилируется
Начнём с минимальной пары. Слева то, что Rust отвергает, справа почти то же, но рабочее.
fn main() {
let s: str = *"привет"; // ошибка E0277: the size for `str` cannot be known
let r: &str = "привет"; // ок
}
Разница в одном символе, в амперсанде, но за ним лежит вся тема урока. Литерал "привет" это значение типа str, последовательности UTF-8 байтов неизвестной заранее длины. Положить её в переменную s: str нельзя: чтобы выделить место под s на стеке, компилятору нужно знать, сколько байт занять, а у str нет фиксированного размера. У одной строки три байта, у другой триста, тип str один и тот же. А вот &str, ссылка на строку, размер имеет, причём всегда один: это и есть толстый указатель, про который пойдёт речь.
Тип, размер которого не известен на этапе компиляции, называется DST, dynamically sized type. Их в Rust ровно три семейства, и дальше мы разберём каждое. Но сначала надо назвать противоположность, потому что именно она по умолчанию и работает молча.
Sized: скрытая граница на каждом дженерике
Почти все типы, которые ты писал, имеют размер, известный компилятору: u32 это четыре байта, bool один, (f64, f64) шестнадцать, любая твоя struct Point { x: i32, y: i32 } фиксированной ширины. Такие типы реализуют маркерный трейт Sized. Ты его никогда не пишешь руками, потому что компилятор сам вешает его на всё, что считает фиксированным по ширине.
Главная тонкость: граница Sized неявно добавлена к каждому параметру типа. Вот эти две сигнатуры для компилятора одинаковы:
fn process<T>(value: T) {}
fn process<T: Sized>(value: T) {} // ровно то же самое, граница подразумевается
Так и должно быть. process принимает value по значению, кладёт его на стек, а для этого нужен размер. Поэтому Rust по умолчанию считает: раз ты ничего не сказал, твой T имеет размер. Это единственная граница в языке, которая включается сама, без твоего слова. Все остальные (T: Clone, T: Display) надо просить явно, а Sized приходится отключать явно, об этом ниже.
Проверить размер можно из кода. size_of работает только для Sized-типов, потому что для остальных ответа просто нет:
use std::mem::size_of;
fn main() {
assert_eq!(size_of::<u32>(), 4);
assert_eq!(size_of::<(f64, f64)>(), 16);
assert_eq!(size_of::<bool>(), 1);
// size_of::<str>() // не скомпилируется: str не Sized, размера нет
}
Когда же размер всё-таки зависит от значения, есть отдельная стабильная функция size_of_val, которая берёт ссылку на значение и спрашивает размер уже у конкретного экземпляра в рантайме. Ей DST по плечу, потому что у конкретной строки или конкретного среза размер есть, его просто нет у типа вообще:
use std::mem::size_of_val;
fn main() {
let nums = [1_i32, 2, 3, 4];
let slice: &[i32] = &nums;
assert_eq!(size_of_val(slice), 16); // 4 элемента по 4 байта, считается в рантайме
let text: &str = "абвг";
assert_eq!(size_of_val(text), 8); // 4 кириллических символа по 2 байта UTF-8
}
Запомни ориентир: Sized это про тип, size_of_val про значение. У типа str размера нет, у конкретной строки за ссылкой есть.
Три семейства DST
Динамически размерных типов в Rust ровно три, и ты все три уже встречал, просто не под общим именем.
[T], срез: подряд лежащиеT, сколько именно, в типе не записано. Массив[T; N]фиксирован поNи потомуSized, а срез[T]нет.str, строковый срез: это[u8]с гарантией, что байты это корректный UTF-8.dyn Trait, трейт-объект: за ним прячется какой-то конкретный тип, но какой именно и какого размера, статически неизвестно.
Плюс четвёртый, производный случай: структура, последнее поле которой это DST, сама становится DST. К нему вернёмся отдельно, он самый недооценённый.
Объединяет все три одно: размер значения становится известен только в рантайме, и компилятор обязан как-то этот размер с собой возить. Возит он его в указателе.
Раскладка толстого указателя: данные плюс метаданные
Обычная ссылка на Sized-тип это одно машинное слово, голый адрес: пришёл по адресу, тип известен, размер известен, всё понятно. Такую ссылку называют тонкой.
Указатель на DST так не может: одного адреса мало, потому что по адресу лежит нечто неизвестного размера. Поэтому Rust делает указатель толстым, шириной в два слова. Первое слово как обычно, адрес данных. Второе слово это метаданные, ровно то знание, которого компилятору не хватает:
&u32 (тонкий, 1 слово): &[i32] (толстый, 2 слова):
┌──────────────┐ ┌──────────────┬──────────────┐
│ адрес данных │ │ адрес данных │ длина 4 │
└──────────────┘ └──────────────┴──────────────┘
│ │
▼ ▼
[ i32 ] [ i32 │ i32 │ i32 │ i32 ]
&dyn Trait (толстый, 2 слова):
┌──────────────┬──────────────┐
│ адрес данных │ адрес vtable │
└──────────────┴──────────────┘
│ │
▼ ▼
[ значение ] [ size │ align │ drop │ метод1 │ метод2 │ ... ]
Какой именно вид метаданных едет вторым словом, зависит от семейства DST. Сведём в таблицу, это центральная мысль урока:
| Тип за указателем | Метаданные (второе слово) | Что они говорят |
|---|---|---|
Sized (u32, Point) | их нет, указатель тонкий | размер уже в типе |
[T], str | длина (usize) | сколько элементов или байт |
dyn Trait | адрес vtable | где методы, размер, выравнивание, деструктор |
Проверим арифметику руками. На 64-битной платформе слово это 8 байт:
use std::mem::size_of;
fn main() {
// тонкие: одно слово
assert_eq!(size_of::<&u8>(), 8);
assert_eq!(size_of::<Box<i32>>(), 8);
// толстые: два слова
assert_eq!(size_of::<&[u8]>(), 16); // адрес + длина
assert_eq!(size_of::<&str>(), 16); // адрес + длина
assert_eq!(size_of::<*const [u8]>(), 16); // сырой указатель тоже толстеет
assert_eq!(size_of::<Box<[u8]>>(), 16); // и Box тоже
}
Толщина это свойство указателя на DST, а не конкретно ссылки. &, &mut, Box, Rc, *const, *mut, все они толстеют одинаково, когда указывают на динамически размерный тип. Метаданные прикручены к указателю, а не к самим данным: в куче лежит только содержимое, длина или vtable живут рядом с адресом, в самом указателе.
Тип метаданных не магия, он выражен в системе типов через ассоциированный тип Pointee::Metadata: для Sized-типов это () (метаданных нет, отсюда тонкость), для срезов и строк usize, для dyn Trait это DynMetadata. На стабильном Rust сам API сборки указателя из частей (std::ptr::from_raw_parts, std::ptr::metadata) пока за фичей ptr_metadata и доступен на nightly, но видеть саму модель полезно: толстый указатель это пара (адрес, метаданные), и компилятор знает тип метаданных по типу цели.
Покрути типы и проследи за вторым словом: у Sized его нет, у среза и строки там длина, у трейт-объекта адрес vtable. Заодно видно, как &[i32; 3] теряет длину из типа и получает её обратно метаданными, превращаясь в &[i32].
Из этой раскладки следует жёсткий запрет
Раз указатель максимум двухсловный, метаданных в нём ровно одно слово. Значит, нельзя собрать тип, которому понадобилось бы два разных вида метаданных сразу. Самый частый пример этого ограничения:
use std::fmt::Display;
fn main() {
let text: &str = "привет";
// let d: &dyn Display = text; // не скомпилируется
}
str реализует Display, казалось бы, приведение законно. Но &str уже потратил второе слово на длину, а &dyn Display хочет потратить его на адрес vtable. Понадобился бы трёхсловный указатель (адрес, длина, vtable), а таких в Rust нет. Это та же стена, о которую в уроке 12 разбивался Box<dyn Report + Display>: два словаря методов требуют двух vtable, два слова метаданных, и привет. Одно слово метаданных, один вид DST за указателем, точка.
Unsized coercion: как Sized превращается в DST
Откуда вообще берутся толстые указатели в обычном коде? Ты их почти никогда не конструируешь руками, их создаёт компилятор через unsized coercion, неявное приведение Sized-значения к DST. Самый частый случай ты пишешь не задумываясь, отдавая массив туда, где ждут срез:
fn sum(slice: &[i32]) -> i32 {
slice.iter().sum()
}
fn main() {
let arr: [i32; 3] = [10, 20, 30]; // массив, Sized, длина 3 в типе
let total = sum(&arr); // &[i32; 3] молча приведён к &[i32]
assert_eq!(total, 60);
}
Здесь &arr имеет тип &[i32; 3], тонкий указатель: длина зашита в тип. Функция ждёт &[i32], толстый. Компилятор делает приведение: берёт длину 3 из типа массива и кладёт её в метаданные указателя. Тонкий стал толстым, число 3 переехало из типа в данные указателя. Ровно так же &String приводится к &str, а &Vec<T> к &[T].
Второй случай это рождение трейт-объекта, его ты видел в уроке 12:
use std::fmt::Display;
fn main() {
let n: i32 = 42; // Sized, тонкая ссылка
let shown: &dyn Display = &n; // приведение: прикрутили адрес vtable пары (i32, Display)
println!("{shown}");
}
Под капотом обе ситуации опираются на трейты Unsize и CoerceUnsized из стандартной библиотеки: [T; N] реализует Unsize<[T]>, а любой T: Trait реализует Unsize<dyn Trait>. Руками их обычно не трогают, но знать, что приведение это не хардкод компилятора, а механизм на трейтах, полезно: именно поэтому оно протекает сквозь Box, Rc, Arc и работает для пользовательских умных указателей.
?Sized: как снять скрытую границу и зачем
Вернёмся к скрытой границе T: Sized. Иногда она мешает. Допустим, пишешь функцию, которой нужна только ссылка, а саму штуку по значению она не берёт. Тогда логично разрешить и DST. Делается это границей ?Sized, единственной в языке границей со знаком вопроса:
use std::mem::size_of_val;
// без ?Sized сюда нельзя передать &str или &[T]: T молча обязан быть Sized
fn byte_len<T: ?Sized>(value: &T) -> usize {
size_of_val(value)
}
fn main() {
let point = (1_i32, 2_i32);
assert_eq!(byte_len(&point), 8); // T = (i32, i32), Sized
let text: &str = "привет";
assert_eq!(byte_len(text), 12); // T = str, DST, теперь проходит
let nums: &[u8] = &[1, 2, 3];
assert_eq!(byte_len(nums), 3); // T = [u8], тоже DST
}
Читается ?Sized буквально: «возможно Sized, возможно нет». Граница не убрана, а ослаблена. Раз T теперь может не иметь размера, по значению его трогать нельзя: только за указателем. Поэтому value: &T законно, а value: T в той же сигнатуре уже нет.
Где это встречается в реальном коде каждый день, хоть ты и не замечал: стандартная библиотека на ?Sized держится. Сигнатура impl<T: ?Sized> AsRef<T>, типы Box<T: ?Sized>, Rc<T: ?Sized>, Arc<T: ?Sized>, все они объявлены с ослабленной границей. Именно поэтому Box<str>, Rc<dyn Trait> и Arc<[u8]> вообще существуют: без ?Sized в определении Box параметр был бы обязан быть Sized, и положить туда DST было бы нельзя.
use std::rc::Rc;
fn main() {
// одна аллокация под данные, метаданные в самом указателе
let owned: Box<str> = "владею строкой".into();
let shared: Rc<[i32]> = Rc::from(vec![1, 2, 3, 4]);
assert_eq!(owned.len(), 25);
assert_eq!(shared.len(), 4);
}
Заметь практический выигрыш Box<str> против String. String это три слова (адрес, длина, ёмкость), потому что умеет дорасти. Box<str> это два слова (адрес, длина): отказались от роста ради того, чтобы сэкономить слово на каждой строке. Когда строк миллион и они не меняются, разница в памяти заметная. Тот же резон у Rc<str> и Arc<[T]>: неизменяемые данные за общим указателем, одна аллокация, метаданные бесплатно едут в указателе.
Структура с DST в хвосте
Четвёртый, самый интересный случай. Если последнее поле структуры это DST, вся структура становится DST. Почему именно последнее: поля лежат в памяти по порядку, и всё, что не имеет фиксированного размера, обязано оказаться в самом конце, иначе компилятор не знал бы, по какому смещению лежат поля после него.
Чистый способ это увидеть, через дженерик с ?Sized-параметром в хвосте, по которому приведение проходит насквозь:
struct Packet<P: ?Sized> {
id: u32,
checksum: u32,
payload: P, // хвост: если P это DST, весь Packet это DST
}
fn main() {
// собираем Sized-версию: payload фиксированной длины 4
let sized: Packet<[u8; 4]> = Packet {
id: 7,
checksum: 99,
payload: [0xDE, 0xAD, 0xBE, 0xEF],
};
// unsized coercion протаскивает приведение сквозь структуру:
// Packet<[u8; 4]> -> Packet<[u8]>, длина 4 переезжает в метаданные
let dynamic: &Packet<[u8]> = &sized;
assert_eq!(dynamic.id, 7);
assert_eq!(dynamic.payload.len(), 4); // длина известна из толстого указателя
}
&Packet<[u8]> это толстый указатель: первое слово адрес структуры, второе слово длина хвостового среза. Так в стандартной библиотеке устроены, например, Rc и Arc: счётчик ссылок и сами данные лежат в одной аллокации, а данные могут быть DST (Rc<str>), потому что сидят в хвосте внутренней структуры. Один поход в аллокатор вместо двух.
Собрать такой Box<Packet<[u8]>> с хвостом произвольной длины напрямую из безопасного кода нельзя: безопасное приведение работает только от конкретной Sized-версии, как выше. Для по-настоящему динамической длины нужен unsafe и ручная сборка толстого указателя из частей, либо готовый крейт вроде slice-dst. Это редкая, низкоуровневая задача, и она логично подводит к уроку про unsafe, поэтому здесь мы её только обозначаем.
Связь с object safety
Теперь видно, что dyn-совместимость (старое имя, object safety) это частный случай темы этого урока. dyn Trait это DST, а его метаданные это адрес vtable. Все правила dyn-совместимости из урока 12 сводятся к одному вопросу: можно ли построить vtable, не зная конкретный размер.
Один пример прямо смыкается с Sized. Метод fn clone(&self) -> Self несовместим с dyn, потому что Self за трейт-объектом это DST неизвестного размера, и компилятор не знает, сколько байт выделить под возврат по значению. А починка точечная, через where Self: Sized: пометка буквально говорит «этот метод существует только там, где размер известен», то есть исключает его из vtable. Тот же Sized, что мы сняли через ?Sized в дженериках, здесь, наоборот, добавляют обратно к одному методу, чтобы спасти dyn-совместимость трейта. Подробный разбор всех правил в уроке про трейт-объекты, а ты теперь видишь под ними общий механизм: размер, метаданные, ширина указателя.
Правило урока
Свернём в одну мысль.
Sizedэто скрытая граница на каждом параметре типа: по умолчанию Rust требует, чтобы размер был известен на этапе компиляции. Типы, у которых его нет (срез[T], строкаstr, трейт-объектdyn Traitи структура с DST в хвосте), называются DST и существуют только за указателем. Указатель на DST становится толстым: второе слово несёт метаданные, длину для срезов и строк или адрес vtable для трейт-объектов. ПриведениеSized-значения к DST (&[T; N]в&[T],&Tв&dyn Trait) делает компилятор автоматически через unsized coercion. Граница?Sizedснимает требование размера, когда значение нужно только за ссылкой, и именно она позволяетBox<str>,Rc<[T]>и всей теме существовать.
Типичная ошибка, которую снимает урок: воспринимать ошибку «the size cannot be known at compilation time» как тупик и пытаться обернуть значение во что попало наугад. На деле компилятор говорит ровно одно: «ты держишь DST по значению, а так нельзя». Лечится это не случайной обёрткой, а пониманием, какой именно тип оказался динамически размерным, и постановкой его за указатель (&, Box, Rc) или ослаблением границы через ?Sized, если речь о дженерике.
ДЗ
Дальше
Ты собрал в одну картину три разрозненных факта из прошлых уроков: срезы, строки и трейт-объекты это всё DST, типы без статического размера, а толстый указатель это способ возить недостающее знание (длину или vtable) рядом с адресом. Заодно понял скрытую границу Sized, увидел, как ?Sized её снимает, и почему на этом держатся Box<str> и Rc<[T]>. Маркерный трейт Sized мы здесь встретили как первый среди равных. В уроке про маркерные трейты он встанет в один ряд с Send, Sync, Copy и Unpin: трейтами без единого метода, которые компилятор не вызывает, а выводит и проверяет, и каждый из которых, как и Sized, даёт жёсткую гарантию о типе ещё до запуска программы.