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

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

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

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

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

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

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, який є трейтом (trait), і ми поговоримо про трейти (traits) в наступному розділі. Поки що знайте, що ця помилка стверджує: тіло largest не працюватиме для всіх можливих типів, якими може бути T. Оскільки ми хочемо порівнювати значення типу T у тілі, ми можемо використовувати лише ті типи, значення яких можна впорядкувати. Щоб увімкнути порівняння, стандартна бібліотека має трейт std::cmp::PartialOrd, який ви можете реалізувати для типів (див. Додаток C для більшої інформації про цей трейт). Щоб виправити Лістинг 10-5, ми можемо скористатися пропозицією з тексту підказки та обмежити типи, допустимі для T, лише тими, що реалізують PartialOrd. Після цього список скомпілюється, тому що стандартна бібліотека реалізує PartialOrd і для i32, і для char.

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

Ми також можемо визначати структури так, щоб вони використовували узагальнений параметр типу в одному або кількох полях, використовуючи синтаксис <>. Listing 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 };
}

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

Зверніть увагу: оскільки ми використали лише один узагальнений тип для визначення 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 обидва є generics, але можуть мати різні типи, ми можемо використовувати кілька узагальнених параметрів типу. Наприклад, у Лістингу 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 з Point 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, який має 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, тому що вони стосуються лише методу.

Продуктивність коду, що використовує generics

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

Rust досягає цього, виконуючи мономорфізацію коду, що використовує узагальнення (generics), під час компіляції. Мономорфізація (monomorphization) — це процес перетворення узагальненого коду на специфічний код шляхом підставляння конкретних типів, які використовуються під час компіляції. У цьому процесі компілятор робить протилежне крокам, які ми використовували для створення узагальненої функції в Listing 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 компілює узагальнений код у код, що вказує тип у кожному екземплярі, ми не сплачуємо витрат під час виконання за використання узагальнень (generics). Коли код виконується, він працює так само, як працював би, якби ми вручну дублювали кожне визначення. Процес мономорфізації робить узагальнення (generics) у Rust надзвичайно ефективними під час виконання.