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

Posted 2023-12-23 13:30:00 by sanyi ‐ 11 min read

Hogyan küzdhetünk meg a Rust élettartamok mitikus világával

Nem igazán találtam még jó magyar nyelvű cikket a Rust legnehezebben megérthető részéről, a borrow checker-ről, ezért gondoltam megpróbálom elmagyarázni én mire jutottam vele eddig.

Kezdésnek néhány alapfogalmat kell tisztáznunk, ezekről szól ez a cikk.

A Rust típusrendszere

A Rust típusrendszere skalár és összetett típusokból áll.

Skalárok az integer, float, boolean és character típusok, de nem skalár a String.

Előjel nélküli egész típusok: u8, u16, u32, u64, u128 Ezek sorban 8, 16, 32, 64 és 128 bites egész számok, pl. u8 esetén 0 és 255 közötti értékek.

Előjeles egész típusok: i8, i16, i32, i64, i128 Ezek sorban 8, 16, 32, 64 és 128 bites előjeles egész számok, pl. i8 esetén -128 és 127 közötti értékek.

Az isize és usize típusok a cél architektúrának megfelelő méretűek, pl. amd64 architektúra esetén 64 bitesek.

Lebegőpontos (float) típusok az f32 és f64 (single és double precision).

Logikai (boolean) típus a bool, értéke true vagy false lehet.

Karakter (char) típus a char (ez egy 4 byte-os unicode karaktert tud tárolni).

Néhány példa:

fn main() {
    let a: i8 = 3;
    let b: f64 = 3.0;
    let c: bool = false;
    let d: char = 'z';
}

Összetett típusok a tuple, az array, a struct és az enum.

A tuple több különböző típusú értéket is összefoghat, pl.:

fn main() {
    let t: (u8, f64, bool) = (3, 3.0, false);
}

A tuple mérete és összetétele fix, nem változtatható.

Az array / tömb típus több azonos típusú elemet tartalmazhat, sem a típusa, sem a mérete nem változtatható. Pl. egy 5 elemű u8 tömb:

fn main() {
    let a: [u8; 5] = [ 1, 2, 3, 4, 5 ];
}

A struct vagy struktúra típus más nyelvek osztály / class típusához hasonló: nevesített tagváltozókat (tagmezőket) tartalmazhat. Pl.:

struct Point {
    x: i64,
    y: i64,
}

fn main() {
    let p = Point { x: 3, y: 5 };
}

Az enum típus elsősorban mintaillesztéshez használatos, különböző alternatívákat tud egy típusban összefogni. Pl.:

enum Message {
    Quit,
    Move(i32, i32),
}

fn main() {
    let m1 = Message::Quit;
    let m2 = Message::Move(3, 5);
}

Említést érdemel még a referencia (&) típus, ami egy másik változóra mutató hivatkozás. Hasonló a C pointer fogalmához, de a Rust esetében garantált hogy egy referencia mindig egy létező, élő változóra fog mutatni. Pl.:

fn main() {
    let a: u8 = 3;
    let b: &u8 = &a;
}

A referenciák a skalár típusokhoz hasonlóan viselkednek.

A Rust-ban léteznek generikus típusok vagy más néven paraméterezhető típusok. Ezek a típusok más típusokat fogadnak paraméterül. Az egyik legegyszerűbb példa a vektor (Vec) típus, ami a tömbhöz hasonlóan sok azonos típusú értéket tud tárolni, de ennek mérete dinamikusan változhat. Pl.:

fn main() {
    let v: Vec<u8> = vec![ 1, 2, 3, 4, 5 ];
}

Ebben az esetben a vektor u8 típusú elemeket fog tartalmazni, a Vec típus paramétere az u8 típus. (A vec! egy makró, ami egy vektort hoz létre a paraméterül átadott értékekből).

Memóriatípusok

A Rust szempontjából alapvetően háromféle memória létezik: program kód (static), verem (stack) és dinamikus memória (heap)

A program kódban tárolódnak a forráskódban leírt értékek, pl.:

fn main() {
    let s: &str = "valami";
}

Ebben az esetben a "valami" karaktersorozat (string slice) a programkódban tárolódik, így a program teljes futásideje alatt elérhető.

A verembe kerül minden olyan változó, amit egy blokkban (függvényben, metódusban, ciklus törzsében, stb.) deklarálunk a let kulcsszóval.

A verem egy nagyon egyszerű szerkezet, csak fix méretű változókat tud tárolni. Minden blokk kezdetén megnyílik egy új verem keret (stack frame) és ahogy deklaráljuk a változókat, azok úgy íródnak sorban a verembe. Amikor egy blokk véget ér, akkor az egész stack frame felszabadul, a verem mutató visszaáll a stack frame kezdetére, a blokkban deklarált változók megszűnnek létezni. Pl. az alábbi esetben az x változó a veremben tárolódik, lemásolva a programkódban megadott 3 értéket.

fn main() {
    let x = 3;
}

Összetett változók esetén magára a stack-re gyakran csak referenciák kerülnek. Pl. egy vektor esetén:

fn main() {
    let v: Vec<u8> = vec![ 1, 2, 3, 4, 5 ];
}

a stack-re csak a Vec<u8> struktúra kerül, ami lényegében egy pointer-t tárol egy dinamikusan allokált memóriaterületre (plusz néhány további adatot, pl. a tárolt elemek számát).

Végül a heap vagy dinamikus memória, az a terület amiből tetszőleges méretű blokkok allokálhatóak futásidőben, de ezeknek már a felszabadításáról is a programkódnak kell gondoskodnia. A heap-en allokált területek túl tudják élni egy-egy blokk, függvény vagy metódus futásidejét.

Pl. a fenti Vec<u8> példa esetén a Vec típus allokál a dinamikus memóriából egy akkora területet amiben el tudja tárolni a fenti 5 db u8 értéket, majd a Vec struktúrába eltárolja a memória területre mutató pointer-t. Ha a v változó megszűnik létezni, akkor felszabadítás előtt meghívódik a drop() metódusa, ami köteles gondoskodni a lefoglalt dinamikus memóriaterület felszabadításáról is.

Egy kezdő Rust programozó általában nem fog direktben pointer-ekkel dolgozni, ez a Rust-ban unsafe művelet, amit csak különös körültekintéssel szabad használni. Egyszerűen az előre megírt típusokat fogjuk használni, amik elrejtik előlünk a dinamikus memóriakezelés problémáit.

Végül pár szó a String típusról, amiről korábban már említettem hogy nem skalár típus. A String egy struktúra, ami lényegében egy Vec<u8> vektort foglal magába, amiben egy UTF-8 karaktersorozat byte-jait tárolja. Ebből következik hogy a String típus a heap-en, dinamikus memóriaterületen (is) tárol adatokat.

Trait-ek vagy jellemzők

Ami a Java-ban és sok más nyelvben az interface, az a Rust-ban a trait. De a Rust trait-jei nem csak az implementált metódusokat határozhatják meg, hanem a típusok alacsony szintű tulajdonságait is leírhatják.

A std::marker modul deklarál néhány alapvető trait-et.

Copy egy típus, ha egyszerű memória másolással többszörözhető. Ilyen pl. minden skalár típus (integer, float, boolean, character) és az ezekből alkotott enum, tuple és struct típusok. Lényegeben minden olyan összetett típus Copy ami nem tartalmaz referenciát vagy pointer-t.

Nem Copy például a String, mert az már tartalmaz egy dinamikusan allokált memóriaterületre mutató pointer-t, és magát a pointer-t hiába másoljuk le, attól még a memóriaterület ahol az adatok valójában vannak, továbbra is csak egy példányban lesz meg. Egy teljes értékű másolat készítéséhez ezt a hivatkozott memória területet is le kellene másolni.

String esetén a másoláshoz már egy clone() metódust kell meghívni ami elkészíti a teljes másolatot. Azok a típusok amik implementálják ezt a clone() metódust egyúttal implementálják a Clone trait-et is.

Amíg a Rust-ban a Copy művelet mindig automatikusan meghívódik ha egy változóról másolatot kell készíteni, a Clone műveletet a Rust soha nem hívja meg automatikusan, azt már nekünk kell explicit megtennünk.

Sized egy típus, ha előre ismert a mérete. Ez igaz pl. a skalár típusokra és azokból alkotott struct-okra, tuple-ökre.

Send egy típus, ha biztonságosan átküldhető thread-ek, szálak között. Alapvetően a Copy típusok ilyenek, de bonyolultabb struktúrák is implementálhatják.

Mutability

Na a mutability-re nincs jó magyar szavam, talán hívjuk "változtathatóságnak".

A Rust változók alapértelmezésben csak olvashatóak: az inicializálásnál felvesznek egy értéket és többé nem módosíthatóak. Ha egy változó értékét később is módosítani szeretnénk, azt a mut kulcsszóval kell jelölni.

fn main() {
    let x = 3;
    // x = x + 1; // - ez már hibát okozna
}

De:

fn main() {
    let mut x = 3;
    x = x + 1; // ez működik
}

A mutability sok helyen szerepet kap majd a borrow checker ellenőrzéseinél, általában egy non-mutable változóra engedékenyebb szabályok vonatkoznak mint egy mutable változóra. Pl. egyazon változóra tetszőleges számú non-mutable reference mutathat, a mutable reference viszont kizárólagos, abból csak egyetlen egy lehet, még egy non-mutable reference-t sem tűr meg maga mellett.

Ez például lefordul:

fn main() {
    let mut a = 3;
    a = a + 1;
    
    let b = &a;
    let c = &a;
    
    print!("{} {}", b, c)
}

ez viszont már hibára vezet:

fn main() {
    let mut a = 3;
    a = a + 1;
    
    let b = &mut a;
    let c = &a;
    
    print!("{} {}", b, c)
}

Élettartam

A következő fontos fogalom a lifetime vagy élettartam. Egy változó élettartama azt fejezi ki, hogy a program futása során mettől meddig érhető el az adott változó tartalma. Ha pl. egy függvényben deklarálunk egy lokális változót, az az adott függvényen belül lesz elérhető, az élettartama a függvény futásának végéig tart.

A lokális változók élettartama mindig egy kapcsos zárójelekkel határolt blokkra korlátozódik. Ez lehet egy függvény vagy metódus, lehet egy ciklus törzse, egy if-else elágazás valamelyik blokkja, de a Rust megenged ilyen egyszerű blokkokat is:

fn main() {
    let a = 3;
    
    {
        let b = 4;
    }
    
    print!("{} {}", a, b)
}

Ez a kód például már nem fog lefordulni, mert a b változó élettartama csupán egyetlen sorra korlátozódik, a print makró meghívásakor már nem elérhető.

Ownership

A Rust egyik fontos szabálya, hogy egy adatnak egyszerre mindig csak egy tulajdonosa (owner) lehet, mindenki más csak kölcsönkérheti egy időre. Pl. ha egy függvényben deklarálunk egy String lokális változót, az lefoglal egy memóriaterületet a heap-en és ennek a tulajdonosa a függvényben deklarált változó lesz. Ennek a String-nek az élettartama addig tart, amíg a függvény végére nem érünk. Amikor ez a lokális változó megszűnik létezni, akkor a Rust a változó törlése előtt meghívja a String drop() metódusát, ami a kapcsolódó memóriaterületet is felszabadítja a heap-en. Ez alól a szigorú egy-tulajdonos megkötés alól majd a referenciaszámlálós típusok (Rc, Arc) adnak felmentést - ott a változó addig fog élni amíg a referenciaszámlálója nullára nem csökken.

A Rust-ban egy változót kétféleképpen lehet továbbpasszolni: vagy kölcsönadjuk vagy mozgatjuk. Ha mozgatjuk, akkor átadjuk a tulajdonjogot pl. egy másik függvénynek. Ha egy változót elmozgatunk, akkor az eredeti helyén többé nem lesz elérhető.

Összetett típusok esetén létezik a részleges mozgatás fogalma is. Pl. ha van egy struct abban egy String és ezt a String-et átmozgatjuk valahova, az egy részleges mozgatás (partial move). Ha egy partial move után az egész struktúrát is megpróbálnánk mozgatni, az már hibához vezetne, hiszen egy részét már korábban elmozgattuk.

A kölcsönadás azt jelenti, hogy létrehozunk egy referenciát ami a változóra mutat és ezt a referenciát adjuk át (mozgatjuk) pl. egy másik függvénynek.

Ha csak kölcsönadunk egy változót, akkor addig nem használhatjuk amíg vissza nem kapjuk (pl. vissza nem tér a meghívott függvény aminek átadtuk). Utána viszont ismét mienk a kontroll.

Szinkron függvények esetében ez viszonylag triviálisan működik, aszinkron működés esetén kezdenek el elbonyolódni a dolgok, azokról majd később beszélek bővebben.

Borrow checker

A Rust borrow checker-ének feladata, hogy betartassa a programozóval azokat a szabályokat, amik a változók illetve a rájuk mutató referenciák élettartamával kapcsolatosak.

Egy egyszerű példa, amikor a borrow checker visítani fog:

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Mi a hiba? Létrehozunk egy lokális változót, ami a dangle függvény végén megszűnik létezni, majd visszaadunk egy rá mutató referenciát. Ahogy visszatérne a függvény, a referencia már a semmire, egy nem létező változóra mutatna. A C nyelvben nagyon könnyen el lehet követni ilyen hibákat, a Rust borrow checker-e viszont ilyenkor a kezünkre csap.

Ha a String-et vissza akarjuk adni, akkor nem referenciát kell visszaadnunk hanem az ownership-et:

fn good() -> String {
    let s = String::from("hello");
    
    s
}

fn main() {
    let s = good();
    
    print!("{}", s)
}

Az ownership átadása azt jelenti, hogy a good() metódus végén az s lokális változó tartalma átmásolódik a veremből a visszatérési értékbe. Az s String által lefoglalt memóriaterületről nem készül másolat (nem klónozzuk), csak a befoglaló struktúráról.

Ez az egy-tulajdonos elv azért nagyon fontos, mert így a Rust egyszerűen meg tudja valósítani a dinamikus memóriamenedzsmentet: amikor egy változó megszűnik létezni, akkor a hozzá tartozó dinamikus memóriaterületek is mind felszabadíthatóak, mert biztos hogy az adott változó a terület egyetlen tulajdonosa, nem lehet valahol máshol a programban másik változó, ami ugyanerre a memóriaterületre hivatkozik.

A Rust programozás során ezért az egyik legnagyobb fejtörést mindig az fogja okozni hogy ki legyen egy változó tulajdonosa. A lokális változók alapvető problémája hogy nem élik túl az adott függvény / metódus futásidejét.

A lokális változókkal szemben a másik véglet a statikus változók: ezek a program teljes futásideje alatt elérhetőek lesznek. Emiatt könnyű őket használni, nincsenek élettartam problémák, de vannak más hátrányaik. Egyrészt statikus jellegükből adódóan csak egyetlen példányban létezhetnek a program egészére, nem tudunk belőlük több különböző példányt létrehozni. Másrészt csak egyszer lehet őket inicializálni, az értékük nem változtatható dinamikusan (ez alól az interior mutability ad majd felmentéseket, erről később írok részletesebben).

Gyakran alkalmazott megoldás az is, hogy a main() függvényben deklarálunk egy változót, ami ebből fakadóan elérhető lesz a program egészére, de nem kötik a statikus változók korlátai. A encapsulation, egységbe zárás elvét persze nyilván ez is sérti.

Általában az a cél, hogy megtaláljuk a program működésében azt a legmélyebb pontot, ahol a változó már elég ideig él ahhoz hogy a számunkra szükséges esetekben mindig elérhető legyen, de csak addig él, amíg valóban szükség van rá.

Következő rész »

Címkék:
rust borrow-checker ownership