Tulajdonlás & Kölcsönzés versus Referenciaszámlálás

A Rust meghatározó eredménye a memóriabiztonság szemétgyűjtő (garbage collector) nélkül. Ezt egy fegyelmezett tulajdonlási rendszer segítségével éri el — amelynek azonban szándékosan van egy menekülési útvonala: a referenciaszámlálás.

A Rust-programban minden értéknek egyetlen tulajdonosa van egy adott pillanatban. Amikor a tulajdonos kikerül a hatókörből, az érték felszabadul — a memória determinisztikusan, az execúció egy ismert pontján szabadul fel. Nincs GC-szünet, nincs lógó mutató, nincs kettős felszabadítás. A fordítóprogram mindezt statikusan kényszeríti ki.

Ám bizonyos adatokat valóban meg kell osztani: egy gráf csúcsát több él is birtokolja, egy konfigurációs objektum sok alrendszeren át áramlik, egy visszahívás hivatkozást tart a körülvevő állapotra. Ide lép be a referenciaszámlálás — az Rc<T> és az Arc<T> —, amely a tulajdonlási logikát a fordítási időből egy kis futásidejű számlálóba helyezi át.

Ezek nem egymással versengő funkciók — egymást kiegészítő eszközök, eltérő kompromisszumokkal. Ez a cikk mindkettőt kibontja: szemantika, teljesítmény, ergonómia, és a döntő kérdés: melyiket válasszuk?


T&K

Tulajdonlás & Kölcsönzés

A tulajdonlási modell a Rust alapvető innovációja. Minden heap-allokációnak pontosan egy tulajdonosa van — az a változókötés, amelyik „tartja" azt. A tulajdonjog átruházható egy másik kötésre, ekkor az eredeti érvénytelenné válik. Sohasem másolódik csendben (hacsak a típus nem implementálja a Copy traitot).

Mozgás Szemantika

tulajdonlas_mozgas.rs
fn main() {
    let s1 = String::from("helló");
    let s2 = s1;  // tulajdonjog ÁTRUHÁZVA — s1 mostantól érvénytelen

    // println!("{}", s1);  ← fordítási hiba: érték mozgatás után kölcsönözve
    println!("{}", s2);  // rendben — s2 az egyetlen tulajdonos
} // s2 itt kerül felszabadításra, a memória automatikusan törlődik

A borrow checker érvényesíti a tulajdonlási szabályokat. Amikor s1-et s2-höz rendeljük, a fordító megérti, hogy s1 nem használható tovább — lemondott a tulajdonjogáról. Ez nullás futásidei költséggel szünteti meg a use-after-free hibákat.

Kölcsönzés — Megosztott (&T) és Módosítható (&mut T)

A tulajdonjog mindenhová való átruházása kényelmetlen lenne. A Rust lehetővé teszi az értékek kölcsönzését: ideiglenes, hatókörbe zárt referencia felvételét a tulajdonjog átadása nélkül.

kolcsonzes.rs
fn hossz_kiszamitas(s: &String) -> usize {
    s.len()
}  // s kikerül a hatókörből, de NEM szabadítja fel a String-et

fn vilag_hozzaadasa(s: &mut String) {
    s.push_str(", világ");
}

fn main() {
    let mut udvozles = String::from("helló");

    let hossz = hossz_kiszamitas(&udvozles); // megosztott kölcsönzés
    vilag_hozzaadasa(&mut udvozles);          // módosítható kölcsönzés

    println!("'{}' {} karakterből áll", udvozles, hossz);
}

A borrow checker egyszerre kényszerít ki két invariánst:

Ez az aliasálás VAGY mutálhatóság — egy alapvető szabály, amely statikusan szünteti meg a hibák egész kategóriáit (iterátor-invalidáció, adatversenyek).

Élettartamok

A referenciák élettartam-annotációkkal egészülnek ki — ezek bizonyítják, hogy a referencia nem élheti túl azt az adatot, amelyre mutat. A legtöbb kódban a fordító kikövetkezteti ezeket; összetett generikus API-kban explicit módon kell megadni.

eletartamok.rs
// 'a azt jelenti: a visszaadott referencia legalább annyit él, mint mindkét bemenet
fn hosszabb<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}
Kulcstulajdonságok
A tulajdonlás & kölcsönzés zéró költségű: minden biztonsági garancia fordítási időben kerül ellenőrzésre, és semmilyen futásidei többletköltséggel nem jár — nincs számláló-növelés, nincs extra heap-allokáció, nincs plusz indirektció.

Rc

Referenciaszámlálás: Rc<T> és Arc<T>

A tulajdonlás egy szigorú, egyetlen-tulajdonos modell. Ám egyes programok megosztott tulajdonlást igényelnek — a program több részének is életben kell tartania ugyanazt az értéket. A klasszikus példa egy gráf, ahol több él mutat ugyanarra a csúcsra.

Az Rc<T> (Reference Counted — Referenciaszámlált) egy értéket csomagol be a heap-en, két számlálóval együtt: egy erős számlálóval (aktív tulajdonosok) és egy gyenge számlálóval. Minden Rc-klón növeli az erős számlálót; minden drop csökkenti. Amikor az erős számláló nullára csökken, a belső érték felszabadul.

Alaphasználat

rc_alap.rs
use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("megosztott adat"));
    let b = Rc::clone(&a);  // erős számláló növelése — olcsó mutatómásolás
    let c = Rc::clone(&a);

    println!("erős számláló = {}", Rc::strong_count(&a)); // 3

    drop(b);
    println!("b eldobása után = {}", Rc::strong_count(&a)); // 2

} // a és c itt esik el; számláló → 0 → String felszabadul

Belső Módosíthatóság RefCell-lel

Az Rc<T> megosztott tulajdonlást biztosít, de csak olvasható hozzáférést. A belső érték módosításához párosítani kell RefCell<T>-lel, amely a kölcsönzés-ellenőrzést fordítási időből futásidőre helyezi:

rc_refcell.rs
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    let megosztott = Rc::new(RefCell::new(vec![1, 2, 3]));

    let klon1 = Rc::clone(&megosztott);
    let klon2 = Rc::clone(&megosztott);

    klon1.borrow_mut().push(4);  // futásidei kölcsönzés-ellenőrzés
    klon2.borrow_mut().push(5);

    println!("{:?}", megosztott.borrow()); // [1, 2, 3, 4, 5]
}

Szálbiztos Megosztás: Arc<T>

Az Rc<T> nem Send vagy Sync — a számlálója nem atomikus, és nem léphet át szálhatárokon. Párhuzamos használathoz cseréljük le Arc<T>-re (Atomically Reference Counted — Atomikusan Referenciaszámlált), amely atomikus CPU-műveleteket használ a számlálóhoz. Szálak közötti belső módosíthatósághoz párosítsuk Mutex<T>-vel vagy RwLock<T>-lal.

arc_mutex.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let szamlalo = Arc::new(Mutex::new(0u32));

    let szalak: Vec<_> = (0..8).map(|_| {
        let sz = Arc::clone(&szamlalo);
        thread::spawn(move || {
            *sz.lock().unwrap() += 1;
        })
    }).collect();

    for szal in szalak { szal.join().unwrap(); }
    println!("számláló = {}", *szamlalo.lock().unwrap()); // 8
}
Figyelem — Referenciakörök
Az Rc<T> és az Arc<T> nem képes automatikusan felismerni a referenciakörö­ket. Ha két Rc érték kölcsönösen hivatkozik egymásra, az erős számlálóik soha nem érnek nullára, és memóriaszivárgás lép fel. Használjunk Weak<T>-t a visszamutató referenciákhoz (pl. szülőmutatók egy fában) a körök megtörésére.

Egymás Melletti Összehasonlítás

Dimenzió Tulajdonlás & Kölcsönzés Rc<T> / Arc<T>
Tulajdonlási modell Egyetlen tulajdonos, szigorúan kikényszerítve Megosztott tulajdonlás számlált handle-ökkel
Ellenőrzés Fordítási idő — nulla futásidei költség Futásidő — számláló-műveletek minden klónolás/felszabadításkor
Teljesítmény Nulla többletköltség — nincs extra indirektció Kis többletköltség: heap-allokáció, számláló, mutatókövetés
Szálbiztonság Send/Sync által kényszerítve fordítási időben Rc: csak egyetlen szál; Arc: szálbiztos
Módosíthatóság &mut T — kizárólagos, statikusan ellenőrzött RefCell/Mutex szükséges — futásidei panic lehetséges
Körök N/A — egyetlen tulajdonos nem alkothat kört magával Referenciakörök memóriaszivárgást okoznak — használj Weak<T>-t
API bonyolultsága Meredekebb tanulási görbe (élettartamok) Egyszerűbb felszín; a bonyolultság futásidőre kerül
Tipikus felhasználás Alapértelmezett szinte minden adathoz Rust-ban Gráfok, megosztott csúcsú fák, közös konfiguráció, visszahívások
Felszabadítás időzítése Determinisztikus — a tulajdonos hatókörének végén Determinisztikus — amikor az utolsó handle esik el (lehet nem nyilvánvaló)
Klónolási költség Mély másolat (vagy mozgatás — ingyenes) Mutatómásolat + számláló növelés — O(1)

Teljesítmény a Gyakorlatban

Az Rc<T> többletköltsége háromrétű: egy extra heap-allokáció a vezérlőblokknak, egy mutatókövetés minden hozzáférésnél, és két egész-növelés/csökkentés klónoláskor és felszabadításkor. A legtöbb program esetében ez elhanyagolható. Másodpercenként millió műveletet végző forró kódban — játékmotorok, fordítók, jelelfeldolgozók esetén — a tulajdonolt értékek előnyben részesítése számít.

Az Arc<T> további költséget jelent: az atomikus műveletek CPU memóriarendezési garanciákat (SeqCst vagy Release/Acquire) igényelnek, amelyek gátolnak bizonyos fordítói és hardveres átrendezéseket. Versenyző többmagos munkaterheléseknél ez mérhetővé válhat.

Ökölszabály
Nyúljunk Rc/Arc-hoz, ha az alternatíva a borrow checkerrel való küzdelem összetett élettartam-annotációkkal vagy unsafe kóddal. A kis futásidei költség ergonomikus, biztonságos megosztott tulajdonlást ad cserébe — ez a legtöbb alkalmazáskódban ésszerű kompromisszum.

?

Döntési Útmutató

Válassz Tulajdonlást + Kölcsönzést, ha…

Válassz Rc<T> / Arc<T>-t, ha…

graf_csucs.rs
// Klasszikus felhasználási eset: gráf megosztott csúcsokkal
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Csucs {
    ertek: i32,
    gyerekek: Vec<Rc<RefCell<Csucs>>>>,
    szulo: Option<Weak<RefCell<Csucs>>>>, // Weak töri meg a szülő→gyerek kört
}

impl Csucs {
    fn uj(ertek: i32) -> Rc<RefCell<Csucs>> {
        Rc::new(RefCell::new(Csucs {
            ertek,
            gyerekek: vec![],
            szulo: None,
        }))
    }
}