Użycie Box<T> do wskazywania na dane na stercie
Najprostszym inteligentnym wskaźnikiem jest pudełko, którego typ zapisuje się
jako Box<T>. Pudełka pozwalają przechowywać dane na stercie zamiast na
stosie. To, co pozostaje na stosie, to wskaźnik do danych na stercie.
Odwołaj się do Rozdziału 4, aby zapoznać się z różnicą między stosem a stertą.
Pudełka nie mają narzutu wydajnościowego, poza przechowywaniem danych na stercie zamiast na stosie. Ale nie mają też wielu dodatkowych możliwości. Będziesz ich używać najczęściej w następujących sytuacjach:
- Kiedy masz typ, którego rozmiar nie może być znany w czasie kompilacji, i chcesz użyć wartości tego typu w kontekście, który wymaga dokładnego rozmiaru
- Kiedy masz dużą ilość danych i chcesz przenieść własność, ale upewnić się, że dane nie zostaną skopiowane, gdy to zrobisz
- Kiedy chcesz być właścicielem wartości, a zależy ci tylko na tym, aby była to typ, który implementuje określoną cechę, zamiast być konkretnym typem
Pokażemy pierwszą sytuację w sekcji „Włączanie typów rekurencyjnych za pomocą pudełek”. W drugim przypadku przeniesienie własności dużej ilości danych może zająć dużo czasu, ponieważ dane są kopiowane na stosie. Aby poprawić wydajność w tej sytuacji, możemy przechowywać dużą ilość danych na stercie w pudełku. Wtedy tylko mała ilość danych wskaźnika jest kopiowana na stosie, podczas gdy dane, do których wskazuje, pozostają w jednym miejscu na stercie. Trzeci przypadek jest znany jako obiekt cechy, a sekcja „Używanie obiektów cech do abstrakcji nad wspólnym zachowaniem” w Rozdziale 18 jest poświęcona temu tematowi. Więc to, czego się tutaj nauczysz, zastosujesz p ponownie w tej sekcji!
Przechowywanie danych na stercie
Zanim omówimy przypadek użycia Box<T> do przechowywania na stercie,
powiemy o składni i sposobie interakcji z wartościami przechowywanymi w Box<T>.
Listing 15-1 pokazuje, jak użyć pudełka do przechowywania wartości i32 na
stercie.
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
i32 na stercie za pomocą pudełkaDefiniujemy zmienną b z wartością Box, która wskazuje na wartość 5,
alokowaną na stercie. Ten program wyświetli b = 5; w tym przypadku możemy
dostęp do danych w pudełku podobnie jak wtedy, gdyby te dane znajdowały się na
stercie. Podobnie jak każda posiadana wartość, gdy pudełko wyjdzie poza
zakres, tak jak b na końcu main, zostanie zdealokowane. Dealokacja
odbywa się zarówno dla pudełka (przechowywanego na stosie), jak i dla danych,
do których wskazuje (przechowywanych na stercie).
Umieszczenie pojedynczej wartości na stercie nie jest zbyt użyteczne, więc
rzadko będziesz używać pudełek w ten sposób. Posiadanie wartości, takich jak
pojedyncze i32 na stosie, gdzie są domyślnie przechowywane, jest bardziej
odpowiednie w większości sytuacji. Spójrzmy na przypadek, w którym pudełka
pozwalają nam definiować typy, których nie moglibyśmy zdefiniować, gdybyśmy
nie mieli pudełek.
Włączanie typów rekurencyjnych za pomocą pudełek
Wartość typu rekurencyjnego może zawierać inną wartość tego samego typu jako swoją część. Typy rekurencyjne stanowią problem, ponieważ Rust musi wiedzieć w czasie kompilacji, ile miejsca zajmuje dany typ. Jednak zagnieżdżanie wartości typów rekurencyjnych mogłoby teoretycznie trwać w nieskończoność, więc Rust nie może wiedzieć, ile miejsca potrzebuje wartość. Ponieważ pudełka mają znany rozmiar, możemy włączyć typy rekurencyjne, wstawiając pudełko do definicji typu rekurencyjnego.
Jako przykład typu rekurencyjnego, przeanalizujmy listę cons. Jest to typ danych powszechnie spotykany w językach programowania funkcyjnego. Typ listy cons, który zdefiniujemy, jest prosty, z wyjątkiem rekurencji; dlatego koncepcje w przykładzie, z którym będziemy pracować, będą przydatne zawsze, gdy znajdziesz się w bardziej złożonych sytuacjach związanych z typami rekurencyjnymi.
Zrozumienie listy cons
Lista cons to struktura danych pochodząca z języka programowania Lisp i jego
dialektów, składająca się z zagnieżdżonych par, i jest lispońską wersją listy
składającej się z połączonych elementów. Jej nazwa pochodzi od funkcji cons
(skrót od construct function) w Lispie, która konstruuje nową parę z dwóch
swoich argumentów. Wywołując cons na parze składającej się z wartości i innej
pary, możemy konstruować listy cons składające się z rekurencyjnych par.
Na przykład, oto pseudokodowa reprezentacja listy cons zawierającej listę 1, 2, 3,
gdzie każda para jest w nawiasach:
(1, (2, (3, Nil)))
Każdy element listy cons zawiera dwa elementy: wartość bieżącego elementu i
następnego elementu. Ostatni element listy zawiera tylko wartość zwaną Nil
bez następnego elementu. Lista cons jest tworzona przez rekurencyjne
wywoływanie funkcji cons. Kanoniczna nazwa oznaczająca przypadek bazowy
rekurencji to Nil. Zauważ, że to nie to samo, co koncepcja „null” lub „nil”
omówiona w Rozdziale 6, która oznacza nieprawidłową lub brakującą wartość.
Lista cons nie jest powszechnie używaną strukturą danych w Rust. W większości
przypadków, gdy masz listę elementów w Rust, Vec<T> jest lepszym wyborem. Inne,
bardziej złożone rekurencyjne typy danych są użyteczne w różnych sytuacjach,
ale zaczynając od listy cons w tym rozdziale, możemy zbadać, jak pudełka
pPozwalają nam definiować rekurencyjny typ danych bez zbytniego rozpraszania.
Listing 15-2 zawiera definicję wyliczenia dla listy cons. Zauważ, że ten kod
jeszcze się nie skompiluje, ponieważ typ List nie ma znanego rozmiaru, co
pokażemy.
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
i32Uwaga: Implementujemy listę cons, która przechowuje tylko wartości i32 dla
celów tego przykładu. Moglibyśmy zaimplementować ją za pomocą generyków, jak
omówiliśmy w Rozdziale 10, aby zdefiniować typ listy cons, który mógłby
przechowywać wartości dowolnego typu.
Użycie typu List do przechowywania listy 1, 2, 3 wyglądałoby jak kod
w Listingu 15-3.
enum List {
Cons(i32, List),
Nil,
}
// --snip--
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
List do przechowywania listy 1, 2, 3Pierwsza wartość Cons przechowuje 1 i inną wartość List. Ta wartość
List to inna wartość Cons, która przechowuje 2 i inną wartość List.
Ta wartość List to jeszcze jedna wartość Cons, która przechowuje 3
i wartość List, która jest w końcu Nil, nierrekurencyjnym wariantem,
który sygnalizuje koniec listy.
Jeśli spróbujemy skompilować kod z Listingu 15-3, otrzymamy błąd pokazany w Listingu 15-4.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing when `List` needs drop again
= note: cycle used when computing whether `List` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Błąd pokazuje, że ten typ „ma nieskończony rozmiar”. Powodem jest to, że
zdefiniowaliśmy List z wariantem, który jest rekurencyjny: przechowuje inną
wartość siebie bezpośrednio. W rezultacie Rust nie potrafi określić, ile miejsca
potrzebuje do przechowywania wartości List. Rozłóżmy na czynniki pierwsze,
dlaczego otrzymujemy ten błąd. Najpierw przyjrzymy się, jak Rust decyduje, ile
miejsca potrzebuje do przechowywania wartości typu nierrekurencyjnego.
Obliczanie rozmiaru typu nierrekurencyjnego
Przypomnijmy wyliczenie Message, które zdefiniowaliśmy w Listingu 6-2,
kiedy omawialiśmy definicje wyliczeń w Rozdziale 6:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Aby określić, ile miejsca należy przeznaczyć na wartość Message, Rust
przechodzi przez każdy z wariantów, aby sprawdzić, który wariant potrzebuje
najwięcej miejsca. Rust widzi, że Message::Quit nie potrzebuje miejsca,
Message::Move potrzebuje wystarczająco dużo miejsca na przechowanie dwóch
wartości i32 i tak dalej. Ponieważ używany będzie tylko jeden wariant,
najwięcej miejsca, jakie będzie potrzebować wartość Message, to miejsce
potrzebne na przechowanie największego z jej wariantów.
Porównaj to z tym, co dzieje się, gdy Rust próbuje określić, ile miejsca
potrzebuje rekurencyjny typ, taki jak wyliczenie List w Listingu 15-2.
Kompilator zaczyna od wariantu Cons, który przechowuje wartość typu i32
i wartość typu List. Dlatego Cons potrzebuje tyle miejsca, ile wynosi
rozmiar i32 plus rozmiar List. Aby dowiedzieć się, ile pamięci potrzebuje
typ List, kompilator patrzy na warianty, zaczynając od wariantu Cons.
Wariant Cons przechowuje wartość typu i32 i wartość typu List, i ten
proces trwa w nieskończoność, jak pokazano na Rysunku 15-1.
Rysunek 15-1: Nieskończona lista List składająca się z nieskończonych wariantów Cons
Uzyskiwanie rekurencyjnego typu o znanym rozmiarze
Ponieważ Rust nie jest w stanie określić, ile miejsca należy przydzielić dla rekurencyjnie zdefiniowanych typów, kompilator zwraca błąd z tą pomocną sugestią:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
W tej sugestii pośrednictwo oznacza, że zamiast bezpośrednio przechowywać wartość, powinniśmy zmienić strukturę danych, aby przechowywać wartość pośrednio, przechowując zamiast tego wskaźnik do wartości.
Ponieważ Box<T> jest wskaźnikiem, Rust zawsze wie, ile miejsca potrzebuje
Box<T>: rozmiar wskaźnika nie zmienia się w zależności od ilości danych, na
które wskazuje. Oznacza to, że możemy umieścić Box<T> w wariancie Cons
zamiast bezpośrednio innej wartości List. Box<T> będzie wskazywać na
kolejną wartość List, która będzie znajdować się na stercie, a nie wewnątrz
wariantu Cons. Koncepcyjnie, nadal mamy listę, utworzoną z list
przechowujących inne listy, ale ta implementacja jest teraz bardziej podobna
do umieszczania elementów obok siebie, a nie wewnątrz siebie.
Możemy zmienić definicję wyliczenia List w Listingu 15-2 i użycie List w
Listingu 15-3 na kod w Listingu 15-5, który się skompiluje.
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
List, która używa Box<T>, aby mieć znany rozmiarWariant Cons potrzebuje rozmiaru i32 plus miejsca na przechowywanie danych
wskaznika pudełka. Wariant Nil nie przechowuje żadnych wartości, więc
potrzebuje mniej miejsca na stosie niż wariant Cons. Wiemy teraz, że każda
wartość List zajmie rozmiar i32 plus rozmiar danych wskaźnika pudełka.
Używając pudełka, przerwaliśmy nieskończony, rekurencyjny łańcuch, więc
kompilator może określić rozmiar potrzebny do przechowywania wartości List.
Rysunek 15-2 pokazuje, jak wygląda teraz wariant Cons.
Rysunek 15-2: List, która nie ma nieskończonego rozmiaru, ponieważ Cons zawiera Box
Pudełka zapewniają jedynie pośrednictwo i alokację na stercie; nie mają żadnych innych specjalnych możliwości, takich jak te, które zobaczymy w przypadku innych typów inteligentnych wskaźników. Nie mają również narzutu wydajnościowego, który wiąże się z tymi specjalnymi możliwościami, więc mogą być przydatne w przypadkach, takich jak lista cons, gdzie pośrednictwo jest jedyną potrzebną nam funkcją. Więcej przypadków użycia pudełek omówimy w Rozdziale 18.
Typ Box<T> jest inteligentnym wskaźnikiem, ponieważ implementuje cechę
Deref, która pozwala traktować wartości Box<T> jak referencje. Kiedy
wartość Box<T> wychodzi poza zakres, dane na stercie, na które wskazuje
pudełko, również są czyszczone ze względu na implementację cechy Drop. Te
dwie cechy będą jeszcze ważniejsze dla funkcjonalności zapewnianej przez inne
typy inteligentnych wskaźników, które omówimy w pozostałej części tego
rozdziału. Przeanalizujmy te dwie cechy bardziej szczegółowo.