RustProgrammierungRust-Entwickler

Wie verhindert die Variabilität des &mut T-Typs, dass man sicher &mut &'long str in &mut &'short str zuweist, und welches Speicherproblem würde dies erlauben, wenn es gestattet wäre?

Bestehen Sie Vorstellungsgespräche mit dem Hintsage-KI-Assistenten

Antwort auf die Frage

Geschichte der Frage

Die Variabilität in Typensystemen bestimmt, wie Untertypbeziehungen zwischen generischen Parametern die Gesamtstruktur beeinflussen. Rust’s Ansatz wurde stark von Forschungen zum bereichsbasierten Speicher-Management und der Notwendigkeit, Use-after-Free-Schwachstellen zu verhindern, beeinflusst. Als Rust veränderbare Referenzen (&mut T) einführte, mussten die Designer entscheiden, ob diese kovariant (wie &T), kontravariant oder invariant sein sollten. Die Wahl der Invarianz für &mut T gegenüber T war entscheidend, um die Speicher-Sicherheit zu gewährleisten, ohne Laufzeitprüfungen zu erfordern.

Das Problem

Wenn &mut T kovariant über T wäre, könnte man &mut U anstelle von &mut V einsetzen, wenn U ein Untertyp von V ist. In Lebensdauertterms würde dies bedeuten, dass man &mut &'long str auf &mut &'short str zuweisen könnte, da 'long ein Untertyp von 'short ist (weil 'long länger lebt als 'short). Das scheint harmlos zu sein, schafft aber eine Unstimmigkeitslücke.

Die Lösung

&mut T ist invariant über T. Das bedeutet, dass &mut &'a str und &mut &'b str nicht verwandte Typen sind, es sei denn, 'a ist genau gleich 'b, unabhängig von der Untertypbeziehung zwischen den Lebensdauern. Der Compiler lehnt Code ab, der versucht, zwischen ihnen zu zwingen, wodurch die Zuweisung von kurzlebigen Daten an Stellen, an denen längere Referenzen erwartet werden, über eine veränderbare Indirektion verhindert wird.

Code-Beispiel:

fn demonstrate_invariance() { let mut long_lived: &'static str = "statischer String"; // Das würde kompilieren, wenn &mut T kovariant wäre: // let short_ref: &mut &'short str = &mut long_lived; // Aber weil &mut T invariant ist, schlägt dies fehl: // Fehler: Lebensdauermismatch // let short_ref: &mut &'_ str = &mut long_lived; let local = String::from("temporär"); // Wenn das oben erlaubt wäre, könnten wir: // *short_ref = &local; // Jetzt zeigt long_lived auf gelöschte Daten (UAF!) } // local hier gelöscht

Lebenssituation

Ein Team baute einen Konfigurationsmanager für einen Hochleistungs-Netzwerkstack. Die Kernstruktur musste eine veränderbare Referenz auf eine Protokollkonfiguration halten, die zur Laufzeit ohne Besitzübertragung ausgetauscht werden konnte.

Das Problem: Das ursprüngliche API-Design verwendete &mut &'a Config, wobei 'a die Lebensdauer der Netzwerksitzung war. Entwickler versuchten, dies mit &mut &'static Config (für globale Standardkonfigurationen) zu initialisieren und dann an Funktionen zu übergeben, die &mut &'session Config erwarteten. Der Compiler wies dies zurück, was zu Verwirrung führte, da unveränderbare Referenzen (& &'static Config) gut funktionierten.

Berücksichtigte Lösungen:

1. Unsafe Transmute zur Erzwingung der Konvertierung Das Team erwog die Verwendung von std::mem::transmute, um &mut &'static Config in &mut &'session Config zu konvertieren. Dies würde die Variabilitätsprüfungen des Compilers umgehen. Allerdings würde dies das Schreiben einer kurzlebigen Konfigurationsreferenz an einen Ort ermöglichen, der möglicherweise über den aktuellen Gültigkeitsbereich hinaus lebt, was zu sofortigem undefiniertem Verhalten führen würde, wenn die Konfiguration nach ihrer Löschung zugegriffen wird. Das Risiko von Use-after-Free im Produktionscode machte dies inakzeptabel.

2. Wechsel zu unveränderbaren Referenzen Sie erwogen, die API so zu ändern, dass sie & &'a Config statt &mut &'a Config verwendete. Da geteilte Referenzen kovariant sind, könnte & &'static Config in & &'session Config umgewandelt werden. Dies würde jedoch die Fähigkeit entfernen, Konfigurationen während Laufzeitaktualisierungen atomar auszutauschen, was ein zentrales Erfordernis für das Hot-Reloading von Einstellungen ohne Neustart der Verbindungen war.

3. Verwendung von Cell<&'a Config> für innere Veränderlichkeit Diese Option würde Mutation über eine geteilte Referenz ermöglichen. Allerdings ist Cell<T ebenfalls invariant über T aus denselben Sicherheitsgründen, sodass es das Variabilitätsproblem nicht löste. Außerdem bietet Cell keine Synchronisierung für den Mehrfachzugriff und die Kosten für die Laufzeitprüfung der Ausleihe mit RefCell wurden als zu teuer für den heißen Pfad angesehen.

4. Neugestaltung mit Besitztypen und Indirektion Die gewählte Lösung entfernte das Muster der Referenz-zu-Referenz vollständig. Anstatt &mut &'a Config zu speichern, speicherte die Struktur &'a mut ConfigHolder, wobei ConfigHolder ein Besitzwrapper war. Dies verlagerte die Veränderlichkeit auf die Inhaberebene anstatt auf die Referenzebene, wodurch die Variabilitätsfalle vermieden wurde, während die Fähigkeit, Konfigurationen auszutauschen, erhalten blieb. Die API wurde ergonomischer, da die Benutzer nicht mehr mit doppelten Referenzen umgehen mussten.

Das Ergebnis: Die Neugestaltung führte zu einer sichereren API, die ohne unsicheren Code kompilierte. Die invariante Natur von &mut T zwang das Team dazu, eine potenzielle architektonische Fehlfunktion zu erkennen, bei der Lebensdauervoraussetzungen verletzt werden konnten. Das endgültige System verhinderte eine Kategorie von Fehlern, bei denen veraltete Konfigurationszeiger über ihre Gültigkeitsdauer hinaus bestehen bleiben konnten.

Was Kandidaten oft übersehen

Warum ist Cell<T> invariant über T, und wie hängt dies mit der Variabilität von &mut T zusammen?

Cell<T> bietet innere Veränderlichkeit, die Mutation über geteilte Referenzen ermöglicht. Wenn Cell<T> kovariant über T wäre, könnte man Cell<&'short str> in Cell<&'static str> umwandeln. Dann könnte man eine kurzlebige Stringreferenz innerhalb speichern und später durch den Cell<&'static str>-Typ lesen, wodurch temporäre Daten als statisch behandelt würden. Das wäre eine Use-after-Free-Schwachstelle. Daher müssen Cell<T> (und UnsafeCell<T>) wie &mut T invariant über T sein, um zu verhindern, dass kurzlebige Daten in einen Slot geschrieben werden, der angeblich längerlebige Daten halten soll. Diese Invarianz verbreitet sich auf RefCell, Mutex und andere Typen der inneren Veränderlichkeit.

Wie beeinflusst PhantomData<T> die Variabilität einer Struktur, die kein tatsächliches T enthält, und warum würde man PhantomData<fn(T)> verwenden, um Kontravarianz zu erreichen?

PhantomData<T> informiert den Compiler darüber, die Struktur so zu behandeln, als ob sie ein T im Hinblick auf Variabilität und das Überprüfen des Löschens besitzt. Standardmäßig gibt PhantomData<T> der Struktur die gleiche Variabilität wie T. Funktion Zeiger haben jedoch eine spezielle Variabilität: fn(A) -> B ist kontravariant in A (dem Argument) und kovariant in B (der Rückgabe). Wenn man eine Struktur benötigt, die kontravariant über eine Lebensdauer sein soll (was bedeutet, dass Struct<'long> ein Untertyp von Struct<'short> ist, wenn 'long länger lebt als 'short), verwendet man PhantomData<fn(T)>. Dies ist entscheidend für den Aufbau von typsicheren Rückrufen oder Vergleicher, bei denen die Beziehung zwischen Lebensdauern umgekehrt werden muss.

In unsicherem Code, warum muss eine Struktur, die einen selbstreferenzierenden Punkt mit rohen Zeigern implementiert, als invariant über ihre Lebensdauerparameter gekennzeichnet werden?

Wenn eine Struktur einen rohen Zeiger enthält, der auf andere Daten innerhalb derselben Struktur (selbstreferenzierend) zeigt, bestimmt die Lebensdauer dieser Struktur die Gültigkeit des Zeigers. Wenn die Struktur kovariant über ihre Lebensdauer 'a wäre, könnte man 'a auf eine kürzere Lebensdauer 'b verkleinern und effektiv behaupten, dass die Struktur nur für 'b lebt. Der rohe Zeiger innerhalb wurde jedoch erstellt, als die Struktur länger lebte, und könnte auf Daten zeigen, die im kürzeren Gültigkeitsbereich möglicherweise nicht mehr gültig sind. Invarianz sorgt dafür, dass die Struktur nicht in eine kürzere Lebensdauer gezwungen werden kann, wodurch die Sicherheitsinvarianz erhalten bleibt, dass die Selbstreferenz während der gesamten im Typsystem kodierten Lebensdauer gültig bleibt. Genau aus diesem Grund wird Pin oft mit expliziten Variabilitätsmarkierungen in unsicheren selbstreferenzierenden Implementierungen kombiniert.