Обобщенные типы данных
Мы используем обобщения, чтобы создавать определения для таких элементов, как сигнатуры функций или структуры, которые затем можно использовать со множеством разных конкретных типов данных. Сначала посмотрим, как определять функции, структуры, enum и методы с помощью обобщений. Затем обсудим, как обобщения влияют на производительность кода.
В определениях функций
При определении функции, использующей обобщения, мы помещаем обобщения в сигнатуру функции там, где обычно указывали бы типы данных параметров и возвращаемого значения. Это делает код более гибким и дает вызывающим нашу функцию больше возможностей, одновременно предотвращая дублирование кода.
Продолжая работу с нашей функцией 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.В этом примере, когда мы присваиваем целочисленное значение 5 полю x, мы
сообщаем компилятору, что обобщенный тип T для этого экземпляра Point<T>
будет целочисленным. Затем, когда мы указываем 4.0 для y, которое
определено как имеющее тот же тип, что и x, мы получим ошибку несоответствия
типов вроде этой:
$ 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 разрешены! В определении можно
использовать сколько угодно параметров обобщенных типов, но если их больше
нескольких, код становится трудно читать. Если вы обнаруживаете, что в вашем
коде нужно много обобщенных типов, это может указывать, что код следует
переструктурировать на более мелкие части.
В определениях enum
Как и со структурами, мы можем определять enum так, чтобы их варианты хранили
обобщенные типы данных. Давайте еще раз посмотрим на enum Option<T>,
предоставляемый стандартной библиотекой, который мы использовали в главе 6:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Теперь это определение должно быть понятнее. Как видите, enum Option<T>
является обобщенным по типу T и имеет два варианта: Some, который хранит
одно значение типа T, и вариант None, который не хранит никакого значения.
Используя enum Option<T>, мы можем выразить абстрактное понятие
необязательного значения, и поскольку Option<T> является обобщенным, мы
можем использовать эту абстракцию независимо от типа необязательного значения.
Enum также могут использовать несколько обобщенных типов. Определение enum
Result, которое мы использовали в главе 9, является одним из примеров:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Enum Result является обобщенным по двум типам, T и E, и имеет два
варианта: Ok, который хранит значение типа T, и Err, который хранит
значение типа E. Это определение делает удобным использование enum Result
везде, где есть операция, которая может завершиться успешно (вернуть значение
некоторого типа T) или завершиться неудачно (вернуть ошибку некоторого типа
E). Фактически именно это мы использовали для открытия файла в листинге 9-3:
T был заменен типом std::fs::File, когда файл успешно открывался, а E –
типом std::io::Error, когда при открытии файла возникали проблемы.
Когда вы распознаете в своем коде ситуации с несколькими определениями структур или enum, которые отличаются только типами хранимых значений, можно избежать дублирования, используя вместо этого обобщенные типы.
В определениях методов
Мы можем реализовывать методы для структур и enum (как делали в главе 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());
}
x для структуры Point<T>, который возвращает ссылку на поле x типа TЗдесь мы определили метод с именем x для Point<T>, который возвращает
ссылку на данные в поле x.
Обратите внимание, что нужно объявить T сразу после impl, чтобы мы могли
использовать T для указания, что реализуем методы для типа 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
использует обобщенные типы X1 и Y1 для структуры Point и X2 и Y2 для
сигнатуры метода mixup, чтобы сделать пример понятнее. Метод создает новый
экземпляр Point со значением x из self типа Point (типа 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. У переменной p3 y будет иметь тип char, потому
что y пришел из p2. Вызов макроса println! напечатает
p3.x = 5, p3.y = c.
Цель этого примера – показать ситуацию, в которой некоторые обобщенные
параметры объявляются с impl, а некоторые – с определением метода. Здесь
обобщенные параметры X1 и Y1 объявлены после impl, потому что они
относятся к определению структуры. Обобщенные параметры X2 и Y2 объявлены
после fn mixup, потому что они относятся только к методу.
Производительность кода, использующего обобщения
Вам может быть интересно, есть ли затраты времени выполнения при использовании параметров обобщенных типов. Хорошая новость в том, что использование обобщенных типов не заставит вашу программу работать медленнее, чем она работала бы с конкретными типами.
Rust достигает этого, выполняя мономорфизацию кода, использующего обобщения, во время компиляции. Мономорфизация – это процесс превращения обобщенного кода в конкретный код путем подстановки конкретных типов, которые используются при компиляции. В этом процессе компилятор делает противоположное тому, что мы делали при создании обобщенной функции в листинге 10-5: компилятор смотрит на все места, где вызывается обобщенный код, и генерирует код для конкретных типов, с которыми вызывается обобщенный код.
Посмотрим, как это работает, используя обобщенный enum 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 чрезвычайно эффективными во время
выполнения.