Az előző leckében egy kvízzel ellenőrizted, mennyire ülnek meg benned az eddig tanult alapok (típusok, függvények, vezérlési szerkezetek). Most egy fogalmi ugrás következik: az ownership, amely a Rust legegyedibb tulajdonsága, és amely nélkül a nyelv fordítója egyszerűen nem tudná garantálni a memóriabiztonságot futásidejű szemétgyűjtő (garbage collector) nélkül. Ha ezt megérted, sokkal könnyebben fogod érteni a következő lecke témáját, a kölcsönzést is.

Miért van szükség ownershipre?

Más nyelvekben (pl. Java, Python) egy szemétgyűjtő figyeli, mikor nincs már szükség egy értékre, és akkor törli. A C-ben és C++-ban neked kell kézzel felszabadítanod a memóriát, amivel könnyű hibázni. A Rust egy harmadik utat választott: fordításidőben, szabályok alapján dönti el, mikor kell egy érték helyét felszabadítani — futásidejű költség nélkül. Ehhez kellenek az ownership-szabályok.

Az ownership három szabálya

A Rust ownership-modellje három egyszerű szabályra épül:

  1. Minden értéknek pontosan egy tulajdonosa (owner) van — egy változó, amelyhez az érték "tartozik".
  2. Egyszerre csak egy tulajdonos lehet.
  3. Amikor a tulajdonos kikerül a scope-jából (hatóköréből), az érték automatikusan megszűnik — ezt hívjuk drop-nak.

Nézzük meg ezt egy egyszerű String példán, amit korábban már megismertél:

fn main() {
    let text = String::from("szia"); // text a tulajdonosa a String értéknek
    println!("{text}");
} // itt, a scope végén, a text értéke automatikusan megszűnik (drop)

Ez még nem tűnik különlegesnek — de most jön az érdekes rész: mi történik, ha egy értéket átadunk egy másik változónak?

Move-szemantika: amikor egy érték elköltözik

Ha egy String-et (vagy más, a kupacon — heap-en — tárolt adatot hordozó típust) egy másik változónak adunk, a Rust nem másolja le az adatot. Helyette áthelyezi (move-olja) a tulajdonjogot, és az eredeti változó onnantól érvénytelen lesz.

fn main() {
    let text = String::from("szia");
    let text2 = text; // a tulajdonjog átkerül text2-be, text ezután nem használható

    println!("{text2}");
}

Ez azért van így, mert egy String belül egy kupac-pointert, egy hosszt és egy kapacitást tárol. Ha ezt egyszerűen lemásolnánk, két változó ugyanarra a kupac-memóriára mutatna — és amikor mindkettő kikerül a scope-jából, mindkettő próbálná felszabadítani ugyanazt a memóriát. Ez duplikált felszabadítás (double free) lenne, ami komoly hiba. A Rust ezt úgy kerüli ki, hogy egyszerűen érvényteleníti a régi változót a move után.

Nézzük meg, mi történik, ha ennek ellenére próbáljuk használni a régi változót:

fn main() {
    let text = String::from("szia");
    let text2 = text; // move történik, text a mostantól nem használható

    let text3 = text; // hiba: text értéke már elköltözött

    println!("{text2} {text3}");
}

A fordító ezt nem engedi:

error[E0382]: use of moved value: `text`
 --> src/main.rs:4:16
  |
2 |     let text = String::from("szia");
  |         ---- move occurs because `text` has type `String`, which does not implement the `Copy` trait
3 |     let text2 = text; // move történik, text a mostantól nem használható
  |                 ---- value moved here
4 |     let text3 = text; // hiba: text értéke már elköltözött
  |                 ^^^^ value used here after move
Megjegyzés

Előretekintés: a hibaüzenetben feltűnik a "trait" szó (Copy trait). A trait egy olyan fogalmi kategória, amely meghatározza, hogy egy típus milyen képességekkel rendelkezik — ezt majd egy későbbi leckében részletesen megismerjük, most csak azt kell tudnod, hogy ez dönti el, egy típus automatikusan másolódik-e.

Jó tudni

Fontos: a hiba fordításidőben derül ki, nem futás közben! Ez az egyik legnagyobb erénye a Rustnak — a memóriával kapcsolatos hibák nagy részét már a cargo build vagy cargo check során elkapja.

Copy vs Clone: mikor másolódik automatikusan egy érték?

Az eddig ismert alaptípusok — i32, f64, bool, char és a belőlük álló tuple-ök — másképp viselkednek. Ezek fix, kicsi méretű, a stack-en (verem) tárolt értékek, ezért nekik nem gond, ha egyszerűen lemásolódnak. Ezért az egész számok, lebegőpontos számok, logikai és karakter típusok automatikusan másolódnak hozzárendelés esetén — nincs move, nincs érvénytelenítés:

fn main() {
    let number = 5;
    let number2 = number; // ez másolás, nem move

    println!("{number} {number2}"); // mindkettő használható
}

Ezzel szemben a String (és a legtöbb komplexebb, kupacon adatot tartó típus) nem másolódik automatikusan, mert ez költséges (vagy félreértést okozó) lenne. Ha mégis szeretnénk egy valódi, független másolatot kapni egy String-ből, explicit módon meg kell kérnünk erre a fordítót a .clone() metódussal:

fn main() {
    let text = String::from("szia");
    let text2 = text.clone(); // valódi, független másolat készül

    println!("{text} {text2}"); // mindkettő érvényes marad
}

A .clone() valódi memóriamásolást végez — ez futásidőben időt és memóriát vesz igénybe, ezért a Rust nem hívja meg automatikusan, csak ha kifejezetten kéred. Ez az egyik alapelve a nyelvnek: a költséges műveletek mindig láthatóak legyenek a kódban.

Másolás módjaEredeti változó még használható?
i32, f64, bool, charautomatikusigen
String egyszerű hozzárendelésselmovenem
String .clone()-nalvalódi másolásigen

Scope és a drop

A scope (hatókör) az a kódrészlet — jellemzán egy {} blokk —, amelyben egy változó él. Amikor a program végrehajtása kilép egy scope-ból, a Rust automatikusan felszabadítja az abban létrehozott, még tulajdonolt értékeket. Ezt a folyamatot hívjuk drop-nak.

fn main() {
    let outer = String::from("kívül");

    {
        let inner = String::from("belül");
        println!("{inner}");
    } // itt inner scope-ja véget ér, az értéke automatikusan drop-olódik

    println!("{outer}");
} // itt outer scope-ja véget ér, az értéke is drop-olódik

Ez a mechanizmus teszi lehetővé, hogy a Rustban ne kelljen kézzel felszabadítani a memóriát, ugyanakkor ne is legyen szükség futásidejű szemétgyűjtőre — a fordító pontosan tudja, hol ér véget minden érték élete, és odateszi a felszabadítási kódot.

Tipp

Gondolj a scope-ra úgy, mint egy fizikai helyiségre: amikor kilépsz egy szobából, minden, amit ott hoztál létre és nem vittél magaddal (nem adtad tovább a tulajdonjogát), ott marad, és megszűnik létezni.

Gyakorlat: javítsuk ki a move-hibát

Nézzünk egy tipikus helyzetet: egy függvény, amely kiírja egy String-et, majd a főprogram később is használni akarja azt az értéket.

fn print_greeting(greeting: String) {
    println!("{greeting}");
}

fn main() {
    let greeting = String::from("szia, Rust!");
    print_greeting(greeting); // a greeting move-ol a függvénybe

    // print_greeting(greeting); // hiba lenne: greeting már elköltözött
}

Ha a print_greeting-et kétszer szeretnénk meghívni ugyanazzal az értékkel, két lehetőségünk van: vagy .clone()-ozzuk a String-et minden hívás előtt, vagy — ahogy a következő leckében látni fogod — a függvénynek nem adjuk oda a tulajdonjogot, csak "kölcsön" a hozzáférést. Most, amíg csak a move-ot ismerjük, a .clone() a megoldás:

fn print_greeting(greeting: String) {
    println!("{greeting}");
}

fn main() {
    let greeting = String::from("szia, Rust!");
    print_greeting(greeting.clone());
    print_greeting(greeting); // az utolsó hívásnál nem kell már clone, ez az utolsó felhasználás
}

Próbáld ki!

Írj egy programot, amelyben van egy String típusú változód egy névvel, és két függvényed: az egyik kiírja "Szia, {name}!" formában, a másik kiírja "Viszlát, {name}!" formában. Hívd meg mindkét függvényt ugyanazzal a névvel! Figyeld meg, hova kell .clone()-t tenned ahhoz, hogy fordítson a program, és próbáld ki azt is, mi történik, ha elhagyod a .clone()-t — olvasd el figyelmesen a hibaüzenetet!

Önellenőrzés

Megoldás

Kérdés: Miért nem .clone()-ozza automatikusan a Rust a String-eket hozzárendelés közben?

Azért, mert a .clone() valódi memóriamásolást (heap-allokációt) igényel, ami futásidőben számolható költséggel jár. A Rust alapelve, hogy a költséges műveletek mindig láthatóak legyenek a forráskódban, ezért ezt explicit módon kell kérni.

Megoldás

Kérdés: Miért nem okoz move-hibát egy i32 típusú változó másik változónak adása?

Azért, mert az i32 (és a hasonló kis, fix méretű alaptípusok) automatikusan másolódnak hozzárendelés esetén — nem kerül sor tulajdonjog-átadásra (move-ra), mindkét változó önállóan, függetlenül él tovább.

Megoldás

Kérdés: Mi történik pontosan akkor, amikor egy változó kikerül a scope-jából?

A Rust automatikusan felszabadítja (drop-olja) az általa tulajdonolt értéket, feltéve hogy az érték tulajdonjoga közben nem került át egy másik változóhoz (move vagy visszaadás egy függvényből).

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

  • Minden értéknek pontosan egy tulajdonosa van, és amikor a tulajdonos kikerül a scope-jából, az érték automatikusan drop-olódik.
  • Összetettebb, kupacon tárolt típusok (pl. String) hozzárendelés vagy függvényhívás során move-olódnak: a régi változó onnantól érvénytelen.
  • Kis, fix méretű alaptípusok (i32, f64, bool, char, ezekből álló tuple-ök) automatikusan másolódnak, náluk nincs move.
  • Ha egy String-ből (vagy hasonló típusból) valódi, független másolatot akarunk, explicit .clone()-t kell hívnunk.
  • A fordító már fordításidőben (futás előtt) elkapja a move utáni téves felhasználást, ez az egyik legnagyobb erőssége a Rustnak.

A következő leckében megismered a kölcsönzést és a referenciákat — ez lesz a válasz arra a kérdésre, hogy hogyan adhatunk át egy értéket egy függvénynek úgy, hogy közben ne kelljen sem move-olni, sem clone-ozni. Ez old fel majd sok mostani feszültséget.

További olvasmány: