Az előző leckében, az ellenőrző kvízben, alighanem összeraktad már a fejedben a borrow-szabályokat, a trait-eket és a generikákat egy nagyobb egésszé. A lifetime-ok pontosan ott lépnek be a képbe, ahol ezek találkoznak: amikor egy függvény vagy egy struct referenciákkal dolgozik, a Rust-nak garantálnia kell, hogy azok a referenciák sosem mutatnak majd "üres helyre" – olyan memóriára, amit már felszabadítottak. Eddig ezt a garanciát a borrow checker többnyire csendben, a hátunk mögött oldotta meg. Most megtanulod azt a szintaxist, amivel magad is elmondhatod neki, hogyan függenek össze a referenciák érvényességi idői.

Miért van szükség egyáltalán lifetime-okra?

Emlékezz vissza: egy &T referencia sosem élhet tovább, mint az az érték, amire mutat – ezt a scope és a drop kapcsán már láttuk. A probléma ott kezdődik, amikor egy függvény több referenciát kap paraméterként, és ezek közül visszaad egyet. A fordítónak ilyenkor tudnia kell, hogy a visszaadott referencia melyik bemenet érvényességi idejéhez van kötve – különben nem tudja garantálni, hogy a hívó kód biztonságosan használja majd az eredményt.

Nézzünk egy klasszikus esetet: egy függvényt, amely két string-szeletből visszaadja a hosszabbikat.

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ú szöveg");
    let s2 = String::from("rövid");
    let result = longest(s1.as_str(), s2.as_str());
    println!("A hosszabb: {result}");
}

A 'a itt nem valamiféle konkrét időtartamot jelöl, hanem egy kapcsolatot ír le: "a visszaadott referencia legalább addig érvényes, ameddig x és y mindegyike érvényes". Ez nem futásidejű ellenőrzés – a 'a semmilyen extra kódot nem generál, tisztán fordítási időben létező annotáció, amit a borrow checker használ a bizonyításhoz.

Megjegyzés

A 'a nevet szabadon választhatnád akár 'x-nek vagy 'input-nak is – a betűk csak konvenció, a fordítónak nem az elnevezés számít, hanem a paraméterek és a visszatérési érték közti megfeleltetés.

A 'a szintaxis: mit jelöl pontosan?

Ha kihagyjuk a lifetime annotációt a longest függvényből, a fordító nem találgat – hibát dob:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:38
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----      ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the
          signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++      ++              ++      ++

Ez a hibaüzenet lényegében pontosan megmondja a megoldást: mivel a függvény két bemeneti referencia közül választ, és a fordító nem tudja eldönteni, melyikhez tartozik a kimenet, neked kell kimondanod, hogy mindkettő ugyanazt a 'a lifetime-ot kapja. Ezzel azt ígéred: a visszatérési érték nem élhet tovább, mint a rövidebb életű bemenet.

Fontos, hogy a 'a a paraméterlistában és a visszatérési típusban ugyanazt a lifetime-ot jelöli – ez egy generikus paraméter, hasonlóan a <T>-hez, csak nem típusra, hanem érvényességi időre vonatkozik. Éppen ezért a <'a> a <T>-hez hasonlóan a függvénynév után, hegyes zárójelben szerepel.

Lifetime elision: amikor a fordító helyetted gondolkodik

Ha minden referenciát tartalmazó függvénynél ki kellene írnod a 'a-t, a Rust-kód gyorsan olvashatatlanná válna. Szerencsére a fordító három egyszerű szabály (az úgynevezett lifetime elision-szabályok) alapján a legtöbb esetben magától kitalálja az annotációt:

  1. Minden referencia-paraméter saját, külön lifetime-ot kap.
  2. Ha pontosan egy bemeneti referencia van, annak lifetime-ja lesz a kimenet lifetime-ja is.
  3. Ha a paraméterek között van &self vagy &mut self (tehát metódusról van szó), akkor a self lifetime-ja lesz a kimenet lifetime-ja.

Nézzünk egy függvényt, ahol pontosan a 2. szabály miatt nem kell semmit kiírnod:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    s
}

fn main() {
    let sentence = String::from("Rust lifetime-ok haladóknak");
    let word = first_word(&sentence);
    println!("Első szó: {word}");
}

A fordító itt magától úgy "látja" a szignatúrát, mintha ezt írtad volna:

fn first_word<'a>(s: &'a str) -> &'a str {
    // ...
    s
}

Ez épp az elision lényege: nem arról van szó, hogy ilyenkor nincs lifetime, hanem arról, hogy a szabály alapján egyértelműen kitalálható, így nem kötelező kiírni. A longest esetében viszont két bemeneti referencia volt, ezért a 2. szabály nem alkalmazható – a fordító nem tudja, melyik paraméterhez kötődik a kimenet, ezért kéri az explicit annotációt.

Tipp

Ökölszabályként: ha a hibaüzenetben E0106-ot (missing lifetime specifier) látsz, majdnem biztos, hogy több bemeneti referencia van, és a fordító nem tudja eldönteni, melyikhez kösse a kimenetet. Ilyenkor te mondod meg neki explicit 'a annotációval.

Struct-lifetime: amikor egy struct referenciát tárol

Eddig csak függvényeket néztünk, de a struct-ok is tárolhatnak referenciát mezőként – ehhez azonban a struct-nak is szüksége van egy lifetime paraméterre, ugyanúgy, ahogy a generikáknál a <T>-re volt szükség. Ha egy struct mezője &str vagy bármilyen más referencia, a Rust nem engedi meg, hogy a struct-nak ne legyen tudása arról, milyen hosszan él az, amire mutat.

struct Wrapper {
    text: &str,
}

fn main() {
    let s = String::from("példa szöveg");
    let w = Wrapper { text: &s };
    println!("{}", w.text);
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:2:11
  |
2 |     text: &str,
  |           ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 | struct Wrapper<'a> {
2 |     text: &'a str,
  |

A javítás pontosan azt teszi, amit a hibaüzenet javasol: a struct maga is kap egy 'a lifetime paramétert, és minden benne tárolt referencia ehhez kötődik.

struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    fn announce(&self, prefix: &str) -> &str {
        println!("{prefix}");
        self.part
    }
}

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    s
}

fn main() {
    let novel = String::from("Élt egyszer egy sárkány. Messze innen.");
    let first_sentence = first_word(&novel);
    let excerpt = Excerpt { part: first_sentence };
    let announced = excerpt.announce("Figyelem, kivonat jön:");
    println!("Kivonat: {announced}");
}

Itt két dolog is történik egyszerre. Egyrészt az Excerpt<'a> struct kimondja: "nem élhetek tovább, mint az a &str, amit a part mezőben tárolok". Ez a borrow checker számára garantálja, hogy sosem keletkezhet olyan Excerpt, amely egy már megszűnt String-re mutatna.

Másrészt az announce metódus szignatúrájában két referencia is szerepel (&self és prefix: &str), de nem kellett kiírni a lifetime-okat – ez a 3. elision-szabály miatt van: mivel van &self, a fordító automatikusan a self lifetime-jához köti a kimenetet, függetlenül attól, hogy prefix-nek is van saját lifetime-ja.

Figyelem

Ha egy impl blokkban lifetime-os struct-hoz írsz metódust, magát az impl-t is meg kell jelölni: impl<'a> Excerpt<'a>. Ez könnyen elfelejthető, és a fordító ilyenkor is világos hibaüzenetet ad – de érdemes már most rögzíteni, hogy ez a szintaxis mindig együtt jár.

Gyakorlat: egy tipikus lifetime hiba és a javítása

Nézzünk egy olyan esetet, ahol a hiba oka nem hiányzó annotáció, hanem az, hogy egy referencia rövidebb életű, mint amire a struct vagy a függvény számít:

struct Excerpt<'a> {
    part: &'a str,
}

fn make_excerpt() -> Excerpt<'static> {
    let text = String::from("ideiglenes szöveg");
    Excerpt { part: &text }
} // `text` itt megszűnik, de az Excerpt még "élne"
error[E0515]: cannot return value referencing local variable `text`
 --> src/main.rs:5:5
  |
5 |     Excerpt { part: &text }
  |     ^^^^^^^^^^^^^^^^^^^^^^^ returns a value referencing data owned by the current function

A hiba oka nem az annotáció hiánya, hanem a valóság: a text a függvény végén, a scope lezárásakor drop-olódik, tehát semmiképp nem élhet olyan hosszan, mint amit a 'static lifetime kér. A javítás itt nem lifetime-trükk, hanem architekturális döntés: vagy a hívótól kapott, hosszabb életű &str-t adjuk tovább, vagy a struct helyett egy önálló String-et tárolunk, amelynek saját ownership-je van:

struct Excerpt {
    part: String,
}

fn make_excerpt() -> Excerpt {
    let text = String::from("ideiglenes szöveg");
    Excerpt { part: text }
}

fn main() {
    let excerpt = make_excerpt();
    println!("{}", excerpt.part);
}

Ez a példa jól mutatja a lifetime-ok igazi célját: nem korlátoznak, hanem kimondják azt, ami a memóriakezelésben már korábban is igaz volt. Ha egy referencia nem élhet elég hosszan, a megoldás gyakran nem egy újabb annotáció, hanem az, hogy a struct inkább birtokolja az adatot (String), ne csak kölcsönvegye (&str).

Próbáld ki!

Írj egy shortest<'a>(x: &'a str, y: &'a str) -> &'a str függvényt, amely a két bemenet közül a rövidebbet adja vissza (egyenlő hossz esetén az elsőt). Ezután írj egy struct Pair<'a> { first: &'a str, second: &'a str } struct-ot és egy hozzá tartozó shorter(&self) -> &str metódust, amely a shortest függvényt hívja meg a két mezővel. Figyeld meg, hogy a metódusnál kell-e explicit lifetime annotáció, és ha nem, melyik elision-szabály miatt.

Önellenőrzés

Megoldás Előbb próbáld megválaszolni magadban, csak utána nyisd ki!

1. Miért nem fordul le a longest függvény lifetime annotáció nélkül, ha first_word-nél nem kellett kiírni semmit?

Megoldás A `first_word`-nek egyetlen bemeneti referenciája van, így a 2. elision-szabály (egy bemenet lifetime-ja = kimenet lifetime-ja) egyértelműen alkalmazható. A `longest`-nek két bemeneti referenciája van, és a fordító nem tudhatja, a kimenet melyikhez kötődik – ezért kell explicit `'a`-val kimondani, hogy mindkettőhöz egyszerre kötődik.

2. Mit jelent a lifetime elision harmadik szabálya, és miért releváns metódusoknál?

Megoldás Ha a paraméterlistában szerepel `&self` vagy `&mut self`, a fordító a kimeneti referencia lifetime-ját automatikusan a `self` lifetime-jához köti, még akkor is, ha van más referencia-paraméter is. Ez azért praktikus, mert a metódusok túlnyomó része pontosan ezt a mintát követi: valamit visszaad a saját belső állapotából.

3. Miért nem lehet egy Excerpt<'a> struct-ból 'static életű értéket visszaadni egy lokális String-ből képzett &str-rel?

Megoldás Mert a lokális `String` a függvény scope-jának végén drop-olódik, tehát a rá mutató referencia legfeljebb addig érvényes, ameddig a `String` létezik – ez pedig sokkal rövidebb, mint a `'static` (a program teljes futása). A borrow checker ezt pontosan felismeri, és nem engedi a \"dangling reference\" létrehozását.

Összefoglalás és mi jön legközelebb

  • A 'a lifetime-annotáció nem futásidejű mechanizmus, hanem fordítási időben létező jelölés, amely megmutatja a borrow checkernek, mely referenciák érvényességi ideje függ össze.
  • A lifetime elision három szabálya (saját lifetime minden paraméternek, egy bemenet → kimenet, &self → kimenet) miatt a mindennapi kódban ritkán kell explicit 'a-t írnod.
  • Amint egy struct referenciát tárol mezőként, a struct-nak is szüksége van egy lifetime paraméterre (struct Excerpt<'a> { part: &'a str }), és az impl blokkot is meg kell jelölni ugyanazzal.
  • Ha egy lifetime-hiba oka nem hiányzó annotáció, hanem valódi életciklus-probléma (pl. lokális változóra mutató referencia visszaadása), a megoldás gyakran nem trükk, hanem ownership: a struct tárolja inkább String-ként az adatot, ne &str-ként.
  • A következő leckében elhagyjuk a lifetime-okat, és a típusos hibakezelés felé fordulunk: megnézzük a Result típust, a ? operátort és a thiserror crate-et, amivel idiomatikus, jól olvasható hibakezelést írhatsz.

Ha szeretnél még mélyebbre menni, ezek jó kiindulópontok: