Перейти к содержимому

Обобщения (generics) в Rust

Ты уже пользовался обобщениями, даже если не знал, как они называются: и Vec<i32>, и Option<T> устроены именно так. На этой странице ты научишься писать свои обобщённые функции и структуры — обобщения (generics).

Допустим, тебе нужна функция, которая находит наибольшее число в списке, причём и для i32, и для f64. Без обобщений её пришлось бы написать дважды:

fn largest_i32(numbers: &[i32]) -> i32 {
let mut largest = numbers[0];
for &n in numbers {
if n > largest {
largest = n;
}
}
largest
}
fn largest_f64(numbers: &[f64]) -> f64 {
let mut largest = numbers[0];
for &n in numbers {
if n > largest {
largest = n;
}
}
largest
}

Логика одна и та же, просто скопирована для каждого типа. Обобщения позволяют написать её один раз.

fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> T {
let mut largest = numbers[0];
for &n in numbers {
if n > largest {
largest = n;
}
}
largest
}
fn main() {
let integers = [10, 25, 3, 47, 8];
let floats = [1.5, 2.8, 0.3];
println!("{}", largest(&integers));
println!("{}", largest(&floats));
}

Вывод:

47
2.8
▶ Попробуй сам

<T: PartialOrd + Copy> читается так: «для любого типа T, если его можно сравнивать (PartialOrd) и копировать (Copy)». T — это заполнитель: Rust создаёт версию функции для того конкретного типа, с которым ты её вызываешь (i32, f64 и так далее).

Структуры тоже бывают обобщёнными:

struct Pair<T> {
first: T,
second: T,
}
fn main() {
let numbers = Pair { first: 5, second: 10 };
let words = Pair { first: "привет", second: "мир" };
println!("{} {}", numbers.first, numbers.second);
println!("{} {}", words.first, words.second);
}

Вывод:

5 10
привет мир
▶ Попробуй сам

Одно определение Pair<T> подходит для чисел, текста и любого другого типа: numbers — это Pair<i32>, а words — Pair<&str>, но саму структуру ты написал только один раз.

Стоит ли это чего-нибудь во время работы программы?

Заголовок раздела «Стоит ли это чего-нибудь во время работы программы?»

Нет. Rust создаёт отдельную, специализированную версию функции или структуры для каждого конкретного типа, который ты используешь (это называется мономорфизацией, monomorphization). Когда программа запускается, обобщённого кода в ней уже нет — только обычные функции с конкретными типами, ровно такие же быстрые, как если бы ты написал каждую вручную.

Методы к обобщённой структуре добавляются блоком impl, точно как на странице Структуры. Новое здесь только одно: у impl тоже должен быть свой <T>:

struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
fn swap(self) -> Pair<T> {
Pair { first: self.second, second: self.first }
}
}
impl Pair<f64> {
fn average(&self) -> f64 {
(self.first + self.second) / 2.0
}
}
fn main() {
let words = Pair::new("левый", "правый").swap();
println!("{} {}", words.first, words.second);
let scores = Pair::new(7.0, 9.0);
println!("{}", scores.average());
}

Вывод:

правый левый
8
▶ Попробуй сам

Здесь два вида блоков impl:

  • impl<T> Pair<T> означает: «эти методы есть у Pair любого типа». Первый <T> читай как «для любого типа T». Без него Rust решит, что T — имя настоящего типа, и пожалуется, что не может его найти.
  • impl Pair<f64> без <T>. Его методы есть только у Pair<f64>. Поэтому average может использовать + и / 2.0. Попробуй вызвать words.average() — компилятор скажет, что у Pair<&str> такого метода нет.

У структуры может быть и несколько параметров типа, например struct Pair<T, U> { first: T, second: U }, если два поля могут быть разных типов. А когда ограничения трейтов становятся длинными, их можно вынести в блок where, чтобы сигнатура оставалась читаемой. Об этом — на странице Продвинутые трейты.

Напиши fn smallest<T: PartialOrd + Copy>(items: &[T]) -> T, которая возвращает наименьший элемент. Проверь её на [8, 3, 5] и на символах ['r', 'u', 's', 't']. Подсказка: это функция largest с этой страницы, в которой перевёрнуто одно сравнение.

Показать решение
fn smallest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut smallest = items[0];
for &item in items {
if item < smallest {
smallest = item;
}
}
smallest
}
fn main() {
println!("{}", smallest(&[8, 3, 5]));
println!("{}", smallest(&['r', 'u', 's', 't']));
}

Вывод:

3
r
▶ Попробуй сам

Добавь к Pair<T> метод larger(&self) -> &T, который возвращает ссылку на то поле, что больше. Проверь его на Pair чисел и на Pair слов. Подсказка: для сравнения нужен PartialOrd, поэтому поставь ограничение прямо на impl: impl<T: PartialOrd> Pair<T>.

Слова здесь английские: строки сравниваются по алфавиту, и так результат совпадёт с английской версией урока.

Показать решение
struct Pair<T> {
first: T,
second: T,
}
impl<T: PartialOrd> Pair<T> {
fn larger(&self) -> &T {
if self.first > self.second {
&self.first
} else {
&self.second
}
}
}
fn main() {
let numbers = Pair { first: 3, second: 9 };
let words = Pair { first: "pear", second: "apple" };
println!("{}", numbers.larger());
println!("{}", words.larger());
}

Вывод:

9
pear
▶ Попробуй сам
  • Обобщения позволяют написать одну функцию или структуру для нескольких типов, а не копировать код.
  • <T> — тип-заполнитель; ограничения трейтов (T: PartialOrd) задают, каким может быть T.
  • impl<T> Pair<T> добавляет методы для любого T; impl Pair<f64> — только для одного конкретного типа.
  • Обобщённый код компилируется в отдельные версии для каждого типа — во время работы программы это ничего не стоит.

Проверь себя

1. Какую проблему решают обобщения?

2. Что делает PartialOrd + Copy в fn largest<T: PartialOrd + Copy>(...)?

3. Стоят ли обобщения чего-нибудь во время работы программы по сравнению с кодом, написанным вручную?

4. Что позволяет одно определение struct Pair<T>?

Результат: 0 / 4