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.
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.
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.
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 `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 `largestMert 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
wherekló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: