Geschichte. Frühes Rust erforderte, dass alle Typen eine statisch bekannte Größe besitzen, um die Stapelzuweisung und effiziente Wertsemantik zu gewährleisten. Als dynamisch dimensionierte Typen (DSTs) wie Slices [T] und Trait-Objekte dyn Trait eingeführt wurden, um flexible Datenstrukturen zu unterstützen, musste die Sprache einen Mechanismus einführen, um zwischen sized und potenziell unsized generischen Parametern zu unterscheiden, ohne vorhandenen Code zu brechen. Die Syntax ?Sized wurde als "entspannter" Bound eingeführt, die es generischen Typen ermöglicht, explizit von der Standardanforderung Sized auszutreten, während das ergonomische Standardverhalten für die Mehrheit der Anwendungsfälle, die keine unsized Daten betreffen, erhalten bleibt.
Das Problem. Der implizite Bound T: **Sized** erzeugt eine fundamentale Spannung: Er ermöglicht die Wertmanipulation und die Speicherberechnungen zur Kompilierungszeit, verhindert jedoch, dass Funktionen dyn Trait oder Slicetypen direkt ohne Indirektion akzeptieren. Diese Einschränkung zwingt Entwickler dazu, Box oder Referenzen zu verwenden, selbst wenn Eigentumsemantiken gewünscht sind, was APIs kompliziert, die sowohl statische als auch dynamische Polymorphie unterstützen möchten. Ohne ?Sized kann generischer Code nicht sowohl über konkrete Typen als auch über zur Laufzeit polymorphe Objekte abstrahieren, was entweder gezwungene Heap-Zuweisungen oder redundante Schnittstellen für sized und unsized Varianten zur Folge hat.
Die Lösung. Der Compiler löst dies, indem er durchsetzt, dass Typen, die durch ?Sized begrenzt sind, nur über fat pointers zugegriffen werden kann – zusammengesetzte Werte, die einen Datenzeiger und Laufzeitmetadaten (Länge für Slices, vtable für Trait-Objekte) enthalten. Wenn ein Generic T: **?Sized** spezifiziert, verbietet der Compiler Operationen, die bekannte Größen erfordern, wie z. B. std::mem::size_of::<T>() oder das Verschieben von Werten durch Wert, und stellt sicher, dass alle Speicheranordnungen zur Kompilierungszeit berechenbar bleiben. Dieses Design ermöglicht null-Kosten-Abstraktionen, bei denen sized Typen dünne Zeiger und unsized Typen fat pointers verwenden, während das Typsystem die Unterscheidung transparent handhabt.
Eine Systemüberwachungsbibliothek musste Fehler protokollieren, die entweder kleine, stapelzugewiesene Fehlercodes oder große, dynamisch formatierte Fehlermeldungen, die dyn **Display** implementieren, sein konnten. Das ursprüngliche API-Design mit fn log<T: **Display**>(error: T) wies Trait-Objekte zurück, da der implizite Sized-Bound dyn Display daran hinderte, die Einschränkung zu erfüllen, und damit eine erhebliche ergonomische Hürde für die dynamische Fehlerbehandlung schuf.
Der erste Ansatz, der in Betracht gezogen wurde, war die Vorgabe Box<dyn **Display**> für alle Fehlertypen, wodurch selbst einfache u32-Fehlercodes in Heap-Zuweisungen umgewandelt wurden. Vorteile: Vereinheitlichte die API-Oberfläche und erlaubte das Eigentum an dynamischen Fehlern ohne komplexe Generics. Nachteile: Führte zu Abhängigkeiten vom Allocator, die für eingebettete Ziele ungeeignet waren, und fügte messbare Latenz in heißen Pfaden hinzu, die einfache, statische Fehler handhaben.
Die zweite Option bestand darin, zwei separate Protokollmethoden zu beibehalten: eine für generische T: **Display**-Typen, die die Größe haben, und eine speziell für &dyn **Display**. Vorteile: Vermeidung von Heap-Zuweisungen für sized Typen und korrekte Unterstützung des dynamischen Dispatchs für komplexe Fehler. Nachteile: Führte zu erheblicher Code-Duplikation, komplizierte die öffentliche API-Dokumentation und zwang Aufrufer, die richtige Methode basierend auf dem Vorwissen über die Größe des Typs auszuwählen.
Das Team wählte einen dritten Ansatz mit fn log<T: **?Sized** + **Display**>(error: &T), um Referenzen sowohl für sized als auch für unsized Typen zu akzeptieren. Diese Lösung wurde gewählt, da sie einen einzigen, kohärenten API-Einstiegspunkt beibehielt, no-std-Umgebungen unterstützte, indem sie obligatorisches Boxing vermied und im Vergleich zum dualen Methodenansatz null Laufzeitüberhead erforderte. Die generische Implementierung wurde zu identischem Maschinen-Code für sized Typen kompiliert wie die ursprüngliche monomorphe Version, während sie Trait-Objekte korrekt über vtable-Dispatch handhabte.
Das resultierende Krate wurde erfolgreich auf Mikrocontrollern und Servern implementiert, die Millionen heterogener Fehlerereignisse ohne Zuweisungsüberhead verarbeiteten. Die einheitliche Schnittstelle erlaubte Entwicklern, sowohl &ConcreteError als auch &dyn Error nahtlos zu übergeben, was zeigte, dass ?Sized echtes null-Kosten-Polymorphismus über verschiedene Bereitstellungsziele ermöglicht.
Warum kann eine Funktion keinen Wert des Typs T zurückgeben, wo T: **?Sized**?
Funktionen, die Werte zurückgeben, müssen diese Werte in Registern oder auf dem Stapel ablegen, was eine zur Kompilierungszeit bekannte Größe erfordert, um den richtigen Calling-Convention-Code zu generieren und den entsprechenden Stapelplatz zu reservieren. Da ?Sized-Typen wie [i32] oder dyn **Debug** zur Laufzeit bestimmte Größen haben, kann der Compiler die für das ABI notwendigen festen Rückgabebefehlssequenzen nicht generieren. Nur Zeigertypen (Box<T>, &T) haben statisch bekannte Größen (usize oder Breite des fat pointers), was sie zu den einzigen zulässigen Rückgabetypen für unsized Daten macht und ?Sized-Generics grundsätzlich auf "View"-Typen und nicht auf "Value"-Typen beschränkt, die durch Wert bewegt werden können.
Wie interagiert **?Sized** mit den Kohärenzregeln bezüglich Trait-Implementierungen für Referenzen?
Bei der Implementierung von Traits für &T, wobei T: **?Sized**, gilt die Implementierung automatisch für fat pointers (wie &[i32] oder &dyn Trait), da diese einfach Referenzen auf ?Sized-Typen sind. Kandidaten übersehen oft, dass impl Trait for &T where T: **?Sized** sowohl dünne als auch fat pointers abdeckt, während impl Trait for T where T: **Sized** dies nicht tut. Diese Unterscheidung ist entscheidend für die Definition von Blanket-Implementierungen, die sowohl mit sized Daten als auch mit Trait-Objekten funktionieren und Kohärenz über die Typ-Hierarchie hinweg gewährleisten, ohne überlappende Implementierungen, die die Rust-Orphan-Regeln verletzen würden.
Was unterscheidet die Speicherrepräsentation von **Box<dyn Trait>** von **&dyn Trait** über die Eigentumsemantiken hinaus?
Während beide fat pointers (Zeiger + vtable) verwenden, besitzt **Box<dyn Trait>** die Zuweisung und speichert den vtable-Zeiger speziell für Dealloziierungszwecke, während **&dyn Trait** lediglich die Daten beobachtet. Entscheidend ist, dass Box<T> mit T: **?Sized** den Allocator erfordert, um dynamisch dimensionierte Dealloziierungen unter Verwendung der im vtable gespeicherten Größe zu behandeln, während Referenzen keine solche Verantwortung tragen. Anfänger übersehen oft, dass Box die Heap-Zuweisung von unsized Typen ermöglicht, die nicht auf dem Stapel existieren können, während Referenzen einfach vorhandenen Speicher ausleihen, wodurch Box entscheidend für die Rückgabe von owned unsized Daten aus Funktionen ist.