Hogyan küzdhetünk meg a Rust élettartamok mitikus világával
Closures avagy zárványok
A zárványok fogalma ma már elég sok programnyelvben létezik. Ezek egyszerű anonim függvények, amik fel tudják használni a környezetükben létrehozott változókat. Például:
fn main() {
let x = 2;
let twice = |num: i64| -> i64 {
num * x
};
println!("Value: {}", twice(10));
}
Ebben a példában a twice
closure felhasználja az x
lokális változó értékét,
ami történetesen 2
így egy olyan anonim függvény jön létre ami kettővel szorozza
meg a paraméterül kapott értéket.
Egy closure a körülötte levő értékeket háromféle módon veheti át:
- csak olvasható kölcsönvétel (borrow unmutably)
- módosítható kölcsönvétel (borrow mutably)
- valamint tulajdonjog átmozgatásával (move ownership)
Illetve plusz egy a fenti példában látott eset, az implicit copy, ami csak skalár típusoknál és skalár típusokból alkotott összetett típusoknál működik, referenciát tartalmazó típusoknál nem.
Egy példa unmutable kölcsönvételre:
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let only_borrows = || println!("From closure: {:?}", list);
println!("Before calling closure: {:?}", list);
only_borrows();
println!("After calling closure: {:?}", list);
}
Mivel unmutable kölcsönvételből tetszőleges számú létezhet egyidőben, ez a kölcsönvétel semmiben nem korlátozza a változó későbbi használatát.
Példa a mutable kölcsönvételre:
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let mut borrows_mutably = || list.push(7);
// println!("Before calling closure: {:?}", list); // hibát okozna
borrows_mutably();
println!("After calling closure: {:?}", list);
}
Itt a változót a closure deklarálása és utolsó meghívása között már senki
nem használhatja, csak a closure utolsó meghívása után
(ezért vezetne hibára a második println!
).
Ha a closure-t nem csak lokálisan használjuk, hanem visszaadjuk a függvényből, akkor tovább komplikálódnak a dolgok:
fn test() -> impl Fn() -> () {
let list = vec![1, 2, 3];
let only_borrows = || println!("From closure: {:?}", list);
return only_borrows; // hiba!
}
fn main() {
let borrows = test();
borrows();
}
Itt már egy sima unmutable borrow is hibára vezet, mert a függvény végén visszaadunk egy closure-t, ami egy olyan változóra hivatkozik referenciával, ami nem éli túl a függvény végét:
error[E0373]: closure may outlive the current function, but it borrows `list`, which is owned by the current function
Ilyenkor kap szerepet a move
kulcsszó, amivel átvesszük az ownership-et:
fn test() -> impl Fn() -> () {
let list = vec![1, 2, 3];
let moves = move || println!("From closure: {:?}", list);
return moves;
}
fn main() {
let moves = test();
moves();
moves();
}
Természetesen ebben az esetben a moves
closure deklarálása után a list
változó
az eredeti függvényben már nem használható tovább, mert azt elmozgattuk.
A fenti impl Fn
deklaráció azt jelenti hogy a visszaadott closure akárhányszor
meghívható. Társai az impl FnOnce
és az impl FnMut
. Az FnOnce
csak egyszer
hívható meg, például azért mert elmozgat egy változót a környezetéből és ezt
csak egyszer lehet megtenni egy változóval. Az FnMut
olyan closure, ami mutable
reference-t tartalmaz a környezetére, vagyis módosítani tudja a környezetét,
így többször egymás után ugyanazokkal a paraméterekkel meghívva más-más
eredményt adhat. Példa az FnMut
használatára:
fn test() -> impl FnMut() -> () {
let mut n = 3;
let borrows = move || {
println!("From closure: {}", n);
n = n +1;
};
return borrows;
}
fn main() {
let mut borrows = test();
borrows();
borrows();
borrows();
}
A fenti példában a borrows()
minden lefutása más-más értéket fog kiírni.
Box, Rc és Arc
Eddig csak olyan típusokról volt szó, amiknek egyetlen tulajdonosa lehet, most továbblépünk a referenciaszámlálós típusok felé.
Az első típus amiről beszélnünk kell, az a Box
. Ez még nem referenciaszámlálós,
csak lehetővé teszi hogy más típusokat "becsomagoljunk". A Box
generikus típus
és mindig a heap-en fogja létrehozni a becsomagolt értéket, maga a Box
típusú
változó csak egy referenciát fog tartalmazni a heap-en létrehozott értékre. Például:
fn main() {
let x: Box<i64> = Box::new(5);
println!("value: {}", x);
}
Normál esetben egy i64
típusú változó a veremben jönne létre, a Box
azonban
a heap-en fogja létrehozni, a verembe csak maga a Box
struktúra kerül, ami
egy referenciát tartalmaz a heap-en tárolt értékre.
A Box
típus nagyon hasonlóan viselkedik az egyszerű referenciákhoz, ezt a
Deref
és DerefMut
trait-ek implementálásával éri el. Az első az unmutable,
a második a mutable referenciákhoz hasonló viselkedést biztosít.
Rc
A legegyszerűbb referenciaszámlálós típus az Rc
. Nagyon hasonló a Box
-hoz,
de ennek van egy clone()
művelete ami létrehoz egy új "másolatot" és megnöveli
eggyel a referenciaszámlálót. A másolat a heap-en tárolt értéket nem másolja le,
mind az eredeti, mind a klón példány ugyanarra a memóriaterületre fog mutatni.
use std::rc::Rc;
fn test() -> Rc<String> {
let x : Rc<String> = Rc::new(String::from("valami"));
let y = x.clone();
println!("value: {}", x);
println!("value: {}", y);
return y;
}
fn main() {
let z = test();
println!("value: {}", z);
}
A fenti példában a test
függvény elején létrehozott x
változó csak a test
függvény
végéig fog élni. A klónozott y
változót viszont visszaadjuk, ezzel együtt a
tulajdonjogát is továbbadjuk a main
függvénynek, így az a z
változóban a main
függvény végéig fog élni.
Amikor az x
változó létrejön, az Rc
referenciaszámlálója 1-re áll. Amikor létrehozzuk
az y
klónt, akkor a referenciaszámláló értéke 2-re nő. Amikor a test
függvény végére
érünk és az x
változó megszűnik, akkor az automatikusan meghívott drop
metódus 1-re
csökkenti a referenciaszámláló értékét. Végül a main
függvény végén a z
változó
megszűnésekor meghívott drop
metódus 0-ra csökkenti a referenciaszámlálót és ezzel
egyidőben felszabadítja a heap-en tárolt értéket.
Arc
A fenti Rc
típusnak van egy komoly korlátozása: a referenciaszámlálója nem thread-safe,
többszálú környezetben nem lehet biztonságosan módosítani, ezért kizárólag single-threaded
környezetben használható.
A mai processzorok biztosítanak olyan utasításokat, amelyek segítségével úgynevezett atomi műveleteket lehet végrehajtani. Ezek a műveletek többszálú környezetben sem szakíthatóak meg, a CPU garantálja hogy két konkurens atomi művelet úgy zajlik le mintha szigorúan egymás után futottak volna le (az hogy milyen sorrendben, nem definiált és nem is lényeges).
Ezekre alapozva a Rust std::sync::atomic
modulja létrehozott néhány atomi műveletekkel
módosítható típust, pl. AtomicBool, AtomicI64 és AtomicPtr. Ezekre alapozva már létrehozhatóak
olyan referenciaszámlálós típusok, amik többszálú környezetben is biztonságosan működnek.
Miért nem ezeket használja az Rc
alapértelmezésben? Azért mert az atomi CPU műveletek
lassabbak mint a normál CPU műveletek.
Az atomi műveletekre alapozott referenciaszámlálós típus az Arc
, alapvetően ugyanúgy kell
használni mint az Rc
-t, csak ez már biztonságosan használható többszálú környezetben
is. Például:
use std::sync::Arc;
fn main() {
let five = Arc::new(5);
let five_clone = five.clone();
println!("value: {}", five_clone);
}
Mind az Rc
, mind az Arc
típusnak van egy hiányossága: könnyedén lehet velük referencia
hurkokat létrehozni. Például ha elképzelünk egy linkelt lista implementációt, ahol minden
elem a következő elemre mutat és "véletlenül" az utolsó elemet sikerül úgy módosítani
hogy az visszamutasson az első elemre, akkor egy végtelen láncot kapunk. Ilyen esetben
a referenciaszámláló soha nem csökken nullára és memory leak-et hozunk létre. Ezeket
a gyenge (Weak) referenciákkal lehet megtörni, de ebbe most már nem mennék bele,
ha valakit érdekel, a Rust Book részletesen tárgyalja.
Interior mutability: RefCell, RwLock, Mutex
Eddig csak olyan változókról volt szó, amik vagy szabadon módosíthatóak,
vagy egyáltalán nem módosíthatóak. Ha módosíthatóak, akkor ezt már a deklarálásuknál
explicit jelölni kell a mut
kulcsszóval. Viszont a mutable változókra szigorú
korlátozások vonatkoznak: ha akár csak egyetlen mutable reference is mutat rájuk,
már sehol máshol nem mutathat rá más hivatkozás. Ez nagyon sok esetben kizárja a
mutable változók használatát. A Rust fordítója pesszimista statikus kódelemzést
használ: sok olyan esetben sem engedi meg a mutable reference létrehozását, ahol
valójában, a tényleges működés során nem jönne létre egy időben két mutable
referencia, de ezt a statikus kódelemzés nem tudja kizárni.
Erre a problémára született meg a RefCell
típus: ez futásidőben képes számontartani,
hogy a benne levő adatra milyen non-mutable és mutable referenciákat adott ki és
panic-ot okozni ha valaki megsérti azt az alapelvet, hogy a benne tárolt adatra
egy időben csak egy mutable reference létezhet. Maga a RefCell
típusú változó
mindig non-mutable lesz, a tartalma mégis módosítható. Egy példakód a dokumentációból:
use std::cell::{RefCell, RefMut};
use std::collections::HashMap;
use std::rc::Rc;
fn main() {
let shared_map: Rc<RefCell<_>> = Rc::new(RefCell::new(HashMap::new()));
// Create a new block to limit the scope of the dynamic borrow
{
let mut map: RefMut<'_, _> = shared_map.borrow_mut();
map.insert("africa", 92388);
map.insert("kyoto", 11837);
}
// Note that if we had not let the previous borrow of the cache fall out
// of scope then the subsequent borrow would cause a dynamic thread panic.
// This is the major hazard of using `RefCell`.
let total: i32 = shared_map.borrow().values().sum();
println!("{total}");
}
Látható hogy a shared_map
változó non-mutable, a tartalma egy borrow_mut()
meghívása után mégis módosítható. A mutable reference élettartamát viszont
korlátoznunk kell, hogy a későbbi non-mutable borrow()
hívásnál már ne legyen
életben, különben ott panic-et kapnánk.
A RefCell
az Rc
-hez hasonlóan nem thread-safe. Többszálú környezetben
biztonságosan használható alternatívái az RwLock
, Mutex
vagy például
az ArcSwap
típus. Az RwLock
és a Mutex
lock-ok (zárak) implementálásával
biztosítják, hogy egyszerre csak egy szál dolgozzon az adatokkal, az ArcSwap
pedig lehetővé teszi, hogy a benne levő referenciát egyetlen atomi művelettel
egy másikra cseréljük.