Дженерики и ограничения
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Дженерики и ограничения
Один код для многих типов без потери скорости. Параметры типов, ограничения трейтами,
where-блоки и мономорфизация: компилятор разворачивает дженерик в отдельную функцию под каждый тип, и абстракция исчезает до нуля.
Идея
Ты уже месяц пользуешься дженериками, просто мы не называли их по имени. Option<T> из урока про enum, Vec<T> и HashMap<K, V> из урока про коллекции, Result<T, E> из прошлого урока: всюду в угловых скобках живёт параметр типа, дырка, в которую подставляется конкретный тип. Стандартная библиотека не писала отдельный VecOfI32 и VecOfString, она написала Vec<T> один раз. Сегодня учимся делать так же.
Мотивация всегда одна и та же: дублирование. Вот две функции, различающиеся только типом:
fn largest_i32(list: &[i32]) -> Option<&i32> {
let mut largest = list.first()?;
for item in list {
if item > largest {
largest = item;
}
}
Some(largest)
}
fn largest_char(list: &[char]) -> Option<&char> {
// тот же код, скопированный подчистую
let mut largest = list.first()?;
for item in list {
if item > largest {
largest = item;
}
}
Some(largest)
}
Алгоритм один, типы разные, копий будет столько, сколько типов понадобится. В TypeScript ты бы вынес тип в параметр, и в Rust решение выглядит почти так же, с одной принципиальной добавкой:
fn largest<T: PartialOrd>(list: &[T]) -> Option<&T> {
let mut largest = list.first()?;
for item in list {
if item > largest {
largest = item;
}
}
Some(largest)
}
<T> объявляет параметр типа, а T: PartialOrd это ограничение: подставить можно не что угодно, а только тип, умеющий сравниваться оператором меньше-больше. И в этой добавке вся философия. Дженерик в Rust это не «любой тип», а «любой тип, который умеет вот это». Внутри функции с T можно делать ровно то, что разрешили ограничения, и ничего больше: убери PartialOrd, и item > largest перестанет компилироваться.
Сравни с динамическими языками, где обобщённая функция компилируется всегда, а падает в рантайме на типе, который «не умеет». Rust проверяет контракт в точке вызова: передал срез типа без сравнения, получил ошибку компиляции с именем недостающей способности, а не загадочный взрыв в глубине стека. Это параметрический полиморфизм из урока про виды полиморфизма, и скоро ты увидишь, что Rust добавляет к нему ad-hoc через трейты.
Дженерик-функции, структуры, методы
Параметр типа объявляется в угловых скобках после имени и дальше используется как обычный тип. Так выглядит дженерик-структура с двумя независимыми параметрами:
struct Pair<A, B> {
first: A,
second: B,
}
let coords: Pair<f64, f64> = Pair { first: 55.75, second: 37.62 };
let entry = Pair { first: String::from("retries"), second: 3 }; // Pair<String, i32>
Во втором случае аннотации нет: компилятор вывел параметры из значений, как выводит типы переменных. Методы на дженерик-структуре требуют объявить параметры ещё раз, после impl:
impl<A, B> Pair<A, B> {
fn swap(self) -> Pair<B, A> {
Pair { first: self.second, second: self.first }
}
}
impl<A, B> читается как «для любых A и B». А можно написать impl только для конкретной подстановки, и тогда метод появится лишь у неё:
impl Pair<f64, f64> {
fn distance_to(&self, other: &Pair<f64, f64>) -> f64 {
((self.first - other.first).powi(2) + (self.second - other.second).powi(2)).sqrt()
}
}
Pair<String, i32> метода distance_to не имеет, и это проверяется на компиляции. Enum параметризуются точно так же, Option<T> и Result<T, E> именно так и объявлены в стандартной библиотеке.
Обычно вывод типов угадывает подстановку сам, но иногда подсказать неоткуда: например, parse умеет парсить во что угодно, и по "42".parse() не понять, во что именно. Для таких случаев есть синтаксис с неофициальным именем turbofish, рыбка ::<>:
let port = "8080".parse::<u16>(); // turbofish: парсим именно в u16
let bytes = Vec::<u8>::new(); // пустой вектор не выдаёт свой тип
Чаще тот же эффект достигается аннотацией слева, let port: Result<u16, _> = "8080".parse();, выбирай, что читается лучше в конкретном месте.
Ограничения и where
Ограничений может быть несколько, они складываются через +:
use std::fmt::Display;
fn print_twice<T: Display + Clone>(value: T) {
let copy = value.clone();
println!("{value} и снова {copy}");
}
Когда параметров и ограничений становится много, сигнатура в одну строку превращается в кашу. Для этого есть блок where, те же ограничения, вынесенные за сигнатуру:
fn merge_report<K, V>(left: &HashMap<K, V>, right: &HashMap<K, V>) -> String
where
K: Display + Eq + std::hash::Hash,
V: Display + Clone,
{
// тело не изменилось бы ни на символ от переноса ограничений
todo!()
}
Правило вкуса простое: одно-два коротких ограничения живут в скобках, всё, что длиннее, уезжает в where. Смысл идентичен.
Самый интересный приём: ограничение можно повесить не на структуру, а на отдельный impl, и тогда метод существует только для подходящих подстановок:
struct Wrapper<T> {
items: Vec<T>,
}
impl<T> Wrapper<T> {
fn new() -> Self {
Wrapper { items: Vec::new() } // доступно для любого T
}
}
impl<T: Display> Wrapper<T> {
fn print_all(&self) {
for item in &self.items {
println!("{item}"); // доступно, только если T печатается
}
}
}
Wrapper<u32> имеет оба метода, Wrapper<КакойТоТипБезDisplay> только new, и никакой проверки в рантайме: у неподходящего типа метода просто нет. Стандартная библиотека построена на этом приёме сверху донизу. Помнишь из урока про коллекции, что ключ HashMap обязан уметь Hash + Eq? Теперь ты знаешь, как это записано: методы вроде insert объявлены в impl-блоке с этими ограничениями, и HashMap с нехешируемым ключом это тип, у которого нет insert, а не ошибка при вставке.
Честный вопрос: а откуда берутся сами способности, Display, Clone, PartialOrd? Что это за сущности и как тип их получает? Это и есть трейты, им посвящён весь следующий урок. Сегодня мы пользуемся ими как словарём ограничений, завтра научимся писать свои.
Мономорфизация и её цена
Остался вопрос, который отличает системщика от пользователя: во что это компилируется. У языков есть два честных способа исполнить дженерик. Один словарь: положить рядом со значением таблицу его операций и в рантайме звать сравнение через указатель, так живут трейт-объекты, ждущие нас в уроке про диспетчеризацию. Второй способ: не исполнять дженерик вообще. Rust по умолчанию выбирает второй, и называется он мономорфизацией.
Компилятор смотрит, с какими типами дженерик реально вызывается, и разворачивает по копии на каждый:
let n = largest(&[3, 7, 1]); // компилятор сгенерирует largest_для_i32
let c = largest(&['a', 'я', 'k']); // и отдельную largest_для_char
В бинарнике не остаётся ни T, ни проверок, ни таблиц: лежат две обычные функции, неотличимые от написанных руками largest_i32 и largest_char, с которых мы начали урок. Каждая со своими типами, своим инлайнингом и своими оптимизациями. Это и означает лозунг нулевых абстракций из первого урока: абстракция существует для программиста и исчезает для машины. В уроке про машинный код мы откроем godbolt и увидим обе копии глазами.
Для контраста полезно знать соседей. TypeScript стирает дженерики целиком, в рантайме их нет вместе с остальными типами. Java стирает до Object и вставляет приведения, поэтому List<Integer> хранит боксы и не может быть списком голых int. C++ с шаблонами мономорфизирует, как Rust. Выбор Rust это выбор скорости: дженерик-код не платит ничего по сравнению с рукописным.
Но бесплатной мономорфизация не бывает, платит не рантайм, а сборка. Десять подстановок это десять копий кода: бинарник толще, компиляция дольше, и в больших проектах дженерик-тяжёлые крейты заметно тормозят пересборку. Излечимо это трейт-объектами, у которых обратный размен, одна копия и плата на каждом вызове; подробное сравнение через два урока. Пока запомни сам факт размена: статически быстрее, динамически компактнее, и у Rust есть оба режима.
Практика
Ограничения осваиваются пальцами, а не глазами. Открой редактор и дай компилятору проверить твой контракт.
ДЗ
Дальше
Дженерики дали форму: один код, много типов, ограничения как контракт, мономорфизация как способ ничего за это не платить. Но содержание контрактов мы пока берём готовым из стандартной библиотеки. В уроке 10 разберём сами трейты: как объявить поведение, реализовать его для своих и чужих типов, и почему это не наследование и не интерфейс из Java, хотя похоже на оба.