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

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

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

Egy tipp a kódrészletek teszteléséhez, ha nem akarsz egy egész Rust fejlesztői környezetet telepíteni: szimplán kattints ide play.rust-lang.org és másold be a kódot.

Slice vagy szelet

Eddig nem ejtettem szót a Rust egyik érdekes primitív típusáról, a slice-ról. A slice vagy magyarul szelet egy nagyobb összefüggő memóriaterület egy bizonyos részletére mutató referencia (lényegében egy pointer ami a memóriaterület kezdőcímére mutat és egy pozitív egész szám, ami a hivatkozott memóriaterület hosszát adja meg).

A leggyakrabban használt slice típusok tömbök, String-ek vagy vektorok egyes részeire mutatnak.

Működésüket legegyszerűbben a string slice-okkal lehet szemléltetni:

fn main() {
    let s1: &str = "valami";
    let s2: String = String::from("valami");
    let slice1: &str = &s1[0..2];
    let slice2: &str = &s2[0..2];

    println!("{} {}", slice1, slice2);
}

Az első s1 string slice a program kódjában rögzített "valami" karakterszekvenciára mutat. Az s2 nem slice hanem egy String típusú struktúra, ami dinamikus memóriából allokált területen tárolja a "valami" érték másolatát.

A slice1 slice az s1 slice első két karakterére mutat, a slice2 slice az s2 String első két karakterére. Látszólag nincs közöttük nagy különbség, mindkettő a "va" értéket írja ki println! makróban.

Amiben lényeges különbség van, az az élettartamok. Az első s1 és a belőle származtatott slice1 slice-ok a program kódjában tárolt "valami" karaktersorozatra mutatnak. Mivel a karaktersorozat a program kódjában rögzített, az élettartama statikus (static), a program működésének ideje alatt végig elérhető. Ebből következik, hogy a rá mutató referenciák élettartama is static, vagyis akárhol használhatjuk őket a program élete során.

Ezzel szemben az s2 String már a dinamikus memóriából allokált területre mutat, a String élettartama csak a main metódus végéig tart, hiszen az s2 lokális változó ott törlődik a stack-ről. Ebből következik, hogy a rá mutató slice2 slice élettartama is csak a main metódus végéig tart. Itt ez most nem jelent óriási különbséget, de változtassunk kicsit a példakódon:

fn test() -> &'static str {
    let s1: &str = "valami";
    let s2: String = String::from("valami");
    let slice1: &str = &s1[0..2];
    let slice2: &str = &s2[0..2];
    
    return slice1;
}

fn main() {
    let s = test();
    
    println!("{}", s);
}

Ez a kód lefordul és működik, mert a static s1 slice-t adjuk vissza. A test metódus deklarációjában megjelent egy &'static lifetime specifier, ezzel adtuk a fordító tudtára, hogy a visszaadott slice statikus élettartamú lesz (magától már nem tudta volna kitalálni).

Ha ezzel szemben az alábbit változatot kísérelnénk meg:

fn test() -> &'static str {
    let s1: &str = "valami";
    let s2: String = String::from("valami");
    let slice1: &str = &s1[0..2];
    let slice2: &str = &s2[0..2];
    
    return slice2;
}

fn main() {
    let s = test();
    
    println!("{}", s);
}

akkor a korábbi dangle példában már látott problémába futunk: egy olyan változóra mutató referenciát próbálnánk meg visszaadni, ami a függvény visszatérése után már nem is fog létezni. A borrow checker ezt a hibát dobja:

error[E0515]: cannot return value referencing local variable `s2`

Kezdő Rust programozók gyakran elkövetik azt a hibát hogy ilyenkor a lifetime specifier megváltoztatásával próbálják megoldani a problémát, de minden ilyen kísérlet hiábavaló. A probléma nem a lifetime specifier, hanem az hogy a változó egyszerűen már nem fog létezni amikor megpróbáljuk visszaadni a rá mutató referenciát. Ezen semmilyen lifetime specifier nem segít.

A fenti problémát úgy lehet megoldani, ha nem egy referenciát hanem egy owned másolatot adunk vissza. Ha a visszatérési értékkel megy az ownership is akkor az érték a meghívó metódusban folytatja életét:

fn test() -> String {
    let s2: String = String::from("valami");
    let slice2: &str = &s2[0..2];
    
    return String::from(slice2);
}

fn main() {
    let s = test();
    
    println!("{}", s);
}

Természetesen ennek a műveletnek az ára plusz egy memória másolási művelet, ahogy létrehozzuk a visszatérési érték String-jét a lokális változóban tárolt String-ből.

Ez látszólag nem nagy költség, de sok kicsi sokra tud menni. Egy szemléletes példa az XML elemzés problémája. Az XML elemzés bemenete egyetlen hosszú String vagy string slice, ami az egész XML-t tartalmazza. Az elemzés eredménye egy DOM (document object model) fa, ami rengeteg apró struktúrából fog állni: minden XML element és attribute egy-egy DOM node struktúra lesz. Itt alapvetően két stratégiát lehet választani. Egyrészt lehet úgy implementálni hogy minden DOM node csak egy string slice-t fog tartalmazni, ami az eredeti nagy XML string egy-egy részletére mutat (pl. az element nevére, az attribute nevére, az attribute értékére vagy az element-ben levő szövegre). Másrészt lehet úgy implementálni, hogy minden DOM node lemásolja az eredeti XML string-ből a számára szükséges szöveg részleteket. Az első megoldás hátránya, hogy így az összes DOM node élettartama az eredeti bemeneti XML string élettartamához fog kötődni: mivel a slice-ok arra mutatnak, nem élhetik túl az eredeti XML string élettartamát. A második megoldás hátránya a lényegesen nagyobb memóriaigény és a lényegesen nagyobb futásidő. A memóriaigény minimum duplája lesz az első megoldásnak, a futásidőben pedig akár öt-tízszeres különbség is lehet! Ennyiért már megéri kicsit figyelni azokra az élettartamokra.

Lifetime specifiers

A Rust-ban a referenciák élettartamának jelölésére lifetime specifier-eket használunk, a formájuk egy aposztróf és utána néhány karakter, pl.: &'a

Egy függvény deklarációban:

fn test<'a>(s: &'a str) -> &'a str {
    return &s[0..2];
}

fn main() {
    let s1 = String::from("valami");
    let s2 = test(&s1);
    
    println!("{}", s2);
}

A test függvény kap egy generikus lifetime specifier-t, aminek neve &'a. Azzal hogy az s paraméterben és a visszatérési értékben is ezt az &'a lifetime specifier-t használjuk, azt fejezzük ki hogy a visszatérési érték élettartama az s paraméter élettartamától függ: nem maradhat tovább életben mint ameddig az s paraméter életben marad.

A main függvényben látható hogy a test függvénynek az s1 String-re mutató referenciát adunk át, aminek élettartama a main függvény végéig tart. Ebből következik hogy a lifetime deklarációnk miatt a test függvényből visszakapott érték élettartama is csak a main függvény végéig tarthat és az s2 változó élettartama is csak a main függvény végéig tarthat.

Egy bonyolultabb példa, két élettartammal:

fn test<'a, 'b>(haystack: &'a str, needle: &'b str) -> &'a str {
    if let Some(idx) = haystack.find(needle) {
        return &haystack[idx..];
    } else {
        return "";
    }
}

fn main() {
    let s1 = String::from("valami");
    let s2 = test(&s1, "la");
    
    println!("{}", s2);
}

A test függvény egyszerűen a needle string első előfordulását keresi meg a haystack string-ben, majd visszaadja a haystack tartalmát ettől az első előfordulástól kezdve.

A haystack és needle paraméterek különböző lifetime specifier-eket kaptak, mert különböző forrásokból érkezhetnek, különböző élettartamuk lehet. A visszatérési érték azért az &'a lifetime specifier-t kapta, mert egy olyan slice-t fog visszaadni ami a haystack tartalmára mutat, ennek következtében élettartama a haystack élettartamától függ.

Ha véletlenül az &'b lifetime specifier-t tettük volna a visszatérési értékre, azzal azt jeleztük volna hogy a visszatérési érték élettartama a needle paraméter élettartamával függ össze, ami az alábbi fordítási hibát eredményezte volna:

error: lifetime may not live long enough

Nagyon fontos hogy a lifetime specifier-rel semmi mást nem fejezünk ki, mint a függvény egyik paraméterének és a visszatérési értéknek a kapcsolatát: azt hogy a visszatérési érték élettartama melyik bemeneti paraméterek élettartamától függ.

Kezdőként nagyon gyakori hiba hogy a fenti lifetime may not live long enough és hasonló problémákat a lifetime specifier-ek módosítgatásával próbáljuk megoldani. Előfordul hogy valóban ez a hiba, ha véletlenül rossz paraméter élettartamával kapcsoltuk össze a visszatérési érték élettartamát, de sokkal gyakoribb hiba hogy egyszerűen a bemeneti paraméter élettartama nem megfelelő, nem él annyi ideig, amennyi ideig később a visszatérési értéket fel szeretnénk használni. Ezen a lifetime specifier-ek módosítgatásával nem lehet segíteni!

A függvényekben tárolt referenciákhoz hasonlóan a struct-okban tárolt referenciák esetén is lifetime specifier-eket kell használni:

#[derive(Debug)]
struct Test<'a> {
    one: &'a str,
    two: &'a str,
}

fn test<'a>(one: &'a str, two: &'a str) -> Test<'a> {
    return Test { one, two };
}

fn main() {
    let a = "one";
    let b = "two";
    
    let c = test(&a, &b);
    
    println!("{:?}", c);
}

Itt a Test struktúra 'a lifetime specifier-e egész egyszerűen azt jelenti hogy a struktúra élettartamát a benne levő két referencia élettartama korlátozza. A Test struktúra egy adott példánya soha nem élhet tovább, mint ameddig a benne hivatkozott két referencia élettartama tart. Ez azt is jelenti hogy ha a két referencia élettartama nem azonos, akkor a kettő közül a rövidebb élettartam lesz a meghatározó.

Végül említést érdemel két speciális lifetime specifier: a 'static és a '_. A static azt jelenti, hogy az adott referencia élettartama a program teljes futásidejére kiterjed (pl. azért mert egy programkódban tárolt karakterszekvenciára vagy egy static változóra mutat). A aláhúzás jel csak egy helyettesítő, amit akkor használunk ha valójában nem is érdekel minket az adott referencia élettartama.

Következő rész »

Címkék:
rust borrow-checker lifetimes