Miért generikák, ha már ott a dyn Trait?

Az előző leckében megismerted a dyn Trait és a Box<dyn Trait> világát: ott a program futás közben dönti el, melyik konkrét típus metódusát hívja meg (dinamikus dispatch), és ezért egy kis extra futásidejű költséggel kell fizetni. A generikák ugyanazt a rugalmasságot adják meg fordítási időben: azt mondod a Rust-nak, hogy "ez a függvény bármilyen típussal működik, amíg az a típus rendelkezik X képességgel", és a fordító minden egyes konkrét típushoz legenerál egy saját, optimalizált verziót. Ez a fejezet a Rust nyelv egyik legfontosabb eszközét adja a kezedbe: attól a ponttól kezdve, hogy megérted a generikákat, valóban "rustos" kódot tudsz írni – nem kell duplikálnod a logikát minden típusra, és mégsem veszítesz sebességet.

Generikus függvények: a <T> szintaxis

Egy generikus függvény a típusparamétert a függvénynév után, hegyes zárójelben (<T>) deklarálja. A T csak egy név – konvenció szerint egybetűs, nagybetűs azonosító, de bármi lehetne. Nézzünk egy egyszerű, de tanulságos esetet: írjunk egy függvényt, amely két érték közül a nagyobbikat adja vissza.

fn largest<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

fn main() {
    println!("{}", largest(3, 7));
    println!("{}", largest(2.5, 1.1));
}

Ha a T: PartialOrd részt kihagynánk, a fordító hibát dobna, mert a > operátor nem definiált akármilyen típusra:

fn largest<T>(a: T, b: T) -> T {
    if a > b { a } else { b }
}
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:2:11
  |
2 |     if a > b { a } else { b }
  |        - ^ - T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(a: T, b: T) -> T {
  |             ++++++++++++++++++++++

A hibaüzenet maga megmondja a megoldást: kösd T-t egy trait-hez, amely garantálja az összehasonlíthatóságot. Ez a trait bound – erről szól a következő szakasz.

Trait bound: mit kell tudnia T-nek?

A trait bound egy megkötés: "T csak olyan típus lehet, amely implementálja ezt (vagy ezeket) a trait-eket". Így a generikus függvény törzsében szabadon hívhatod azokat a metódusokat, amelyeket a trait garantál – a fordító ellenőrzi, hogy a hívó fél tényleg megfelelő típust adott át.

Több trait bound + jellel köthető össze. Nézzünk egy generikus struct-ot, amelynek metódusa két bound-ot is megkövetel:

use std::fmt::Display;

struct Pair<T> {
    first: T,
    second: T,
}

impl<T: PartialOrd + Display> Pair<T> {
    fn cmp_display(&self) {
        if self.first >= self.second {
            println!("A nagyobb elem: {}", self.first);
        } else {
            println!("A nagyobb elem: {}", self.second);
        }
    }
}

fn main() {
    let pair = Pair { first: 5, second: 10 };
    pair.cmp_display();
}

Figyeld meg: a struct Pair<T> definíciója maga nem ír elő semmilyen bound-ot – bármilyen T típusból alkothatsz Pair-t. A bound csak az impl blokkban jelenik meg, ott, ahol tényleg szükség van rá (>= összehasonlításhoz PartialOrd, a {} formázáshoz Display). Ez fontos idióma: csak ott írj elő megkötést, ahol azt valóban használod.

Tipp

Ha egy generikus típusnak vagy függvénynek nincs rá szüksége, ne tégy bele bound-ot "elővételezésként" – ez csak szűkíti a felhasználhatóságát. A bound-ot mindig a konkrét metódus igénye diktálja.

where klóz: ha a bound-lista túl hosszú lenne

Ha egy függvénynek több típusparamétere és sok bound-ja van, a <T: Trait1 + Trait2, U: Trait3> szintaxis hamar olvashatatlanná válik. Ilyenkor a where klózt használjuk, amely a bound-okat a függvényfej után, külön blokkban sorolja fel:

use std::fmt::Display;

fn compare_and_print<T, U>(t: &T, u: &U)
where
    T: Display,
    U: Display,
{
    println!("t = {}, u = {}", t, u);
}

fn main() {
    compare_and_print(&42, &"szia");
}

A where klóz szemantikailag pontosan ugyanazt jelenti, mint a hegyes zárójelben megadott bound – csak olvashatóbb, ha sok paraméterről vagy összetettebb megkötésekről van szó. Gyakorlott Rust-kódban gyakran találkozol olyan szabállyal, hogy egy-két egyszerű bound-ot inline írnak, de háromnál több paraméter vagy összetett feltétel esetén már where-t használnak.

Megjegyzés

A where klózban nem csak típusparaméterekre írhatsz megkötést, hanem konkrét típusokra is (pl. where String: Display) – ez ritkán kell, de jó tudni, hogy létezik ez a rugalmasság.

Monomorfizáció: mi történik a színfalak mögött

Amikor a Rust fordítja a generikus kódot, nem generál egyetlen, "univerzális" gépi kódot, amely bármilyen típussal működik – ehelyett minden konkrét típushoz, amellyel a generikus függvényt meghívod, legenerál egy külön, specializált verziót. Ezt a folyamatot monomorfizációnak nevezzük.

A korábbi largest példában két hívás volt: largest(3, 7) (i32-vel) és largest(2.5, 1.1) (f64-gyel). A fordító a színfalak mögött valahogy úgy dolgozik, mintha ezt írtad volna:

fn largest_i32(a: i32, b: i32) -> i32 {
    if a > b { a } else { b }
}

fn largest_f64(a: f64, b: f64) -> f64 {
    if a > b { a } else { b }
}

Ennek két fontos következménye van. Egyrészt a lefordított kód pontosan olyan gyors, mintha eleve kézzel írtad volna meg típusonként – nincs futásidejű dispatch, nincs extra pointer-indirekció, ezért ezt statikus dispatch-nek hívjuk (ahogy már a dyn Trait leckében is szó volt a statikus és dinamikus dispatch különbségéről). Másrészt, ha egy generikus függvényt sokféle típussal hívsz meg egy nagy programban, a bináris mérete nőhet, mert minden verzió külön gépi kódot kap.

Figyelem

A monomorfizáció miatt túl sokféle konkrét típussal használt, nagy generikus függvények felduzzaszthatják a végleges bináris méretét ("code bloat"). Ha ez gondot jelent, és a sebességveszteség elfogadható, a dyn Trait alternatíva egyetlen közös implementációt fordít le minden típushoz.

Ez pontosan a döntési szempont a generikák és a dyn Trait között: a generikák sebességet adnak (statikus dispatch, monomorfizáció), a dyn Trait pedig rugalmasságot és kisebb bináris méretet (dinamikus dispatch, egyetlen implementáció). Nézzük egymás mellett a két megközelítést:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("Cikk: {}", self.title)
    }
}

// Generikus verzió: minden híváskor a fordító monomorfizál,
// statikus dispatch, nincs futásidejű overhead.
fn print_summary_generic<T: Summary>(item: &T) {
    println!("{}", item.summarize());
}

// dyn Trait verzió: egyetlen lefordított függvény minden Summary-t
// implementáló típusra, dinamikus dispatch egy vtable-en keresztül.
fn print_summary_dyn(item: &dyn Summary) {
    println!("{}", item.summarize());
}

fn main() {
    let article = Article { title: "Rust generikák".to_string() };
    print_summary_generic(&article);
    print_summary_dyn(&article);
}

Mindkét függvény ugyanazt csinálja, de más az ára: a generikus verzióból annyi lefordított másolat készül, ahányféle típussal meghívod, a dyn Trait verzióból egy. A gyakorlatban a legtöbb Rust-kód generikákat használ ott, ahol tudja (mert ez az idiomatikus, alapértelmezett választás), és dyn Trait-re csak akkor vált, ha heterogén kollekciót kell tárolnia (például Vec<Box<dyn Summary>>), vagy ha a bináris méret / fordítási idő számít.

Próbáld ki!

Írj egy largest_in_slice<T: PartialOrd + Copy>(items: &[T]) -> T függvényt, amely egy slice-ból (nem csak két elemből!) adja vissza a legnagyobb elemet – ciklussal, if-fel, manuálisan összehasonlítva az elemeket (ne használj még semmilyen beépített segédfüggvényt ehhez). Próbáld ki i32 és f64 slice-okkal is, és gondold végig: hány verziót generál belőle a fordító, ha a programodban mindkét típussal meghívod?

Önellenőrzés

Megoldás Kérdés: Mi a szintaktikai különbség a `` és a `where T: Trait` forma között, és mikor érdemes az egyiket a másik helyett használni?

A két forma szemantikailag azonos – ugyanazt a megkötést fejezik ki. A <T: Trait> rövidebb, egyszerű, egy-két bound esetén olvasható. A where klóz akkor előnyös, ha sok típusparaméter vagy összetett bound van, mert külön sorokba rendezve jobban olvasható a függvényfej.

Megoldás Kérdés: Miért nem fordul le a bound nélküli `largest(a: T, b: T) -> T { if a > b { a } else { b } }` függvény?

Mert a > operátor nincs definiálva minden lehetséges típusra – a fordító nem tudja garantálni, hogy egy tetszőleges T típus két értéke összehasonlítható. A PartialOrd trait bound előírásával kötelezzük a hívó felet, hogy csak olyan típust adjon át, amely implementálja ezt a trait-et, így a > operátor biztonságosan használható a törzsben.

Megoldás Kérdés: Ha egy programban egy generikus függvényt tíz különböző típussal hívsz meg, mi történik a lefordított bináris méretével, és hogyan kerülhetnéd el ezt, ha gondot jelentene?

Monomorfizáció miatt a fordító tíz külön, specializált gépi kód verziót generál a függvényből, ami növeli a bináris méretét. Ha ez problémát okoz, a generikus függvény helyett dyn Trait-et (dinamikus dispatch) használhatsz, amelyből csak egyetlen közös implementáció készül – a cserébe egy kis futásidejű overheadet fizetsz.

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

  • A generikus függvények és típusok (<T>) lehetővé teszik, hogy egyszer írj meg egy logikát, és az tetszőleges, megfelelő típussal újrafelhasználható legyen.
  • A trait bound (T: Trait) írja elő, milyen képességgel kell rendelkeznie a generikus típusnak – csak ott tedd fel a megkötést, ahol tényleg szükséges.
  • A where klóz ugyanazt fejezi ki, mint az inline bound-lista, de olvashatóbb sok paraméter vagy összetett feltétel esetén.
  • A monomorfizáció miatt a generikus kód statikus dispatch-csel, extra futásidejű költség nélkül fut – az ára a lehetséges bináris méretnövekedés, ha sok konkrét típussal használod.
  • A generikák és a dyn Trait (előző lecke) két oldala ugyanannak az éremnek: fordítási idejű vs. futásidejű polimorfizmus – a legtöbb esetben a generikák az alapértelmezett, idiomatikus választás.

A következő leckében ("Az Iterator trait") megnézzük, hogyan épül fel a Rust talán leggyakrabban használt generikus trait-je, amelyre a for ciklusok és számtalan gyűjtemény-művelet is épül.

További olvasmány: