Miért nem elég a borrow checker önmagában
A borrow checker az, ami garantálja, hogy nem nyúlsz hozzá már felszabadított memóriához, és hogy nincs egyszerre mutable és immutable referenciád ugyanarra az adatra. De ez csak a fele a történetnek. A borrow checker ugyanis nem varázsol – ő is információra épít, és ez az információ a lifetime. A lifetime nem egy futásidejű dolog, nincs runtime költsége, tisztán fordítási időben létező metaadat, ami megmondja a compilernek, hogy egy referencia meddig érvényes.
Amikor a fordító önmagától is ki tudja találni a lifetime-okat, nem kell kiírnod – ez az elision. De amint a függvényed több referenciát is visszaad, vagy egy struct referenciát tárol, a compiler elveszti a fonalat, és neked kell megmondanod neki, mi hogyan függ össze egymással.
A lifetime annotáció nem hosszabbítja meg semmi élettartamát. Csak leírja a compilernek azt, ami már eddig is igaz volt a kódban – ez tehát nem egy új szabály, hanem egy meglévő tény kimondása.
Lifetime annotációk szintaxisa függvényeknél
Nézzük a klasszikus példát: egy függvény, amely két string szeletből (&str) visszaadja a hosszabbat.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("hosszú alma");
let s2 = String::from("körte");
let result = longest(s1.as_str(), s2.as_str());
println!("A leghosszabb: {result}");
}
Itt az 'a egy lifetime paraméter, amit generic paraméterként deklarálunk a <> közé, ugyanúgy, mint egy típusparamétert. A jelentése: "x, y és a visszatérési érték élettartama legalább annyi ideig tart, amíg az 'a érvényes." Nem azt mondja meg, hogy pontosan mekkora ez az élettartam – azt a hívási helyen dönti el a compiler, mindig a legszűkebb közös részt választva a bemenő referenciák közül.
Gondolj a lifetime paraméterre úgy, mint egy szerződésre: "a visszaadott referencia nem élhet tovább, mint a bemenő referenciák közül a rövidebb." A compiler ezt a szerződést kényszeríti ki minden hívási helyen.
Fontos, hogy az 'a maga nem határoz meg semmilyen konkrét hosszt – csak összekapcsolja a paraméterek és a visszatérési érték élettartamát egymással.
Struktúrák lifetime paraméterekkel
Ha egy struct referenciát tárol, a structnak is meg kell kapnia a lifetime paramétert, mert a compiler-nek tudnia kell, hogy a struct nem élhet tovább, mint a benne tárolt referencia.
struct Kereso<'a> {
szoveg: &'a str,
}
impl<'a> Kereso<'a> {
fn new(szoveg: &'a str) -> Self {
Kereso { szoveg }
}
fn tartalmaz(&self, minta: &str) -> bool {
self.szoveg.contains(minta)
}
}
fn main() {
let mondat = String::from("A Rust borrow checkere kőkemény, de igazságos.");
let kereso = Kereso::new(&mondat);
println!("{}", kereso.tartalmaz("borrow"));
}
Ha a mondat kikerülne a scope-ból, mielőtt a kereso-t használnánk, a fordító hibát dobna – pontosan ezt akarjuk elkerülni. A struct élettartama szorosan a benne tárolt &'a str élettartamához van kötve, tehát a Kereso<'a> sosem élhet tovább, mint a mondat.
Sokan itt esnek abba a hibába, hogy a struct-ba referencia helyett owned adatot (pl. String-et) tárolnak, csak hogy elkerüljék a lifetime paramétert. Ez működik, de néha felesleges klónozást (.to_owned(), .clone()) eredményez. Ha teljesítménykritikus a kód, gondold át, valóban szükséged van-e a saját tulajdonra, vagy elég a referencia.
Az elision szabályok, amikor nem kell kiírni
A Rust fordítója három egyszerű szabály alapján próbálja kitalálni a lifetime-okat, mielőtt hibát dobna. Ezeket hívjuk lifetime elision rules-nak:
- Minden referencia paraméter saját, elkülönült lifetime-ot kap.
- Ha pontosan egy input lifetime van, az lesz a kimenet lifetime-ja is.
- Ha a paraméterek között van
&selfvagy&mut self, akkor aselflifetime-ja lesz a kimenet lifetime-ja.
Ezért írhatunk így annotáció nélkül:
fn elso_szo(s: &str) -> &str {
match s.split_whitespace().next() {
Some(szo) => szo,
None => "",
}
}
Annotáció nélkül is fordul, mert a 2. szabály alapján a bemenet és kimenet lifetime-ja megegyezik – a compiler ezt automatikusan kikövetkezteti, mintha ezt írtuk volna: fn elso_szo<'a>(s: &'a str) -> &'a str.
De a longest függvényünknél, mivel két bemenő referencia van, az 1. szabály miatt mindkettő külön lifetime-ot kapna, és a 2. szabály nem alkalmazható (nem egyértelmű, melyikhez kötődjön a visszatérési érték) – ezért kellett explicit módon leírnunk az 'a-t, és rákényszerítenünk mindkét paramétert ugyanarra.
A &self metódusoknál a 3. szabály miatt ritkán kell explicit lifetime-ot írni, még akkor is, ha a metódus egy referenciát ad vissza a struct belsejéből.
Gyakorlati példák hibaüzenetek megfejtésével
Nézzünk egy tipikus hibát, amivel minden Rust-tanuló szembekerül előbb-utóbb:
struct Config<'a> {
nev: &'a str,
}
fn keszits_configot() -> Config<'static> {
let nev = String::from("ideiglenes");
Config { nev: &nev } // hiba!
}
A fordító ezt üzeni (nagyjából):
error[E0515]: cannot return value referencing local variable `nev`
--> src/main.rs:7:5
|
7 | Config { nev: &nev }
| ^^^^^^^^^^^^^^^^^^^^ returns a value referencing data owned by the current function
A magyarázat egyszerű: a nev egy lokális String, ami a függvény végén megszűnik létezni (drop-olódik). A Config<'static> visszatérési típus viszont azt ígéri, hogy a benne tárolt referencia örökké érvényes marad – ezt egy stack-en élő lokális változóval fizikailag nem lehet betartani. A megoldás vagy az, hogy owned String-et tárolunk a structban, vagy hogy statikus stringliterált adunk vissza:
struct Config<'a> {
nev: &'a str,
}
fn keszits_configot() -> Config<'static> {
Config { nev: "alapertelmezett" } // string literál -> 'static
}
Egy másik gyakori hibaüzenet a "borrowed value does not live long enough":
fn main() {
let r;
{
let x = 5;
r = &x; // hiba: x nem él elég ideig
}
println!("{r}");
}
Itt a fordító azt jelzi, hogy r a belső blokk lezárása után is használva van, de az x, amire mutat, a blokk végén megszűnik. A hibaüzenet mindig ugyanazt a mintát követi: keresd meg, melyik változó szűnik meg előbb, mint ahogy a rá mutató referenciát még használnád.
A hibaüzenetek olvasásánál mindig a --> nyilat követő fájlnév:sor:oszlop hármast nézd meg elsőként, majd a ^^^ jelölést – az pontosan megmutatja, melyik kifejezés okozza a problémát. A note: és help: sorok gyakran konkrét javítási javaslatot is adnak.
Összefoglalás
A lifetime-ok nem egy különálló, misztikus nyelvi réteg, hanem egyszerűen annak explicit kimondása, ami a memóriabiztonság mögött mindig is ott volt: melyik referencia meddig érvényes. A borrow checker ezt az infót használja fel, hogy garantálja, sosem nyúlsz hozzá már megszűnt adathoz. Ha megérted a három elision szabályt, hamar rájössz, hogy a valóságban a lifetime annotációk többségét a compiler magától kitalálja – csak azokban az esetekben kell kiírnod őket, amikor több referencia között kell egyértelműsítened a kapcsolatot. Gyakorolj sokat egyszerű függvényekkel és structokkal, olvasd figyelmesen a hibaüzeneteket, és a lifetime-ok hamarosan természetes részévé válnak a Rust-kódolási reflexeidnek.