Utilizarea Box<T> pentru a arăta spre date situate pe heap

Cel mai simplu smart pointer este boxa, ale cărei tip este notat ca Box<T>. Boxele permit stocarea datelor pe heap în loc de pe stivă. Ce rămâne pe stivă este pointerul către datele de pe heap. Pentru a revizui diferența dintre stivă și heap, putem reveni la Capitolul 4.

Boxele nu aduc un supra-cost de performanță decât în ceea ce privește stocarea datelor pe heap în locul stivei. Dar, ele nu oferă nici multe funcționalități extra. Vom utiliza boxele cel mai frecvent în următoarele situații:

  • Atunci când deținem un tip a cărui dimensiune nu poate fi stabilită în timpul compilării și dorim să utilizăm o valoare de acest tip într-un context care necesită o dimensiune exactă
  • Atunci când avem o cantitate mare de date și dorim să transferăm posesiunea, dar să ne asigurăm că datele nu vor fi copiate în acest proces
  • Atunci când dorim să deținem o valoare și ne interesează doar ca aceasta să implementeze o anumită trăsătură, mai degrabă decât să fie de un tip anume

Primul caz va fi demonstrat în secțiunea “Activarea Tipurilor Recursive folosind Boxe”. În al doilea caz, transferul posesiunii asupra unei cantități mari de date poate dura mult deoarece datele sunt copiate pe stivă. Pentru îmbunătățirea performanței în această situație, putem stoca acele date pe heap într-o boxă. Astfel, doar o mică parte din datele pointerului sunt copiate pe stivă, pe când datele la care se referă rămân într-un singur loc pe heap. Al treilea caz este cunoscut sub numele de obiect-trăsătură, iar Capitolul 17 consacră o întreagă secțiune, “Utilizarea obiectelor-trăsătură ce permit valori de tipuri diverse,” exclusiv acestui subiect. Deci, tot ce învățăm aici va fi aplicat din nou în Capitolul 17!

Utilizarea unei Box<T> pentru a stoca date pe heap

Înainte de a discuta cazul de utilizare a Box<T> pentru stocare pe heap, vom revizui sintaxa și cum interacționăm cu valorile închise într-o Box<T>.

Listarea 15-1 ilustrează cum să folosești o boxă pentru a păstra o valoare i32 pe heap:

Numele fișierului: src/main.rs

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

Listarea 15-1: Stocarea unei valori i32 pe heap utilizând o boxă

Definim variabila b cu valoarea unui Box care indică spre valoarea 5, alocată pe heap. Acest program va afișa b = 5; în acest context, putem accesa datele din boxă într-un mod asemănător cu cel în care am accesa datele dacă ar fi pe stivă. Așa cum se întâmplă cu orice valoare deținută, când un Box iese din domeniul de vizibilitate, cum se întâmplă pentru b la finalul main function, se va proceda la dealocarea acestuia. Dealocarea are loc atât pentru boxă (pe stivă), cât și pentru datele la care face referire (pe heap).

A așeza o singură valoare pe heap nu este frecvent utilă, prin urmare utilizarea în izolare a boxelor în acest mod nu este des întâlnită. În majoritatea situațiilor, este mai potrivit să avem valori precum o instanță i32 pe stivă, unde în mod implicit sunt stocate. Să examinăm acum o situație în care boxele ne permit să definim tipuri care în alt mod n-ar fi posibile fără existența boxelor.

Permiterea tipurilor recursive cu boxe

O valoare de tip recursiv poate include în ea însăși o altă valoare de același tip. Tipurile recursive creează o problemă în Rust deoarece, la momentul compilării, este necesar să se știe cât spațiu ocupă fiecare tip. Însă, întrepătrunderea valorilor de tipuri recursive teoretic nu are sfârșit, astfel Rust nu poate deduce cât spațiu va fi necesar. Utilizarea boxelor, care au o dimensiune fixă cunoscută, ne permite să activăm tipurile recursive prin introducerea unei boxe în definiția tipului recursiv.

Drept exemplu de tip recursiv, să analizăm lista cons. Aceasta este un tip de date des întâlnit în limbajele de programare funcțională. Tipul listei cons pe care îl vom defini este simplu, mai puțin partea recursivă; deci, conceptele din exemplul cu care vom lucra ne vor fi de folos în orice moment când ne confruntăm cu situații mai complexe ce implică tipuri recursive.

Mai multe informații despre lista cons

O cons list (listă cons) este o structură de date originară din limbajul de programare Lisp și din dialectele acestuia, alcătuită din perechi încapsulate și reprezintă versiunea în Lisp a unei liste înlănțuite. Numele provine de la funcția cons (prescurtarea pentru “construct function”) din Lisp, care construiește o nouă pereche pe baza celor doi parametri. Prin apelarea recursivă a funcției cons pe o pereche formată dintr-o valoare și o altă pereche, putem crea liste cons alcătuite din perechi recursive. Iată un exemplu de reprezentare în pseudocod a unei liste cons care include secvența 1, 2, 3; fiecare pereche fiind închisă în paranteze:

(1, (2, (3, Nil)))

Fiecare element al unei liste cons cuprinde două componente: valoarea actuală și următorul element din listă. Ultimul element conține numai o valoare numită Nil, fără un succesor. O listă cons este generată prin apeluri recursive ale funcției cons. Termenul recunoscut universal pentru a desemna situația inițială a recursivității este Nil. Este important de subliniat că acesta nu coincide cu noțiunea de „null” sau „nil” menționată în Capitolul 6, ce se referă la o valoare nevalidă sau lipsă.

Lista cons nu este o structură de date des întâlnită în Rust. Majoritatea timpului, când avem de-a face cu o listă de elemente în Rust, opțiunea Vec<T> se dovedește a fi mai practică. Alte structuri de date recursive mai complexe sunt utile în situații variate, dar introducerea noțiunii de listă cons în acest capitol ne permite să explorăm cum boxele ne ajută să definim un tip de date recursiv, fără alte distrageri.

Listarea 15-2 conține definiția unui enum pentru o listă de tip cons. Observăm că acest cod nu o să se compileze încă, pentru că tipul List nu are o mărime cunoscută, lucru pe care îl vom demonstra.

Numele fișierului: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

Listarea 15-2: Prima încercare de definire a unui enum pentru a reprezenta o structură de date listă cons de valori i32

Notă: Implementăm o listă cons care conține numai valori i32 pentru acest exemplu. Am fi putut să utilizăm generici, așa cum am discutat în Capitolul 10, pentru a defini un tip de listă cons capabil să stocheze valori de orice tip.

Utilizând tipul List pentru a stoca lista 1, 2, 3 arată ca și codul din Listarea 15-3:

Numele fișierului: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

Listarea 15-3: Folosind enum-ul List pentru a stoca lista 1, 2, 3

Prima valoare Cons păstrează 1 și încă o valoare List. Această valoare List este alte o valoare Cons care păstrează 2 și încă o valoare List. Ultima valoare List este încă o valoare Cons care păstrează 3 și o valoare List, care până la urmă este Nil, varianta non-recursivă ce semnalează finalul listei.

Dacă încercăm să compilăm codul din Listarea 15-3, vom întâmpina eroarea afișată în Listarea 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>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` due to previous error

Listarea 15-4: Eroarea întâlnită când încercăm să definim un enum recursiv

Eroarea indică că acest tip "are dimensiune infinită". Acest lucru se întâmplă deoarece am definit List cu o variantă care este recursivă, aceasta conținând direct altă valoare de tipul său. Drept rezultat, Rust nu poate stabili cât spațiu e necesar pentru a stoca o valoare de tip List. Să analizăm de ce apare această eroare. În primul rând, să vedem cum Rust decide cât spațiu e necesar pentru a stoca o valoare de tip non-recursiv.

Calcularea dimensiunii unui tip non-recursiv

Reamintim enum-ul Message definit în Listarea 6-2, unde am analizat definițiile enum-urilor în Capitolul 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Pentru a calcula cât spațiu este necesar pentru o valoare de tip Message, Rust inspectează fiecare variantă pentru a determina care variază cel mai mult în dimensiune. Rust observă că Message::Quit nu ocupă spațiu, Message::Move necesită destul spațiu pentru două valori i32, etc. Cum doar una dintre variante va fi utilizată, cantitatea maximă de spațiu pe care o valoare Message o poate ocupa este dată de dimensiunea celei mai mari variante.

Prin contrast, observăm ce se întâmplă când Rust încearcă să determine cât spațiu este necesar pentru un tip recursiv, cum ar fi enum-ul List din Listarea 15-2. Compilatorul începe analiza cu varianta Cons, care include o valoare i32 și una de tip List. Astfel, Cons necesită un spațiu egal cu dimensiunea unui i32 adăugată la dimensiunea unui List. Pentru a deduce cât spațiu îi trebuie tipului List, compilatorul se uită la variante, pornind de la Cons. Aceasta conține o valoare i32 și una List, iar această recursivitate continuă ad infinitum, așa cum e prezentat în Figura 15-1.

Figura 15-1: O listă List infinită compusă din variante Cons la infinit

Folosirea lui Box<T> pentru a realiza un tip recursiv cu dimensiunea cunoscută

Pentru că Rust nu este capabil să calculeze automat cât spațiu de memorie trebuie alocat pentru tipurile definite recursiv, compilatorul va arăta o eroare cu următoarea sugestie utilă:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

Aici, „un nivel de indirecție” sugerează că în loc să stocăm direct o valoare, ar trebui să modificăm structura de date astfel încât valoarea să fie stocată în mod indirect, prin intermediul unui pointer care să indice către acea valoare.

Din moment ce un Box<T> este un pointer, Rust întotdeauna va ști cât spațiu necesită un Box<T>: mărimea unui pointer rămâne constantă, indiferent de volumul de date la care face referire. Asta înseamnă că putem utiliza o boxă Box<T> în varianta Cons pe locul unei valori List directe. Box<T> va referi la următoarea intrare List, care va fi amplasată în heap și nu direct în varianta Cons. În esență, avem în continuare o listă, alcătuită din liste care conțin alte liste, însă această implementare este acum mai aproape de ideea de a așeza elementele unul lângă celălalt decât unul în altul.

Putem modifica definiția enum-ului List din Listarea 15-2 și utilizarea List din Listarea 15-3 cu codul din Listarea 15-5, care se va compila:

Numele fișierului: src/main.rs

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

Listarea 15-5: Definiția lui List care folosește Box<T> pentru a asigura o dimensiune cunoscută

Varianta Cons necesită dimensiunea unui i32 plus spațiul necesar pentru a stoca datele pointer-ului boxei. Varianta Nil nu stochează nicio valoare, deci ocupă mai puțin spațiu decât varianta Cons. Acum știm că orice valoare de tip List va ocupa dimensiunea unui i32 plus dimensiunea datelor pointer-ului unei boxe. Folosind o boxe am întrerupt lanțul infinit, recursiv, permițând astfel compilatorului să calculeze dimensiunea de care are nevoie pentru a stoca o valoare List. Figura 15-2 ilustrează aspectul curent al variantei Cons.

O listă cons finită

Figura 15-2: O listă List care nu este de dimensiuni infinite deoarece Cons conține un tip Box

Boxele oferă doar indirectare și alocarea memoriei pe heap; ele nu dispun de alte capabilități speciale, asemenea celor pe care le vom examina la alte categorii de pointeri inteligenți. Totodată, ele nu implică o supraplată de performanță asociată acestor capabilități speciale, fiind astfel folositoare în situații precum lista cons, unde indirectarea este singurul atribut necesar. Vom analiza mai multe întrebuințări pentru boxe și în Capitolul 17.

Tipul Box<T> este considerat un pointer inteligent deoarece implementează trăsătura Deref, ceea ce îi permite lui Box<T> să fie tratat ca o referință. Când o valoare de tip Box<T> iese din domeniul de vizibilitate, datele de pe heap la care indică boxa sunt și ele eliberate, datorită implementării trăsăturii Drop. Aceste două trăsături sunt și mai importante pentru funcționalitățile oferite de celelalte tipuri de pointeri inteligenți pe care le vom discuta în restul capitolului. Să ne aprofundăm cunoștințele despre aceste două trăsături.