Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

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

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

Продолжая с функцией largest, Листинг 10-4 показывает две функции, которые обе находят наибольшее значение в срезе. Затем мы объединим их в одну функцию, использующую обобщения.

Filename: src/main.rs
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');
}
Listing 10-4: Две функции, которые отличаются только именами и типами в их сигнатурах

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

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

Когда мы используем параметр в теле функции, мы должны объявить имя параметра в сигнатуре, чтобы компилятор знал, что означает это имя. Аналогично, когда мы используем имя параметра типа в сигнатуре функции, мы должны объявить имя параметра типа перед его использованием. Чтобы определить обобщённую функцию largest, мы размещаем объявления имён типов внутри угловых скобок, <>, между именем функции и списком параметров, вот так:

fn largest<T>(list: &[T]) -> &T {

Мы читаем это определение так: функция largest обобщена относительно некоторого типа T. Эта функция имеет один параметр с именем list, который является срезом значений типа T. Функция largest вернёт ссылку на значение того же типа T.

Листинг 10-5 показывает объединённое определение функции largest, использующее обобщённый тип данных в своей сигнатуре. Листинг также показывает, как мы можем вызвать функцию либо со срезом значений i32, либо значений char. Обратите внимание, что этот код пока не скомпилируется.

Filename: src/main.rs
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}");
}
Listing 10-5: Функция 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

Проблема выше в том, что когда largest принимает срез &[T] на входе, функция не может предполагать ничего о типе T. Это может быть i32, это может быть String, это может быть File. Однако largest требует, чтобы T был чем-то, с чем можно сравнить с помощью > (т.е. чтобы T реализовывал PartialOrd, типаж, который мы обсудим в следующем разделе). Некоторые типы, такие как i32 и String, сравнимы, но другие типы, такие как File, несравнимы.

В языке, подобном C++, с шаблонами, компилятор не стал бы жаловаться на реализацию largest, а вместо этого стал бы жаловаться на попытку вызова largest для, например, среза файлов &[File]. Rust вместо этого требует, чтобы вы заранее заявили ожидаемые возможности обобщённых типов. Если T должен быть сравнимым, то largest должен это указать. Поэтому эта ошибка компиляции говорит, что largest не скомпилируется, пока T не будет ограничен.

Кроме того, в отличие от языков, подобных Java, где все объекты имеют набор основных методов, таких как Object.toString(), в Rust нет основных методов. Без ограничений обобщённый тип T не имеет возможностей: его нельзя вывести, скопировать или изменить (хотя его можно удалить).

В определениях структур

Мы также можем определять структуры, чтобы использовать параметр обобщённого типа в одном или нескольких полях, используя синтаксис <>. Листинг 10-6 определяет структуру Point<T> для хранения значений координат x и y любого типа.

Filename: src/main.rs
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 };
}
Listing 10-6: Структура Point<T>, хранящая значения x и y типа T

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

Обратите внимание, что поскольку мы использовали только один обобщённый тип для определения Point<T>, это определение говорит, что структура Point<T> обобщена относительно некоторого типа T, и поля x и y — это оба один и тот же тип, каким бы он ни был. Если мы создадим экземпляр Point<T>, имеющий значения разных типов, как в Листинге 10-7, наш код не скомпилируется.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: Поля 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.

Filename: src/main.rs
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 };
}
Listing 10-8: 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 везде, где у нас есть операция, которая может succeeded (вернуть значение некоторого типа T) или failed (вернуть ошибку некоторого типа E). Фактически, это то, что мы использовали для открытия файла в Листинге 9-3, где T был заполнен типом std::fs::File при успешном открытии файла, а E был заполнен типом std::io::Error при проблемах с открытием файла.

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

В определениях методов

Мы можем реализовывать методы на структурах и перечислениях (как мы делали в Главе 5) и использовать обобщённые типы в их определениях. Листинг 10-9 показывает структуру Point<T>, которую мы определили в Листинге 10-6, с методом с именем x, реализованным для неё.

Filename: src/main.rs
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());
}
Listing 10-9: Реализация метода с именем 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.

Filename: src/main.rs
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());
}
Listing 10-10: Блок impl, который применяется только к структуре с конкретным типом для параметра обобщённого типа T

Этот код означает, что тип Point<f32> будет иметь метод distance_from_origin; другие экземпляры Point<T>, где T не является типом f32, не будут иметь этого метода определённым. Метод измеряет, как далеко наша точка находится от точки с координатами (0.0, 0.0) и использует математические операции, доступные только для типов с плавающей запятой.

Вы не можете одновременно реализовывать специфические и обобщённые методы с одним и тем же именем таким способом. Например, если вы реализовали общий distance_from_origin для всех типов T и специфический distance_from_origin для f32, то компилятор отвергнет вашу программу: Rust не знает, какую реализацию использовать при вызове Point<f32>::distance_from_origin. В более общем смысле, Rust не имеет механизмов, подобных наследованию, для специализации методов, как вы могли бы найти в объектно-ориентированном языке, за одним исключением (методы типажей по умолчанию), обсуждаемым в следующем разделе.

Параметры обобщённого типа в определении структуры не всегда совпадают с теми, которые вы используете в сигнатурах методов этой же структуры. Листинг 10-11 использует обобщённые типы X1 и Y1 для структуры Point и X2 Y2 для сигнатуры метода mixup, чтобы сделать пример более ясным. Метод создаёт новый экземпляр Point со значением x из self Point (типа X1) и значением y из переданного Point (типа Y2).

Filename: src/main.rs
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);
}
Listing 10-11: Метод, использующий обобщённые типы, отличные от определения его структуры

В main мы определили Point, который имеет i32 для x (со значением 5) и f64 для y (со значением 10.4). Переменная p2 — это структура Point, которая имеет строковый срез для x (со значением "Hello") и char для y (со значением c). Вызов mixup для p1 с аргументом p2 даёт нам p3, который будет иметь i32 для x, потому что x взялся из p1. Переменная p3 будет иметь char для y, потому что 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, тем самым заменяя обобщённое определение конкретными.

Мономорфизированная версия кода выглядит подобно следующей (компилятор использует другие имена, чем те, которые мы используем здесь, для иллюстрации):

Filename: src/main.rs
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 чрезвычайно эффективными во время выполнения.