Узагальнені типи даних

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

У визначеннях функцій

При визначенні функції, що використовує узагальнені типи, ми розмістимо їх в сигнатурі функції, де зазвичай ми вказуємо типи даних параметрів та результату. Це робить наш код більш гнучким і забезпечує більше функціоналу користувачам нашої функції, водночас запобігаючи дублюванню коду.

Продовжимо з нашою функцією largest. Роздрук 10-4 показує дві функції, які шукають найбільше значення у слайсі. Пізніше ми обʼєднаємо їх в одну функцію, яка використовує узагальнені типи.

Файл: 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: Two functions that differ only in their names and the types in their signatures

Функція largest_i32 – це та сама, яку ми винесли у роздруку 10-3, яка шукає найбільше значення типу i32 у слайсі. Функція largest_char шукає найбільше значення типу char у слайсі. Тіла функції мають той самий код, тому можна усунути дублювання, ввівши узагальнений параметр-тип в обʼєднаній функції.

Щоб параметризувати типи в новій обʼєднаній функції, нам потрібно дати імʼя параметру, так само як ми даємо імʼя параметрам-значенням у функції. Ви можете використовувати будь-який ідентифікатор як імʼя параметра-типу. Але ми використаємо T, тому що, за домовленістю, назви параметрів у Rust короткі і часто складаються лише з однієї букви, а імена типів, за домовленістю, слідують "camel case" (окремі слова пишуться без пробілів і з великої букви; наприклад, так: "CamelCase"). Оскільки це скорочення від "тип", T – це типовий вибір для програмістів на Rust.

Коли ми використовуємо параметр у тілі функції, ми маємо оголосити його імʼя у сигнатурі, щоб компілятор знав, що воно означає. Так само, коли ми використовуємо імʼя параметру-типу у сигнатурі функції, ми маємо оголосити цей параметр-тип перед використанням. Щоб оголосити узагальнену функцію largest, вставте оголошення імен типів у кутові дужки, <>, між імʼям функції та списком параметрів, ось так:

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

Ми читаємо це визначення так: функція largest узагальнена відносно певного типу T. Ця функція має один параметр з назвою list, який є слайсом значень типу T. Функція largest поверне посилання на значення того самого типу T.

Роздрук 10-5 показує визначення обʼєднаної функції largest з використанням узагальненого типу в її сигнатурі. Цей приклад також показує, як можна викликати функцію зі слайсом значень i32 або char. Зверніть увагу, що цей код поки не скомпілюється, але ми виправимо це пізніше у цьому розділі.

Файл: 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: The largest function using generic type parameters; this doesn’t yet compile

Якщо ми скомпілюємо цей код зараз, ми отримаємо таку помилку:

$ 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` due to previous error

Текст довідки згадує std::cmp::PartialOrd, який є трейтом, але ми будемо обговорювати трейти в наступній секції. На цей час, запамʼятайте, що ця помилка вказує, що тіло largest не працюватиме для всіх можливих типів, якими може бути T. Оскільки, ми хочемо порівняти значення типу T в тілі, ми можемо використовувати лише типи, значення яких можна впорядкувати. Щоб дозволити операції порівняння стандартна бібліотека має трейт std::cmp::PartialOrd, який ви можна реалізувати для типів (див. додаток C для деталей щодо цього трейту). Слідуючи підказці, ми обмежуємо припустимі типи T до тих, що реалізують PartialOrd, і цей приклад компілюється, оскільки стандартна бібліотека реалізує PartialOrd для i32 і char.

У визначеннях структур

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

Файл: 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: A Point<T> struct that holds x and y values of type T

Синтаксис для використання узагальнених типів у визначеннях структур схожий на той, що використовується в визначеннях функцій. Спочатку ми оголошуємо ім'я параметру типу всередині кутових дужок одразу після назви структури. Далі ми використовуємо узагальнений тип у визначенні структури де б ми інакше вказували конкретні типи даних.

Зауважте, що оскільки ми тільки використовуємо один узагальнений тип, щоб визначити Point<T>, це визначення означає, що структура Point<T> узагальнена відносно певного типу T, і поля x та y обоє мають той самий тип, яким би він не був. Якщо ми створимо екземпляр Point<T> зі значеннями різних типів, як у роздруку 10-7, наш код не буде компілюватися.

Файл: src/main.rs

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

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Listing 10-7: The fields x and y must be the same type because both have the same generic data type T.

У цьому прикладі, коли ми присвоюємо ціле значення 5 до x, ми повідомимо компілятору що тип T буде цілим числом для даного екземпляру Point<T>. Потім ми вкажемо 4,0 для у, який ми визначили як такий же тип, що й 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` due to previous error

Щоб визначити структуру Point, де x і y є обидва узагальненими, але можуть мати значення різних типів, можна використовувати декілька узагальнених параметрів-типів. Наприклад, у роздруку 10-8, ми змінюємо визначення Point на узагальнене відносно типів T та U, де x має тип T, а y має тип U.

Файл: 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: A Point<T, U> generic over two types so that x and y can be values of different types

Тепер всі екземпляри 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, коли виникли проблеми з відкриттям файлу.

When you recognize situations in your code with multiple struct or enum definitions that differ only in the types of the values they hold, you can avoid duplication by using generic types instead.

У визначеннях методів

Ми можемо імплементувати методи структур та енамів (як це було у розділі 5), і використовувати у їх визначеннях узагальнені типи. Роздрук 10-9 показує структуру Point<T>, яку ми визначили у роздруку 10-6 з імплементованим методом x.

Файл: 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: Implementing a method named x on the Point<T> struct that will return a reference to the x field of type T

Here, we’ve defined a method named x on Point<T> that returns a reference to the data in the field x.

Зверніть увагу, що ми повинні оголосити T відразу після impl, тож ми можемо використовувати T, щоб вказати, що ми застосовуємо методи на типі Point<T>. Оголосивши T як узагальнений тип після impl, Rust може визначити, що тип у кутових дужках у Point – узагальнений, а не конкретний тип. Ми могли б вибрати іншу назву, ніж назва параметра з визначення структури, для даного узагальненого параметра, але за домовленістю ми використовуємо ту саму назву. Методи, написані в межах impl, який оголошує узагальнений тип, буде визначено в будь-якому екземплярі типу, неважливо, який конкретний тип ми отримаємо, коли підставимо конкретний тип на місце параметра.

Ми також можемо вказати обмеження для узагальнених типів при визначенні методів у типі. Наприклад, ми можемо реалізувати методи лише на екземплярах Point<f32>, а не екземплярах Point<T> з будь-яким узагальненим типом. У роздруку 10-10 ми використовуємо конкретний тип f32, тобто ми не оголошуємо жодних типів після impl.

Файл: 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: An impl block that only applies to a struct with a particular concrete type for the generic type parameter 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).

Файл: 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: A method that uses generic types different from its struct’s definition

У main, ми визначили Point, що має тип i32 для x (зі значенням 5) і тип f64 для y (зі значенням 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; компліятор шукає всі місця, де код з узагальненими типами викликається і генерує код для кожного конкретного типу, з яким він викликається.

Let’s look at how this works by using the standard library’s generic Option<T> enum:

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

Коли Rust компілює цей код, він виконує мономорфізацію. Під час цього процесу, компілятор читає значення, які були використані в екземплярах Option<T> і визначає два види Option<T>: один з i32, а інший – з f64. Таким чином, він розкладає узагальнене визначення Option<T> на два визначення, які використовують i32 і f64, замінюючи узагальнене визначення на визначення з конкретизовані.

The monomorphized version of the code looks similar to the following (the compiler uses different names than what we’re using here for illustration):

Файл: 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 надзвичайно ефективними під час виконання коду.