Miért van szükség generikus típusokra kezdőként is

Kezdőként hamar szembejön a probléma: írsz egy függvényt, ami két i32-t összehasonlít, aztán kell egy ugyanilyen f64-hez, majd String-hez. A duplikáció fájdalmas, és a Rust nem szereti a felesleges kódot – sem te, sem a fordító.

A generikák pontosan erre valók: egyetlen definícióval leírod a viselkedést, a konkrét típust pedig a fordítási időben dönti el a compiler. Ez nem csak kényelem, hanem típusbiztonság is – nincs futásidejű típusellenőrzés, nincs Object-szerű univerzális típus, minden a fordítás során dől el.

Megjegyzés

Ha C++-ból vagy Java-ból jössz, a generikák Rustban közelebb állnak a C++ template-ekhez, mint a Java generics-hez – de a monomorphization miatt (lásd lentebb) sokkal inkább, mint gondolnád.

Generikus függvények és struktúrák írása alapoktól

Kezdjük egy egyszerű példával: egy függvény, ami egy szeletből (slice) kiválasztja a legnagyobb elemet.

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    println!("A legnagyobb szám: {}", largest(&numbers));

    let chars = vec!['y', 'm', 'a', 'q'];
    println!("A legnagyobb karakter: {}", largest(&chars));
}

A <T: PartialOrd + Copy> rész a típusparaméter deklarációja trait bound-okkal – erről mindjárt bővebben. Fontos, hogy a T nem egy konkrét típus, hanem egy helyőrző, amit a fordító minden egyes híváskor konkretizál.

Struktúráknál ugyanez a logika:

struct Point<T> {
    x: T,
    y: T,
}

impl<T: std::fmt::Display> Point<T> {
    fn describe(&self) -> String {
        format!("({}, {})", self.x, self.y)
    }
}

fn main() {
    let integer_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.5, y: 4.2 };

    println!("{}", integer_point.describe());
    println!("{}", float_point.describe());
}

Ha x és y különböző típusúak lehetnének, két típusparamétert kellene bevezetni: struct Point<T, U> { x: T, y: U }. Ez teljesen valid, csak akkor kell rá figyelni, hogy a metódusaidban is konzisztensen kezeld.

Trait bound-ok és where klózok érthetően

A trait bound egy ígéret a fordítónak: "ez a T típus biztosan implementálja ezt a trait-et, tehát ezeket a metódusokat biztonsággal hívhatom rajta". Enélkül a fordító nem tudná, mit kezdjen egy általános T-vel – hiszen T bármi lehet.

fn print_and_return<T: std::fmt::Debug + Clone>(value: T) -> T {
    println!("{:?}", value);
    value.clone()
}

Ha több bound van, és a szignatúra kezd túl zsúfolttá válni, érdemes where klózt használni:

fn complex_function<T, U>(t: T, u: U) -> String
where
    T: std::fmt::Display + Clone,
    U: std::fmt::Debug,
{
    format!("{} - {:?}", t, u)
}

Ugyanaz a jelentés, csak olvashatóbb, főleg ha sok típusparaméter és bound van egyszerre.

Tipp

Ökölszabály: ha a <T: A + B + C> sor már nem fér el kényelmesen egy sorban, tedd át where klózba. A Rust fordító teljesen közömbös arra, melyik formát választod, csak az olvasó szeme nem.

Érdemes megemlíteni a impl Trait szintaxist is, ami sok esetben egyszerűbb, mint a teljes generikus paraméterezés:

fn notify(item: &impl std::fmt::Display) {
    println!("Figyelem: {}", item);
}

Ez valójában ugyanaz, mint egy fn notify<T: std::fmt::Display>(item: &T), csak tömörebb írásmód – a fordító alatta ugyanazt a monomorphizációs mechanizmust futtatja.

Figyelem

Ne tegyél rá minden lehetséges trait bound-ot "biztonságból" – minél szűkebb a bound, annál általánosabban használható a függvényed. Ha nem kell Clone, ne kérj Clone-t.

Monomorphization: hogyan fordítja le a Rust a generikus kódot

Itt jön a lényeg, amitől a Rust generikái annyira hatékonyak: monomorphization. A fordító minden egyes konkrét típushasználathoz, amit a kódodban meghívsz, legenerál egy külön, specializált gépikód-változatot.

Ha a fenti largest<T> függvényt meghívod i32-vel és char-ral is, a fordító a háttérben gyakorlatilag ezt csinálja:

fn largest_i32(list: &[i32]) -> i32 { /* ... */ }
fn largest_char(list: &[char]) -> char { /* ... */ }

Ez azt jelenti, hogy futásidőben nincs dinamikus típusellenőrzés, nincs virtuális tábla-hívás, nincs boxing – a generikus kód pontosan olyan gyors lesz, mintha kézzel írtad volna meg mindegyik variánst külön-külön. Ez a "zero-cost abstraction" elv gyakorlati megvalósulása.

A hátránya: ha sokféle típussal használod ugyanazt a generikus kódot, a bináris mérete nőhet ("code bloat"), mert minden variánsnak külön gépikódja lesz. Ez általában nem probléma kis-közepes projekteknél, de nagyobb rendszereknél érdemes tudni róla.

Jó tudni

Ha runtime polimorfizmusra van szükséged (pl. heterogén kollekció különböző típusokból), a dyn Trait trait object-eket kell használnod generikák helyett – az egy másik mechanizmus, dinamikus diszpécselő hívással, kis futásidejű költséggel, de kisebb bináris mérettel.

Gyakorlati példa: egy egyszerű generikus verem (stack) implementálása

Nézzünk egy teljes, működő példát: egy generikus verem (stack) adatstruktúrát, ami bármilyen típust tud tárolni.

#[derive(Debug)]
struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self {
        Stack { items: Vec::new() }
    }

    fn push(&mut self, item: T) {
        self.items.push(item);
    }

    fn pop(&mut self) -> Option<T> {
        self.items.pop()
    }

    fn peek(&self) -> Option<&T> {
        self.items.last()
    }

    fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    fn len(&self) -> usize {
        self.items.len()
    }
}

impl<T: std::fmt::Display> Stack<T> {
    fn print_top(&self) {
        let Some(top) = self.peek() else {
            println!("A verem üres.");
            return;
        };
        println!("A tetején: {}", top);
    }
}

fn main() {
    let mut int_stack: Stack<i32> = Stack::new();
    int_stack.push(1);
    int_stack.push(2);
    int_stack.push(3);
    int_stack.print_top();

    while let Some(value) = int_stack.pop() {
        println!("Levéve: {}", value);
    }

    let mut string_stack: Stack<String> = Stack::new();
    string_stack.push(String::from("első"));
    string_stack.push(String::from("második"));
    string_stack.print_top();

    println!("Méret: {}", string_stack.len());
}

Ami itt érdekes: az impl<T> Stack<T> blokkban lévő metódusok (push, pop, peek) semmilyen extra trait bound-ot nem igényelnek, hiszen csak tárolnak és visszaadnak elemeket. Viszont a print_top metódust egy külön impl<T: std::fmt::Display> Stack<T> blokkba tettem, mert csak ott van szükség a Display trait-re. Ez egy nagyon hasznos Rust-idióma: csak ott kérj bound-ot, ahol ténylegesen szükséges, ne a teljes struktúra definíciójában.

Észrevehető a let-else szintaxis is a print_top metódusban – ez egy stabil, kényelmes minta, amivel egy Option-ből vagy Result-ból kényelmesen tudsz "korai kilépést" csinálni, ha az érték nem az elvárt variáns.

Tipp

Ha a projekted komplexebb generikus trait-eket igényel, ahol az associated type maga is generikus (pl. egy iterátor trait, ami különböző élettartamú referenciákat ad vissza), érdemes megnézni a Generic Associated Types (GATs) funkciót – ez már stabil a Rust legújabb verzióiban, bár haladóbb témakör, amit egy külön cikkben érdemes körbejárni.

Összefoglalás

A generikák nem csak egy "extra feature" a Rustban, hanem a nyelv típusrendszerének gerince: lehetővé teszik, hogy DRY (Don't Repeat Yourself) kódot írj úgy, hogy közben megtartod a teljes fordítás idejű típusbiztonságot és a nulla futásidejű overhead-et. A trait bound-ok és a where klózok pontosan meghatározzák, milyen viselkedésre számíthatsz egy adott típustól, a monomorphization pedig gondoskodik róla, hogy mindez ne kerüljön semmibe futásidőben.

Ha most kezdesz ismerkedni a témával, a legjobb gyakorlat, ha kis lépésekben haladsz: írj egy egyszerű generikus függvényt, aztán egy generikus struktúrát, majd próbáld ki a trait bound-okat konkrét metódusokon – pontosan úgy, ahogy a fenti Stack<T> példában. Idővel ez a minta annyira természetessé válik, hogy észre sem veszed, mikor nyúlsz automatikusan generikákhoz egy duplikált kódrészlet helyett.