Ha C++ vagy Java háttérrel érkezel a Rust világába, az első dolog, amit hiányolni fogsz, az osztályhierarchia és az öröklődés. A Rust helyette trait-eket ad, amik sokkal inkább az interfészekre hasonlítanak – de van néhány trükk a kezükben, amit érdemes alaposan megérteni.

Mi az a trait és hogyan definiáljuk

Egy trait egyszerűen egy viselkedés-halmazt ír le: metódusokat sorol fel, amiket egy típusnak implementálnia kell, ha "be akar szállni" abba a viselkedésbe. Gondolj rá úgy, mint egy szerződésre: aki aláírja (implementálja), az garantálja, hogy rendelkezik az adott funkcionalitással.

trait Hangot Ad {
    fn hang(&self) -> String;

    // Alapértelmezett implementáció is lehet
    fn bemutatkozik(&self) -> String {
        format!("Ezt a hangot adom: {}", self.hang())
    }
}

Várj, ez a kód nem is fordulna – a trait neve nem tartalmazhat szóközt! Íme a helyes verzió:

trait HangotAd {
    fn hang(&self) -> String;

    // Alapértelmezett implementáció is lehet
    fn bemutatkozik(&self) -> String {
        format!("Ezt a hangot adom: {}", self.hang())
    }
}

A hang metódusnak nincs teste, ezt kötelező lesz megadni minden implementáló típusnak. A bemutatkozik viszont már kap egy default implementációt, amit a típusok felül is írhatnak, de nem muszáj.

Megjegyzés

A trait-ek metódusai mindig &self, &mut self vagy self paraméterrel kezdődnek (kivéve a statikus, ún. "associated function"-öket, mint például a Default::default()).

Trait implementáció saját típusokra

Az impl Trait for Típus szintaxissal kapcsoljuk össze a trait-et a konkrét típussal. Nézzünk egy klasszikus állatos példát:

trait HangotAd {
    fn hang(&self) -> String;

    fn bemutatkozik(&self) -> String {
        format!("Ezt a hangot adom: {}", self.hang())
    }
}

struct Kutya;
struct Macska;

impl HangotAd for Kutya {
    fn hang(&self) -> String {
        "Vau!".to_string()
    }
}

impl HangotAd for Macska {
    fn hang(&self) -> String {
        "Miáu!".to_string()
    }

    fn bemutatkozik(&self) -> String {
        format!("Nyalom-falom, aztán: {}", self.hang())
    }
}

fn main() {
    let kutya = Kutya;
    let macska = Macska;

    println!("{}", kutya.bemutatkozik());
    println!("{}", macska.bemutatkozik());
}

A Kutya megelégszik az alapértelmezett bemutatkozik-kal, a Macska viszont felülírja azt. Ez a legegyszerűbb formája a Rust-os polimorfizmusnak: a viselkedés a típusból következik, nem egy közös ősosztályból.

Jó tudni

Csak akkor implementálhatsz egy trait-et egy típusra, ha legalább az egyik – a trait vagy a típus – a saját crate-edben van definiálva. Ezt hívják coherence vagy orphan rule-nak, és ez akadályozza meg, hogy két különböző crate ellentmondó implementációkat adjon ugyanarra a párosra.

Statikus vs dinamikus diszpécselés (impl Trait vs dyn Trait)

Itt kezdődik az igazi Rust-varázslat. Amikor egy függvény paramétereként trait-et akarsz elfogadni, két fő útvonalad van.

Statikus diszpécselés (impl Trait vagy generikus paraméter): a compiler minden konkrét típushoz külön, monomorfizált kódot generál. Ez gyors, mert nincs futásidejű overhead, de a bináris mérete növekedhet.

fn hangoztat(allat: &impl HangotAd) {
    println!("{}", allat.bemutatkozik());
}

// Ez ugyanaz, generikus formában:
fn hangoztat_generikus<T: HangotAd>(allat: &T) {
    println!("{}", allat.bemutatkozik());
}

Dinamikus diszpécselés (dyn Trait): itt egy virtuális metódustáblán (vtable) keresztül futásidőben dől el, melyik implementációt hívjuk. Ez rugalmasabb, mert egyetlen kód képes kezelni bármilyen, a trait-et implementáló típust – akár olyat is, amiről fordítási időben semmit sem tudunk.

fn hangoztat_dinamikus(allat: &dyn HangotAd) {
    println!("{}", allat.bemutatkozik());
}

fn main() {
    let kutya = Kutya;
    let macska = Macska;

    hangoztat_dinamikus(&kutya);
    hangoztat_dinamikus(&macska);
}
Tipp

Ökölszabály: ha nem vagy biztos benne, melyiket használd, kezdd a generikus (impl Trait / <T: Trait>) verzióval. Csak akkor válts dyn Trait-re, ha valóban heterogén kollekciót akarsz kezelni, vagy ha a bináris méret/fordítási idő problémát jelent.

Fontos különbség: a dyn Trait nem méretezett (?Sized) típus, ezért csak referencia (&dyn Trait), Box<dyn Trait>, Rc<dyn Trait> vagy hasonló pointer mögött használható – önmagában sosem lehet egy változó típusa.

Trait objektumok és a Box minta

Amikor egy vektorban, vagy egy struct mezőjében akarunk különböző konkrét típusú, de ugyanazt a trait-et implementáló értékeket tárolni, a Box<dyn Trait> a barátunk. A heap-allokáció miatt a mérete fixen ismert (egy pointer + vtable pointer), így beleférnek egy Vec-be.

trait HangotAd {
    fn hang(&self) -> String;
}

struct Kutya;
struct Macska;
struct Tehen;

impl HangotAd for Kutya {
    fn hang(&self) -> String { "Vau!".to_string() }
}

impl HangotAd for Macska {
    fn hang(&self) -> String { "Miáu!".to_string() }
}

impl HangotAd for Tehen {
    fn hang(&self) -> String { "Bú!".to_string() }
}

fn main() {
    let allatkert: Vec<Box<dyn HangotAd>> = vec![
        Box::new(Kutya),
        Box::new(Macska),
        Box::new(Tehen),
    ];

    for allat in &allatkert {
        println!("{}", allat.hang());
    }
}

Ez a minta rendkívül hasznos plugin-szerű architektúráknál, ahol futásidőben nem tudjuk előre, milyen konkrét típusokkal fogunk dolgozni – például parser-ek, renderer-ek vagy stratégia mintát megvalósító rendszerek esetén.

Figyelem

Nem minden trait alkalmas trait objektumnak. Ha a trait-ednek generikus metódusa van, vagy Self-t ad vissza értékként (nem referenciaként), az a trait nem lesz object-safe, és nem tudod dyn-ként használni. Ilyenkor gyakran segít egy segéd-trait bevezetése, vagy a metódus kivétele a trait-ből.

Gyakori beépített trait-ek: Debug, Clone, Default

A standard könyvtár számos hasznos trait-et biztosít, amiket a derive makróval gyakran egyetlen sorban rá tudsz húzni a saját típusaidra.

#[derive(Debug, Clone, Default)]
struct Konfiguracio {
    nev: String,
    verzio: u32,
    debug_mod: bool,
}

fn main() {
    let alap = Konfiguracio::default();
    println!("{:?}", alap);

    let mासolat = alap.clone();
    println!("{:?}", mासolat);
}

Várj, ez tartalmaz egy elgépelést a masolat névben – nézzük meg helyesen:

#[derive(Debug, Clone, Default)]
struct Konfiguracio {
    nev: String,
    verzio: u32,
    debug_mod: bool,
}

fn main() {
    let alap = Konfiguracio::default();
    println!("{:?}", alap);

    let masolat = alap.clone();
    println!("{:?}", masolat);
}
  • A Debug trait ad neked {:?} formázást – ez elengedhetetlen a fejlesztés és debug-olás során, gyakorlatilag minden típusra érdemes rátenni.
  • A Clone explicit, mélymásolatot készít – ellentétben a Copy-val, ami implicit és csak egyszerű, stack-en élő típusokra alkalmazható.
  • A Default egy alapértelmezett értéket ad, amit gyakran konfigurációs struct-oknál vagy builder mintáknál használunk.
Megjegyzés

Ha a struct minden mezője implementálja az adott trait-et, a #[derive(...)] automatikusan legenerálja a te típusod implementációját is. Ha valamelyik mező nem támogatja (pl. egy Box<dyn Trait> mező nem Clone), a derive hibát fog dobni, és neked kézzel kell megírnod az implementációt.

Érdemes megjegyezni, hogy ezek a trait-ek is tökéletesen működnek trait objektumként is – bár a Clone speciális eset, mert Self-t ad vissza érték szerint, ezért maga a Clone trait nem object-safe. Ha Box<dyn Trait>-et akarsz klónozhatóvá tenni, gyakran egy külön clone_box metódust definiálsz a saját trait-eden belül, ami belül Box::new(self.clone())-ot hív a konkrét típuson.

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

impl<T> Klonozhato for T
where
    T: 'static + Clone + Klonozhato,
{
    fn clone_box(&self) -> Box<dyn Klonozhato> {
        Box::new(self.clone())
    }
}

Ez a minta gyakori könyvtárkódban, és jól mutatja, hogy a trait-ek kombinálásával nagyon kifejező, ugyanakkor típusbiztos absztrakciókat építhetsz.

Összefoglalás

A trait-ek a Rust polimorfizmusának alapkövei: nem osztályhierarchiát adnak, hanem viselkedés-szerződéseket, amiket a típusaid tetszés szerint implementálhatnak. A statikus diszpécselés (impl Trait, generikusok) a teljesítmény és a fordítási idő optimalizálás felé húz, míg a dinamikus diszpécselés (dyn Trait, Box<dyn Trait>) rugalmasságot ad ott, ahol futásidőben kell heterogén típusokat kezelni. A beépített trait-ek – Debug, Clone, Default – pedig nap mint nap megkönnyítik az életedet, gyakran egyetlen #[derive(...)] sorral. Gyakorolj sokat ezekkel a mintákkal, és hamarosan a trait-ek lesznek a legerősebb eszközeid a Rust kódod strukturálásához.