Обобщённые типы данных
С помощью обобщений мы создаём определения — например, сигнатуры функций или структуры, — которые потом можно использовать с множеством разных конкретных типов данных. Сначала посмотрим, как определять с обобщениями функции, структуры, перечисления и методы. А потом обсудим, как обобщения влияют на производительность кода.
В определениях функций
Определяя функцию с обобщениями, мы ставим их в сигнатуру — туда, где обычно указываются типы параметров и возвращаемого значения. Так код становится гибче и даёт вызывающему больше возможностей, а дублирования при этом нет.
Продолжим с функцией largest. В листинге 10-4 две функции, каждая из которых
находит наибольшее значение в срезе. Затем мы объединим их в одну обобщённую
функцию.
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
assert_eq!(*result, 'y');
}
Функция largest_i32 — та самая, что мы выделили в листинге 10-3: она
находит наибольшее i32 в срезе. Функция largest_char находит наибольший
char в срезе. Тела у функций одинаковые, так что избавимся от
дублирования, введя в единственную функцию обобщённый параметр типа.
Чтобы параметризовать типы в новой функции, нужно дать имя параметру типа —
так же, как мы даём имена параметрам-значениям. Именем параметра типа может
быть любой идентификатор. Но мы возьмём T: по соглашению имена параметров
типа в Rust короткие, часто из одной буквы, а типы в Rust называют в стиле
UpperCamelCase. T — сокращение от type (тип), и большинство программистов
на Rust выбирают именно его.
Когда мы используем параметр в теле функции, его имя нужно объявить в
сигнатуре, чтобы компилятор знал, что оно означает. Точно так же, прежде чем
использовать в сигнатуре имя параметра типа, его нужно объявить. Чтобы
определить обобщённую функцию largest, объявления имён типов ставят в
угловых скобках <> между именем функции и списком параметров:
fn largest<T>(list: &[T]) -> &T {
Читается это так: «функция largest обобщена по некоторому типу T». У неё
один параметр list — срез значений типа T. Функция largest вернёт
ссылку на значение того же типа T.
В листинге 10-5 — объединённое определение функции largest с обобщённым
типом данных в сигнатуре. Там же показано, как вызывать функцию и со срезом
значений i32, и со срезом значений char. Учтите, что этот код пока не
компилируется.
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
largest с обобщёнными параметрами типа; пока не компилируетсяЕсли скомпилировать этот код сейчас, получим ошибку:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
В подсказке упоминается std::cmp::PartialOrd — это трейт, о трейтах мы
поговорим в следующем разделе. Пока достаточно понимать, что ошибка говорит:
тело largest будет работать не со всеми типами, которыми может оказаться
T. Мы хотим сравнивать в теле функции значения типа T, а значит, годятся
только типы, значения которых можно упорядочить. Для сравнения в стандартной
библиотеке есть трейт std::cmp::PartialOrd, который можно реализовать для
типов (подробнее о нём — в приложении C). Чтобы исправить листинг 10-5, можно
последовать подсказке и ограничить допустимые для T типы теми, что
реализуют PartialOrd. Тогда код скомпилируется: стандартная библиотека
реализует PartialOrd и для i32, и для char.
В определениях структур
Структуры тоже можно определять так, чтобы одно или несколько полей имели
обобщённый тип, — с помощью того же синтаксиса <>. В листинге 10-6
определена структура Point<T>, которая хранит координаты x и y любого
типа.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
Point<T> со значениями x и y типа TСинтаксис обобщений в определениях структур похож на синтаксис в определениях функций. Сначала объявляем имя параметра типа в угловых скобках сразу после имени структуры. Затем используем обобщённый тип в определении структуры там, где иначе указали бы конкретные типы данных.
Обратите внимание: раз в определении Point<T> только один обобщённый тип,
это определение говорит, что структура Point<T> обобщена по некоторому типу
T, и поля x и y — оба этого типа, каким бы он ни был. Если создать
экземпляр Point<T> со значениями разных типов, как в листинге 10-7, код не
скомпилируется.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x и y должны быть одного типа, потому что у обоих один и тот же обобщённый тип TПрисваивая x целое число 5, мы сообщаем компилятору, что для этого
экземпляра Point<T> обобщённый тип T — целое число. А когда для y,
которое по определению того же типа, что и x, мы указываем 4.0, получаем
ошибку несовпадения типов:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Чтобы определить структуру Point, где и x, и y обобщённые, но могут быть
разных типов, можно использовать несколько обобщённых параметров типа.
Например, в листинге 10-8 мы делаем Point обобщённой по типам T и U:
x имеет тип T, а y — тип U.
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
Point<T, U> обобщена по двум типам, поэтому x и y могут быть значениями разных типовТеперь допустимы все показанные экземпляры Point! Обобщённых параметров
типа в определении может быть сколько угодно, но если их больше нескольких,
код становится трудно читать. Если вам нужно много обобщённых типов, возможно,
код стоит разбить на части поменьше.
В определениях перечислений
Как и структуры, перечисления можно определять так, чтобы их варианты хранили
обобщённые типы данных. Ещё раз взглянем на перечисление Option<T> из
стандартной библиотеки, которым мы пользовались в главе 6:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Теперь это определение должно быть вам понятнее. Перечисление Option<T>
обобщено по типу T, и у него два варианта: Some, который хранит одно
значение типа T, и None, который не хранит ничего. С помощью Option<T>
можно выразить абстрактную идею необязательного значения, а раз Option<T>
обобщённое, этой абстракцией можно пользоваться, какого бы типа ни было
необязательное значение.
Перечисления тоже могут использовать несколько обобщённых типов. Пример —
определение перечисления Result, которым мы пользовались в главе 9:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Перечисление Result обобщено по двум типам, T и E, и у него два
варианта: Ok, который хранит значение типа T, и Err, который хранит
значение типа E. Благодаря этому Result удобно использовать везде, где
операция может завершиться успехом (вернуть значение некоторого типа T) или
неудачей (вернуть ошибку некоторого типа E). Именно так мы открывали файл в
листинге 9-3: при успешном открытии вместо T был тип std::fs::File, а при
проблемах с открытием вместо E был тип std::io::Error.
Если вы замечаете в коде несколько определений структур или перечислений, которые отличаются только типами хранимых значений, дублирования можно избежать с помощью обобщённых типов.
В определениях методов
Для структур и перечислений можно реализовывать методы (как мы делали в главе
5) и использовать в их определениях обобщённые типы. В листинге 10-9 —
структура Point<T> из листинга 10-6, для которой реализован метод x.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
Point<T> метод x, который возвращает ссылку на поле x типа TЗдесь мы определили для Point<T> метод x, который возвращает ссылку на
данные в поле x.
Обратите внимание: T нужно объявить сразу после impl, чтобы с его помощью
указать, что мы реализуем методы для типа Point<T>. Объявив T обобщённым
типом после impl, мы даём Rust понять, что тип в угловых скобках у Point —
обобщённый, а не конкретный. Этому обобщённому параметру можно было дать и
другое имя, не такое, как в определении структуры, но по соглашению имя
используют то же. Метод, написанный внутри impl с объявленным обобщённым
типом, будет определён для любого экземпляра типа — какой бы конкретный тип
ни подставился вместо обобщённого.
Определяя методы для типа, можно также накладывать ограничения на
обобщённые типы. Например, можно реализовать методы только для экземпляров
Point<f32>, а не для Point<T> с любым обобщённым типом. В листинге 10-10
используется конкретный тип f32, поэтому после impl никаких типов мы не
объявляем.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
impl, который применяется только к структуре с определённым конкретным типом вместо обобщённого параметра TЭтот код означает, что у типа Point<f32> будет метод
distance_from_origin, а у остальных экземпляров Point<T>, где T — не
f32, такого метода не будет. Метод вычисляет, насколько наша точка удалена
от точки с координатами (0.0, 0.0), и использует математические операции,
доступные только для типов с плавающей точкой.
Обобщённые параметры типа в определении структуры не всегда совпадают с теми,
что используются в сигнатурах её методов. В листинге 10-11 для структуры
Point используются обобщённые типы X1 и Y1, а для сигнатуры метода
mixup — X2 и Y2, чтобы пример был нагляднее. Метод создаёт новый
экземпляр Point, взяв значение x из Point в self (типа X1) и значение
y из переданного Point (типа Y2).
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
В main мы определили Point, у которой x — i32 (со значением 5), а y
— f64 (со значением 10.4). Переменная p2 — структура Point, у которой x
— строковый срез (со значением "Hello"), а y — char (со значением c).
Вызов mixup у p1 с аргументом p2 даёт p3, у которой x будет i32,
потому что x взят из p1, а y — char, потому что y взят из p2.
Макрос println! выведет p3.x = 5, p3.y = c.
Этот пример показывает ситуацию, когда часть обобщённых параметров
объявляется в impl, а часть — в определении метода. Здесь X1 и Y1
объявлены после impl, потому что относятся к определению структуры. А X2
и Y2 объявлены после fn mixup, потому что нужны только этому методу.
Производительность кода с обобщениями
Возможно, вас интересует, не стоят ли обобщённые параметры типа чего-то во время выполнения. Хорошая новость: с обобщёнными типами программа работает ничуть не медленнее, чем с конкретными.
Rust добивается этого с помощью мономорфизации кода с обобщениями при компиляции. Мономорфизация (monomorphization) — это превращение обобщённого кода в конкретный путём подстановки тех конкретных типов, которые используются при компиляции. При этом компилятор делает обратное тому, что мы делали, создавая обобщённую функцию в листинге 10-5: он смотрит на все места, где вызывается обобщённый код, и генерирует код для конкретных типов, с которыми его вызывают.
Посмотрим, как это работает, на примере обобщённого перечисления Option<T>
из стандартной библиотеки:
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Компилируя этот код, Rust выполняет мономорфизацию. Компилятор читает
значения, использованные в экземплярах Option<T>, и находит два вида
Option<T>: с i32 и с f64. Поэтому он разворачивает обобщённое
определение Option<T> в два определения, специализированных для i32 и
f64, и заменяет ими обобщённое.
Мономорфизированная версия кода выглядит примерно так (компилятор использует другие имена — эти мы взяли для наглядности):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Обобщённый Option<T> заменяется конкретными определениями, которые создал
компилятор. Rust компилирует обобщённый код в код, где для каждого случая
указан конкретный тип, поэтому за обобщения мы ничего не платим во время
выполнения. Работающий код ведёт себя так же быстро, как если бы мы
продублировали каждое определение вручную. Благодаря мономорфизации обобщения
в Rust чрезвычайно эффективны во время выполнения.