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

Generyczne typy danych

Używamy generyków do tworzenia definicji dla elementów, takich jak sygnatury funkcji lub struktury, które możemy następnie wykorzystać z wieloma różnymi konkretnymi typami danych. Najpierw przyjrzymy się, jak definiować funkcje, struktury, wyliczenia i metody za pomocą generyków. Następnie omówimy, jak generyki wpływają na wydajność kodu.

W definicjach funkcji

Podczas definiowania funkcji używającej generyków, umieszczamy generyki w sygnaturze funkcji, tam gdzie zazwyczaj określamy typy danych parametrów i wartość zwracaną. Dzięki temu nasz kod jest bardziej elastyczny i zapewnia większą funkcjonalność wywołującym naszą funkcję, jednocześnie zapobiegając duplikacji kodu.

Kontynuując naszą funkcję largest, Listing 10-4 pokazuje dwie funkcje, które obie znajdują największą wartość w wycinku. Następnie połączymy je w jedną funkcję, która używa generyków.

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!("Największa liczba to {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("Największy znak to {result}");
    assert_eq!(*result, 'y');
}

Funkcja largest_i32 jest tą, którą wyodrębniliśmy w Listing 10-3, która znajduje największą i32 w wycinku. Funkcja largest_char znajduje największy char w wycinku. Ciała funkcji mają ten sam kod, więc wyeliminujmy duplikację, wprowadzając generyczny parametr typu w jednej funkcji.

Aby sparametryzować typy w nowej pojedynczej funkcji, musimy nadać nazwę parametrowi typu, tak jak to robimy dla parametrów wartości funkcji. Możesz użyć dowolnego identyfikatora jako nazwy parametru typu. My jednak użyjemy T, ponieważ, zgodnie z konwencją, nazwy parametrów typu w Rust są krótkie, często składają się tylko z jednej litery, a konwencja nazewnictwa typów w Rust to UpperCamelCase. T (skrót od type) jest domyślnym wyborem większości programistów Rusta.

Kiedy używamy parametru w ciele funkcji, musimy zadeklarować nazwę parametru w sygnaturze, aby kompilator wiedział, co ta nazwa oznacza. Podobnie, kiedy używamy nazwy parametru typu w sygnaturze funkcji, musimy zadeklarować nazwę parametru typu, zanim jej użyjemy. Aby zdefiniować generyczną funkcję largest, umieszczamy deklaracje nazw typów w nawiasach ostrych, <>, między nazwą funkcji a listą parametrów, tak:

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

Tę definicję czytamy jako „funkcja largest jest generyczna względem jakiegoś typu T”. Ta funkcja ma jeden parametr o nazwie list, który jest wycinkiem wartości typu T. Funkcja largest zwróci referencję do wartości tego samego typu T.

Listing 10-5 pokazuje połączoną definicję funkcji largest używającą generycznego typu danych w swojej sygnaturze. Listing pokazuje również, jak możemy wywołać funkcję z wycinkiem wartości i32 lub char. Zauważ, że ten kod jeszcze się nie skompiluje.

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!("Największa liczba to {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("Największy znak to {result}");
}

Jeśli skompilujemy ten kod teraz, otrzymamy następujący błąd:

$ 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

Tekst pomocy wspomina o std::cmp::PartialOrd, który jest cechą (trait), a o cechach będziemy mówić w następnej sekcji. Na razie wiedz, że ten błąd oznacza, że ciało funkcji largest nie zadziała dla wszystkich możliwych typów, którymi mogłoby być T. Ponieważ chcemy porównywać wartości typu T w ciele funkcji, możemy używać tylko typów, których wartości można uporządkować. Aby umożliwić porównania, standardowa biblioteka posiada cechę std::cmp::PartialOrd, którą można zaimplementować na typach (więcej na temat tej cechy znajdziesz w Dodatku C). Aby naprawić Listing 10-5, możemy zastosować się do sugestii tekstu pomocy i ograniczyć typy dozwolone dla T tylko do tych, które implementują PartialOrd. Listing następnie się skompiluje, ponieważ standardowa biblioteka implementuje PartialOrd zarówno dla i32, jak i char.

W definicjach struktur

Możemy również definiować struktury, aby używały generycznego parametru typu w jednym lub więcej polach, używając składni <>. Listing 10-6 definiuje strukturę Point<T> do przechowywania wartości współrzędnych x i y dowolnego typu.

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 };
}

Składnia używania generyków w definicjach struktur jest podobna do tej używanej w definicjach funkcji. Najpierw deklarujemy nazwę parametru typu w nawiasach ostrych tuż po nazwie struktury. Następnie używamy typu generycznego w definicji struktury tam, gdzie w przeciwnym razie określilibyśmy konkretne typy danych.

Zauważ, że ponieważ użyliśmy tylko jednego typu generycznego do zdefiniowania Point<T>, ta definicja mówi, że struktura Point<T> jest generyczna względem jakiegoś typu T, a pola x i yobydwa tego samego typu, niezależnie od tego, jaki to typ. Jeśli stworzymy instancję Point<T>, która ma wartości różnych typów, jak w Listing 10-7, nasz kod się nie skompiluje.

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

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

W tym przykładzie, gdy przypisujemy wartość całkowitą 5 do x, informujemy kompilator, że generyczny typ T będzie liczbą całkowitą dla tej instancji Point<T>. Następnie, gdy określamy 4.0 dla y, które zdefiniowaliśmy jako mające ten sam typ co x, otrzymamy błąd niezgodności typów, taki jak ten:

$ 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

Aby zdefiniować strukturę Point, w której x i y są obydwa generyczne, ale mogą mieć różne typy, możemy użyć wielu generycznych parametrów typu. Na przykład, w Listing 10-8 zmieniamy definicję Point tak, aby była generyczna względem typów T i U, gdzie x jest typu T, a y jest typu 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 };
}

Teraz wszystkie pokazane instancje Point są dozwolone! Możesz używać tylu generycznych parametrów typu w definicji, ile chcesz, ale używanie więcej niż kilku utrudnia czytanie kodu. Jeśli okaże się, że potrzebujesz wielu typów generycznych w swoim kodzie, może to wskazywać na potrzebę restrukturyzacji kodu na mniejsze części.

W definicjach wyliczeń

Podobnie jak w przypadku struktur, możemy definiować wyliczenia, aby zawierały generyczne typy danych w swoich wariantach. Przyjrzyjmy się ponownie wyliczeniu Option<T> dostarczanemu przez standardową bibliotekę, którego użyliśmy w Rozdziale 6:

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

Ta definicja powinna być teraz bardziej zrozumiała. Jak widać, enum Option<T> jest generyczny względem typu T i ma dwa warianty: Some, który przechowuje jedną wartość typu T, oraz wariant None, który nie przechowuje żadnej wartości. Używając enum Option<T>, możemy wyrazić abstrakcyjną koncepcję wartości opcjonalnej, a ponieważ Option<T> jest generyczny, możemy używać tej abstrakcji niezależnie od typu wartości opcjonalnej.

Wyliczenia mogą również używać wielu typów generycznych. Definicja wyliczenia Result, którego użyliśmy w Rozdziale 9, jest jednym z przykładów:

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

Enum Result jest generyczny względem dwóch typów, T i E, i ma dwa warianty: Ok, który przechowuje wartość typu T, oraz Err, który przechowuje wartość typu E. Ta definicja sprawia, że wygodnie jest używać enum Result wszędzie tam, gdzie mamy operację, która może zakończyć się sukcesem (zwrócić wartość jakiegoś typu T) lub niepowodzeniem (zwrócić błąd jakiegoś typu E). W rzeczywistości, tego właśnie użyliśmy do otwarcia pliku w Listing 9-3, gdzie T zostało wypełnione typem std::fs::File, gdy plik został pomyślnie otwarty, a E zostało wypełnione typem std::io::Error, gdy wystąpiły problemy z otwarciem pliku.

Kiedy rozpoznajesz sytuacje w swoim kodzie z wieloma definicjami struktur lub wyliczeń, które różnią się tylko typami przechowywanych wartości, możesz uniknąć duplikacji, używając zamiast tego typów generycznych.

W definicjach metod

Możemy implementować metody na strukturach i wyliczeniach (jak to zrobiliśmy w Rozdziale 5) i używać generycznych typów również w ich definicjach. Listing 10-9 pokazuje strukturę Point<T>, którą zdefiniowaliśmy w Listing 10-6 z zaimplementowaną na niej metodą o nazwie 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());
}

Tutaj zdefiniowaliśmy metodę o nazwie x na Point<T>, która zwraca referencję do danych w polu x.

Zauważ, że musimy zadeklarować T tuż po impl, abyśmy mogli użyć T do określenia, że implementujemy metody na typie Point<T>. Deklarując T jako typ generyczny po impl, Rust może zidentyfikować, że typ w nawiasach ostrych w Point jest typem generycznym, a nie typem konkretnym. Moglibyśmy wybrać inną nazwę dla tego parametru generycznego niż parametr generyczny zadeklarowany w definicji struktury, ale używanie tej samej nazwy jest konwencjonalne. Jeśli napiszesz metodę w impl, która deklaruje typ generyczny, ta metoda zostanie zdefiniowana dla każdej instancji typu, niezależnie od tego, jaki konkretny typ ostatecznie zastąpi typ generyczny.

Możemy również określać ograniczenia dla typów generycznych podczas definiowania metod na tym typie. Moglibyśmy, na przykład, zaimplementować metody tylko na instancjach Point<f32> zamiast na instancjach Point<T> z dowolnym typem generycznym. W Listing 10-10 używamy konkretnego typu f32, co oznacza, że nie deklarujemy żadnych typów po 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());
}

Ten kod oznacza, że typ Point<f32> będzie miał metodę distance_from_origin; inne instancje Point<T>, gdzie T nie jest typu f32, nie będą miały tej metody zdefiniowanej. Metoda mierzy, jak daleko nasz punkt znajduje się od punktu o współrzędnych (0.0, 0.0) i używa operacji matematycznych, które są dostępne tylko dla typów zmiennoprzecinkowych.

Generyczne parametry typu w definicji struktury nie zawsze są takie same, jak te, których używasz w sygnaturach metod tej samej struktury. Listing 10-11 używa generycznych typów X1 i Y1 dla struktury Point oraz X2 i Y2 dla sygnatury metody mixup, aby przykład był jaśniejszy. Metoda tworzy nową instancję Point z wartością x z self Point (typu X1) i wartością y z przekazanego Point (typu 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: "Witaj", y: 'c' };

    let p3 = p1.mixup(p2);

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

W funkcji main zdefiniowaliśmy Point, który ma i32 dla x (o wartości 5) i f64 dla y (o wartości 10.4). Zmienna p2 to struktura Point, która ma wycinek ciągu znaków dla x (o wartości "Witaj") i char dla y (o wartości c). Wywołanie mixup na p1 z argumentem p2 daje nam p3, który będzie miał i32 dla x, ponieważ x pochodzi z p1. Zmienna p3 będzie miała char dla y, ponieważ y pochodzi z p2. Wywołanie makra println! wydrukuje p3.x = 5, p3.y = c.

Celem tego przykładu jest zademonstrowanie sytuacji, w której niektóre parametry generyczne są zadeklarowane za pomocą impl, a niektóre za pomocą definicji metody. Tutaj parametry generyczne X1 i Y1 są zadeklarowane po impl, ponieważ pasują do definicji struktury. Parametry generyczne X2 i Y2 są zadeklarowane po fn mixup, ponieważ są istotne tylko dla metody.

Wydajność kodu używającego generyków

Możesz zastanawiać się, czy istnieje koszt czasu wykonania przy używaniu generycznych parametrów typu. Dobra wiadomość jest taka, że używanie typów generycznych nie spowolni programu bardziej niż użycie konkretnych typów.

Rust osiąga to, wykonując monomorfizację kodu używającego generyków w czasie kompilacji. Monomorfizacja to proces przekształcania kodu generycznego w kod specyficzny poprzez wypełnienie konkretnych typów używanych podczas kompilacji. W tym procesie kompilator wykonuje przeciwieństwo kroków, których użyliśmy do utworzenia funkcji generycznej w Listing 10-5: kompilator przegląda wszystkie miejsca, w których wywoływany jest kod generyczny, i generuje kod dla konkretnych typów, z którymi wywoływany jest kod generyczny.

Przyjrzyjmy się, jak to działa, używając generycznego enum Option<T> ze standardowej biblioteki:

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

Kiedy Rust kompiluje ten kod, wykonuje monomorfizację. Podczas tego procesu kompilator odczytuje wartości, które zostały użyte w instancjach Option<T>, i identyfikuje dwa rodzaje Option<T>: jeden to i32, a drugi to f64. W związku z tym rozszerza generyczną definicję Option<T> na dwie definicje wyspecjalizowane dla i32 i f64, zastępując w ten sposób generyczną definicję specyficznymi.

Monomorfizowana wersja kodu wygląda podobnie do następującej (kompilator używa innych nazw niż te, których używamy tutaj dla ilustracji):

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);
}

Generyczny Option<T> jest zastępowany przez specyficzne definicje utworzone przez kompilator. Ponieważ Rust kompiluje kod generyczny do kodu, który określa typ w każdej instancji, nie ponosimy kosztów wykonania za używanie generyków. Gdy kod działa, zachowuje się tak samo, jakbyśmy ręcznie zduplikowali każdą definicję. Proces monomorfizacji sprawia, że generyki Rusta są niezwykle wydajne w czasie wykonywania.