Varianties in type-systemen bepalen hoe subtyprelaties tussen generieke parameters de algehele type beïnvloeden. De aanpak van Rust was sterk geïnspireerd door onderzoek naar op regio's gebaseerde geheugbeheer en de noodzaak om gebruik-ná-vrijgeven-kwulnerabiliteiten te voorkomen. Toen Rust mutabele referenties (&mut T) introduceerde, moesten de ontwerpers beslissen of deze covariant (zoals &T), contravariant of invariant zouden zijn. De keuze voor invariantie van &mut T boven T was cruciaal voor het behoud van geheugveiligheid zonder runtime-controles.
Als &mut T covariant over T zou zijn, zou je &mut U kunnen substitueren waar &mut V wordt verwacht als U een subtype van V is. In termen van levensduur betekent dit dat, aangezien 'long een subtype van 'short is (omdat 'long langer leeft dan 'short), je &mut &'long str zou kunnen toewijzen aan &mut &'short str. Dit lijkt onschadelijk, maar creëert een geluidsheidsissue.
&mut T is invariant over T. Dit betekent dat &mut &'a str en &mut &'b str ongepartnerde types zijn, tenzij 'a precies gelijk is aan 'b, ongeacht de subtyprelatie tussen de levensduur. De compiler wijst code af die probeert tussen deze te dwingen, waardoor de toewijzing van kortlevende gegevens aan locaties die langere levensduurreferenties verwachten via een mutabele indirectie wordt voorkomen.
Code Voorbeeld:
fn demonstrate_invariance() { let mut long_lived: &'static str = "static string"; // Dit zou compileren als &mut T covariant was: // let short_ref: &mut &'short str = &mut long_lived; // Maar omdat &mut T invariant is, faalt dit: // fout: levensduur mismatch // let short_ref: &mut &'_ str = &mut long_lived; let local = String::from("temporair"); // Als het bovenstaande was toegestaan, konden we: // *short_ref = &local; // Nu wijst long_lived naar gegevens die zijn verwijderd (UAF!) } // lokale variabele hier verwijderd
Een team was een configuratiebeheerder aan het bouwen voor een netwerkstack met hoge prestaties. De kernstructuur moest een mutabele referentie bevatten naar een protocolconfiguratie die op runtime kon worden verwisseld zonder eigendom over te nemen.
Het Probleem: Het initiële API-ontwerp gebruikte &mut &'a Config waar 'a de levensduur van de netwerk sessie was. Ontwikkelaars probeerden dit te initialiseren met &mut &'static Config (voor globale standaardconfiguraties) en het vervolgens door te geven aan functies die &mut &'session Config verwachtten. De compiler wees dit af, wat verwarring veroorzaakte omdat onveranderlijke referenties (& &'static Config) prima werkten.
Oplossingen Overwogen:
1. Onveilige Transmute om de Conversie te Forceren Het team overwoog het gebruik van std::mem::transmute om &mut &'static Config naar &mut &'session Config te converteren. Dit zou de variantiecontroles van de compiler omzeilen. Echter, dit zou het mogelijk maken om een kortlevende configuratiereferentie op te slaan in een locatie die mogelijk de huidige reikwijdte overschreed, wat zou leiden tot onmiddellijke ongedefinieerd gedrag als de configuratie na verwijdering werd geopend. Het risico van gebruik-ná-vrijgeven in productiecode maakte dit onaanvaardbaar.
2. Overstappen naar Onveranderlijke Referenties Ze overwogen het API-ontwerp te wijzigen om & &'a Config in plaats van &mut &'a Config te gebruiken. Aangezien gedeelde referenties covariant zijn, zou & &'static Config naar & &'session Config kunnen dwingen. Dit zou echter de mogelijkheid om configuraties atomair te verwisselen tijdens runtime-updates verwijderen, wat een kernvereiste was voor het warm herladen van instellingen zonder verbindingen opnieuw te starten.
3. Cell<&'a Config> voor Interne Mutabiliteit Gebruiken Deze optie zou mutatie via een gedeelde referentie mogelijk maken. Echter, Cell<T> is ook invariant over T om dezelfde veiligheidsredenen, dus het loste het variantieprobleem niet op. Bovendien biedt Cell geen synchronisatie voor toegang via meerdere threads, en de overhead van runtime uitleencontrole met RefCell werd als te duur beschouwd voor de warme route.
4. Herontwerpen met Eigen Types en Indirectie De gekozen oplossing elimineerde het patroon van referentie-naar-referentie volledig. In plaats van &mut &'a Config op te slaan, slaat de structuur &'a mut ConfigHolder op, waarbij ConfigHolder een eigenaarwrapper is. Dit verplaatste de mutabiliteit naar het niveau van de houder in plaats van het referentieniveau, waarmee de variantievalkuil werd vermeden en tegelijkertijd de mogelijkheid om configuraties te verwisselen werd behouden. De API werd gebruiksvriendelijker omdat gebruikers zich niet meer om dubbele referenties hoefden te bekommeren.
Het Resultaat: Het herontwerp produceerde een veiligere API die compileerde zonder onveilige code. De invariantie van &mut T dwong het team om een potentiële architectonische flaw te erkennen waar levensduurassumpties konden worden geschonden. Het uiteindelijke systeem voorkwam een categorie fouten waarbij verouderde configuratiepointers langer konden aanhouden dan hun geldigheidsperiode.
Waarom is Cell<T> invariant over T, en hoe verhoudt dit zich tot de variantie van &mut T?
Cell<T> biedt interne mutabiliteit, waardoor mutatie via gedeelde referenties mogelijk is. Als Cell<T> covariant zou zijn over T, zou je Cell<&'short str> kunnen upcasten naar Cell<&'static str>. Dan zou je een kortlevende stringreferentie binnenin kunnen opslaan en deze later lezen via het Cell<&'static str>-type, waarbij tijdelijke gegevens als statisch zouden worden behandeld. Dit zou een gebruik-ná-vrijgevenfout zijn. Daarom moeten, zoals bij &mut T, Cell<T> (en UnsafeCell<T>) invariant zijn over T om te voorkomen dat kortlevende gegevens in een slot worden geschreven dat beweert langere levendigheid te bevatten. Deze invariantie verspreidt zich naar RefCell, Mutex en andere typen van interne mutabiliteit.
Hoe beïnvloedt PhantomData<T> de variantie van een struct die geen echt T bevat, en waarom zou je PhantomData<fn(T)> gebruiken om contravariantie te bereiken?
PhantomData<T> vertelt de compiler om de struct te beschouwen alsof deze een T bezit voor de doeleinden van variantie en afleiding controleren. Standaard geeft PhantomData<T> de struct dezelfde variantie als T. Echter, functiewijzers hebben speciale variantie: fn(A) -> B is contravariant in A (de argument) en covariant in B (de terugkeer). Als je een struct nodig hebt die contravariant is over een levensduur (wat betekent dat Struct<'long> een subtype is van Struct<'short> wanneer 'long langer leeft dan 'short), gebruik je PhantomData<fn(T)>. Dit is cruciaal voor het bouwen van type-veilige callbacks of vergelijkers waar de relatie tussen levensduurlangten omgekeerd moet worden.
In onveilige code, waarom moet een zelfverwijzende struct als invariant worden gemarkeerd over zijn levensduurparameters?
Wanneer een struct een ruwe pointer bevat die naar andere gegevens binnen dezelfde struct wijst (zelfverwijzend), bepaalt de levensduur van die struct de geldigheid van de pointer. Als de struct covariant zou zijn over zijn levensduur 'a, zou je 'a kunnen verkorten tot een kortere levensduur 'b, effectief claimend dat de struct alleen voor 'b leeft. Echter, de ruwe pointer die van binnen is, is gemaakt toen de struct langer leefde, en kan wijzen naar gegevens die niet langer geldig zijn in de kortere reikwijdte. Invariantie zorgt ervoor dat de struct niet kan worden gedwongen naar een kortere levensduur, waardoor de veiligheidsinvariant behouden blijft dat de zelfverwijzing geldig blijft voor de gehele levensduur die in het type-systeem is gecodeerd. Dit is waarom Pin vaak wordt gecombineerd met expliciete variantiemarkers in onveilige zelfverwijzende implementaties.