Ha valaha kaptál már cannot infer an appropriate lifetime hibát, és órákig bámultad a képernyőt, tudod, miről beszélek. A jó hír: a lifetime-ok nem egy külön, misztikus nyelvi réteg, hanem egyszerűen annak a formalizálása, amit egy C++ fejlesztő fejben tartana (vagy nem tartana, és emiatt dangling pointerje lenne).

Miért léteznek a lifetime-ok?

A Rust ownership modellje szerint minden értéknek pontosan egy tulajdonosa van, de rengeteg helyzetben nem akarjuk átadni a tulajdonjogot, csak kölcsönvenni egy referenciát (&T vagy &mut T). A kérdés, ami ilyenkor mindig felmerül: meddig érvényes ez a referencia? Ha a referált adat kikerül a scope-jából, mielőtt a referenciát még használnád, az egy use-after-free hiba lenne — pontosan az a kategória, amit C-ben és C++-ban a legnehezebb debugolni.

A lifetime egyszerűen egy neve annak az időtartamnak, amíg egy referencia biztosan érvényes. Nem befolyásolja a lefordított kódot (nincs runtime költsége!), csak a fordítási időben ellenőrzött metaadat.

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // hiba: `x` nem él elég sokáig
    }
    println!("{}", r);
}

Ez a kód nem fordul le, és pontosan ez a lényeg: a borrow checker inkább leállít téged fordítási időben, mint hogy futásidőben omoljon össze a program.

A borrow checker szerepe

A borrow checker az a komponens a fordítóban, amely a lifetime-okat követi nyomon, és ellenőrzi, hogy egy referencia sosem "éli túl" azt az adatot, amire mutat. Két fő szabályt tart be:

  1. Egy adatra egyszerre lehet több &T (immutable) referencia, VAGY pontosan egy &mut T (mutable) referencia — sosem mindkettő egyszerre.
  2. Minden referencia élettartama nem lehet hosszabb, mint az adat, amire mutat.
Megjegyzés

A lifetime-ok nem "létrehoznak" semmit futásidőben. Csak arra szolgálnak, hogy a fordító bizonyítani tudja: a kódod memóriabiztos. A generált assembly-ben nyomuk sincs.

Az elision szabályok — amikor a Rust kitalálja helyetted

A legtöbb esetben egyáltalán nem kell lifetime jelölést írnod, mert a Rust az úgynevezett lifetime elision szabályok alapján kikövetkezteti azokat. Három egyszerű szabály van:

  1. Minden referencia paraméter saját lifetime-ot kap.
  2. Ha pontosan egy input lifetime van, az lesz az output lifetime is.
  3. Ha a metódusnak van &self vagy &mut self paramétere, akkor self lifetime-ja lesz az output lifetime.

Ezért működik simán ez a függvény anélkül, hogy egyetlen 'a-t is leírnál:

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

fn main() {
    let sentence = String::from("lifetime-ok tényleg nem ördögtől valók");
    println!("Első szó: {}", first_word(&sentence));
}

A fordító alatta pontosan úgy "látja" ezt, mintha te írtad volna ki: fn first_word<'a>(s: &'a str) -> &'a str. Az elision pontosan ezt a boilerplate-et spórolja meg neked.

Tipp

Ha nem vagy biztos benne, mit "gondol" a fordító egy elision-ös szignatúráról, próbáld meg kiírni explicit módon a lifetime-okat — sokszor ez segít megérteni, mi történik a háttérben.

Explicit lifetime jelölés függvényeken

Az elision addig szuper, amíg egyértelmű a helyzet. De ha egy függvénynek több referencia paramétere van, és az output attól függ, melyiktől, a fordító nem tud dönteni — neked kell megmondanod.

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

Itt az 'a generic paraméter azt fejezi ki: az output referencia lifetime-ja legalább annyira szűk, mint a két input közül a rövidebb. Ez nem azt jelenti, hogy x és y-nak azonos élettartamúnak kell lenniük — csak azt, hogy az eredmény csak addig garantáltan érvényes, ameddig mindkettő él.

Figyelem

Ha a println!-t a belső blokkon kívülre próbálnád tenni, a kód nem fordulna le, mert s2 már megszűnt létezni, és az 'a lifetime nem terjedhet ki tovább, mint a legszűkebb bemenet.

Lifetime-ok struktúrákban

Ha egy struct referenciát tárol, a struct definíciójában is jelölnöd kell a lifetime-ot, mert a fordítónak tudnia kell: a struct instance sosem élhet tovább, mint az adat, amire a benne lévő referencia mutat.

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

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

fn main() {
    let novel = String::from("Élt egyszer egy Rust fejlesztő. Boldogan élt tovább.");
    let first_sentence = novel.split('.').next().expect("nincs pont a szövegben");
    let excerpt = Excerpt { part: first_sentence };

    println!("Kivonat: {}", excerpt.part);
    excerpt.announce("Ez egy fontos idézet");
}

Az Excerpt<'a> struct nem tud tovább élni, mint a novel String — a fordító ezt kényszeríti ki. Az impl blokkban a announce metódus a harmadik elision szabályt használja: mivel van &self paraméter, az output lifetime a self-hez lesz kötve, így nem kell külön kiírni.

Gyakori lifetime hibák és a megoldásuk

1. "Returning a value referencing data owned by the current function"

Ez akkor jön elő, ha egy függvényből olyan referenciát adnál vissza, ami a függvényen belül létrehozott lokális adatra mutat.

fn dangle() -> &String { // hiba: hiányzik a lifetime, és amúgy is dangling lenne
    let s = String::from("hoppá");
    &s
}

A megoldás egyszerű: ne referenciát adj vissza, hanem tulajdonjogot — return type legyen String, ne &String.

fn no_dangle() -> String {
    let s = String::from("most már jó");
    s
}

2. Struct mezők lifetime-ja túl szűk

Ha egy struct több referenciát tárol különböző élettartammal, előfordulhat, hogy két lifetime paramétert kell felvenned ('a, 'b), nem csak egyet — különben a fordító a legszűkebb közös lifetime-ot fogja rákényszeríteni mindkettőre, ami feleslegesen korlátozó lehet.

struct Pair<'a, 'b> {
    first: &'a str,
    second: &'b str,
}

3. let-else a korai kilépéshez

A Rust 2021-ben (1.65 óta) elérhető let-else szintaxis kifejezetten hasznos, amikor egy referenciát próbálunk kicsomagolni, és korai kilépést akarunk hiba esetén, anélkül hogy plusz mélységet nyitnánk match-csel:

fn get_first_char(s: &str) -> char {
    let Some(c) = s.chars().next() else {
        return '?';
    };
    c
}

Ez nem old meg lifetime problémát önmagában, de sokszor pont azokban a helyzetekben segít tisztán tartani a kódot, amikor referenciákkal dolgozol és el akarod kerülni a beágyazott if let-eket.

Jó tudni

A lifetime hibák 90%-a valójában abból jön, hogy egy referencia "kiszökik" a scope-jából. Ha a hibaüzenetben ilyet látsz: "borrowed value does not live long enough", első lépésként mindig azt nézd meg, hol keletkezik az eredeti adat, és meddig él.

Gyakorlati példa: egyszerű tokenizáló

Nézzünk egy kicsit összetettebb, valós használatot: egy tokenizáló, amely a bemeneti string szeleteire (slice-aira) mutató referenciákat ad vissza, allokáció nélkül.

struct Tokenizer<'a> {
    remaining: &'a str,
}

impl<'a> Tokenizer<'a> {
    fn new(input: &'a str) -> Self {
        Tokenizer { remaining: input.trim() }
    }

    fn next_token(&mut self) -> Option<&'a str> {
        if self.remaining.is_empty() {
            return None;
        }

        let Some(space_idx) = self.remaining.find(' ') else {
            let token = self.remaining;
            self.remaining = "";
            return Some(token);
        };

        let token = &self.remaining[..space_idx];
        self.remaining = self.remaining[space_idx..].trim_start();
        Some(token)
    }
}

fn main() {
    let text = String::from("a Rust nyelvben a lifetime-ok ingyenesek");
    let mut tokenizer = Tokenizer::new(&text);

    while let Some(token) = tokenizer.next_token() {
        println!("Token: {}", token);
    }
}

Ez a Tokenizer egyetlen bájtot sem allokál — csak slice-okat ad vissza, amelyek mind a text eredeti memóriaterületére mutatnak. Az 'a lifetime paraméter garantálja, hogy amíg a Tokenizer és a belőle kinyert token-ek élnek, addig a text String is biztosan életben van. Ez a klasszikus Rust minta: nulla-költségű absztrakció, amit a borrow checker garantál, futásidejű ellenőrzés nélkül.

Összefoglalás

A lifetime-ok nem büntetésül vannak a nyelvben, hanem azért, hogy explicit módon dokumentáld (vagy a fordító kitalálja helyetted) a referenciáid érvényességi tartományát. A gyakorlatban a legtöbb kódodban sosem fogsz explicit 'a-t írni, mert az elision szabályok lefedik a triviális eseteket. Amikor viszont több bemeneti referencia van, vagy egy struct önmagában is referenciát tárol, a fordító kéri a segítségedet — és ez pontosan az a pillanat, amikor a lifetime jelölés nem bonyolítja, hanem egyértelműsíti a kódod szerződését.

Ha legközelebb egy borrow checker hibaüzenettel találod szemben magad, ne ess kétségbe: olvasd el figyelmesen, hol keletkezik az adat, meddig él, és honnan próbálsz rá referenciát tartani ennél tovább. A Rust fordítója valójában a barátod — csak épp nagyon szigorú barátod, aki nem enged neked use-after-free hibákat elkövetni.