сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Трейты и дженерики

Дженерики в Rust

Дженерики и ограничения

Здесь смотрят, понимаешь ли ты цену абстракции. Спрашивают, что делает компилятор с дженерик-функцией, зачем нужны trait bounds, откуда берётся странная ошибка от derive на дженерик-структуре.

Стержень: дженерики мономорфизируются - под каждый конкретный тип создаётся своя копия кода, поэтому в рантайме они бесплатны, а платить приходится временем сборки и размером бинарника.

// Формулировки: «как реализованы дженерики?», «зачем bounds?», «почему derive(Clone) требует T: Clone?»

Мономорфизация

Компилятор не хранит один универсальный код с рантайм-информацией о типах. Для каждого использованного набора типов он создаёт отдельную специализированную копию с прямыми вызовами - как если бы вы написали её руками. Отсюда нулевая цена в рантайме и хороший инлайнинг.

Плата - время компиляции и размер бинарника: десять типов дадут десять копий тела. На больших дженерик-API это заметно, и часть кода сознательно прячут за dyn, чтобы копия была одна.

// Приём из практики: внешняя дженерик-функция тонкая, а тело выносится во внутреннюю не-дженерик функцию, работающую с &dyn или конкретным типом. Так копий кода меньше при том же удобном API.

мономорфизация
генерация копии кода под каждый конкретный тип
code bloat
рост бинарника от множества копий дженерик-кода

Ограничения задают доступное

Тело дженерик-функции компилируется один раз для всех T, поэтому внутри доступно ровно то, что обещано в ограничениях: без T: Clone вызвать clone() не выйдет, даже если конкретный тип его умеет. Проверка идёт с двух сторон - тело не выходит за рамки, вызывающий обязан подставить подходящий тип.

Длинные списки ограничений выносят в where - читается лучше, и туда же вешают ограничения на ассоциированные типы: where I: Iterator, I::Item: Display.

// Полезная деталь про сигнатуры: impl AsRef<str> в аргументе принимает и &str, и String, и Cow - вызывающему не приходится ничего преобразовывать вручную.

fn print_all<I>(it: I)
where
    I: Iterator,
    I::Item: std::fmt::Display,
{
    for x in it { println!("{x}"); }
}
trait bound
требование к параметру типа: что он умеет
where
вынесенный блок ограничений для читаемости

Грабля derive на дженериках

Классическая загадка: структура #[derive(Clone)] struct W<T> { v: Rc<T> }, а w.clone() не компилируется, если T не Clone. Хотя Rc<T> клонируется при любом T - он просто увеличивает счётчик.

Дело в том, что derive работает механически: он вешает ограничение на каждый параметр типа и порождает impl<T: Clone> Clone for W<T>. Компилятор не анализирует, нужен ли Clone на самом деле.

// Лечится ручной реализацией без лишнего ограничения: impl<T> Clone for W<T> с клонированием внутреннего Rc. Ошибка при этом выглядит как E0599 «метод существует, но его границы не выполнены» - по ней и узнают эту ситуацию.

derive
макрос, генерирующий реализацию трейта по структуре
E0599
метод есть, но ограничения трейта не выполнены

Как отвечать: «Как реализованы дженерики и сколько они стоят?»

Через мономорфизацию: компилятор создаёт отдельную копию кода под каждый использованный набор типов, с прямыми вызовами вместо косвенных. В рантайме это стоит ноль - получается тот же код, который написали бы руками для конкретного типа, и он хорошо инлайнится. Платим на этапе сборки: время компиляции и размер бинарника растут с числом инстанцирований. Если копий становится слишком много, часть кода сознательно уводят под dyn Trait - там одна реализация и таблица методов, зато динамический вызов.

Ты называешь механизм и обе стороны сделки. Упоминание перехода на dyn при разрастании кода показывает, что ты думал об этом на реальном проекте.

На чём валятся

  • Считают, что дженерики работают через рантайм-информацию о типах, как в языках с рефлексией.
  • Не знают про цену мономорфизации и удивляются времени сборки на больших API.
  • Ловят ошибку от derive на дженерик-структуре и не понимают, откуда взялось T: Clone.
  • Пишут ограничения в угловых скобках, пока сигнатура не становится нечитаемой, вместо where.
  • Разводят дженерики там, где хватило бы одного конкретного типа.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #rs_generics1 / 5
    Зачем нужны trait bounds вроде <T: Clone + Debug>?
    A)Они ускоряют мономорфизацию, сужая набор кандидатов
    B)Они нужны только для документации: компилятор их не проверяет
    C)Они разрешают приводить T к конкретному типу через as
    D)Они говорят, что тип умеет, и разрешают звать эти методы в теле
    показать ответ и разбор
    +D)Они говорят, что тип умеет, и разрешают звать эти методы в теле

    // разбор: Дженерик-функция компилируется один раз для всех T, поэтому внутри доступно ровно то, что обещано в ограничениях: без T: Clone вызвать clone() не выйдет. Проверка идёт с двух сторон — тело не может выйти за рамки обещанного, а вызывающий обязан подставить подходящий тип. Длинные списки выносят в where.

  2. #rs_generics2 / 5
    Зачем в вызове нужен турбофиш: "42".parse::<i32>()?
    A)Чтобы указать тип, который компилятор не может вывести из контекста
    B)Чтобы обойти мономорфизацию и получить динамический вызов
    C)Чтобы вызвать реализацию конкретного трейта вместо родного метода
    D)Чтобы отключить проверку ошибок разбора и получить значение напрямую
    показать ответ и разбор
    +A)Чтобы указать тип, который компилятор не может вывести из контекста

    // разбор: parse дженерик по типу результата, и вывести его получается только из контекста: let n: i32 = s.parse()? работает, а внутри println! — уже нет. Турбофиш задаёт параметр явно. Формально это просто синтаксис ::<> — двоеточия нужны, чтобы < не спутали с оператором сравнения.

  3. #rs_generics3 / 5
    Структура #[derive(Clone)] struct W<T> { v: Rc<T> }. Почему w.clone() не компилируется для T без Clone?
    A)Клонирование Rc требует, чтобы значение внутри тоже копировалось
    B)Rc<T> реализует Clone только для T: Clone
    C)derive добавляет ограничение T: Clone, хотя Rc клонируется и без него
    D)derive не работает с дженерик-структурами — нужен ручной impl
    показать ответ и разбор
    +C)derive добавляет ограничение T: Clone, хотя Rc клонируется и без него

    // разбор: Известная грабля derive: макрос механически вешает ограничение на каждый параметр типа, поэтому получается impl<T: Clone> Clone for W<T>, хотя Rc<T>::clone лишь увеличивает счётчик и в Clone у T не нуждается. Лечится ручной реализацией Clone без лишнего ограничения.

  4. #rs_generics4 / 5
    Функция принимает s: impl AsRef<str> вместо &str. Что это даёт?
    A)Аргумент становится необязательным: можно вызвать функцию без него
    B)Строка копируется внутрь функции, и вызывающий может её изменить
    C)Функция начинает принимать нестроковые типы тоже
    D)Вызов принимает и &str, и String, и Cow без ручных преобразований
    показать ответ и разбор
    +D)Вызов принимает и &str, и String, и Cow без ручных преобразований

    // разбор: AsRef<str> описывает «из этого можно дёшево получить &str». Такая сигнатура удобна вызывающему: не нужно писать &s или .as_str(). Плата — мономорфизация под каждый переданный тип и чуть более шумная сигнатура, поэтому в приватном коде обычно хватает обычного &str.

  5. #rs_generics5 / 5
    Что дают const generics — параметры вроде struct Buf<const N: usize>?
    A)Параметром типа становится число: размер известен на этапе компиляции
    B)Размер буфера подбирается в рантайме под нагрузку
    C)Ограничение на число полей у структуры
    D)Замену массивов срезами без потери длины
    показать ответ и разбор
    +A)Параметром типа становится число: размер известен на этапе компиляции

    // разбор: Параметризовать тип можно не только типом, но и значением: Buf<4> и Buf<16> — разные типы разного размера, и функции над массивами [T; N] перестают требовать отдельной реализации на каждую длину. В базовом виде стабилизировано с 1.51. Плата обычная для мономорфизации — своя копия кода на каждое использованное N, поэтому бесконтрольно плодить длины не стоит.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.