Einführung in generische assoziierte Typen in Rust
Ethan Miller
Product Engineer · Leapcell

Ein kleiner Einblick in generische assoziierte Typen (GATs)
Dieser Name ist so lang! Was zum Teufel ist das?
Keine Sorge, lasst es uns von Anfang an aufschlüsseln. Beginnen wir mit der Überprüfung einiger Syntaxstrukturen von Rust. Was macht ein Rust-Programm aus? Die Antwort: Elemente.
Jedes Rust-Programm besteht aus einzelnen Elementen. Wenn du beispielsweise eine Struktur in main.rs
definierst, dann einen impl
-Block mit zwei Methoden hinzufügst und schließlich eine main
-Funktion schreibst - das sind drei Elemente innerhalb des Modulelements deines Crates.
Nachdem wir nun Elemente behandelt haben, sprechen wir über assoziierte Elemente. Assoziierte Elemente sind keine eigenständigen Elemente! Der Schlüssel liegt im Wort "assoziiert" - womit assoziiert? Es bedeutet "assoziiert mit einem bestimmten Typ", und diese Assoziation ermöglicht es dir, ein spezielles Schlüsselwort namens Self
zu verwenden. Dieses Schlüsselwort bezieht sich auf den Typ, mit dem du assoziierst.
Assoziierte Elemente können an zwei Stellen definiert werden: innerhalb der geschweiften Klammern einer Trait-Definition oder innerhalb eines Implementierungsblocks.
Es gibt drei Arten von assoziierten Elementen: assoziierte Konstanten, assoziierte Funktionen und assoziierte Typen (Aliase). Sie entsprechen direkt den drei Elementtypen im allgemeinen Rust: Konstanten, Funktionen und Typen (Aliase).
Schauen wir uns ein Beispiel an!
#![feature(generic_associated_types)] #![allow(incomplete_features)] const A: usize = 42; fn b<T>() {} type C<T> = Vec<T>; trait X { const D: usize; fn e<T>(); type F<T>; // ← Dies ist der neue Teil! Bisher konntest du hier kein <T> schreiben. } struct S; impl X for S { const D: usize = 42; fn e<T>() {} type F<T> = Vec<T>; }
Wozu dient das?
Es ist sehr nützlich, aber nur in bestimmten Situationen. In der Rust-Community gibt es zwei klassische Anwendungsfälle für generische assoziierte Typen. Versuchen wir, sie vorzustellen.
Bevor wir jedoch eintauchen, lasst uns schnell Generics wiederholen. Das Wort "generisch" bedeutet im Deutschen "allgemein". Was ist also ein generischer Typ? Einfach ausgedrückt ist es ein Typ, dem einige Parameter fehlen - Parameter, die vom Benutzer ausgefüllt werden.
Eine kurze Anmerkung: Frühere Übersetzer haben "generic" als "泛型" (was wörtlich "allgemeiner Typ" bedeutet) dargestellt, da viele Systeme es dir ermöglichen, über Typen zu parametrisieren. Aber in Rust sind Generics nicht auf Typen beschränkt - es gibt tatsächlich drei Arten von generischen Parametern: Typen, Lebensdauern und Konstanten.
Okay, hier ist ein konkretes Beispiel für einen generischen Typ: Rc<T>
. Dies ist ein generischer Typ mit einem Parameter. Beachte, dass Rc
allein kein Typ ist - nur wenn du ein Typargument angibst (wie bool
in Rc<bool>
), erhältst du einen tatsächlichen Typ.
Stell dir nun vor, du schreibst eine Datenstruktur, die Daten intern freigeben muss, aber du weißt nicht im Voraus, ob der Benutzer Rc
oder Arc
verwenden möchte. Was machst du? Der einfachste Weg ist, den Code zweimal zu schreiben. Es ist ein bisschen umständlich, ja, aber es funktioniert. Am Rande bemerkt, die Crates im
und im-rc
sind weitgehend identisch, nur dass eines Arc
und das andere Rc
verwendet.
In der Tat sind GATs perfekt, um dieses Problem zu lösen. Schauen wir uns den ersten klassischen Anwendungsfall für generische assoziierte Typen an: Typfamilien.
Aufgabe Nr. 1: Verwenden von GATs zur Unterstützung von Typfamilien
Okay, lasst uns einen "Selektor" erstellen, mit dem der Compiler bestimmen kann, ob Rc<T>
oder Arc<T>
verwendet werden soll. Der Code sieht so aus:
trait PointerFamily { type PointerType<T>; } struct RcPointer; impl PointerFamily for RcPointer { type PointerType<T> = Rc<T>; } struct ArcPointer; impl PointerFamily for ArcPointer { type PointerType<T> = Arc<T>; }
Ziemlich einfach, oder? Damit hast du zwei "Selektor"-Typen definiert, die verwendet werden können, um anzugeben, ob du Rc
oder Arc
verwenden möchtest. Sehen wir uns an, wie das in der Praxis funktioniert:
struct MyDataStructure<T, PointerSel: PointerFamily> { data: PointerSel::PointerType<T> }
Mit diesem Setup kann dein generischer Parameter entweder RcPointer
oder ArcPointer
sein, und das bestimmt die tatsächliche Darstellung deiner Daten. Dank dessen könnten die beiden zuvor genannten Crates zu einem einzigen zusammengeführt werden.
Aufgabe Nr. 2: Verwenden von GATs zur Implementierung eines Streaming-Iterators
Hier ist ein weiteres Problem - dieses ist irgendwie spezifisch für Rust. In anderen Sprachen existiert dieses Problem entweder nicht oder sie haben es einfach aufgegeben, es zu lösen (hust).
Das Problem ist folgendes: Du möchtest Abhängigkeitsbeziehungen in deiner API darstellen - zwischen Eingabewerten oder zwischen Eingaben und Ausgaben. Diese Abhängigkeiten sind nicht immer einfach auszudrücken. Was ist die Lösung von Rust?
Dieser kleine Lebensdauermarker '_
- wir haben ihn alle gesehen. Er wird verwendet, um diese Abhängigkeiten auf API-Ebene auszudrücken.
Sehen wir uns das in Aktion an. Jeder ist wahrscheinlich mit dem Iterator
-Trait aus der Standardbibliothek vertraut. Er sieht so aus:
pub trait Iterator { type Item; fn next(&'_ mut self) -> Option<Self::Item>; // ... }
Sieht toll aus, aber es gibt ein kleines Problem. Der Item
-Typ hat überhaupt keine Abhängigkeit vom Typ des Iterator
selbst (Self
). Warum ist das so?
Weil der Aufruf von next
einen temporären Lebensdauerbereich erzeugt (den '_'
), der ein generischer Parameter der next
-Funktion ist. In der Zwischenzeit ist Item
ein eigenständiger assoziierter Typ - es gibt keine Möglichkeit, ihn an diese Lebensdauer zu binden.
In den meisten Fällen ist dies kein Problem. Aber in einigen Bibliotheken wird dieser Mangel an Ausdruckskraft zu einer echten Einschränkung. Stell dir einen Iterator vor, der dem Benutzer temporäre Dateien gibt - er kann die Datei schließen, wann immer er möchte. In diesem Fall funktioniert der Iterator
-Trait einwandfrei.
Aber was ist, wenn der Iterator eine temporäre Datei generiert, einige Daten hineinlädt und du die Datei nachdem der Benutzer damit fertig ist, löschen musst? Oder noch besser, den Speicherplatz für die nächste Datei wiederverwenden? In diesem Fall muss der Iterator wissen, wann der Benutzer die Verwendung des Elements beendet hat.
Genau hier kommen GATs ins Spiel - wir können sie verwenden, um eine API wie diese zu entwerfen:
pub trait StreamingIterator { type Item<'a>; fn next(&'_ mut self) -> Option<Self::Item<'_>>; // ... }
Jetzt kann die Implementierung Item
zu einem abhängigen Typ machen, wie eine Referenz. Das Typsystem stellt sicher, dass bevor du next
erneut aufrufst oder den Iterator fallen lässt, der Item
-Wert nicht mehr verwendet wird.
Du warst so bodenständig - können wir etwas abstrakter werden?
Okay, von hier an hören wir auf, menschliche Sprache zu sprechen. (Nur ein Scherz - aber wir werden jetzt abstrakt.) Hinweis: Diese Erklärung ist immer noch vereinfacht - wir werden beispielsweise Binder und Prädikate beiseite lassen.
Beginnen wir mit der Herstellung der Beziehung zwischen generischen Typkonstruktoren und konkreten Typen. Im Wesentlichen ist es eine Zuordnung.
/// Pseudocode fn generic_type_mapping(_: GenericTypeCtor, _: Vec<GenericArg>) -> Type;
Zum Beispiel ist in Vec<bool>
Vec
der Name des generischen Typs und auch der Konstruktor. <bool>
ist die Liste der Typargumente - nur eines in diesem Fall. Wenn du beides in die Zuordnung einfügst, erhältst du einen bestimmten Typ: Vec<bool>
.
Als nächstes: Traits. Was ist ein Trait wirklich? Ein Trait ist auch eine Zuordnung.
/// Pseudocode fn trait_mapping(_: Type, _: Trait) -> Option<Vec<AssociateItem>>;
Hier kann der Trait
als Prädikat betrachtet werden - etwas, das ein Urteil über einen Typ abgibt. Das Ergebnis ist entweder None
(was bedeutet "implementiert diesen Trait nicht") oder Some(items)
(was bedeutet "dieser Typ implementiert den Trait"), zusammen mit einer Liste von assoziierten Elementen.
/// Pseudocode enum AssociateItem { AssociateType(Name, Type), GenericAssociateType(Name, GenericTypeCtor), // ← Dies ist die neue Ergänzung AssociatedFunction(Name, Func), GenericFunction(Name, GenericFunc), AssociatedConst(Name, Const), }
Von diesen ist AssociateItem::GenericAssociateType
derzeit der einzige Ort in Rust, an dem generic_type_mapping
indirekt aufgerufen wird.
Indem du verschiedene Type
s als ersten Parameter an trait_mapping
übergibst, kannst du verschiedene GenericTypeCtor
s vom selben Trait
erhalten. Dann wendest du generic_type_mapping
an, und boom - du hast verschiedene generische Typkonstruktoren mit spezifischen Vec<GenericArg>
-Argumenten kombiniert, alles innerhalb des Syntax-Frameworks von Rust!
Eine kurze Anmerkung: Konstrukte wie GenericTypeCtor
werden in einigen Artikeln als HKT - Higher-Kinded Types - bezeichnet. Dank des oben beschriebenen Ansatzes hat Rust nun zum ersten Mal eine benutzerseitige Unterstützung für HKT. Obwohl es nur in dieser einen Form vorkommt, können andere Verwendungsmuster daraus aufgebaut werden.
Kurz gesagt: Seltsame neue Kräfte freigeschaltet!
Laufen lernen: Nachahmung fortgeschrittener Konstrukte mit GATs
Okay, um das Ganze abzurunden, versuchen wir, mit GATs einige Konstrukte aus anderen Sprachen nachzuahmen.
#![feature(generic_associated_types)] #![allow(incomplete_features)] trait FunctorFamily { type Type<T>; fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> U; } trait ApplicativeFamily: FunctorFamily { fn pure<T>(inner: T) -> Self::Type<T>; fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U> where F: FnMut(T) -> U; } trait MonadFamily: ApplicativeFamily { fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> Self::Type<U>; }
Implementieren wir nun diese Traits für einen bestimmten "Selektor":
struct OptionType; impl FunctorFamily for OptionType { type Type<T> = Option<T>; fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> U, { value.map(f) } } impl ApplicativeFamily for OptionType { fn pure<T>(inner: T) -> Self::Type<T> { Some(inner) } fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U> where F: FnMut(T) -> U, { value.zip(f).map(|(v, mut f)| f(v)) } } impl MonadFamily for OptionType { fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> Self::Type<U>, { value.and_then(f) } }
Okay, jetzt können wir OptionType
als "Selektor" verwenden, um das Verhalten von Option
als Functor, Applicative und Monad auszudrücken und zu implementieren.
Also - wie fühlt es sich an? Haben wir gerade eine ganz neue Welt von Möglichkeiten erschlossen?
Wir sind Leapcell, deine erste Wahl für das Hosten von Rust-Projekten.
Leapcell ist die Next-Gen Serverless Plattform für Webhosting, Async Tasks und Redis:
Multi-Language Support
- Entwickle mit Node.js, Python, Go oder Rust.
Stelle unbegrenzt Projekte kostenlos bereit
- Zahle nur für die Nutzung - keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein operativer Overhead - konzentriere dich einfach auf das Bauen.
Entdecke mehr in der Dokumentation!
Folge uns auf X: @LeapcellHQ