Generics in Rust verstehen: Ein vollständiger Leitfaden
Wenhao Wang
Dev Intern · Leapcell

Ein häufiges Bedürfnis in der Programmierung ist die Verwendung derselben Funktion zur Verarbeitung von Daten unterschiedlicher Typen. In Programmiersprachen, die keine Generics unterstützen, müssen Sie normalerweise für jeden Datentyp eine separate Funktion schreiben. Die Existenz von Generics bietet Entwicklern Komfort, reduziert Codedopplungen und bereichert die Ausdrucksmöglichkeiten der Sprache erheblich. Sie ermöglicht es einer einzelnen Funktion, viele Funktionen zu ersetzen, die dieselbe Aufgabe ausführen, aber mit unterschiedlichen Datentypen arbeiten.
Wenn beispielsweise keine Generics verwendet werden, sieht die Definition einer double
-Funktion, deren Parameter vom Typ u8
, i8
, u16
, i16
, u32
, i32
usw. sein kann, wie folgt aus:
fn double_u8(i: u8) -> u8 { i + i } fn double_i8(i: i8) -> i8 { i + i } fn double_u16(i: u16) -> u16 { i + i } fn double_i16(i: i16) -> i16 { i + i } fn double_u32(i: u32) -> u32 { i + i } fn double_i32(i: i32) -> i32 { i + i } fn double_u64(i: u64) -> u64 { i + i } fn double_i64(i: i64) -> i64 { i + i } fn main(){ println!("{}", double_u8(3_u8)); println!("{}", double_i16(3_i16)); }
Die obigen double
-Funktionen haben eine identische Logik; der einzige Unterschied liegt in den Datentypen.
Generics können verwendet werden, um dieses Problem der Codeduplikation aufgrund von Typunterschieden zu lösen. Bei Verwendung von Generics:
use std::ops::Add; fn double<T>(i: T) -> T where T: Add<Output=T> + Clone + Copy { i + i } fn main(){ println!("{}", double(3_i16)); println!("{}", double(3_i32)); }
Der Buchstabe T
oben ist der Generic (ähnlich der Bedeutung einer Variablen wie x
), der zur Darstellung verschiedener möglicher Datentypen verwendet wird.
Verwendung von Generics in Funktionsdefinitionen
Beim Definieren einer Funktion mit Generics werden, anstatt die Parameter- und Rückgabetypen explizit in der Funktionssignatur anzugeben, Generics verwendet, um den Code anpassungsfähiger zu machen. Dies bietet mehr Flexibilität für Funktionsaufrufer und vermeidet Codeduplikation.
In Rust können generische Parameternamen beliebig sein, aber konventionsgemäß wird T
(der erste Buchstabe von "Type") bevorzugt.
Vor der Verwendung eines generischen Parameters muss dieser deklariert werden:
fn largest<T>(list: &[T]) -> T {...}
In der generischen Version der Funktion erscheint die Typparameterdeklaration <T>
zwischen dem Funktionsnamen und der Parameterliste, wie in largest<T>
. Dies deklariert den generischen Parameter T
, der dann im Parameter list: &[T]
und dem Rückgabetyp T
verwendet wird.
Im Parameterteil bedeutet list: &[T]
, dass der Parameter list
ein Slice von Elementen des Typs T
ist.
Im Rückgabeteil gibt -> T
an, dass der Rückgabewert der Funktion ebenfalls vom Typ T
ist.
Daher kann diese Funktionsdefinition wie folgt verstanden werden: Die Funktion hat einen generischen Parameter T
, ihr Argument ist ein Slice von Elementen des Typs T
, und sie gibt einen Wert vom Typ T
zurück.
Zusammenfassend lässt sich sagen, dass für eine generische Funktion gilt: Das <T>
nach dem Funktionsnamen bedeutet, dass ein generisches T
im Gültigkeitsbereich der Funktion definiert ist. Dieser Generic kann nur innerhalb der Funktionssignatur und des Funktionskörpers verwendet werden, genau wie eine Variable, die innerhalb eines Gültigkeitsbereichs definiert ist, nur in diesem Gültigkeitsbereich verwendbar ist. Ein Generic stellt einfach eine Variable für einen Datentyp dar.
Die Bedeutung, die durch diesen Teil der Funktionssignatur ausgedrückt wird, ist also: Ein Parameter eines bestimmten Datentyps wird übergeben, und ein Wert desselben Typs wird zurückgegeben - und dieser Typ kann beliebig sein.
Verwenden von Generics in Strukturen
Die Feldtypen in einer Struktur können auch mit Generics definiert werden, zum Beispiel:
struct Point<T> { x: T, y: T, }
Hinweis: Sie müssen den generischen Parameter zuerst mit Point<T>
deklarieren, bevor Sie T
als Typ in den Strukturfeldern verwenden. Auch in diesem Fall sind x
und y
vom gleichen Typ.
Wenn Sie möchten, dass x
und y
unterschiedliche Typen haben, können Sie verschiedene generische Parameter verwenden:
struct Point<T, U> { x: T, y: U, } fn main() { let p = Point { x: 1, y: 1.1 }; }
Verwenden von Generics in Enums
Generics können auch in Enums verwendet werden. Die häufigsten generischen Enum-Typen in Rust sind Option<T>
und Result<T, E>
:
enum Option<T> { Some(T), None, } enum Result<T, E> { Ok(T), Err(E), }
Option
und Result
werden oft als Rückgabetypen für Funktionen verwendet. Option
wird verwendet, um das Vorhandensein oder Nichtvorhandensein eines Wertes anzuzeigen, während sich Result
darauf konzentriert, ob der Wert gültig ist oder ein Fehler aufgetreten ist.
Wenn eine Funktion normal läuft, gibt Result
ein Ok(T)
zurück, wobei T
der tatsächliche Rückgabetyp ist. Wenn die Funktion fehlschlägt, gibt sie ein Err(E)
zurück, wobei E
der Fehlertyp ist.
Verwenden von Generics in Methoden
Generics können auch in Methoden verwendet werden. Bevor Sie generische Parameter in Methodendefinitionen verwenden, müssen Sie diese im Voraus mit impl<T>
deklarieren. Erst nach einer solchen Deklaration kann Point<T>
im Inneren verwendet werden, so dass Rust weiß, dass der Typ in den spitzen Klammern ein Generic und kein konkreter Typ ist.
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } }
Hinweis: Point<T>
in der Methodendeklaration ist keine generische Deklaration; es ist der vollständige Typ der Struktur, da die Struktur als Point<T>
definiert wurde, nicht nur als Point
.
Neben der Verwendung der generischen Parameter der Struktur können Sie auch zusätzliche generische Parameter innerhalb der Methoden selbst definieren, genau wie in generischen Funktionen:
struct Point<T, U> { // Struktur-Generics x: T, y: U, } impl<T, U> Point<T, U> { // Funktions-Generics fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> { Point { x: self.x, y: other.y, } } }
In diesem Beispiel sind T
und U
die generischen Parameter, die in der Struktur Point
definiert sind, während V
und W
generische Parameter sind, die in der Methode definiert sind. Es gibt keinen Konflikt - stellen Sie sie sich als Struktur-Generics vs. Funktions-Generics vor.
Sie können auch Einschränkungen zu Generics hinzufügen, um Methoden nur für bestimmte Typen zu definieren. Für den Typ Point<T>
können Sie beispielsweise Methoden nicht nur basierend auf T
, sondern speziell für bestimmte Typen definieren. Dies bedeutet, dass eine Methode für diesen spezifischen Typ definiert ist und andere Instanzen von Point<T>
mit unterschiedlichen T
-Typen diese Methode nicht haben. Dies ermöglicht die Definition spezialisierter Methoden für bestimmte generische Typen, während andere Typen ohne diese Methode bleiben.
Hinzufügen von Einschränkungen zu Generics
Das Einschränken von Generics wird auch als Trait-Grenzen bezeichnet. Es gibt zwei Hauptsyntaxen dafür:
- Verwenden Sie bei der Definition des generischen Typs
T
eine Syntax wieT: Trait_Name
, um Einschränkungen anzuwenden. - Verwenden Sie das Schlüsselwort
where
nach dem Rückgabetyp und vor dem Funktionskörper, z. B.where T: Trait_Name
.
Kurz gesagt, es gibt zwei Hauptgründe für das Einschränken eines Generics:
- Der Funktionskörper benötigt Funktionen, die von einem bestimmten Trait bereitgestellt werden.
- Der durch den Generic
T
dargestellte Datentyp muss präzise genug sein. (Wenn keine Einschränkungen angewendet werden, kann ein Generic jeden Datentyp darstellen.)
Const Generics
Bisher können Generics wie folgt zusammengefasst werden: Generics, die für Typen implementiert sind - alle Generics wurden verwendet, um über verschiedene Typen zu abstrahieren.
Arrays desselben Typs, aber unterschiedlicher Länge werden in Rust ebenfalls als unterschiedliche Typen betrachtet. Beispielsweise sind [i32; 2]
und [i32; 3]
unterschiedliche Typen. Sie können Slices (Referenzen) und Generics verwenden, um Arrays beliebigen Typs zu verarbeiten, zum Beispiel:
fn display_array<T: std::fmt::Debug>(arr: &[T]) { println!("{:?}", arr); }
Die obige Methode funktioniert jedoch nicht gut (oder überhaupt nicht) in Szenarien, in denen Referenzen ungeeignet sind. In solchen Fällen können Const Generics - die die Abstraktion über Werte ermöglichen - verwendet werden, um Unterschiede in der Array-Länge zu beheben:
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { println!("{:?}", arr); }
Dieser Code definiert ein Array vom Typ [T; N]
, wobei T
ein typbasierter generischer Parameter ist und N
ein wertbasierter generischer Parameter ist - in diesem Fall stellt er die Länge des Arrays dar.
N
ist ein Const Generic. Es wird mit const N: usize
deklariert, was bedeutet, dass N
ein const-generischer Parameter basierend auf einem usize
-Wert ist. Vor den Const Generics war Rust für komplexe Matrixberechnungen nicht gut geeignet. Mit Const Generics ändert sich das.
Hinweis: Angenommen, Sie benötigen Code, der auf einer speicherbeschränkten Plattform ausgeführt werden soll, und möchten einschränken, wie viel Speicher die Parameter einer Funktion verbrauchen - in solchen Fällen können Sie Const Generics verwenden, um diese Einschränkungen auszudrücken.
Leistung von Generics
Die Generics von Rust sind Nullkosten-Abstraktionen, was bedeutet, dass Sie sich bei der Verwendung keine Sorgen um Leistungseinbußen machen müssen. Andererseits sind die längeren Kompilierzeiten und möglicherweise größeren endgültigen Binärdateigrößen der Kompromiss, da Rust während der Kompilierung separaten Code für jeden spezifischen Typ generiert, der mit einem Generic verwendet wird.
Rust stellt die Effizienz sicher, indem es generischen Code zur Kompilierzeit monomorphisiert. Monomorphisierung ist der Prozess der Umwandlung von generischem Code in spezifischen Code, indem die im Programm verwendeten konkreten Typen ausgefüllt werden. Was der Compiler tut, ist das Gegenteil von dem, was wir tun, wenn wir generische Funktionen schreiben - der Compiler betrachtet jede Stelle, an der der generische Code verwendet wird, und generiert konkrete Implementierungen für diese spezifischen Typen. Dies ist der Grund, warum die Verwendung von Generics in Rust keine Laufzeitkosten verursacht. Die Monomorphisierung ist der Grund, warum die Generics von Rust zur Laufzeit so effizient sind.
Wenn rustc
Code kompiliert, ersetzt es alle Generics durch die tatsächlichen konkreten Datentypen, die sie darstellen - so wie Variablennamen zur Kompilierzeit durch Speicheradressen ersetzt werden. Da der Compiler generische Typen durch konkrete ersetzt, kann dies zu Code Bloat führen, bei dem eine einzelne Funktion zu mehreren spezialisierten Versionen für verschiedene Datentypen erweitert wird. Manchmal kann diese Aufblähung die Größe der kompilierten Binärdatei erheblich erhöhen. In den meisten Fällen ist dies jedoch kein großes Problem.
Auf der positiven Seite verursacht der Aufruf einer Funktion, die generisch war, keine zusätzlichen Laufzeitberechnungen zur Bestimmung der Typen, da Generics bereits zur Kompilierzeit in konkrete Typen aufgelöst wurden. Daher haben Rust-Generics keinen Laufzeit-Overhead.
Zusammenfassung
In Rust können Sie Generics verwenden, um Definitionen für Elemente wie Funktionssignaturen oder Strukturen zu erstellen, so dass diese mit mehreren konkreten Datentypen verwendet werden können. Generics können in Funktionen, Strukturen, Enums und Methoden verwendet werden, wodurch Ihr Code flexibler wird und Wiederholungen reduziert werden. Generische Typparameter werden mit spitzen Klammern mit Großbuchstaben in CamelCase angegeben, z. B. <A, B, ...>
. Rust stellt die Effizienz von Generics durch Monomorphisierung während der Kompilierung sicher - Umwandlung von generischem Code in spezialisierten konkreten Code durch Ausfüllen tatsächlicher Typen. Dies führt zu einer gewissen Code-Aufblähung, garantiert aber eine hohe Laufzeitleistung.
Wir sind Leapcell, Ihre erste Wahl für das Hosten von Rust-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Sprachen-Unterstützung
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt Projekte kostenlos bereit
- zahlen Sie nur für die Nutzung - keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für müheloses Setup.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeit-Metriken und -Protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Auto-Scaling zur einfachen Bewältigung hoher Parallelität.
- Kein Betriebsaufwand - konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ