Rust Traits erklärt: Wie sie funktionieren und warum sie wichtig sind
Ethan Miller
Product Engineer · Leapcell

In Rust’s Designzielen sind Zero-Cost-Abstraktionen eines der wichtigsten Prinzipien. Sie ermöglichen es Rust, die Ausdruckskraft einer High-Level-Sprache zu haben, ohne die Leistung zu beeinträchtigen. Die Grundlage dieser Zero-Cost-Abstraktionen liegt in Generics und Traits, die es ermöglichen, High-Level-Syntax während der Kompilierung in effizienten Low-Level-Code zu kompilieren und so Laufzeiteffizienz zu erreichen. Dieser Artikel führt in Traits ein, einschließlich ihrer Verwendung, und analysiert drei häufige Probleme, wobei die zugrunde liegenden Mechanismen durch die Untersuchung dieser Probleme erläutert werden.
Verwendung
Grundlegende Verwendung
Hauptzweck von Traits ist es, Verhalten zu abstrahieren, ähnlich wie "Interfaces" in anderen Programmiersprachen. Hier ist ein Beispiel zur Veranschaulichung der grundlegenden Verwendung von Traits:
trait Greeting { fn greeting(&self) -> &str; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } } struct Dog; impl Greeting for Dog { fn greeting(&self) -> &str { "Woof!" } }
Im obigen Code wird ein Trait Greeting
definiert und von zwei Structs implementiert. Abhängig davon, wie die Funktion aufgerufen wird, gibt es zwei Hauptmöglichkeiten, sie zu verwenden:
- Statische Dispatch auf der Basis von Generics
- Dynamische Dispatch auf der Basis von Trait-Objekten
Das Konzept der Generics ist bekannter, daher konzentrieren wir uns hier auf Trait-Objekte:
Ein Trait-Objekt ist ein intransparenter Wert eines anderen Typs, der eine Reihe von Traits implementiert. Die Menge der Traits besteht aus einem Object-Safe-Basis-Trait plus einer beliebigen Anzahl von Auto-Traits.
Ein wichtiges Detail ist, dass Trait-Objekte zu Dynamically Sized Types (DST) gehören, was bedeutet, dass ihre Größe zur Kompilierzeit nicht bestimmt werden kann. Der Zugriff muss indirekt über Zeiger erfolgen. Häufige Formen sind Box<dyn Trait>
, &dyn Trait
usw.
fn print_greeting_static<G: Greeting>(g: G) { println!("{}", g.greeting()); } fn print_greeting_dynamic(g: Box<dyn Greeting>) { println!("{}", g.greeting()); } print_greeting_static(Cat); print_greeting_static(Dog); print_greeting_dynamic(Box::new(Cat)); print_greeting_dynamic(Box::new(Dog));
Statische Dispatch
In Rust werden Generics mithilfe von Monomorphisierung implementiert, die zur Kompilierzeit verschiedene Versionen einer Funktion für verschiedene Typen generiert. Daher werden Generics auch als Typparameter bezeichnet. Der Vorteil ist, dass es keinen Overhead durch virtuelle Funktionsaufrufe gibt, der Nachteil ist jedoch eine erhöhte Binärdateigröße. Im obigen Beispiel würde print_greeting_static
in zwei Versionen kompiliert:
print_greeting_static_cat(Cat); print_greeting_static_dog(Dog);
Dynamische Dispatch
Nicht bei allen Funktionsaufrufen kann der Aufrufertyp zur Kompilierzeit bestimmt werden. Ein häufiges Szenario sind Rückrufe für Ereignisse in der GUI-Programmierung. Typischerweise kann ein Ereignis mehreren Rückruffunktionen entsprechen, die zur Kompilierzeit nicht bekannt sind. Daher sind Generics in solchen Fällen nicht geeignet, und eine dynamische Dispatch ist erforderlich:
trait ClickCallback { fn on_click(&self, x: i64, y: i64); } struct Button { listeners: Vec<Box<dyn ClickCallback>>, }
impl Trait
In Rust 1.26 wurde eine neue Art der Verwendung von Traits eingeführt: impl Trait
, die an zwei Stellen verwendet werden kann – Funktionsparametern und Rückgabewerten. Dies dient hauptsächlich dazu, die Verwendung komplexer Traits zu vereinfachen, und kann als Sonderfall von Generics betrachtet werden. Bei Verwendung von impl Trait
handelt es sich immer noch um statische Dispatch. Wenn es jedoch als Rückgabetyp verwendet wird, muss der Datentyp über alle Rückgabepfade hinweg derselbe sein – dies ist ein kritischer Punkt!
fn print_greeting_impl(g: impl Greeting) { println!("{}", g.greeting()); } print_greeting_impl(Cat); print_greeting_impl(Dog); // Der folgende Code führt zu einem Kompilierungsfehler fn return_greeting_impl(i: i32) -> impl Greeting { if i > 10 { return Cat; } Dog } // | fn return_greeting_impl(i: i32) -> impl Greeting { // | ------------- expected because this return type... // | if i > 10 { // | return Cat; // | --- ...is found to be `Cat` here // | } // | Dog // | ^^^ expected struct `Cat`, found struct `Dog`
Erweiterte Verwendung
Assoziierte Typen
Im obigen Abschnitt zur grundlegenden Verwendung sind die Parameter- oder Rückgabetypen in Trait-Methoden festgelegt. Rust bietet einen Mechanismus namens Lazy Binding von Typen, nämlich assoziierte Typen, der es ermöglicht, den konkreten Typ bei der Implementierung des Traits anzugeben. Ein häufiges Beispiel ist der Iterator
-Trait der Standardbibliothek, bei dem der Rückgabewert von next
Self::Item
ist:
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } /// Ein Beispieliterator, der nur gerade Zahlen ausgibt struct EvenNumbers { count: usize, limit: usize, } impl Iterator for EvenNumbers { type Item = usize; fn next(&mut self) -> Option<Self::Item> { if self.count > self.limit { return None; } let ret = self.count * 2; self.count += 1; Some(ret) } } fn main() { let nums = EvenNumbers { count: 1, limit: 5 }; for n in nums { println!("{}", n); } } // Ausgaben: 2 4 6 8 10
Die Verwendung von assoziierten Typen ähnelt Generics. Der Iterator
-Trait kann auch mithilfe von Generics definiert werden:
pub trait Iterator<T> { fn next(&mut self) -> Option<T>; }
Die Hauptunterschiede zwischen den beiden Ansätzen sind:
- Ein bestimmter Typ (wie die obige
Cat
-Struktur) kann einen generischen Trait mehrfach implementieren. Zum Beispiel können Sie mit demFrom
-Trait sowohlimpl From<&str> for Cat
als auchimpl From<String> for Cat
haben. - Ein Trait mit einem assoziierten Typ kann jedoch nur einmal implementiert werden. Mit
FromStr
können Sie beispielsweise nur einenimpl FromStr for Cat
haben. Traits wieIterator
undDeref
folgen diesem Muster.
Derive-Makros
In Rust kann das Attribut derive
verwendet werden, um einige gängige Traits wie Debug
oder Clone
automatisch zu implementieren. Für benutzerdefinierte Traits ist es auch möglich, prozedurale Makros zu implementieren, um derive
zu unterstützen. Weitere Informationen finden Sie unter: How to write a custom derive macro?. Wir werden hier nicht weiter ins Detail gehen.
Häufige Probleme
Upcasting
Für Traits, bei denen SubTrait: Base
gilt, ist es in der aktuellen Version von Rust nicht möglich, ein &dyn SubTrait
in ein &dyn Base
zu konvertieren. Diese Einschränkung hängt mit dem Speicherlayout von Trait-Objekten zusammen.
In dem Artikel Exploring Rust fat pointers verwendete der Autor transmute
, um eine Trait-Objektreferenz in zwei usize
-Werte zu konvertieren, und verifizierte, dass sie auf die Daten bzw. die Vtable zeigen:
use std::mem::transmute; use std::fmt::Debug; fn main() { let v = vec![1, 2, 3, 4]; let a: &Vec<u64> = &v; // Konvertieren in ein Trait-Objekt let b: &dyn Debug = &v; println!("a: {}", a as *const _ as usize); println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) }); } // a: 140735227204568 // b: (140735227204568, 94484672107880)
Dies zeigt, dass Rust Fat Pointers (d. h. zwei Zeiger) verwendet, um Trait-Objektreferenzen darzustellen: einer, der auf die Daten zeigt, und der andere auf die Vtable. Dies ist sehr ähnlich der Art und Weise, wie Schnittstellen in Go behandelt werden.
+--------------------+
| fat object pointer |
+---------+----------+
| data | vtable |
+----|----+----|-----+
| | |
v v v
+---------+ +-----------+
| object | | vtable |
+---------+ +-----+-----+
| ... | | S | S |
+---------+ +-----+-----+
pub struct TraitObjectReference { pub data: *mut (), pub vtable: *mut (), } struct Vtable { destructor: fn(*mut ()), size: usize, align: usize, method: fn(*const ()) -> String, }
Obwohl Fat Pointers die Größe der Zeiger erhöhen (was sie für atomare Operationen unbrauchbar macht), sind die Vorteile erheblich:
- Traits können für vorhandene Typen implementiert werden (z. B. Blanket-Implementierungen)
- Der Aufruf einer Methode aus der Vtable erfordert nur eine Indirektionsebene. Im Gegensatz dazu befindet sich in C++ die Vtable innerhalb des Objekts, sodass jeder Funktionsaufruf zwei Indirektionsebenen umfasst, wie folgt:
Objektzeiger --> Objektinhalt --> Vtable --> DynamicType::method() Implementierung
Wenn ein Trait eine Vererbungsbeziehung hat, wie speichert die Vtable Methoden aus mehreren Traits? In der aktuellen Implementierung werden alle Methoden sequenziell in einer einzigen Vtable gespeichert, wie folgt:
Trait-Objekt
+---------------+
| data | <------------ +------------------+
+---------------+
| data |
+------------------+
| vtable | ------------> +---------------------+
+------------------+
| destructor |
+---------------------+
| size |
+---------------------+
| align |
+---------------------+
| base.fn1 |
+---------------------+
| base.fn2 |
+---------------------+
| subtrait.fn1 |
+---------------------+
| ...... |
+---------------------+
Wie Sie sehen, werden alle Trait-Methoden sequenziell gespeichert, ohne dass zwischen Methoden unterschieden wird, welche zu welchem Trait gehören. Aus diesem Grund ist Upcasting nicht möglich. Es gibt eine laufende RFC – RFC 2765 –, die dieses Problem verfolgt. Anstatt die von der RFC vorgeschlagene Lösung hier zu erörtern, stellen wir eine allgemeinere Problemumgehung vor, indem wir einen AsBase
-Trait hinzufügen:
trait Base { fn base(&self) { println!("base..."); } } trait AsBase { fn as_base(&self) -> &dyn Base; } // Blanket-Implementierung impl<T: Base> AsBase for T { fn as_base(&self) -> &dyn Base { self } } trait Foo: AsBase { fn foo(&self) { println!("foo.."); } } #[derive(Debug)] struct MyStruct; impl Foo for MyStruct {} impl Base for MyStruct {} fn main() { let s = MyStruct; let foo: &dyn Foo = &s; foo.foo(); let base: &dyn Base = foo.as_base(); base.base(); }
Downcasting
Downcasting bezieht sich auf die Konvertierung eines Trait-Objekts zurück in seinen ursprünglichen konkreten Typ. Rust bietet den Any
-Trait, um dies zu erreichen.
pub trait Any: 'static { fn type_id(&self) -> TypeId; }
Die meisten Typen implementieren Any
, außer solchen, die nicht-'static
-Referenzen enthalten. Mithilfe von type_id
können wir den Typ zur Laufzeit bestimmen. Hier ist ein Beispiel:
use std::any::Any; trait Greeting { fn greeting(&self) -> &str; fn as_any(&self) -> &dyn Any; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } fn as_any(&self) -> &dyn Any { self } } fn main() { let cat = Cat; let g: &dyn Greeting = &cat; println!("greeting {}", g.greeting()); // Konvertieren in &Cat let downcast_cat = g.as_any().downcast_ref::<Cat>().unwrap(); println!("greeting {}", downcast_cat.greeting()); }
Der Schlüssel hier ist downcast_ref
, dessen Implementierung lautet:
pub fn downcast_ref<T: Any>(&self) -> Option<&T> { if self.is::<T>() { unsafe { Some(&*(self as *const dyn Any as *const T)) } } else { None } }
Wie gezeigt, wird der Datenzeiger des Trait-Objekts (der erste Zeiger) sicher in eine Referenz des konkreten Typs umgewandelt, wenn der Typ übereinstimmt, indem unsafe
-Code verwendet wird.
Objektsicherheit
In Rust können nicht alle Traits als Trait-Objekte verwendet werden. Um in Frage zu kommen, muss ein Trait bestimmte Bedingungen erfüllen – dies wird als Objektsicherheit bezeichnet. Die Hauptregeln sind:
-
Trait-Methoden dürfen nicht
Self
zurückgeben (d. h. den implementierenden Typ). Dies liegt daran, dass die ursprünglichen Typinformationen verloren gehen, sobald ein Objekt in ein Trait-Objekt konvertiert wurde, sodassSelf
unbestimmt wird. -
Trait-Methoden dürfen keine generischen Parameter haben. Der Grund dafür ist, dass die Monomorphisierung eine große Anzahl von Funktionsimplementierungen generieren würde, was zu einer Methodenüberlastung innerhalb des Traits führen könnte. Zum Beispiel:
trait Trait { fn foo<T>(&self, on: T); // weitere Methoden } // 10 Implementierungen fn call_foo(thing: Box<Trait>) { thing.foo(true); // Dies könnte jeder der 10 oben genannten Typen sein thing.foo(1); thing.foo("hello"); } // Würde zu 10 * 3 = 30 verschiedenen Implementierungen führen
- Traits, die als Trait-Objekte verwendet werden, dürfen nicht
Sized
erben (eine Trait-Bound haben). Rust geht davon aus, dass ein Trait-Objekt seinen Trait implementiert, und generiert Code wie:
trait Foo { fn method1(&self); fn method2(&mut self, x: i32, y: String) -> usize; } // Automatisch generierte Implementierung impl Foo for TraitObject { fn method1(&self) { // `self` ist ein `&Foo`-Trait-Objekt. // Laden Sie den richtigen Funktionszeiger und rufen Sie ihn mit dem undurchsichtigen Datenzeiger auf (self.vtable.method1)(self.data) } fn method2(&mut self, x: i32, y: String) -> usize { // `self` ist ein `&mut Foo`-Trait-Objekt // Wie oben, wobei die anderen Argumente weitergeleitet werden (self.vtable.method2)(self.data, x, y) } }
Wenn Foo
Sized
erben würde, würde dies erfordern, dass das Trait-Objekt auch Sized
ist. Aber Trait-Objekte sind DST (Dynamically Sized Types), was bedeutet, dass sie ?Sized
sind, und daher würde die Einschränkung fehlschlagen.
Für unsichere Traits, die die Objektsicherheit verletzen, besteht der beste Ansatz darin, sie in objektsichere Formen umzugestalten. Wenn dies nicht möglich ist, ist die Verwendung von Generics eine alternative Problemumgehung.
Schlussfolgerung
Zu Beginn dieses Artikels haben wir eingeführt, dass Traits die Grundlage für Zero-Cost-Abstraktionen sind. Mit Traits können Sie vorhandenen Typen neue Methoden hinzufügen, das Ausdrucksproblem lösen, Operatorüberlastung ermöglichen und eine Schnittstellenorientierte Programmierung ermöglichen. Wir hoffen, dass dieser Artikel den Lesern ein fundiertes Verständnis dafür vermittelt, wie Traits effektiv eingesetzt werden können, und ihnen das Vertrauen gibt, Compilerfehler bei der Arbeit mit Traits in Rust problemlos zu beheben.
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:
Mehrsprachige 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ützt 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für verwertbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur mühelosen 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