Voorbij de &

Rust beschikt over verschillende soorten smart pointers, zowel in de standaardbibliotheek als door gebruikers gedefinieerd. Toch zijn er bepaalde operaties die wel mogelijk zijn met ingebouwde referenties, maar niet met door de gebruiker gedefinieerde smart pointers. Tijdens RustConf 2026 sprak Tyler Mandry, leider van het taalteam van het Rust-project, over de langdurige inspanning om dit te veranderen en smart pointers net zo flexibel te maken als ingebouwde referenties.

Mandry gaf aan dat hij gedurende heel 2026 aan dit probleem heeft gewerkt, samen met verschillende andere geïnteresseerde leden van het taalteam, onder de projectnaam "Beyond the &". Na veel overleg zijn ze tot een ontwerp gekomen dat een aanzienlijk eenvoudiger mentaal model moet opleveren van hoe pointers en referenties werken.

Het probleem: Mutexen en de borrow checker

Om het probleem dat dit ontwerp oplost te illustreren, toonde Mandry een voorbeeld van een vereenvoudigd Rust-programma dat dynamische inhoud berekent en deze opslaat in een hash map. Het voorbeeld maakt gebruik van de map-entry API van Rust, met de functie orinsertwith(), die een callback aanroept om de entry in de hash-map in te vullen als deze leeg is.

struct RenderState {
    cache: HashMap<String, Arc<String>>,
    template: String,
}

fn render_page(state: &mut RenderState, name: &str) -> Arc<String> {
    // Mutabel lenen van `state.cache` via de Entry API.
    let entry = state.cache.entry(name.to_string());
    entry.or_insert_with(|| {
        // Maak de entry aan als deze niet bestaat.
        Arc::new(state.template.replace("$name", name))
    }).clone()
}

Deze code zoekt een cache-sleutel (name) op in de hash map en geeft ofwel een kopie van de gecachte waarde terug, of berekent deze door name in een template te plaatsen. De gedeelde status wordt benaderd via een ingebouwde mutabele referentie: &mut RenderState.

Deze code werkt, maar kan niet veilig worden gedeeld tussen threads. Wanneer een Mutex wordt toegevoegd om de cache deelbaar te maken, wijst de borrow checker het programma af:

fn render_page(state: &Mutex<RenderState>, name: &str) -> Arc<String> {
    let state: MutexGuard <' _ , RenderState> = state.lock().unwrap();
    // De borrow checker klaagt dat "state" hier mutabel wordt geleend:
    let entry = state.cache.entry(name.to_string());
    entry.or_insert_with(|| {
        // ... maar vervolgens hier wordt gebruikt terwijl het nog steeds is geleend:
        Arc::new(state.template.replace("$name", name))
    }).clone()
}

Volgens Mandry werkte de oorspronkelijke code omdat de borrow checker kon volgen dat state.cache en state.template afzonderlijke velden waren, waardoor het veilig was om ze gelijktijdig te benaderen. Met de toevoeging van de Mutex ziet de borrow checker enkel ondoorzichtige toegangen tot een MutexGuard-structuur. Hij kan niet langer vaststellen dat de toegangen gescheiden zijn en moet rekening houden met het scenario waarin de mutabele lening wordt gebruikt om de status te bewerken op hetzelfde moment dat de callback deze leest, wat kan leiden tot een ongeldige data race.

Een eenvoudige oplossing in dit geval is om de MutexGuard één keer te derefereren en de structuur daarachter opnieuw te lenen:

let state: &mut RenderState = &mut *state.lock().unwrap();

Dit creëert een nieuwe ingebouwde referentie, net als in de oorspronkelijke code, waardoor de borrow checker opnieuw kan zien dat de toegangen elkaar niet storen. Hoewel dit werkt, is het niet intuïtief. Mandry stelde dat dit soort complexiteit bijdraagt aan de steile leercurve van Rust. Het zou beter zijn als door de gebruiker gedefinieerde pointer-types, zoals MutexGuard, zich meer zouden gedragen als ingebouwde referenties.

De oplossing: "Places" en "Handles"

Pointer-types zijn overal aanwezig en hebben allemaal net iets andere semantiek. Sommige pointers kunnen niet veilig worden gedereferereerd (raw pointers), andere kunnen wel naar geschreven maar niet gelezen worden (MaybeUninit), enzovoort. Dit toont de veelzijdigheid van Rust aan, maar maakt het ook moeilijk om een ontwerp te bedenken dat voor alle mogelijke pointer-types werkt.

De oplossing waar het taalteam op is uitgekomen, gebaseerd op het werk van Nadrieril en Benno Lossin, is om het interne concept van een "place" van de compiler toegankelijk te maken voor gebruikerscode. Een place is de Rust-equivalent van de linkerzijde van een expressie in C (lvalues): een locatie waarvan waarden gelezen of waarnaar geschreven kunnen worden. Het verschil tussen een place en een pointer is dat een place een abstracte expressie is die bestaat tijdens het compileren (zoals state.cache), terwijl een pointer een manier is om een place tijdens runtime te representeren.

Het idee is om voor elke smart pointer een nieuw handle-type te creëren. Vaak is deze handle simpelweg een wrapper rond een unsafe pointer naar dezelfde locatie. De compiler maakt vervolgens automatisch handles aan om places te representeren waar het programma naar verwijst. Hierdoor kan gebruikerscode traits implementeren op een handle-type. Dit beïnvloedt hoe de borrow checker omgaat met handles die corresponderen met een aangepaste smart pointer, met een hogere mate van flexibiliteit dan de bestaande Deref en DerefMut traits.

Implementatie via traits

Als voorbeeld van hoe dit eruit zou zien, liet Mandry zien hoe een bibliotheekauteur de borrow checker kan leren hoe hij moet omgaan met schrijfacties naar places die worden benaderd via een NonNull-pointer:

unsafe impl<T> WritePlace for NonNullHandle<T> {
    const SAFE: bool = false;
    unsafe fn write_place(self, value: T) {
        unsafe { self.0.write(value) }
    }
}

De WritePlace trait wordt gebruikt om de borrow checker te vertellen dat een specifiek type handle (in dit geval een handle die verwijst naar een NonNull-pointer) beschrijfbaar is, en hoe dat gebeurt. Wanneer SAFE op false staat, behandelt de borrow checker het schrijven naar de bijbehorende place als een unsafe operatie. De eigenlijke schrijfactie wordt doorgezet naar de raw pointer die NonNull omhult. De volledige trait-implementatie is unsafe omdat een incorrecte implementatie de borrow checker tot fouten kan dwingen, wat kan leiden tot onveilig gedrag (unsound behavior).

Een corresponderende ReadPlace trait legt vast hoe er van een handle gelezen kan worden. Interessanter zijn ProjectPlace en BorrowPlace:

  • ProjectPlace: Vertelt de borrow checker hoe een place die een structuur bevat, kan worden omgezet in een place die een van de velden van die structuur bevat. Bijvoorbeeld: hoe een (handle voor) een MutexGuard<RenderState> wordt omgezet in een MutexGuard<String> wanneer de programmeur state.template schrijft.
  • BorrowPlace: Vertelt de borrow checker hoe er een nieuwe smart pointer gemaakt kan worden die een gegeven place leent, op dezelfde manier als & werkt voor ingebouwde referenties.

Het taalteam debatteert nog over de syntaxis voor het creëren van een nieuwe smart pointer met BorrowPlace. Hoewel hetzelfde & symbool gebruikt zou kunnen worden, zou dat verwarrend kunnen zijn en type-inferentie bemoeilijken. Een voorstel is om in plaats daarvan een @ symbool te gebruiken, maar daar zijn mensen ook nog niet volledig tevreden over. Welke syntaxis er uiteindelijk ook wordt gekozen, de BorrowPlace trait codeert alle informatie die de borrow checker nodig heeft om het pointer-type veilig af te handelen.

Dit betekent dat huidig ingebouwd gedrag via hetzelfde mechanisme gedefinieerd kan worden. Een implementatie voor het ingebouwde referentietype &T zou er als volgt uitzien:

unsafe impl<'a, T> BorrowPlace<&'a T> for RefHandle<'a, T> {
    // Vertel de borrow checker dat er gelijktijdig meerdere referenties
    // naar dezelfde place kunnen worden gemaakt:
    const ACCESS: AccessKind = AccessKind::Shared;
    // En dat de lening duurt voor de lifetime 'a:
    type Timing = Lifetime<'a>;
    // En dat het maken van referenties een veilige operatie is:
    const SAFE: bool = true;
    unsafe fn borrow(self) -> &'a T {
        unsafe { &*self.ptr.as_ptr() }
    }
}

Dit geeft gebruikers een voorbeeld van hoe ze hun eigen smart pointers kunnen schrijven die zich gedragen als referenties, en geeft onderhouders van de standaardbibliotheek een plek om bestaande, contra-intuïtieve ingebouwde mechanismen te documenteren. Met een soortgelijke implementatie van een handle-type voor MutexGuard werkt het eerder genoemde render_page() voorbeeld zonder fouten. Mandry noemde dit "een kleine codewijziging voor dit voorbeeld, maar een grote semantische verschuiving."

Voordelen en toekomstperspectief

Het nauwer koppelen van smart pointers aan de interne werking van de compiler biedt andere voordelen. Momenteel is het mogelijk om pattern matching te gebruiken op een waarde achter een referentie, maar niet op een waarde achter een smart pointer — tenminste, niet zonder de pointer te derefereren en de waarde daarachter opnieuw te lenen. De details die door BorrowPlace worden blootgelegd, zouden voldoende zijn om de compiler deze pattern matches veilig te laten implementeren.

Een van de criteria die het taalteam hanteert voor nieuwe functies is composability: hoe goed een voorgestelde functie integreert in de bestaande structuren van de taal en hoe goed meerdere toepassingen van de functie samenwerken. Handles en places "componeren prachtig" omdat ze simpelweg details blootleggen van hoe de compiler de taal al begrijpt en verwerkt.

Ondanks dit is het blootleggen van places en handles aan bibliotheekcode nog steeds een prototype. Mandry vroeg om hulp om te garanderen dat het ontwerp werkt voor alle use-cases; hij vroeg het publiek om de ontwerpdocumentatie te bekijken en eigen voorbeelden toe te voegen van smart pointers met ongebruikelijke semantiek.

Het volgende deel van het ontwerp dat hij verder wil uitwerken, betreft fouten en diagnostische berichten. Idealiter zouden gebruikers nooit foutmeldingen moeten zien die verwijzen naar de nieuw toegevoegde traits; deze zouden intern blijven, en de foutmeldingen zouden het probleem direct uitleggen, zoals gebeurt bij ingebouwde referenties. Hoewel er nog veel werk te doen is voordat dit een stabiel onderdeel van de taal wordt, is Mandry optimistisch. "Mijn hoop is dat het bibliotheken in staat stelt om Rust nog krachtiger en gebruiksvriendelijker te maken."

Vragen en antwoorden

Een toehoorder wilde weten of dit ontwerp ook destructive pattern matching zou ondersteunen (een gecombineerde operatie die eigenaarschap van een waarde overneemt terwijl deze wordt gematcht tegen mogelijke patronen, waarbij de waarde wordt ontleed in zijn componenten). Mandry zei van wel, zolang de ontwikkelaar een VariantPlace trait implementeert voor de betreffende handle. Hij voegde toe dat er zes tot acht operaties moeten worden geïmplementeerd om elke operatie op een handle-type te ondersteunen, en dat dit er één van is.

Een andere toehoorder vroeg of het ontwerp ook zou werken voor enumeraties in het algemeen. Mandry antwoordde aarzelend bevestigend. Hij legde uit dat het projecteren van een veld uit een enumeratie in het algemeen waarschijnlijk niet mogelijk zal zijn, maar dat er wel verkennend werk in die richting is gedaan. Zo is de vraag of het mogelijk moet zijn om een veld uit een Option te projecteren om een Option van de veldwaarde te verkrijgen. "Ik weet het antwoord niet, maar het is een interessante vraag," merkte Mandry op.

***

Bedankt aan de Linux Foundation, de reissponsor van LWN, voor de ondersteuning bij de reis naar Montreal voor RustConf.