Шаблонные типы данных (родовые типы, дженерики) (Generic Data Types)

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

Использование шаблонных типов данных в определении функций

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

Продолжим исследование функции, которую мы создали в предыдущем разделе - largest. Создадим коды искомых функций для срезов. Первая функция будет искать наибольшее значение в данных в типа i32. Вторая функция будет искать наибольшее значение в типах данных char:

Filename: src/main.rs

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        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 и largest_char имеют абсолютно одинаковое содержание. Было бы, конечно, замечательно если бы была возможность объединить содержание в одну функцию. Это возможно с дженериков.

Для параметризирования параметров функции, необходимо дать имя типу параметра. Также, как мы даём имя параметру функции. Назовём этот тип T. В качестве этого имени могут быть любой идентификатор, но учитывая конвенции языка программирования Rust - имя будет таковым. T - это сокращения от "тип". Для упрощения это имя в языке Rust выбирается по умолчанию.

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

В результате описание функции largest будет иметь следующий вид:

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

Читает это определение следующим образом: обобщенная функция largest имеет тип параметров T. Эта финкция имеет один параметр list. Тип данных данного параметра T. Функция возвращает значение типа данных T.

В следующим тексте программы (10-5) будет продемонстрирован полный текст данной функции, а также её использование. Обратите внимание, что данный код ещё имеет недостатки! Пожалуйста, попробуйте скомпилировать код данного примера и ознакомьтесь с сообщениями компилятора!

Filename: src/main.rs

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        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, которая использует концепцию обобщенного программирования (дженерики) для параметризации используемых типов данных

Описание ошибки:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

Мы подробнее поговорим о типажах (таких как std::cmp::PartialOrd) в последующих главах книги. Эта ошибка сообщает нам о том, что содержание функции не будет работать для всех типов данных, т.к. внутри функции используется оператор сравнения >, а для его использования типы параметризированных переменных должны реализовать типаж std::cmp::PartialOrd.

Использование обобщенных типов данных при определении структур

Мы можем определять структуры для использования внутри. Для этого также, как и в функции между названием структуры и списком параметров пишем в квадратных скобах имена используемых типов данных. Код программы (10-6) наглядно демонстрирует это. Структура Point содержит параметризированные поля x и y:

Filename: src/main.rs

#[derive(Debug)]
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 };
    println!("{:?}", integer);
    println!("{:?}", float);
}

Listing 10-6: Использование структуры Point содержащей поля x и y типа T

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

Также обратите внимание, что типы данных полей структуры имеют один и тот же тип дынных. Если структура будет инициирована различными числовыми типами данных - код не скомпилируется:

Filename: src/main.rs

#[derive(Debug)]
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 };
    println!("{:?}", integer);
    println!("{:?}", float);

    */

    let wont_work = Point { x: 5, y: 4.0 };
    println!("{:?}", wont_work);
}

Listing 10-7:Пример ошибки. Поля x и y должны быть инициированы одинаковыми типами данных T

Описание ошибки:

error[E0308]: mismatched types
 -->
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integral variable, found
  floating-point variable
  |
  = note: expected type `{integer}`
  = note:    found type `{float}`

Когда мы присваиваем значение первой переменной (в данном случае x), компилятор определяет тип данных обобщенного типа. Следовательно, все последующие входные параметры должны иметь тотже тип данных. Поэтому введение иного типа данных приводит к ошибке.

Если же необходимо использовать различные типы данных для входящих значений и переменных, для этого надо описать все типы при определении и корректно их использовать.

Пример:

Filename: src/main.rs

#[derive(Debug)]
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 };

    println!("{:?}", both_integer);
    println!("{:?}", both_float);
    println!("{:?}", integer_and_float);
}

Listing 10-8: Структура Point имеет два поля разного типа

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

Использование обобщенного программирования в перечислениях

Также как и в структурах, перечисления могут иметь обобщенные типа данные. Мы уже использовали такой тип данных в наших предыдущих примерах - Option<T> (в Главе 6). Рассмотрим определение данного перечисления подробнее:


# #![allow(unused_variables)]
#fn main() {
enum Option<T> {
    Some(T),
    None,
}
#}

Это определение перечисления с обобщенным типом данных T. Перечисление имеет два значения: Some, которое содержит значение типа T и None, которое не содержит каких-либо данных. Стандартная библиотека предоставляет такой функционал - опциональное значение, идея которого более абстрактная. Она позволяет обойтись без дублирования.

Как и структура и функция, перечисления также могут использовать список обобщенных параметров. Примером этого - определение перечисления Result из Главы 9:


# #![allow(unused_variables)]
#fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
#}

Перечисление Result имеет два обобщенных типа T и E. Result имеет два значения: Ok, которое содержит тип T и Err, которое содержит тип E. Такое определение позволяет использовать перечисление Result везде, где операции могут быть выполнены успешно (возвращение значение типа данных T) или неуспешно (возвращение значения типа данных E). Обратимся к коду программы 9-2, где мы открывали файл. При открытии файла предоставлялись данные типа T, т.е. в том примере std::fs::File или, при ошибке, E (std::io::Error - т.е. при каких-либо проблемах с открытием файла).

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

Использование обобщенных типов данных в определении методов

Также как и в Главе 5, мы может реализовать методы структур и перечислений с помощью обобщенного программирования. Код программы 10-9 демонстрирует пример добавления метода x в структуру Point<T>. Метод возвращает ссылку на значение данных поля 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 p1 = Point { x: 5, y: 10 };
    let p2 = Point { x: p1.x, y: p1.y };

    println!("p1.x() = {}", p1.x());
    println!("p2.x = {}", p2.x());
}

Код программы 10-9: Реализация метода x в структуре Point<T>. Метод x() возвращает ссылку на данные поля x (имеет тип T).

Конечно же, Вы обратили внимание на особенную структуры при описании обобщенного типа данных. После impl находится имя обобщенного типа impl<T>. Таким образом в синтаксисе языка компилятору передаётся информация о типах данных внутри структуры. Например, мы можем выбрать реализацию методов Point<f32>. Пример::


# #![allow(unused_variables)]
#fn main() {
# struct Point<T> {
#     x: T,
#     y: T,
# }
#
impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
#}

Код программы 10-10: Реализация impl блока структуры Point. При данной реализации метод структуры Point можно использовать только с определенным типом данных. В данном случае это f32.

Пожалуйста, попробуйте откомпилировать код примера программы и познакомьтесь с описанием ошибки:

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 p2 = Point { x: 3., y: 1. };
    println!("p2.x() = {}", p2.x());
    println!("distance from origin =  {}", p2.distance_from_origin());

    ///////////////////

    let p1 = Point { x: 5, y: 10 };

    println!("p1.x() = {}", p1.x());

    println!("distance from origin =  {}", p1.distance_from_origin());
}

Описание ошибки помогает понять, что экземпляры типа Point<f32> имеют метод distance_from_origin, а экземпляры типа Point<T>, где T не является типом данных f32 такого метода не имеют.

Обобщенные типы параметров в определении структур не всегда такие же, которые вы бы хотели использовать в методах. Код программы 10-11 описывает метод mixup структуры Point<T, U>. Метод получает другую структуру Point в качестве параметра, которая может содержать другие типы данных в качестве обобщенных типов данных. Метод создаёт новый экземпляр структуры Point, который получает значение x из self Point (типа T) и y из Point (типа W):

Filename: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        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);
}

Код программы 10-11: Методы могут иметь различные обобщенные типы, нежели те, которые есть в определении структур

В методе main мы создали экземпляр Point, который имеет тип данных i32 для x и f64 для y. Далее мы создали экземпляр Point, который имеет тип данных срез строкового типа для x и char для y. Вызов метода mixup из переменной p1 с аргументом p2 создаёт новый экземпляр типа Point, копируя данные из уже имеющихся экземпляров.

Обратите внимание, что параметры типов T и U объявляется в блоке реализации после ключевого слова impl. Параметры V и W объявляются после имени метода, и, следовательно, могут быть использованы только в этом методе.

Производительность программ, использующие обобщенное программирование

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

Рассмотрим пример компиляции кода. Создадим два экземпляра перечисления Option:


# #![allow(unused_variables)]
#fn main() {
let integer = Some(5);
let float = Some(5.0);
#}

При компиляции произойдет оптимизация (мономорфизации). Компилятор прочитает значения внутри значений перечисления Option и создаст необходимы типы Option<i32> и Option<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);
}

Подведём итоги. Мы познакомились с обобщенным программированием в Rust. Благодаря внутренней оптимизации обобщенного кода, нет накладных расходов при работе программ.