Обобщенные типы данных (Generic)
Мы используем обобщенные типы для создания определений таких элементов, как сигнатуры функций или структуры, которые затем можем использовать со многими различными конкретными типами данных. Давайте сначала рассмотрим, как определять функции, структуры, перечисления и методы с использованием обобщенных типов. Затем мы обсудим, как обобщенные типы влияют на производительность кода.
В определениях функций
При определении функции, которая использует обобщенные типы, мы помещаем их в сигнатуру функции там, где обычно указываем типы данных параметров и возвращаемого значения. Это делает наш код более гибким и предоставляет больше функциональности вызывающим сторонам, предотвращая дублирование кода.
Продолжая с нашей функцией 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. Сокращение от type, T является выбором по умолчанию для большинства программистов на 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}");
}
Если мы скомпилируем этот код прямо сейчас, мы получим эту ошибку:
$ 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>, это определение говорит, что структура 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 };
}
В этом примере, когда мы присваиваем целочисленное значение 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 разрешены! Вы можете использовать столько параметров обобщенного типа в определении, сколько хотите, но использование более чем нескольких делает ваш код трудным для чтения. Если вы обнаруживаете, что вам нужно много обобщенных типов в вашем коде, это может указывать на то, что ваш код нужно реструктуризировать на более мелкие части.
В определениях перечислений
Как мы делали со структурами, мы можем определять перечисления, которые содержат обобщенные типы данных в своих вариантах. Давайте еще раз посмотрим на перечисление 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()); }
Здесь мы определили метод с именем 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()); }
Этот код означает, что тип 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: компилятор смотрит на все места, где вызывается обобщенный код, и генерирует код для конкретных типов, с которыми вызывается обобщенный код.
Давайте посмотрим, как это работает, на примере обобщенного перечисления 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 чрезвычайно эффективными во время выполнения.