Tipuri avansate
Sistemul de tipuri din Rust include unele caracteristici care ne-au fost deja prezentate, dar nu le-am explorat în profunzime până acum. Vom începe prin a discuta despre pattern-ul newtype în general, analizând motivul pentru care tipurile newtype sunt utile. Apoi vom aborda pseudonimele de tip, o funcționalitate similară cu newtype, dar cu semantici ușor diferite. De asemenea, vom discuta despre tipul !
și despre tipurile cu dimensiune dinamică.
Utilizarea pattern-ului newtype pentru siguranța și abstractizarea tipului
Notă: Această secțiune presupune că ai citit secțiunea anterioară „Utilizarea pattern-ului newtype pentru implementarea trăsăturilor externe pe tipuri externe”
Pattern-ul newtype este de asemenea util în alte situații decât cele pe care le-am tratat până acum, servind atât pentru a ne asigura că valorile nu sunt confundate între ele în mod static, cât și pentru a indica unitățile unei valori. Am văzut un exemplu în care tipurile newtype sunt folosite pentru a indica unitățile în Listarea 19-15: să ne amintim că structurile Millimeters
și Meters
conțineau valori u32
învelite în newtype. Dacă am compune o funcție cu un parametru de tip Millimeters
, programul nostru nu ar compila dacă am încerca să apelăm acea funcție cu o valoare de tip Meters
sau un u32
simplu.
În plus, putem folosi pattern-ul newtype pentru a abstractiza anumite detalii de implementare ale unui tip: noul tip poate expune o interfață API publică diferită de cea a tipului intern privat.
Newtype este folositor și pentru a masca implementarea internă. De exemplu, am putea crea un tip People
pentru a împacheta un HashMap<i32, String>
care păstrează ID-ul unei persoane asociat cu numele acesteia. Codul care interacționează cu People
s-ar limita doar la interfața API publică pe care o furnizăm, cum ar fi o metodă pentru adăugarea unui șir de caractere reprezentând numele în colecția People
; acest cod nu ar avea nevoie să cunoască faptul că intern folosim un ID de tip i32
pentru nume. Pattern-ul newtype este o cale eficientă de a atinge încapsularea pentru a masca detalii de implementare, un aspect pe care l-am discutat în secțiunea „Încapsularea care ascunde detaliile implementării” din Capitolul 17.
Crearea sinonimelor de tip prin aliasuri de tip
Rust ne permite să declarăm un alias de tip pentru a oferi unui tip existent un nume alternativ. Facem acest lucru utilizând cuvântul cheie type
. De exemplu, putem crea aliasul Kilometers
pentru i32
astfel:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Aliasul Kilometers
acum funcționează ca un sinonim pentru i32
; în contrast cu tipurile Millimeters
și Meters
pe care le-am creat anterior, în Listarea 19-15, Kilometers
nu constituie un tip nou și separat. Valorile de tipul Kilometers
vor fi tratate identic cu valorile de tip i32
:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Fiindcă Kilometers
și i32
sunt de fapt același tip, putem adăuga împreună valori de ambele tipuri și putem transmite valori de tip Kilometers
către funcții care acceptă parametrii de tip i32
. Cu toate acestea, această metodă nu ne conferă avantajele verificării de tipuri pe care le avem cu modelul newtype, despre care am discutat anterior. Adică, dacă am confunda valorile Kilometers
cu cele i32
într-un anumit loc, compilatorul nu va genera eroare.
Utilizarea principală a sinonimelor de tip este de a diminua repetiția. De pildă, putem să ne confruntăm cu un tip complicat ca acesta:
Box<dyn Fn() + Send + 'static>
Scriind acest tip extins în semnăturile funcțiilor și ca adnotări de tip de-a lungul codului poate fi anevoios și susceptibil de erori. Imaginează-ți un proiect plin cu cod similar acelui din Listarea 19-24.
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(|| ()) } }
Listarea 19-24: Folosirea unui tip lung în numeroase locuri
Un alias de tip simplifică gestionarea acestui cod prin reducerea frecvenței repetării. În Listarea 19-25, am introdus aliasul Thunk
pentru tipul lung și acum putem înlocui toate aparițiile acelui tip cu aliasul mai concis 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(|| ()) } }
Listarea 19-25: Introducerea unui alias de tip Thunk
pentru diminuarea repetiției
Acest cod este mult mai simplu de citit și scris! Selectarea unui nume sugestiv pentru un alias de tip poate contribui și la transmiterea intenției noastre (termenul thunk se referă la codul care va fi evaluat ulterior, deci este un nume adecvat pentru o închidere care este păstrată încă neevaluată).
Aliasurile de tip sunt frecvent utilizate și împreună cu tipul Result<T, E>
pentru a diminua repetiția. Să luăm ca exemplu modulul std::io
din biblioteca standard. Operațiile de I/O returnează de obicei un Result<T, E>
pentru a aborda cazurile când aceste operații eșuează. Biblioteca conține structura std::io::Error
, care reprezintă toate erorile de I/O posibile. Multe dintre funcțiile din std::io
vor returna un Result<T, E>
unde E
este std::io::Error
, cum ar fi funcțiile din trăsătura 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>;
}
Expresia Result<..., Error>
se repetă deseori. Drept urmare, std::io
declară acest alias de tip:
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<()>;
}
Fiindcă această declarație se află în modulul std::io
, putem utiliza aliasul complet specificat std::io::Result<T>
; adică, un Result<T, E>
unde E
este specificat drept std::io::Error
. Funcțiile din semnăturile trăsăturii Write
capătă în cele din urmă următoarea înfățișare:
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<()>;
}
Aliasul de tip ne ajută în două feluri: îmbunătățește concizia codului și oferă o interfață coerentă întregului modul std::io
. Deoarece este un alias, rămâne pur și simplu un alt Result<T, E>
, ceea ce înseamnă că putem aplica asupra lui orice metodă compatibilă cu Result<T, E>
, inclusiv folosirea de sintaxă specială, cum ar fi operatorul ?
.
Tipul `never`` care nu returnează niciodată
Rust dispune de un tip special numit !
, cunoscut în terminologia teoriei tipurilor ca tipul gol deoarece nu posedă nicio valoare. Totuși, preferăm să-l numim tipul never deoarece este utilizat în locul tipului de retur când o funcție nu va returna niciodată. Iată un exemplu:
fn bar() -> ! {
// --snip--
panic!();
}
Acest cod se citește ca „funcția bar
returnează never.” Funcțiile care returnează never sunt numite funcții divergente. Imposibilitatea creării de valori de tipul !
implică faptul că bar
nu va putea returna niciodată.
Dar care este utilitatea unui tip pentru care nu putem crea valori? Vom reaminti codul din Listarea 2-5, care face parte din jocul de ghicit numerele; iată o reproducere parțială în Listarea 19-26:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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;
}
}
}
}
Listarea 19-26: Un match
cu o ramură care se încheie cu continue
Pe atunci, am omis detalii importante ale acestui cod. În Capitolul 6, secțiunea „Operatorul de control al fluxului match
”, am explicat că toate ramurile match
trebuie să returneze același tip. Astfel, codul următor nu este funcțional:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
Tipul lui guess
de aici ar trebui să fie atât integer cât și string, dar Rust afirmă că guess
trebuie să aibă un singur tip definit. Atunci, ce returnează continue
? Cum a fost posibil să returnăm u32
dintr-o ramură și o altă ramură să se încheie cu continue
în Listarea 19-26?
După cum probabil ai dedus, continue
are valoarea !
. Adică, când Rust determină tipul lui guess
, analizează ambele ramuri ale match
-ului, prima cu o valoare u32
și a doua cu valoarea !
. Din moment ce !
nu poate avea vreo valoare, Rust decide că tipul lui guess
este u32
.
Descrierea formală a acestui comportament este că expresiile de tip !
pot fi transformate în orice alt tip. Este permis să finalizăm această ramură de match
cu continue
deoarece continue
nu produce o valoare; în schimb, redirecționează controlul la începutul buclei, așadar în cazul Err
, guess
nu primește nicio valoare.
Tipul never este de asemenea valoros în contextul macro-ului panic!
. Să ne amintim de funcția unwrap
pe care o utilizăm pe valori de tip Option<T>
pentru a obține o valoare sau a genera panică; iată definiția ei:
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"),
}
}
}
Aici, se întâmplă exact ca în cazul match
-ului din Listarea 19-26: Rust observă că val
are tipul T
și panic!
are tipul !
, astfel rezultatul întregii expresii match
este T
. Acest cod este valid pentru că panic!
nu generează o valoare; el încheie execuția programului. În cazul None
, nu vom returna o valoare din unwrap
, deci acest fragment de cod este corect.
O ultimă expresie care are valoarea !
este loop
:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
În acest caz, bucla nu se finalizează niciodată, prin urmare !
este valoarea expresiei. Totuși, aceasta nu ar fi adevărat dacă am adăuga un break
, întrucât bucla s-ar opri odată ce s-ar ajunge la break
.
Tipuri dinamic dimensionate și trăsătura Sized
Rust trebuie să cunoască detalii specifice despre tipurile sale, cum ar fi cantitatea de spațiu necesară alocării pentru o valoare de un anumit tip. Acest lucru crează o anumită confuzie în sistemul de tipuri, la prima vedere: conceptul de tipuri dinamic dimensionate (dynamically sized types, DST) sau tipuri fără mărime fixă. Aceste tipuri ne permit să scriem cod folosind valori a căror mărime o putem determina doar în timpul execuției.
Să explorăm în detaliu un tip dinamic dimensionat numit str
, pe care l-am utilizat constant în această carte. Da, nu &str
, ci str
în sine este un DST. Nu putem determina lungimea string-ului decât în timpul execuției, ceea ce înseamnă că nu putem crea o variabilă de tip str
, nici nu putem accepta un argument de acest tip. Să explorăm următorul exemplu de cod, care nu va compila:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust necesită să știe de câtă memorie are nevoie fiecare valoare a unui tip specific, și fiecare valoare a acelui tip trebuie să folosească aceeași cantitate de memorie. Dacă Rust ar permite acest cod să fie scris, cele două valori str
ar trebui să ocupe același spațiu. Dar ele au lungimi diferite: s1
necesită 12 octeți de stocare, s2
- 15. Acest motiv face imposibilă crearea unei variabile de tip dinamic dimensionat.
Deci, ce putem face? Deja știi soluția: tipizăm s1
și s2
ca &str
în loc de str
. Așa cum am discutat în secțiunea “Secțiuni de string” din Capitolul 4, o structură de tip secțiune stochează doar poziția de start și lungimea secțiunii. De aceea, în timp ce un &T
este o valoare care menține adresa de memorie unde T
se află, un &str
este format din două valori: adresa 'str'-ului și lungimea acestuia. Astfel, noi putem cunoaște mărimea unei valori &str
în momentul compilării: este de două ori mărimea unui usize
. Asta înseamnă că știm întotdeauna mărimea unui &str
, indiferent cât de lung este string-ul referențiat. În mod obișnuit, în Rust, DST-urile se folosesc astfel: conțin o porțiune suplimentară de metadate care stochează mărimea informației dinamice. Regula fundamentală a tipurilor dinamic dimensionate este aceea că valorile lor trebuie să fie întotdeauna plasate în spatele unui tip de pointer.
Putem să combinăm str
cu diverse tipuri de pointeri, cum ar fi Box<str>
sau Rc<str>
. De fapt, te-ai întâlnit deja cu acest concept, dar în cazul unui alt tip dinamic dimensionat: trăsăturile. Fiecare trăsătură este un DST la care ne putem referi folosind numele trăsăturii. În Capitolul 17, în secțiunea “Utilizând obiecte trăsătură care termit valori de diferite tipuri”, am menționat că pentru a folosi trăsăturile ca obiecte trăsătură, trebuie să le punem în spatele unui pointer, cum ar fi &dyn Trait
sau Box<dyn Trait>
; varianta Rc<dyn Trait>
este, de asemenea, validă.
Pentru a lucra cu DST-uri, Rust folosește trăsătura Sized
, care ne spune dacă mărimea unui tip este cunoscută la momentul compilării. Această trăsătură este automat implementată pentru toate elementele ale căror dimensiuni pot fi determinate la compilare. Mai mult, Rust adaugă implicit o constrângere Sized
la orice funcție generică. Astfel, definiția unei funcții generice precum:
fn generic<T>(t: T) {
// --snip--
}
este tratată de parcă am fi scris de fapt așa:
fn generic<T: Sized>(t: T) {
// --snip--
}
Implicit, funcțiile generice vor funcționa doar cu tipuri a căror mărime este cunoscută la momentul compilării. Cu toate acestea, putem folosi următoarea sintaxă specială pentru a slăbi această restricție:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
O constrângere pe ?Sized
înseamnă că "T poate sau nu poate fi Sized
", și această notație anulează implicitul conform căruia tipurile generice trebuie să aibă o dimensiune cunoscută la momentul compilării. Sintaxa ?Trait
cu acest înțeles este disponibilă numai pentru Sized
, nu și pentru alte trăsături.
Notăm, de asemenea, că am schimbat tipul parametrului t
din T
în &T
, deoarece tipul poate să nu fie Sized
, și astfel, trebuie să lucrăm cu el prin intermediul unui anumit tip de pointer. În acest caz, am optat pentru o referință.
În următoare secțiune, vom aborda subiectul funcțiilor și închiderilor!