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

Zaawansowane Typy

System typów Rust posiada pewne funkcje, o których wspominaliśmy, ale jeszcze nie omawialiśmy. Zaczniemy od omówienia ogólnych “nowych typów” (newtypes), badając, dlaczego są one przydatne jako typy. Następnie przejdziemy do aliasów typów, funkcji podobnej do “nowych typów”, ale z nieco odmienną semantyką. Omówimy także typ ! oraz typy o dynamicznym rozmiarze.

Bezpieczeństwo typów i abstrakcja z wzorcem nowego typu

Ta sekcja zakłada, że przeczytałeś wcześniejszą sekcję „Implementowanie cech zewnętrznych za pomocą wzorca nowego typu”. Wzorzec nowego typu jest również przydatny do zadań wykraczających poza te, które omówiliśmy do tej pory, w tym do statycznego egzekwowania, aby wartości nigdy nie były mylone, oraz do wskazywania jednostek wartości. Przykład użycia nowych typów do wskazywania jednostek widziałeś w Listing 20-16: Przypomnij sobie, że struktury Millimeters i Meters opakowywały wartości u32 w nowy typ. Gdybyśmy napisali funkcję z parametrem typu Millimeters, nie bylibyśmy w stanie skompilować programu, który przypadkowo próbowałby wywołać tę funkcję z wartością typu Meters lub zwykłym u32.

Możemy również użyć wzorca nowego typu, aby odseparować niektóre szczegóły implementacji typu: Nowy typ może ujawniać publiczne API, które różni się od API prywatnego typu wewnętrznego.

Nowe typy mogą również ukrywać wewnętrzną implementację. Na przykład, moglibyśmy dostarczyć typ People, aby opakować HashMap<i32, String>, który przechowuje ID osoby skojarzone z jej imieniem. Kod używający People wchodziłby w interakcję tylko z publicznym API, które dostarczamy, takim jak metoda dodawania ciągu znaków z imieniem do kolekcji People; ten kod nie musiałby wiedzieć, że wewnętrznie przypisujemy imionom ID typu i32. Wzorzec nowego typu to lekki sposób na osiągnięcie hermetyzacji w celu ukrycia szczegółów implementacji, co omówiliśmy w sekcji „Hermetyzacja ukrywająca szczegóły implementacji” w Rozdziale 18.

Synonimy typów i aliasy typów

Rust umożliwia deklarowanie aliasu typu, aby nadać istniejącemu typowi inne imię. Używamy do tego słowa kluczowego type. Na przykład, możemy utworzyć alias Kilometers dla i32 w następujący sposób:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

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

Teraz alias Kilometers jest synonimem dla i32; w przeciwieństwie do typów Millimeters i Meters, które stworzyliśmy w Listing 20-16, Kilometers nie jest osobnym, nowym typem. Wartości, które mają typ Kilometers, będą traktowane tak samo jak wartości typu i32:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

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

Ponieważ Kilometers i i32 są tego samego typu, możemy dodawać wartości obu typów i przekazywać wartości Kilometers do funkcji, które przyjmują parametry i32. Jednakże, używając tej metody, nie uzyskujemy korzyści z kontroli typów, które daje wzorzec nowego typu omówiony wcześniej. Innymi słowy, jeśli gdzieś pomylimy wartości Kilometers i i32, kompilator nie zgłosi nam błędu.

Głównym przypadkiem użycia synonimów typów jest zmniejszenie powtórzeń. Na przykład, możemy mieć długi typ, taki jak ten:

Box<dyn Fn() + Send + 'static>

Pisanie tego długiego typu w sygnaturach funkcji i jako adnotacji typów w całym kodzie może być męczące i podatne na błędy. Wyobraź sobie projekt pełen kodu takiego jak w Listing 20-25.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-25: Użycie długiego typu w wielu miejscach

Alias typu sprawia, że ten kod jest łatwiejszy do zarządzania, redukując powtórzenia. W Listing 20-26 wprowadziliśmy alias nazwany Thunk dla obszernego typu i możemy zastąpić wszystkie użycia typu krótszym aliasem Thunk.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-26: Wprowadzenie aliasu typu, Thunk, w celu zmniejszenia powtórzeń

Ten kod jest znacznie łatwiejszy do czytania i pisania! Wybranie znaczącej nazwy dla aliasu typu może pomóc w komunikowaniu intencji (thunk to słowo opisujące kod, który ma być ewaluowany w późniejszym czasie, więc jest to odpowiednia nazwa dla przechowywanego domknięcia).

Aliasy typów są również powszechnie używane z typem Result<T, E> w celu zmniejszenia powtórzeń. Rozważ moduł std::io w standardowej bibliotece. Operacje wejścia/wyjścia często zwracają Result<T, E>, aby obsługiwać sytuacje, gdy operacje kończą się niepowodzeniem. Ta biblioteka posiada strukturę std::io::Error, która reprezentuje wszystkie możliwe błędy I/O. Wiele funkcji w std::io będzie zwracać Result<T, E>, gdzie E to std::io::Error, takie jak te funkcje w cesze Write:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> powtarza się często. W związku z tym std::io ma tę deklarację aliasu typu:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Ponieważ ta deklaracja znajduje się w module std::io, możemy użyć w pełni kwalifikowanego aliasu std::io::Result<T>; to znaczy Result<T, E>, gdzie E jest wypełnione jako std::io::Error. Sygnatury funkcji cechy Write wyglądają tak:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Alias typu pomaga na dwa sposoby: ułatwia pisanie kodu i zapewnia nam spójny interfejs w całym std::io. Ponieważ jest to alias, jest to po prostu kolejny Result<T, E>, co oznacza, że możemy z nim używać dowolnych metod działających na Result<T, E>, a także specjalnej składni, takiej jak operator ?.

Typ Nigdy, który nigdy nie zwraca

Rust posiada specjalny typ nazwany !, który w terminologii teorii typów nazywany jest typem pustym, ponieważ nie posiada żadnych wartości. My wolimy nazywać go typem nigdy, ponieważ zajmuje on miejsce typu zwracanego, gdy funkcja nigdy nie zwraca wartości. Oto przykład:

fn bar() -> ! {
    // --snip--
    panic!();
}

Ten kod czytamy jako „funkcja bar nigdy nie zwraca”. Funkcje, które nigdy nie zwracają, nazywane są funkcjami rozbieżnymi. Nie możemy tworzyć wartości typu !, więc bar nigdy nie może zwrócić wartości.

Ale jakie jest zastosowanie typu, dla którego nigdy nie można stworzyć wartości? Przypomnijmy kod z Listing 2-5, część gry w zgadywanie liczb; odtworzyliśmy jego fragment tutaj w Listing 20-27.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: match z ramieniem, które kończy się na continue

Wówczas pominęliśmy pewne szczegóły tego kodu. W sekcji „Konstrukcja przepływu sterowania match w Rozdziale 6 omówiliśmy, że ramiona match muszą zwracać ten sam typ. Na przykład, poniższy kod nie działa:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

Typ guess w tym kodzie musiałby być liczbą całkowitą i ciągiem znaków, a Rust wymaga, aby guess miał tylko jeden typ. Zatem, co zwraca continue? Jak mogliśmy zwrócić u32 z jednego ramienia i mieć drugie ramię, które kończy się na continue w Listing 20-27?

Jak się domyślasz, continue ma wartość !. Oznacza to, że gdy Rust oblicza typ guess, patrzy na oba ramiona match, pierwsze z wartością u32 i drugie z wartością !. Ponieważ ! nigdy nie może mieć wartości, Rust przyjmuje, że typ guess to u32.

Formalny sposób opisu tego zachowania jest taki, że wyrażenia typu ! mogą być konwertowane na dowolny inny typ. Możemy zakończyć to ramię match za pomocą continue, ponieważ continue nie zwraca wartości; zamiast tego przenosi sterowanie na początek pętli, więc w przypadku Err nigdy nie przypisujemy wartości do guess.

Typ nigdy jest również przydatny z makrem panic!. Przypomnij sobie funkcję unwrap, którą wywołujemy na wartościach Option<T>, aby wyprodukować wartość lub wywołać panikę, z następującą definicją:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

W tym kodzie dzieje się to samo, co w match w Listing 20-27: Rust widzi, że val ma typ T, a panic! ma typ !, więc wynikiem całego wyrażenia match jest T. Ten kod działa, ponieważ panic! nie produkuje wartości; kończy program. W przypadku None nie zwrócimy wartości z unwrap, więc ten kod jest poprawny.

Ostatnie wyrażenie, które ma typ !, to pętla:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

Tutaj pętla nigdy się nie kończy, więc ! jest wartością wyrażenia. Jednakże, nie byłoby to prawdą, gdybyśmy dodali break, ponieważ pętla zakończyłaby się wtedy, gdy doszłaby do break.

Typy o dynamicznym rozmiarze i cecha Sized

Rust musi znać pewne szczegóły dotyczące swoich typów, takie jak ile miejsca przydzielić dla wartości konkretnego typu. To pozostawia jeden zakątek jego systemu typów na początku nieco mylący: koncepcję typów o dynamicznym rozmiarze. Czasami nazywane DST lub typami bez rozmiaru, te typy umożliwiają pisanie kodu z użyciem wartości, których rozmiar możemy poznać tylko w czasie wykonania.

Zagłębmy się w szczegóły typu o dynamicznym rozmiarze o nazwie str, którego używaliśmy w całej książce. Zgadza się, nie &str, ale str sam w sobie, jest DST. W wielu przypadkach, takich jak przechowywanie tekstu wprowadzonego przez użytkownika, nie możemy wiedzieć, jak długi jest ciąg, dopóki program się nie uruchomi. Oznacza to, że nie możemy utworzyć zmiennej typu str, ani przyjąć argumentu typu str. Rozważ poniższy kod, który nie działa:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust musi wiedzieć, ile pamięci przydzielić dla każdej wartości danego typu, i wszystkie wartości danego typu muszą używać tej samej ilości pamięci. Gdyby Rust pozwolił nam napisać ten kod, te dwie wartości str musiałyby zajmować tę samą ilość miejsca. Ale mają one różne długości: s1 potrzebuje 12 bajtów pamięci, a s2 potrzebuje 15. Dlatego nie jest możliwe utworzenie zmiennej przechowującej typ o dynamicznym rozmiarze.

Więc co robimy? W tym przypadku znasz już odpowiedź: Zmieniamy typ s1 i s2 na wycinek ciągu znaków (&str), a nie str. Przypomnij sobie z sekcji „Wycinki ciągów znaków” w Rozdziale 4, że struktura danych wycinka przechowuje tylko pozycję początkową i długość wycinka. Zatem, chociaż &T jest pojedynczą wartością, która przechowuje adres pamięci, gdzie znajduje się T, wycinek ciągu znaków to dwie wartości: adres str i jego długość. W związku z tym możemy znać rozmiar wartości wycinka ciągu znaków w czasie kompilacji: Jest to dwukrotność długości usize. Oznacza to, że zawsze znamy rozmiar wycinka ciągu znaków, niezależnie od tego, jak długi jest ciąg, do którego się odnosi. Ogólnie rzecz biorąc, w ten sposób używa się typów o dynamicznym rozmiarze w Rust: Posiadają dodatkowy bit metadanych, który przechowuje rozmiar dynamicznych informacji. Złotą zasadą typów o dynamicznym rozmiarze jest to, że zawsze musimy umieszczać wartości typów o dynamicznym rozmiarze za wskaźnikiem jakiegoś rodzaju.

Możemy łączyć str z różnymi rodzajami wskaźników: na przykład Box<str> lub Rc<str>. W rzeczywistości widziałeś to już wcześniej, ale z innym typem o dynamicznym rozmiarze: cechami. Każda cecha jest typem o dynamicznym rozmiarze, do którego możemy się odwoływać, używając nazwy cechy. W sekcji „Używanie obiektów cech do abstrakcji nad wspólnym zachowaniem” w Rozdziale 18 wspomnieliśmy, że aby używać cech jako obiektów cech, musimy umieścić je za wskaźnikiem, takim jak &dyn Trait lub Box<dyn Trait> (Rc<dyn Trait> również by działało).

Aby pracować z DST, Rust udostępnia cechę Sized, aby określić, czy rozmiar typu jest znany w czasie kompilacji. Ta cecha jest automatycznie implementowana dla wszystkiego, czego rozmiar jest znany w czasie kompilacji. Dodatkowo, Rust niejawnie dodaje ograniczenie na Sized do każdej funkcji generycznej. Oznacza to, że definicja funkcji generycznej, taka jak ta:

fn generic<T>(t: T) {
    // --snip--
}

jest w rzeczywistości traktowana tak, jakbyśmy napisali to:

fn generic<T: Sized>(t: T) {
    // --snip--
}

Domyślnie funkcje generyczne będą działać tylko na typach, które mają znany rozmiar w czasie kompilacji. Możesz jednak użyć następującej specjalnej składni, aby złagodzić to ograniczenie:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

Ograniczenie cechy na ?Sized oznacza „T może, ale nie musi być Sized”, a ta notacja zastępuje domyślną zasadę, że typy generyczne muszą mieć znany rozmiar w czasie kompilacji. Składnia ?Trait z tym znaczeniem jest dostępna tylko dla Sized, a nie dla żadnych innych cech.

Zauważ również, że zmieniliśmy typ parametru t z T na &T. Ponieważ typ może nie być Sized, musimy używać go za wskaźnikiem jakiegoś rodzaju. W tym przypadku wybraliśmy referencję.

Następnie porozmawiamy o funkcjach i domknięciach!