Раздел 23 · Rust

DST и толстые указатели

senior~45 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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, даёт жёсткую гарантию о типе ещё до запуска программы.