A Rust nem ismeri a klasszikus OOP-s öröklődést, helyette a trait-ekre épít, amikkel viselkedést (nem adatot) adhatunk hozzá típusokhoz. Ez a cikk végigveszi, hogyan definiálunk és implementálunk trait-eket, majd megnézzük a két nagy polimorfizmus-stílust: a statikus dispatch-et (generikus függvények trait bound-okkal) és a dinamikus dispatch-et (dyn Trait, trait objektumok).
Mi az a trait, és hogyan implementáljuk?
A trait egy olyan interfész, amely metódusszignatúrákat ír elő. Bármilyen típuson implementálhatjuk, akár a saját típusainkon, akár — coherence szabályok mellett — külső típusokon is, ha a trait vagy a típus a mi crate-ünkben van (ez az úgynevezett orphan rule).
trait Hangado {
fn hangot_ad(&self) -> String;
// Default implementáció is lehet:
fn bemutatkozik(&self) -> String {
format!("Én ezt mondom: {}", self.hangot_ad())
}
}
struct Kutya;
struct Macska;
impl Hangado for Kutya {
fn hangot_ad(&self) -> String {
"Vau!".to_string()
}
}
impl Hangado for Macska {
fn hangot_ad(&self) -> String {
"Miau!".to_string()
}
}
fn main() {
let kutya = Kutya;
let macska = Macska;
println!("{}", kutya.bemutatkozik());
println!("{}", macska.bemutatkozik());
}
Figyeld meg, hogy a bemutatkozik metódusnak van egy alapértelmezett implementációja, amit a Kutya és a Macska sem ír felül — de akár felülírhatnák.
A trait-eken belül lehetnek asszociált típusok és konstansok is, nem csak metódusok. Ha generikus asszociált típusokra (GAT-okra) van szükséged, azok már stabilak — ez viszont haladóbb téma, egy külön cikket megér.
Generikus függvények trait bound-okkal — statikus dispatch
Ha egy függvénynek olyan paraméterre van szüksége, amely implementál egy adott trait-et, két szintaxis közül választhatunk: az impl Trait szintaxist, vagy a generikus paraméterezést where klózzal. Mindkettő statikus dispatch-et eredményez: a fordító minden konkrét típushoz külön, monomorfizált kódot generál.
fn koszont(allat: &impl Hangado) {
println!("Köszönés: {}", allat.hangot_ad());
}
// Ugyanez generikus paraméterrel és where klózzal:
fn koszont2<T>(allat: &T)
where
T: Hangado,
{
println!("Köszönés: {}", allat.hangot_ad());
}
fn main() {
let kutya = Kutya;
koszont(&kutya);
koszont2(&kutya);
}
A lényeg, hogy fordítási időben minden hívási helyhez a fordító tudja, pontosan melyik hangot_ad implementációt kell meghívni — nincs futásidejű keresés, a metódushívás inline-olható, és a fordító akár teljesen el is tüntetheti a függvényhívást. Ez a monomorfizáció: minden T-re, amivel a függvényt meghívjuk, generálódik egy saját másolat a bináris kódban.
A monomorfizáció ára a bináris méret növekedése ("code bloat"), ha sok különböző típussal hívjuk meg ugyanazt a generikus függvényt. Cserébe viszont nincs futásidejű overhead, és a fordító optimalizálhat az inline-olás miatt.
dyn Trait és trait objektumok — dinamikus dispatch
Ha futásidőben szeretnénk különböző, trait-et implementáló típusokat egy kollekcióban tárolni — mondjuk egy Vec-ben —, statikus dispatch-hel ez nem megy egyszerűen, mert a Vec<T>-nek egyetlen konkrét T típusa lehet. Itt jönnek képbe a trait objektumok, amiket a dyn Trait szintaxissal jelölünk.
fn main() {
let allatok: Vec<Box<dyn Hangado>> = vec![
Box::new(Kutya),
Box::new(Macska),
];
for allat in &allatok {
println!("{}", allat.bemutatkozik());
}
}
A Box<dyn Hangado> egy heap-allokált, ismeretlen konkrét típusú érték, amelyhez egy vtable (virtual method table) is tartozik. Amikor meghívjuk a hangot_ad-ot, a program futás közben a vtable-ből néz ki a megfelelő függvénypointert, és azon keresztül hívja meg a metódust. Ez a dinamikus dispatch.
A dyn Trait referenciaként is használható (&dyn Hangado), nem csak Box-ban — a lényeg, hogy egy mutató mellé mindig egy vtable-pointer is jár, ezért a dyn Trait mérete futásidőben "fat pointer", azaz két szó (adatpointer + vtable-pointer).
fn koszont_dinamikusan(allat: &dyn Hangado) {
println!("Dinamikus köszönés: {}", allat.hangot_ad());
}
fn main() {
let kutya = Kutya;
koszont_dinamikusan(&kutya);
}
A &dyn Trait és az &impl Trait szintaktikailag hasonlóan néznek ki, de teljesen más mechanizmust jelentenek! Az impl Trait paraméterként statikus dispatch-et generál (monomorfizáció), a dyn Trait pedig dinamikus dispatch-et vtable-lel. Ne keverd össze a kettőt.
Statikus vs dinamikus: mikor melyiket?
A döntés több szempontból is lényeges:
- Teljesítmény: a statikus dispatch általában gyorsabb, mert nincs vtable-lookup, és a fordító inline-olhat. Ha egy szűk ciklusban milliószor hívunk meg egy trait metódust, a statikus dispatch szinte biztos nyerő.
- Bináris méret: a monomorfizáció minden konkrét típushoz külön kódot generál, ami nagyobb binárist eredményezhet sok típus és sok generikus függvény esetén. A
dyn Traitezzel szemben egyetlen implementációt generál, amely a vtable-ön keresztül dolgozik — kisebb bináris, cserébe kis futásidejű overhead. - Rugalmasság: ha heterogén kollekciót akarsz (különböző konkrét típusok egy
Vec-ben), adyn Trait(vagyBox<dyn Trait>) az egyetlen praktikus út — generikus paraméterezéssel ezt nem tudod megoldani, mert egyVec<T>-nek egy konkrétT-je van. - API-felszín stabilitása: ha könyvtárat írsz, és nem akarod, hogy a publikus API-d monomorfizáció miatt robbanjon (minden hívási helyen újra és újra generálódjon kód), a
dyn Traitegyszerűbbé teheti a fordítási időt is.
Ökölszabályként: ha tudod fordítási időben, milyen konkrét típusokkal fogsz dolgozni, és a teljesítmény kritikus, válasszd a generikus, statikus megoldást. Ha futásidőben eldöntött, heterogén típusokat kell kezelned (plugin-rendszer, callback-listák, UI-widget fák), a dyn Trait a természetesebb választás.
Érdemes megjegyezni, hogy a különbség a legtöbb alkalmazásban a gyakorlatban nem mérhető — a Rust fordítója (LLVM-alapú backend-del) remekül optimalizál, és a vtable-lookup költsége egy modern CPU-n minimális, ha nem forró útvonalon (hot path) történik. Először mérj, csak azután optimalizálj erre alapozva.
Gyakori csapdák: az object safety szabályai
Nem minden trait alkalmas trait objektumként. Ahhoz, hogy egy trait-et dyn Trait formában használhassunk, object safe-nek kell lennie. A legfontosabb szabályok:
- A trait metódusai nem tartalmazhatnak generikus típusparamétert (bár a
where Self: Sizedklózzal kizárhatók a dinamikus dispatch alól). - A metódusok nem adhatják vissza
Self-et érték szerint (mert a konkrét méret futásidőben ismeretlen). - A trait nem lehet
Sizedbound nélküli, de maga a trait objektum mindig unsized.
Nézzünk egy konkrét ütközést:
trait Klonozhato {
fn klonoz(&self) -> Self; // hiba forrása: Self visszaadása
}
Ha ezt próbáljuk dyn Klonozhato-ként használni, a fordító hibát jelez, mert nem tudja, mekkora helyet kell allokálni a visszatérési értéknek — a konkrét típus mérete csak fordítási időben ismert, futásidőben a trait objektum már elvesztette ezt az infót.
A megoldás gyakran az, hogy generikus metódust where Self: Sized-del kizárunk a trait objektumból, vagy más mintát választunk (pl. Box<dyn Klonozhato>-t visszaadó metódust írunk, ami object-safe marad):
trait KlonozhatoDoboz {
fn klonoz_doboz(&self) -> Box<dyn KlonozhatoDoboz>;
}
struct Szamlalo(i32);
impl KlonozhatoDoboz for Szamlalo {
fn klonoz_doboz(&self) -> Box<dyn KlonozhatoDoboz> {
Box::new(Szamlalo(self.0))
}
}
Egy másik gyakori csapda: ha egy trait-nek generikus metódusa van (fn valami<T>(&self, x: T)), az a trait sem lesz object-safe, hiszen minden T-hez külön vtable-bejegyzés kellene, amit a Rust fordítója nem tud statikusan felsorolni. Ilyenkor a metódust vagy kiveheted a trait-ből, vagy where Self: Sized klózzal jelezheted, hogy csak statikus dispatch esetén elérhető.
Egy másik apró, de fontos részlet: az asszociált konstansok is kizárják az object safety-t, mert nincs futásidejű módjuk annak, hogy a vtable-ön keresztül elérhetők legyenek típusfüggő módon.
Összefoglalás
A trait-ek a Rust két polimorfizmus-stílusának közös alapját adják: a statikus dispatch (generikus függvények, impl Trait, trait bound-ok) fordítási időben monomorfizálja a kódot, gyorsabb futást és inline-olási lehetőséget ad, de nagyobb bináris méretet eredményezhet. A dinamikus dispatch (dyn Trait, trait objektumok) futásidőben, vtable-ön keresztül dispatchel, kisebb bináris méretet és rugalmasabb, heterogén típuskezelést biztosít, apró futásidejű overhead árán. Az object safety szabályai (nincs generikus metódus, nincs Self visszatérési érték méret nélkül, nincs asszociált konstans) meghatározzák, mikor egyáltalán lehet egy trait-et dyn-ként használni — ezekre mindig figyelj, ha egy API-t trait objektumokra terveznél. A gyakorlatban a legtöbb esetben mindkét eszközt kombinálva fogod használni: statikus dispatch-et a forró útvonalakon, dinamikus dispatch-et pedig ott, ahol a futásidejű rugalmasság a fontosabb szempont.