Rust verlangt, dass alle Typen, die als Felder in Strukturen oder als Elemente in Arrays verwendet werden, das Sized-Trait implementieren, um sicherzustellen, dass der Compiler feste Speicher-Offsets und Layouts des Stack-Frames zur Compilezeit berechnen kann. Der dyn Trait-Konstrukttyp repräsentiert ein dynamisch dispatchtes Trait-Objekt, das von Natur aus !Sized (nicht festgelegt) ist, da der konkrete Typ hinter dem Interface gelöscht wird, sodass unterschiedliche Implementierungen mit variierenden Speicheranforderungen denselben abstrakten Typ beanspruchen können. Um dynamisches Dispatch zu ermöglichen, wird dyn Trait von Rust als fat pointer dargestellt – eine Struktur mit zwei Wörtern, die einen Datenzeiger auf das Objekt und einen vtable-Zeiger, der methodenadressen und Informationen zum Zerstörer hält, enthält – dennoch bleibt der Typ selbst unspezifiziert, da die Größe des Zeigers unbekannt ist. Folglich würde das direkte Einbetten von dyn Trait gegen die Sized-Bedingung verstoßen, da der Compiler die Grenzen der Struktur oder den Array-Schritt nicht bestimmen kann; eine Indirektion durch Box, Rc, Arc oder Referenzen & ist erforderlich, um den Fat Pointer innerhalb eines Sized-Containers zu kapseln.
Sie entwerfen eine Plugin-Architektur für eine Spiel-Engine, bei der Modder verschiedene Implementierungen eines Behavior-Traits bereitstellen – einige speichern einfache ganzzahlige Flags, andere verwalten große räumliche Hash-Gitter – und die Engine muss eine Sammlung aktiver Verhaltensweisen in der GameState-Struktur aufrechterhalten.
Der Versuch, struct GameState { behaviors: Vec<dyn Behavior> } zu definieren, schlägt sofort mit dem Fehler fehl, dass dyn Behavior zur Compilezeit keine konstante Größe hat, was den Build blockiert.
Eine überlegte Lösung war die Nutzung von Vec<&dyn Behavior>, um ausgeliehene Trait-Objekte zu speichern und Heap-Allokationen für die Zeiger selbst zu vermeiden. Dieser Ansatz stellt strenge Lebenszeitanforderungen, die alle Plugin-Daten mindestens solange leben müssen wie die GameState und komplexe Hot-Reloadszenarien, in denen Plugins dynamisch entladen werden, erschwert, was sich letzten Endes als zu restriktiv für eine modifizierbare Engine erwies.
Eine weitere bewertete Alternative war die Enum-Dispatch, wobei enum BehaviorType { Ai(AiModule), Physics(PhysicsBody) } definiert wurde, um alle bekannten Implementierungen zu kapseln. Während dies statisches Dispatch und hervorragende Cache-Lokalisierung bietet, erzeugt es ein geschlossenes Set, das grundlegende Engine-Modifikationen für jedes neue Plugin erfordert, was das Open/Closed-Prinzip verletzt und Drittanbieter-Binärerweiterungen ohne die Neukompilierung der Engine verhindert.
Die gewählte Lösung war der Einsatz von Vec<Box<dyn Behavior>>, wobei jede Instanz des Verhaltens im Heap alloziert und die resultierenden Fat Pointer im Vektor gespeichert wurden. Dies erfüllte das Sized-Erfordernis durch Box-Indirektion, während die Laufzeit-Polymorphie erhalten blieb und heterogene Sammlungen ermöglicht wurden, obwohl dies vorhersehbare Heap-Fragmente Kosten einführte, die durch einen benutzerdefinierten Arena-Allokator für kleine Verhaltenskomponenten gemildert wurden.
Wie erleichtert CoerceUnsized die Umwandlung von Box<T> zu Box<dyn Trait>, ohne zur Laufzeit eine neue vtable zuzuweisen, und welche Speicherlayoutbeschränkungen stellt dies auf den pointee?
CoerceUnsized ist ein Markierungs-Trait, der von Smart-Pointern wie Box, Rc und Arc implementiert wird, der unspezifizierte Umformungen erlaubt. Bei der Umwandlung von Box<Concrete> zu Box<dyn Trait> generiert der Compiler die vtable für Concrete, die Trait während der Kompilierung statisch implementiert, indem sie in den schreibgeschützten Abschnitt der Binärdatei eingebettet wird. Die Umformung interpretiert lediglich die Zeiger-Metadaten neu und verbreitert sie von einem dünnen Zeiger (einzelnes Wort) zu einem Fat Pointer (Datenadresse + vtable-Adresse), ohne die zugrunde liegenden Daten zu verschieben oder zur Laufzeit Speicher zuzuweisen. Dies stellt die strikte Bedingung, dass der konkrete Typ ein kompatibles Speicherlayout mit der erwarteten Darstellung des Trait-Objekts besitzen muss – speziell muss der Datenzeiger mit dem Beginn des Objekts ausgerichtet sein, wo die vtable Felder erwartet, und der Typ muss die #[repr(Rust)]- oder kompatiblen Darstellungs-Garantien entsprechen, um sicherzustellen, dass die Methodenoffsets in der vtable korrekt auf die Funktionen der konkreten Implementierung zurückgreifen können.
Warum verbietet Rust die Erstellung von Trait-Objekten (dyn Trait) aus Traits, die Methoden definieren, die Self nach Wert verbrauchen (fn consume(self)), und wie hängt dies mit der Sized-Anforderung für Funktionen Rückgabetypen zusammen?
Dieses Verbot ergibt sich aus den Regeln der Objektsicherheit. Wenn eine Methode self nach Wert verbraucht, muss der Compiler die genaue Größe des konkreten Typs kennen, um das korrekte Stack-Frame für die Verschiebung des Wertes zu generieren und den richtigen Zerstörungsaufruf an der genauen Speicheradresse einzufügen. Im Kontext von dyn Trait wird der konkrete Typ gelöscht; während die vtable Größe und Drop-Information enthält, kann der Stack-Frame des Aufrufers nicht dynamisch angepasst werden, um die unbekannte Größe des verschobenen Wertes zu berücksichtigen. Darüber hinaus würden Methoden, die Self zurückgeben, erfordern, dass der Aufrufer Speicherplatz für die Rückgabeschablone für unbekannte Größe zuweist. Um Stack-Korrosion und undefiniertes Verhalten zu verhindern, verbietet Rust Trait-Objekte für Traits mit Methoden, die self-Wert-Parameter verwenden, und stellt sicher, dass alle Interaktionen durch Indirektion (&self oder &mut self) erfolgen, wo die Zeigergröße konstant ist.
Was ist der Unterschied zwischen dyn Trait, das automatisch Send implementiert, wenn Trait Send als Supertrait trägt, gegenüber der expliziten Annotation von dyn Trait + Send, und warum führt das Fehlen beider dazu, dass das Trait-Objekt die Thread-Sicherheitsprüfungen trotz der Implementierung von Send durch den zugrunde liegenden konkreten Typ nicht besteht?
Wenn Trait Send als Supertrait deklariert (z.B. trait Trait: Send {}), propagiert der Compiler diese Begrenzung und implementiert automatisch Send für dyn Trait, weil jeder Implementierer unbedingt Send sein muss. Im Gegensatz dazu erstellt das Fehlen dieser Supertrait, wenn man dyn Trait + Send explizit schreibt, ein Trait-Objekt, das nur konkrete Typen akzeptiert, die sowohl Trait als auch Send implementieren, und damit den zulässigen Typen am Umformungsort einschränkt. Wenn weder die Supertrait noch die explizite Begrenzung vorhanden sind, implementiert dyn Trait nicht Send, selbst wenn die konkrete Instanz hinter dem Zeiger thread-sicher ist, da die Typlöschung diese Information verwirft – der Compiler kann nicht garantieren, dass alle möglichen Typen, die diesen vtable-Slot besetzen könnten, Send sind. Dies verhindert die versehentliche Übertragung von nicht thread-sicheren Typen über Thread-Grenzen hinweg durch die Typlöschung des Trait-Objekts.