A Rust borrow checker működése, 3.

Posted 2023-12-30 14:30:00 by sanyi ‐ 8 min read

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.

Következő rész »

Címkék:
rust borrow-checker arc