Cél és előfeltételek

Az előző leckében ("Alapértelmezett metódusok") megtanultad, hogy egy trait-hez alapértelmezett viselkedést is rendelhetsz, amit az implementáló típusok felülírhatnak vagy örökölhetnek. Ebben a leckében egy lépéssel továbbmegyünk: megnézzük, hogyan tárolhatsz és kezelhetsz egyetlen gyűjteményben (pl. egy Vec-ben) különböző konkrét típusokat, amíg azok mind ugyanazt a trait-et implementálják. Ehhez kell a dyn Trait szintaxis és a Box<dyn Trait>. Útközben megérted a statikus és dinamikus dispatch közti különbséget, és megismered az object safety szabályait — azt, hogy egy trait mikor NEM használható trait objektumként.

Előfeltételek: trait-ek és metódusaik, impl blokkok, struct-definíció, Vec<T> alapmetódusai, referenciák (&T), ownership alapjai.

Előkészítés

Hozz létre egy új projektet:

cargo new dyn_shapes
cd dyn_shapes

Ehhez a leckéhez nincs szükség külső crate-re, tisztán a standard könyvtárral dolgozunk. A Cargo.toml-od így néz ki:

[package]
name = "dyn_shapes"
version = "0.1.0"
edition = "2024"

A probléma: eltérő típusok egy dobozban

Képzeld el, hogy egy geometriai rajzolóprogramot írsz, amiben körök, téglalapok, háromszögek keverednek egy listában, és mindegyiknek ki kell tudni számolni a területét. Ha eddig tanult eszközökkel próbálnád megoldani, minden alakzattípusnak külön Vec-et kellene készítened (Vec<Circle>, Vec<Rectangle>, ...), és nem tudnád egyetlen listában, egységesen kezelni őket.

A trait pontosan erre a közös viselkedésre ad nevet — de a trait maga nem egy konkrét típus, nincs fix mérete. A Circle mondjuk 8 bájt, a Rectangle 16 bájt — a fordító nem tudja, mennyi helyet foglaljon egy ilyen elem, ha közvetlenül trait-et próbálnál típusként használni egy Vec-ben, ezért ez nem is fordul le. Kell egy közbülső réteg, ami mindig ugyanolyan méretű "pointert" ad, függetlenül attól, milyen konkrét típusra mutat éppen.

dyn Trait és a Box

Mielőtt rátérnénk a Box<dyn Trait>-ra, essünk szót röviden a Box<T>-ről is, ha még nem találkoztál vele: ez egy egyszerű, tulajdonló (owning) pointer, amely a benne tárolt értéket a heap-en helyezi el, a doboz maga pedig egy pointer méretű érték, ami a stacken vagy egy másik struktúrában él. Amikor egy Box<T> kikerül a scope-ból, a benne tárolt érték is felszabadul (drop) — pontosan úgy, mintha közvetlenül birtokolnád az értéket, csak közben heap-allokáció is történt.

A dyn kulcsszó azt jelenti: "valamilyen, futásidőben eldöntött típus, amely implementálja ezt a trait-et". Mivel nem ismert a mérete fordítási időben, dyn Trait-et sosem tárolhatsz közvetlenül egy változóban vagy Vec-ben — mindig egy indirekción (pointeren) keresztül kell elérni: &dyn Trait, &mut dyn Trait, vagy Box<dyn Trait>.

A Box<dyn Trait> a heap-en foglal helyet a konkrét értéknek, és a doboz maga egy úgynevezett "fat pointer" (kövér pointer): két szót tartalmaz — egy pointert az adatra, és egy pointert egy vtable-ra (metódustáblára), amely az adott konkrét típus metódusainak tényleges implementációját tartalmazza. Amikor egy dyn Shape értéken meghívod az area() metódust, a program a vtable-ból nézi ki, melyik konkrét area függvényt kell futtatni.

Ez lehetővé teszi, hogy egy Vec<Box<dyn Shape>>-ban keverve tarthass Circle-öket, Rectangle-öket, akármit — mindegyik doboz ugyanannyi (két szónyi) helyet foglal a Vec-ben, csak az, hogy éppen mire mutat, különbözik.

Megjegyzés

Mindig ki kell írni a dyn kulcsszót, ha trait objektumra hivatkozol — ez egyértelműsíti a kódban, hogy itt futásidejű, dinamikus típusról van szó, nem egy konkrét, fordítási időben ismert típusról.

Statikus vs dinamikus dispatch

Amikor egy konkrét típusra (mondjuk egy &Circle-re) hívod meg az area() metódust, a fordító pontosan tudja fordítási időben, melyik impl Shape for Circle blokkban lévő függvényt kell meghívni. Ezt hívjuk statikus dispatch-nek: a metódushívás közvetlenül, futásidejű keresés nélkül történik, és a fordító akár be is építheti (inline) a hívott kódot a hívási helyre. Ez a leggyorsabb forma.

Amikor viszont egy Box<dyn Shape>-en hívod meg az area()-t, a fordító fordítási időben NEM tudja, hogy éppen egy Circle vagy egy Rectangle van a dobozban — ez csak futásidőben derül ki. Ezért a hívás a vtable-on keresztül, egy extra pointer-dereferenálással történik. Ezt hívjuk dinamikus dispatch-nek. Az extra lépés apró teljesítményköltséget jelent (egy indirekt hívás, nincs inline-olás), de cserébe rugalmasságot kapsz: futásidőben dönthetsz arról, milyen konkrét típusokkal dolgozol, és egyetlen Vec-ben tarthatod őket.

Tipp

A következő leckében ("Generikus függvények és típusok") egy másik, gyakran gyorsabb megoldást is megismersz ugyanerre a problémára — érdemes lesz majd összevetni, mikor melyiket választanád.

Object safety: mikor NEM használható dyn

Nem minden trait alakítható át trait objektummá. Ahhoz, hogy egy trait "object safe" (dyn-kompatibilis) legyen, néhány szabálynak teljesülnie kell:

  • Minden metódusnak self, &self vagy &mut self paraméterrel kell rendelkeznie — egy olyan asszociált függvény, amely nem vesz át semmilyen self-et (pl. egy konstruktor, fn new() -> Self), nem hívható meg egy trait objektumon, mert a fordító nem tudja, melyik konkrét típus new függvényét kellene futtatni.
  • Egy metódus nem adhat vissza Self típusú értéket (érték szerint), mert a Self mérete a konkrét implementáló típustól függ, és a dyn-kompatibilis kódnak minden implementáló típusra ugyanúgy kell működnie, fix méret nélkül.
  • Egy metódus nem lehet saját típusparaméterrel rendelkező, generikus metódus — ezt a fogalmat a következő leckében ismered meg részletesen, most elég annyi, hogy ilyen metódusok sincsenek megengedve trait objektumoknál.

Íme egy illusztráció — a következő kód nem fordul le, ha próbálnál belőle trait objektumot készíteni:

trait Builder {
    fn new() -> Self; // HIBA forrása: nincs self paraméter, Self-et ad vissza
}

Ha ebből próbálnál Box<dyn Builder>-t létrehozni, a fordító hibát jelezne, mert nem tudja, mekkora helyet foglaljon a visszatérési érték, és melyik new implementációt hívja. Az ilyen trait-eket csak konkrét típussal együtt, közvetlenül lehet használni.

Figyelem

Az object safety hiánya nem azt jelenti, hogy a trait haszontalan — csak azt, hogy nem csomagolható dyn-be. Simán használhatod impl-lel konkrét típusokon, csak trait objektumként nem.

Gyakorlat: alakzatok trait objektumokban

Most magad építs egy kis programot, amely egy Shape trait-et definiál, több alakzattal implementálja, és mindet egyetlen Vec<Box<dyn Shape>>-ban tárolja.

1. lépés: A Shape trait

Feladat: Definiálj egy Shape trait-et két metódussal: area(&self) -> f64 (terület) és name(&self) -> String (az alakzat neve). Adj hozzá egy alapértelmezett describe(&self) -> String metódust is, amely a name() és area() felhasználásával összeállít egy formázott szöveget, pl. "Kör, területe: 12.57".

Megoldás
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> String;

    // alapértelmezett metódus, amely a másik kettőre épül
    fn describe(&self) -> String {
        format!("{}, területe: {:.2}", self.name(), self.area())
    }
}

2. lépés: Konkrét alakzatok

Feladat: Hozz létre egy Circle struct-ot (radius: f64 mezővel) és egy Rectangle struct-ot (width: f64, height: f64 mezőkkel), majd implementáld rájuk a Shape trait-et.

Megoldás
struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn name(&self) -> String {
        String::from("Kör")
    }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn name(&self) -> String {
        String::from("Téglalap")
    }
}

3. lépés: Összesített terület trait objektumokból

Feladat: Írj egy total_area függvényt, amely paraméterként egy &[Box<dyn Shape>] szeletet kap, és egy for ciklussal összeadja az egyes alakzatok területét, majd visszaadja az összeget f64-ként.

Megoldás
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
    let mut sum = 0.0;
    for shape in shapes {
        sum += shape.area();
    }
    sum
}

4. lépés: A heterogén Vec összeállítása

Feladat: A main függvényben hozz létre egy Vec<Box<dyn Shape>>-ot, tegyél bele legalább egy Circle-t és egy Rectangle-t (Box::new(...)-tal becsomagolva), majd egy for ciklussal írd ki mindegyik describe() kimenetét, végül a total_area eredményét is.

Megoldás
fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Rectangle { width: 3.0, height: 4.0 }),
    ];

    for shape in &shapes {
        println!("{}", shape.describe());
    }

    println!("Összes terület: {:.2}", total_area(&shapes));
}

5. lépés: Egy harmadik alakzat hozzáadása

Feladat: Adj hozzá egy Triangle struct-ot (base: f64, height: f64 mezőkkel, terület = base * height / 2.0), implementáld rá a Shape trait-et, és bővítsd a Vec-et egy Triangle-lel — a total_area és a kiíró ciklus kódját nem kell módosítanod.

Megoldás
struct Triangle {
    base: f64,
    height: f64,
}

impl Shape for Triangle {
    fn area(&self) -> f64 {
        self.base * self.height / 2.0
    }

    fn name(&self) -> String {
        String::from("Háromszög")
    }
}

A main-ben csak egy sort kell hozzáadnod a vec! makróhoz:

Box::new(Triangle { base: 5.0, height: 3.0 }),

Figyeld meg: a total_area függvényt és a kiíró ciklust egyáltalán nem kellett módosítani — ez a trait objektumok egyik legnagyobb előnye.

Teljes megoldás

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> String;

    fn describe(&self) -> String {
        format!("{}, területe: {:.2}", self.name(), self.area())
    }
}

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

struct Triangle {
    base: f64,
    height: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn name(&self) -> String {
        String::from("Kör")
    }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn name(&self) -> String {
        String::from("Téglalap")
    }
}

impl Shape for Triangle {
    fn area(&self) -> f64 {
        self.base * self.height / 2.0
    }

    fn name(&self) -> String {
        String::from("Háromszög")
    }
}

fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
    let mut sum = 0.0;
    for shape in shapes {
        sum += shape.area();
    }
    sum
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Rectangle { width: 3.0, height: 4.0 }),
        Box::new(Triangle { base: 5.0, height: 3.0 }),
    ];

    for shape in &shapes {
        println!("{}", shape.describe());
    }

    println!("Összes terület: {:.2}", total_area(&shapes));
}

Bónusz-kihívások

  • Adj a Shape trait-hez egy perimeter(&self) -> f64 metódust (kerület), implementáld mindhárom alakzatra, és írj egy total_perimeter függvényt is a total_area mintájára.
  • Írj egy find_largest függvényt, amely paraméterül kap egy &[Box<dyn Shape>] szeletet, és visszaadja a legnagyobb területű alakzatra mutató referenciát (&dyn Shape). Gondold át: milyen esetben (pl. üres szelet) nem tudna érvényes referenciát visszaadni, és hogyan jelezné ezt a hívónak — erről a következő leckéken lesz még szó a hibakezeléssel kapcsolatban.

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

Megtanultad, hogy a dyn Trait és a Box<dyn Trait> segítségével futásidőben, egységesen kezelhetsz eltérő konkrét típusokat, amíg azok ugyanazt a trait-et implementálják. Megértetted a statikus és dinamikus dispatch közti különbséget, és azt is, hogy egy trait csak akkor válhat trait objektummá, ha "object safe" — minden metódusa self-en keresztül hívható, és nem ad vissza Self-et.

A következő leckében ("Generikus függvények és típusok") egy másik gyakori választ is megismersz ugyanerre a "sokféle típus, egy kód" problémára — érdemes lesz majd összevetni, mikor melyiket választanád.