Раздел 23 · Rust

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

middle~30 мин

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

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

Один код для многих типов без потери скорости. Параметры типов, ограничения трейтами, 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, хотя похоже на оба.