A Rust egyik legnagyobb erénye, hogy a polimorfizmust anélkül tudja megvalósítani, hogy feláldozná a teljesítményt vagy a memóriabiztonságot. Ennek a kulcsa a trait rendszer, amely valahol az interfészek és a típusosztályok között helyezkedik el. De a trait-ek használatakor egy fontos elágazási ponthoz érünk: statikus (compile-time) vagy dinamikus (runtime) diszpatch legyen? Ez a cikk pont ebben segít eligazodni.

Mi az a trait, és hogyan implementáljuk saját típusokra

A trait egy olyan közös viselkedést ír le, amit különböző típusok megvalósíthatnak. Gondolj rá úgy, mint egy szerződésre: aki aláírja (implementálja), az garantálja, hogy megvannak benne a megadott metódusok.

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str {
        "ismeretlen alakzat"
    }
}

struct Circle {
    radius: f64,
}

struct Square {
    side: f64,
}

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

    fn name(&self) -> &str {
        "kör"
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

Figyeld meg, hogy a name metódusnak van alapértelmezett implementációja a trait-ben, amit a Square nem is ír felül — így az az alap szöveget adja vissza. Ez egy apró, de nagyon hasznos trükk: nem kell minden implementációban mindent újra megírni.

Megjegyzés

A trait-ek nem csak metódusokat tartalmazhatnak, hanem asszociált típusokat és konstansokat is. Az advanced use case-ekben (pl. iterátoroknál) ezek kulcsszerepet kapnak.

Statikus diszpatch: generikák és az impl Trait szintaxis

Amikor egy függvényt generikus típusparaméterrel írunk meg, és egy trait bound-dal korlátozzuk, a fordító minden konkrét típushoz külön, monomorfizált verziót generál a függvényből. Ez a statikus diszpatch.

fn print_area<T: Shape>(shape: &T) {
    println!("{} területe: {:.2}", shape.name(), shape.area());
}

// Ugyanez impl Trait szintaxissal, ami cukorka a generikára:
fn print_area_v2(shape: &impl Shape) {
    println!("{} területe: {:.2}", shape.name(), shape.area());
}

A két verzió a fordító szemében gyakorlatilag azonos — az impl Trait paraméterként csak egy rövidebb, olvashatóbb szintaxis a generikus típusparaméterhez. A lényeg, hogy fordításidőben a Circle és a Square esetére is külön-külön, teljesen specializált gépi kód keletkezik. Ennek köszönhetően a metódushívás inline-olható, nincs runtime overhead, és a CPU előre tudja jósolni, hova ugorjon.

Ez a mechanizmus az, amit "zero-cost abstraction"-nek szoktunk hívni: a trait-ek használata semmilyen extra futásidejű költséggel nem jár, mert a fordító mindent statikusan felold.

A hátulütője viszont a bináris méret: ha sok különböző típussal hívod meg ugyanazt a generikus függvényt, a fordító minden egyes típushoz legenerálja a saját másolatát a kódnak. Ezt hívják kód-bloatnak, és nagy, sokféle típust kezelő rendszereknél mérhető hatása lehet a fordítási időre és a bináris méretére is.

Dinamikus diszpatch: dyn Trait és trait objektumok működése

Amikor futásidőben szeretnénk különböző, trait-et implementáló típusokat egy közös gyűjteményben kezelni — mondjuk egy Vec-ben —, a statikus diszpatch nem elég, mert a Vec egyetlen konkrét típust vár. Itt jön képbe a dyn Trait, azaz a trait objektum.

fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    for shape in &shapes {
        println!("{}: {:.2}", shape.name(), shape.area());
    }

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

A dyn Shape egy úgynevezett trait objektum, amely mögött egy vtable (virtuális metódustábla) rejtőzik. A trait objektum egy "fat pointer": egyszerre tárol egy pointert az adatra, és egy másikat a vtable-re, amiben a konkrét típushoz tartozó metódusimplementációk cím szerint szerepelnek. Amikor meghívod az area()-t, a program futásidőben néz bele a vtable-be, és onnan tudja, melyik konkrét kódot kell futtatni.

Ezért van szükség a Box-ra (vagy &dyn, Rc<dyn> stb.): a dyn Trait maga nem Sized, mert nem tudjuk fordításidőben, mekkora lesz a mögötte álló konkrét típus, így valamilyen pointeren keresztül kell hivatkozni rá.

Tipp

Ha csak egyetlen referenciát kell átadnod, és nincs szükséged tulajdonjogra, elég a &dyn Shape is — nem kell mindig Box-ba csomagolni.

Teljesítmény: statikus vs dinamikus diszpatch

A generikák (statikus diszpatch) előnye a nyers sebesség: nincs indirekció, a fordító inline-olhatja a hívásokat, és az optimalizáló akár teljesen el is tüntetheti a metódushívás "költségét". A hátránya a bináris méret és néha a fordítási idő növekedése sok típus esetén.

A dyn Trait (dinamikus diszpatch) ára egy extra pointer-dereferencia minden metódushívásnál, plusz az, hogy a fordító nem tudja inline-olni a hívást — nem tudja fordításidőben, melyik konkrét kód fut majd. Ez a legtöbb üzleti logikában elenyésző különbség, de szoros ciklusokban, ahol milliószor hívsz meg egy apró metódust, mérhető lehet.

Ami viszont sokat nyom a latba a dyn Trait mellett: heterogén gyűjtemények kezelése (pl. plugin rendszerek, GUI widget fák), kisebb bináris méret, és gyorsabb fordítás nagy kódbázisokban, mert nem kell minden típushoz újra és újra monomorfizálni a kódot.

// Statikus diszpatch: minden T-hez saját kód generálódik
fn describe_static<T: Shape>(shape: &T) -> String {
    format!("{} - {:.2}", shape.name(), shape.area())
}

// Dinamikus diszpatch: egyetlen kód, futásidejű döntés
fn describe_dynamic(shape: &dyn Shape) -> String {
    format!("{} - {:.2}", shape.name(), shape.area())
}

Ökölszabályként: ha nem vagy biztos benne, kezdd generikákkal (impl Trait vagy <T: Trait>), és csak akkor válts dyn Trait-re, ha valódi futásidejű polimorfizmusra van szükséged, például amikor egy heterogén listát kell tárolnod, vagy amikor egy plugin-szerű API-t építesz, ahol a konkrét típus fordításidőben nem is ismert.

Gyakori hibák trait objektumokkal

A dyn Trait használata néhány buktatót is rejt, amivel korán szembe fogsz kerülni.

Object safety. Nem minden trait alkalmas trait objektumnak. Ha egy trait-nek van generikus metódusa, vagy Self-et ad vissza (nem referenciaként), akkor nem "object safe", és nem készíthetsz belőle dyn Trait-et.

trait Cloneable {
    fn clone_box(&self) -> Box<dyn Cloneable>;
}

// Ez viszont NEM object safe, mert Self-t ad vissza értékként:
// trait NotObjectSafe {
//     fn duplicate(&self) -> Self;
// }

Méret és Sized korlátozás. Alapból minden generikus típusparaméter implicit Sized bound-ot kap. Ha a trait-edet dyn-nel akarod használni, ügyelj arra, hogy a metódusai ne írjanak elő Sized-et a paraméterekre feleslegesen.

Elfelejtett indirekció. Kezdők gyakran próbálnak trait objektumot közvetlenül, pointer nélkül visszaadni:

// Ez NEM fordul:
// fn make_shape() -> dyn Shape { Circle { radius: 1.0 } }

// Helyesen:
fn make_shape() -> Box<dyn Shape> {
    Box::new(Circle { radius: 1.0 })
}
Figyelem

A dyn Shape mint visszatérési típus önmagában nem fordul, mert a fordítónak nem ismeri a méretét fordításidőben. Mindig pointer (Box, &, Rc) mögé kell rejtened.

A impl Trait visszatérési típus félreértése. Az impl Trait visszatérési típusnál statikus diszpatch történik, és egyetlen konkrét típust takarhat — nem cserélhetsz benne futásidőben másik implementációt.

fn make_circle() -> impl Shape {
    Circle { radius: 1.0 }
}

// Ez viszont hibás lenne, mert két különböző konkrét típust ad vissza:
// fn make_shape_bad(flag: bool) -> impl Shape {
//     if flag { Circle { radius: 1.0 } } else { Square { side: 1.0 } }
// }

Ha futásidőben kell választanod típusok között, ott már tényleg a Box<dyn Shape> a helyes megoldás.

Összefoglalás

A trait-ek a Rust típusrendszerének gerincét adják, és a statikus/dinamikus diszpatch közötti választás nem fekete-fehér kérdés. A generikák és az impl Trait a legtöbb esetben a gyorsabb, jobban optimalizálható utat jelentik, míg a dyn Trait és a trait objektumok akkor válnak elengedhetetlenné, amikor valódi futásidejű polimorfizmusra van szükség — például heterogén gyűjteményeknél vagy plugin architektúráknál. A jó hír, hogy a Rust mindkettőt natívan, jól dokumentáltan támogatja, neked csak azt kell eldöntened, melyik illik jobban a konkrét feladatodhoz.