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(|| ())
}
}
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(|| ())
}
}
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;
}
}
}
}
match z ramieniem, które kończy się na continueWó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!