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.