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.
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.
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,&selfvagy&mut selfparaméterrel kell rendelkeznie — egy olyan asszociált függvény, amely nem vesz át semmilyenself-et (pl. egy konstruktor,fn new() -> Self), nem hívható meg egy trait objektumon, mert a fordító nem tudja, melyik konkrét típusnewfüggvényét kellene futtatni. - Egy metódus nem adhat vissza
Selftípusú értéket (érték szerint), mert aSelfmé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.
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
Shapetrait-hez egyperimeter(&self) -> f64metódust (kerület), implementáld mindhárom alakzatra, és írj egytotal_perimeterfüggvényt is atotal_areamintájára. - Írj egy
find_largestfü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.