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á.