Дженерики в 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, остальные разбираются в тренажёре.
- Зачем нужны trait bounds вроде <T: Clone + Debug>?A)Они ускоряют мономорфизацию, сужая набор кандидатовB)Они нужны только для документации: компилятор их не проверяетC)Они разрешают приводить T к конкретному типу через asD)Они говорят, что тип умеет, и разрешают звать эти методы в теле
показать ответ и разбор
+D)Они говорят, что тип умеет, и разрешают звать эти методы в теле// разбор: Дженерик-функция компилируется один раз для всех T, поэтому внутри доступно ровно то, что обещано в ограничениях: без T: Clone вызвать clone() не выйдет. Проверка идёт с двух сторон — тело не может выйти за рамки обещанного, а вызывающий обязан подставить подходящий тип. Длинные списки выносят в where.
- Зачем в вызове нужен турбофиш: "42".parse::<i32>()?A)Чтобы указать тип, который компилятор не может вывести из контекстаB)Чтобы обойти мономорфизацию и получить динамический вызовC)Чтобы вызвать реализацию конкретного трейта вместо родного методаD)Чтобы отключить проверку ошибок разбора и получить значение напрямую
показать ответ и разбор
+A)Чтобы указать тип, который компилятор не может вывести из контекста// разбор: parse дженерик по типу результата, и вывести его получается только из контекста: let n: i32 = s.parse()? работает, а внутри println! — уже нет. Турбофиш задаёт параметр явно. Формально это просто синтаксис ::<> — двоеточия нужны, чтобы < не спутали с оператором сравнения.
- Структура #[derive(Clone)] struct W<T> { v: Rc<T> }. Почему w.clone() не компилируется для T без Clone?A)Клонирование Rc требует, чтобы значение внутри тоже копировалосьB)Rc<T> реализует Clone только для T: CloneC)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 без лишнего ограничения.
- Функция принимает s: impl AsRef<str> вместо &str. Что это даёт?A)Аргумент становится необязательным: можно вызвать функцию без негоB)Строка копируется внутрь функции, и вызывающий может её изменитьC)Функция начинает принимать нестроковые типы тожеD)Вызов принимает и &str, и String, и Cow без ручных преобразований
показать ответ и разбор
+D)Вызов принимает и &str, и String, и Cow без ручных преобразований// разбор: AsRef<str> описывает «из этого можно дёшево получить &str». Такая сигнатура удобна вызывающему: не нужно писать &s или .as_str(). Плата — мономорфизация под каждый переданный тип и чуть более шумная сигнатура, поэтому в приватном коде обычно хватает обычного &str.
- Что дают const generics — параметры вроде struct Buf<const N: usize>?A)Параметром типа становится число: размер известен на этапе компиляцииB)Размер буфера подбирается в рантайме под нагрузкуC)Ограничение на число полей у структурыD)Замену массивов срезами без потери длины
показать ответ и разбор
+A)Параметром типа становится число: размер известен на этапе компиляции// разбор: Параметризовать тип можно не только типом, но и значением: Buf<4> и Buf<16> — разные типы разного размера, и функции над массивами [T; N] перестают требовать отдельной реализации на каждую длину. В базовом виде стабилизировано с 1.51. Плата обычная для мономорфизации — своя копия кода на каждое использованное N, поэтому бесконтрольно плодить длины не стоит.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.