Miért nincs szükség garbage collectorra
A legtöbb modern nyelv (Go, Java, Python) garbage collectorral (GC) oldja meg a memóriakezelést: a futásidejű rendszer időnként végigpásztázza a heapet, és felszabadítja, amire már senki sem hivatkozik. Ez kényelmes, de ára van: időszakos szünetek (pause), extra memóriahasználat, és a determinizmus hiánya.
A Rust más utat választott. Ahelyett, hogy futásidőben derítené ki, mi szabadítható fel, a fordító statikusan, már compile time-ban eldönti ezt, az ún. ownership (tulajdonlás) rendszer segítségével. Ennek köszönhetően:
- nincs GC overhead,
- nincs runtime pause,
- a memóriafelszabadítás pontosan megjósolható (determinisztikus),
- és mégis emlékbiztonságot (memory safety) kapsz C/C++ szintű teljesítmény mellett.
Az ownership nem varázslat, hanem egy egyszerű, de szigorúan betartatott szabályrendszer. Ha megérted a három alapszabályt, a borrow checker hibaüzenetei is sokkal barátságosabbak lesznek.
A három alapszabály
A Rust ownership modellje három alapszabályra épül:
- Minden értéknek pontosan egy tulajdonosa van (owner) egy adott pillanatban.
- Amikor a tulajdonos kikerül a scope-ból, az érték felszabadul (drop).
- A tulajdonjog átadható (move), de csak egy helyen létezhet egyszerre.
Nézzük meg ezt kódban:
fn main() {
let s1 = String::from("szia rust.hu");
let s2 = s1; // move történik: s1 tulajdonjoga átkerül s2-be
// println!("{}", s1); // fordítási hiba: s1 már nem érvényes
println!("{}", s2); // ez működik
}
Itt a String egy heap-allokált típus. Amikor s1-et s2-nek adjuk, a Rust nem másolja az adatot (ellentétben pl. egy sekély C++ copy-val), hanem áthelyezi a tulajdonjogot. Az s1 ezek után érvénytelenné válik – ezt hívjuk move szemantikának.
Az egyszerű, stack-en élő típusok (pl. i32, bool, char) implementálják a Copy trait-et, így náluk nincs move, hanem tényleges másolás történik. Ezért működik ez simán:
let x = 5;
let y = x;
println!("{} {}", x, y); // mindkettő érvényes
A scope vége is kulcsfontosságú. Amikor egy változó kikerül a blokkjából, a Rust automatikusan meghívja a Drop implementációját (ha van neki), és felszabadítja az erőforrást:
fn main() {
{
let s = String::from("ideiglenes");
println!("{}", s);
} // itt s kikerül a scope-ból, a String memóriája felszabadul
println!("a scope már lezárult");
}
Borrowing: kölcsönadás garancia nélküli felelősség helyett
Ha minden függvényhívásnál át kellene adnunk a tulajdonjogot, az iszonyú kényelmetlen lenne. Szerencsére a Rust lehetővé teszi az érték kölcsönzését (borrowing) referenciákkal, anélkül hogy a tulajdonjog átadásra kerülne.
Két fajta referencia létezik:
- immutable referencia (
&T): olvasható, de nem módosítható. Egyszerre tetszőleges számú immutable referencia lehet érvényben. - mutable referencia (
&mut T): módosítható, de egyszerre csak egy lehet belőle érvényben, és amíg létezik, nem lehet más referencia (se mutable, se immutable) ugyanarra az értékre.
fn calculate_length(s: &String) -> usize {
s.len()
} // s kikerül a scope-ból, de mivel csak referencia, nem szabadít fel semmit
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("'{}' hossza {}", s1, len); // s1 még mindig érvényes!
}
Mutable referencia esetén:
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{}", s); // hello, world
}
A borrow checker szabálya egyszerű: vagy egy mutable referencia, vagy tetszőleges számú immutable referencia – de a kettő sosem keveredhet egyszerre ugyanarra az adatra. Ez zárja ki a data race-eket már fordítási időben, konkurrens kód esetén is.
Gyakori borrow checker hibák és megoldásuk
Minden Rust-fejlesztő átesik ezen a tűzkeresztségen. Nézzünk pár klasszikus hibát, és hogy hogyan oldjuk meg őket.
1. Move érvénytelenített értéken
fn main() {
let v = vec![1, 2, 3];
let v2 = v;
println!("{:?}", v); // hiba: value borrowed here after move
}
Megoldás: ha mindkét változóra szükséged van, klónozz explicit módon:
fn main() {
let v = vec![1, 2, 3];
let v2 = v.clone();
println!("{:?} {:?}", v, v2); // mindkettő érvényes
}
2. Egyszerre mutable és immutable referencia
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // hiba: cannot borrow `s` as mutable
println!("{} {}", r1, r2);
}
Megoldás: a Rust borrow checkere ún. non-lexical lifetimes-t (NLL) használ, ami azt jelenti, hogy egy referencia élettartama ott ér véget, ahol utoljára használják, nem a blokk végén. Ezt kihasználva egyszerűen szét kell választani a használatokat:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
println!("{}", r1); // r1 élettartama itt véget ér
let r2 = &mut s; // most már oké
r2.push_str(", world");
println!("{}", r2);
}
3. Dangling referencia
fn dangle() -> &String {
let s = String::from("hello");
&s // hiba: `s` a függvény végén megsemmisül
}
Megoldás: add vissza az ownership-et, ne referenciát:
fn no_dangle() -> String {
let s = String::from("hello");
s // move történik, minden rendben
}
A Rust borrow checkere időnként túl konzervatívnak tűnhet – olyan kódot is elutasít, ami logikailag helyes lenne. Ez nem hiba, hanem tudatos tervezési döntés: inkább utasítson vissza néhány valójában biztonságos esetet, mint hogy egyetlen memóriahibás esetet is átengedjen.
4. let-else a hibakezelés egyszerűsítésére
A Rust 1.65 óta stabil let-else szintaxis remekül jön, amikor egy értéket ki akarunk nyerni, de ha az nem sikerül, korán akarunk visszatérni:
fn parse_and_double(input: &str) -> Option<i32> {
let Ok(num) = input.parse::<i32>() else {
return None;
};
Some(num * 2)
}
fn main() {
println!("{:?}", parse_and_double("21")); // Some(42)
println!("{:?}", parse_and_double("nem szám")); // None
}
Ez ownership szempontból is szép: nem kell felesleges match ágakat írni, a kód lineárisan olvasható marad, miközben az ownership szabályok érintetlenek maradnak.
Kis példakód: hogyan mozog egy érték a programban
Foglaljuk össze egy kicsit nagyobb példában, hogyan vándorol egy érték ownership és borrowing között:
struct Munkalap {
cim: String,
sorok: Vec<String>,
}
impl Munkalap {
fn new(cim: &str) -> Self {
Munkalap {
cim: cim.to_string(),
sorok: Vec::new(),
}
}
// &mut self: kölcsönveszi a Munkalapot módosításra
fn sor_hozzaadasa(&mut self, sor: String) {
self.sorok.push(sor);
}
// &self: csak olvassa a Munkalapot
fn sorok_szama(&self) -> usize {
self.sorok.len()
}
// self: átveszi a teljes tulajdonjogot, a Munkalap ezután megsemmisül
fn lezaras(self) -> String {
format!("{} lezárva, {} sorral", self.cim, self.sorok.len())
}
}
fn main() {
let mut mv = Munkalap::new("Havi jelentés");
mv.sor_hozzaadasa(String::from("Bevétel: 120000"));
mv.sor_hozzaadasa(String::from("Kiadás: 45000"));
println!("Sorok száma: {}", mv.sorok_szama());
let osszegzes = mv.lezaras(); // itt mv tulajdonjoga átkerül lezaras()-ba
println!("{}", osszegzes);
// mv.sorok_szama(); // hiba lenne: mv már mozgatva lett (moved)
}
Ebben a példában szépen látszik a három ownership-módszer egymás mellett:
&mut self– kölcsönvesszük módosításra,&self– kölcsönvesszük olvasásra,self– teljesen átvesszük, a struct élettartama itt véget ér.
Ha egy metódus self-et vár (nem &self-et vagy &mut self-et), az mindig azt jelenti, hogy a hívás után az eredeti érték már nem használható. Ez tökéletes eszköz az ún. "builder" vagy "state machine" mintákhoz, ahol egy állapotból véglegesen egy másikba akarsz lépni.
Összefoglalás
Az ownership rendszer első pillantásra ijesztő lehet, főleg ha C++-ból vagy egy GC-s nyelvből érkezel. De ha megérted a három alapszabályt – egy tulajdonos, move szemantika, scope vége felszabadítás –, és hozzászoksz a borrowing (immutable/mutable referenciák) logikájához, hamar rájössz, hogy a borrow checker nem az ellenséged, hanem egy nagyon szigorú, de igazságos code reviewer, aki soha nem enged data race-t vagy use-after-free-t a kódodba.
A legjobb tanács kezdőknek: ne küzdj a borrow checkerrel, hanem próbáld megérteni, miért utasítja el a kódot. A hibaüzenetek a Rust 1.67-es verzióban már nagyon informatívak, gyakran konkrét javaslatot is adnak. Gyakorolj sokat kis példákkal, és pár hét után az ownership már nem teherré, hanem természetes gondolkodásmóddá válik.