Обобщённые типы данных

Мы используем обобщения в, например, сигнатурах функций и структурах, которые затем можно использовать с различными конкретными типами данных. Сначала мы посмотрим, как объявлять функции, структуры, перечисления и методы, используя обобщения. Затем мы обсудим, как обобщения влияют на производительность кода.

В определениях функций

Когда мы объявляем функцию с обобщёнными типами, мы размещаем обобщённые типы в сигнатуре функции там, где мы обычно указываем типы данных параметров и возвращаемого значения. Используя обобщённые типы, мы делаем код более гибким и предоставляем большую функциональность при вызове нашей функции, предотвращая дублирование кода.

Рассмотрим пример с нашей функцией 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!("Наибольшее число: {result}"); assert_eq!(*result, 100); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest_char(&char_list); println!("Наибольший символ: {result}"); assert_eq!(*result, 'y'); }

Функция largest_i32 уже встречалась нам: мы извлекли её в Листинге 10-3, когда боролись с дублированием кода; она находит наибольшее значение типа i32 в срезе. Функция largest_char находит самое большое значение типа char в срезе. Тело у этих функций одинаковое, поэтому давайте избавимся от дублируемого кода, используя параметр обобщённого типа в одной функции.

Для параметризации типов данных в новой объявляемой функции нам нужно дать имя обобщённому типу — так же, как мы это делаем для аргументов функций. Можно использовать любой идентификатор для имени параметра типа, но мы будем использовать T, потому что по соглашению имена параметров типов в Rust должны быть короткими (обычно длиной в один символ), а именование типов в Rust делается в нотации UpperCamelCase. Сокращение слова type до одной буквы T является стандартным выбором большинства программистов.

Когда мы используем параметр в теле функции, мы должны объявить имя параметра в сигнатуре, чтобы компилятор знал, что означает это имя. Аналогично, когда мы используем имя типа параметра в сигнатуре функции, мы должны объявить это имя раньше, чем мы его используем. Чтобы определить обобщённую функцию 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!("Наибольшее число: {result}"); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest(&char_list); println!("Наибольший символ: {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` | 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). Следуя совету в сообщении компилятора, ограничим тип 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 (имеющей тип 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 это достигается во время компиляции при помощи мономорфизации кода, использующего обобщённые типы. Мономорфизация — это процесс превращения обобщённого кода в конкретный код путём подстановки конкретных типов, использующихся при компиляции. В этом процессе компилятор выполняет шаги, противоположные тем, которые мы использовали для создания обобщённой функции в Листинге 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 чрезвычайно эффективными во время выполнения.