Rust eigendomsmodel en de borrow checker

We duiken diep in de Rust borrow checker en onderzoeken hoe het werkt, welke voordelen het biedt en hoe het soms een uitdaging kan zijn om mee te werken.

Rust is een systeemprogrammeertaal die de laatste jaren aan populariteit heeft gewonnen vanwege de sterke focus op veiligheid, prestaties en gelijktijdigheid. Een van de belangrijkste eigenschappen die Rust onderscheidt van andere talen is de borrow checker. De borrow checker is een soort statische analyse tool die helpt bij het voorkomen van veel voorkomende fouten zoals null pointer dereferences, use-after-free bugs en data races. Of je nu een doorgewinterde Rustaceaan bent of net begint met de taal, deze post zal waardevolle inzichten geven in een van de meest krachtige en unieke functies van Rust.

De stack vs. de hoop

Het is belangrijk om het onderscheid te zien tussen geheugen dat op de stack wordt gealloceerd en geheugen dat op de heap wordt gealloceerd.

De stapel

Stel je de volgende code voor

Je kunt dit zien als een vel papier waarop de volgende items staan.

De stack ziet er nu zo uit.

Als we een methode toevoegen en deze vervolgens aanroepen, zal er een wijziging zijn.

Wanneer een methode wordt aangeroepen, wordt een nieuwe scope aangemaakt. Je kunt elke nieuwe scope zien als een nieuw vel papier dat bovenaan de stapel wordt gelegd.

En de stapel ziet er nu zo uit:

Wanneer een methode wordt aangeroepen, worden twee nieuwe items toegevoegd aan het stackgeheugen om de argumenten en lokale variabelen van de methode weer te geven. Echter, zodra we het bereik van de methode verlaten door de afsluitende accolade te bereiken, wordt alles binnen dat bereik vernietigd, inclusief alle waarden die op de stack waren gealloceerd, zoals x en y. Deze waarden worden niet langer als levend beschouwd en kunnen niet meer worden geopend of gebruikt nadat het bereik is beëindigd.

De stack ziet er nu weer zo uit.

Het is de moeite waard om op te merken dat de compiler vooraf geheugen kan toewijzen voor de stack, omdat alle items waaraan we toewijzen een vaste grootte hebben. Bijvoorbeeld, de waarde var_a is van het type i8, dus het zal altijd 8 bits in het geheugen gebruiken, ongeacht hoe groot het getal is. De stack kan echter alleen worden gebruikt voor toewijzingen van vaste grootte en items op de stack worden naast elkaar geplaatst, waardoor er geen ruimte is voor uitbreiding tijdens runtime. Het benaderen en kopiëren van waarden van/naar de stack is daarom erg snel en goedkoop.

Rust wijst altijd standaard geheugen toe op de stack wanneer dat mogelijk is, tenzij de programmeur anders specificeert. Dit draagt bij aan de geheugenveiligheid en prestatiewinst van Rust. Verzamelingen zoals String, een verzameling van u8's, kunnen echter groter worden en kunnen niet op de stack geplaatst worden. Daarom moeten we de heap gebruiken.

De hoop

De heap wordt gebruikt voor waarden die kunnen groeien of krimpen in grootte en moeten worden doorgegeven tussen scopes. Je kunt de heap zien als een rij kluisjes die kunnen worden gebruikt om items van verschillende groottes op te slaan. Wanneer we geheugen toewijzen op de heap, vragen we om een blok geheugen van een bepaalde grootte en het besturingssysteem zoekt een geschikte plek om dat blok te plaatsen.

In tegenstelling tot de stack, die automatisch wordt beheerd door de Rust compiler, moet heap geheugen handmatig worden toegewezen en verwijderd met behulp van speciale functies. Dit introduceert een aantal complexiteiten, zoals de mogelijkheid van geheugenlekken of bungelende pointers, die bugs en beveiligingsproblemen kunnen veroorzaken. De eigendoms- en leenregels van Rust helpen echter om deze problemen te voorkomen en zorgen ervoor dat geheugen veilig en efficiënt wordt beheerd.

De String-waarde verschilt van andere waarden met een vaste grootte zoals i8 omdat deze kan worden gewijzigd, waardoor de grootte van het toegewezen geheugen toeneemt of afneemt. Daarom kan het niet op de stack geplaatst worden. In plaats daarvan wordt var_b op de heap geplaatst, waar het kan worden weergegeven als een pointer naar het begin van het toegewezen geheugenblok, samen met metadata die de grootte en capaciteit van het blok specificeert.

Heap-allocated waarden zoals var_b moeten expliciet gedeallocated worden wanneer ze niet langer nodig zijn. Als een heap-allocated waarde niet op de juiste manier wordt gedeallocated, kan dit leiden tot geheugenlekken of andere problemen. De eigendoms- en leenregels van Rust helpen deze problemen te voorkomen door ervoor te zorgen dat heap-toegewezen waarden goed worden beheerd en worden gedesalloceerd als ze niet langer nodig zijn.

Zoals je kunt zien, bevat var_b nu een pointer die verwijst naar geheugen op de heap, samen met metadata die de lengte en capaciteit van het toegewezen blok specificeert. Als we "Hello" zouden veranderen in "Hell", zou de lengte van het toegewezen geheugen voor dat item worden gewijzigd in 4.

Geheugentoewijzingen op de heap zijn duurder dan geheugentoewijzingen op de stack omdat ze meer beheer vereisen. In de meeste moderne programmeertalen zorgt een garbage collector voor dit beheer. Garbage collection kan echter pauzes in de programma-uitvoering veroorzaken, waardoor het ongeschikt is voor systeemprogrammering.

Rust heeft geen vuilnisman, maar zorgt wel voor geheugenbeheer. Dit is waar het vlaggenschip van Rust, het eigendomsmodel, om de hoek komt kijken. Het eigendomsmodel zorgt ervoor dat elke waarde in het geheugen een unieke eigenaar heeft en dat eigendom kan worden overgedragen tussen verschillende delen van het programma. Door dit te doen, zorgt Rust ervoor dat geheugen efficiënt wordt beheerd en zonder run-time overhead te introduceren, waardoor het een geweldige keuze is voor systeemprogrammering.

Rust eigendomsmodel

Het eigendomsmodel van Rust heeft een paar belangrijke kenmerken die het uniek en krachtig maken:

  • Er is maar één eigenaar voor gealloceerd geheugen, of het nu op de stack of de heap staat.
  • Geheugen wordt altijd vrijgemaakt zodra de eigenaar van de stack wordt verwijderd.

Door deze eenvoud kan Rust geheugenveiligheid garanderen zonder dat er een vuilnisman nodig is. Wanneer een eigenaar van de stack wordt verwijderd, maakt Rust automatisch het geheugen vrij dat bij die eigenaar hoort. Dit maakt Rust-code zowel veilig als efficiënt, omdat het geheugen altijd correct wordt beheerd zonder overhead van een garbage collector.

Het eigendomsmodel wordt afgedwongen door het leensysteem van Rust, dat flexibel en veilig delen van geheugen tussen verschillende delen van het programma mogelijk maakt.

In de volgende secties zullen we dieper ingaan op de details van het eigendomsmodel en het leensysteem van Rust en hoe ze samenwerken om Rust-code veilig en efficiënt te maken.

Vraag: wie is de eigenaar van de beginwaarde 6?

In dit geval wordt er een kopie gemaakt bij het toewijzen van var_b van de waarde van var_a Onthoud: het is zo goedkoop en snel om waarden op de stack te hebben, dat roest waar mogelijk gewoon de waarde zal kopiëren naar een nieuwe waarde op de stack. Dus nu hebben we 2 waarden op de stack. var_a bezit een waarde 6, en var_b bezit ook een andere waarde 6.

Dit geldt ook voor het verplaatsen van stackallocaties naar andere scopes, zoals een methode-aanroep:

De uitvoer zal zijn:

Dit komt doordat er een kloon wordt gemaakt van var_a, die wordt doorgegeven aan some_other_function en eigendom wordt gegeven aan parameter val. Er is in dit geval geen verband tussen var_a en val. Onthoud deze kopieeractie, we zullen dit later nog zien.

Een complexer voorbeeld

Dus als we het volgende voorbeeld hebben:

Ons stack- en heap-geheugen ziet er nu zo uit:

Maar zodra we het einde van de some_other_function() scope bereiken (bij de accolades),

var_x wordt verwijderd van de stack, en daarom wordt het bijbehorende toegewezen geheugen op de heap ook verwijderd. Dit proces van het dealloceren van geheugen op de heap is eenvoudig in Rust.

In Rust kan het eigendom van toegewezen geheugen worden overgedragen of verplaatst tussen variabelen.

Zie het volgende voorbeeld:

Niets nieuws hier, toch? Zoals verwacht wordt de string 'Hello' afgedrukt op de console.

Hier is echter iets veranderd. Het eigendom van het toegewezen geheugen is verschoven van var_b naar input. Laten we eens kijken wat er gebeurt als we var_b proberen te gebruiken na de some_other_function() call'.

Nadat het eigendom van het toegewezen geheugen voor var_b is verplaatst naar de invoervariabele in de some_other_function() aanroep, staat Rust niet meer toe dat var_b wordt gebruikt. Dit komt omdat het eigendom van var_b is overgedragen en het niet langer de eigenaar is van het geheugen. Als wordt geprobeerd var_b te gebruiken na de functieaanroep, detecteert de compiler dat deze is verplaatst en klaagt over een 'borrow after the move'-fout.

WAT???

Zoals eerder vermeld, worden items in Rust verwijderd wanneer hun eigenaar wordt verwijderd. In het voorbeeld hebben we het eigendom van "Hallo" verplaatst van de variabele var_b naar de invoerparameter. De invoerparameter leeft alleen tijdens het bereik van de methode some_other_function(). Zodra we het einde van het bereik bereiken, leeft de invoerparameter niet langer en wordt deze "Hallo" verwijderd uit de hoop.

Bovendien implementeert het object String van Rust de eigenschap Copy niet, wat betekent dat het niet automatisch kan worden gekopieerd wanneer het als referentie wordt doorgegeven. Dit verschilt van basistypes zoals gehele getallen of booleans, die gemakkelijk gekopieerd kunnen worden.

Hoe lossen we dit op?

We kunnen hier een paar dingen doen.

  • We kunnen de waarde "Hallo" (a) klonen. We krijgen een andere instantie van "Hallo" (b) op de hoop, die alleen levend is tijdens het bereik van some_other_function waardoor de invoerparameter de eigenaar van die verwijzing wordt. var_b blijft de eigenaar van zijn eigen kopie (a) en a blijft levend tijdens de 3e regel waar de uitvoer wordt afgedrukt. Dit is echter duur en niet noodzakelijk.

We kunnen het eigendom van "Hallo" teruggeven aan var_b. We hebben geen 2 kopieën op de hoop en het item zal lang genoeg leven terwijl het eigendom heen en weer wordt verplaatst. Merk op dat we var_b wijzigen en dat het dus gemarkeerd moet worden als muteerbaar.

Het is goed om te vermelden dat in roest alles standaard onveranderbaar is.

Lenen!

Hoewel de bovenstaande voorbeelden werken, worden ze niet aanbevolen omdat ze niet idiomatisch Rust zijn. In plaats daarvan heeft Rust een veel beter mechanisme voor het omgaan met toegewezen geheugen dat wordt gebruikt door andere variabelen en scopes. Dit wordt gedaan met behulp van het leenmechanisme in Rust. Een geleende waarde wordt aangegeven met het & teken.

In dit geval leent de input temporary het toegewezen geheugen van var_b en geeft het het eigendom terug zodra het er klaar mee is. Merk op dat we de variabele var_b niet meer als mutabel hoeven te declareren.

Dit werkt zoals verwacht, maar laten we iets interessanters proberen. Laten we een struct toevoegen met enkele leden en proberen het eigendom te lenen terwijl we enkele waarden wijzigen.

Hoewel dit voor de meesten van jullie prima lijkt, wil de compiler er niets van weten.

Onthoud dat in Rust alles standaard onveranderbaar is, inclusief geleende waarden. Je kunt echter een muteerbare geleende waarde krijgen door de &mut syntaxis te gebruiken in plaats van &.

"In Rust kun je zoveel onveranderlijke geleende waarden hebben als je wilt, maar je kunt maar één muteerbare geleende waarde per keer hebben. Dit is een harde beperking die voorkomt dat we tegen verschillende geheugenbeheerproblemen aanlopen en die geheugenbeheer begrijpelijker maakt."

"Omwille van de eenvoud is een struct gebruikt in plaats van een String-waarde. Bij het gebruik van boxed allocations op de heap, moeten we ook werken met lifetimes, wat de dingen nog complexer maakt. Laten we ons voor nu echter richten op lenen."

We kunnen de fout herstellen door de invoerparameter als muteerbaar te markeren met &mut.

Nog enkele voorbeelden

Bekijk het volgende codevoorbeeld:

Bij het compileren van de code kan de Rust compiler detecteren dat geheugen voortijdig wordt vrijgegeven. Hij zal een compileerfout geven, zodat we niet tegen problemen aanlopen tijdens runtime. Dit is een krachtige eigenschap van het eigendomssysteem van Rust, omdat het helpt om potentiële bugs op te sporen voordat ze problemen kunnen veroorzaken.

Levens

Een ander goed voorbeeld van de leenchecker in actie is hoe het omgaat met levens

Hoewel deze code er normaal uitziet, zullen er compilatieproblemen optreden.

Het terugkeertype van deze functie bevat een geleende waarde, maar de handtekening geeft niet aan of deze geleend is van x of y.

De reden hiervoor is dat x en y een verschillende levensduur kunnen hebben. De langste functie kent de levensduur niet en we moeten weten welke levensduur we moeten teruggeven. De compiler heeft ons al verteld hoe we dit moeten oplossen.

We zullen de lifetime annotaties toevoegen aan onze methode, zoals de compiler voorstelde.

Wat we nu specificeren is een relatie tussen a, b en de retourwaarde. Dit betekent dat de levensduur van de retourwaarde hetzelfde is als de kortste levensduur van de argumenten. Dus als x een kleinere levensduur heeft dan y, dan is de levensduur van de geretourneerde waarde hetzelfde als x. Omgekeerd, als de levensduur van y kleiner is dan x, dan is de levensduur van de geretourneerde waarde hetzelfde als y.

Als we teruggaan naar onze hoofdfunctie, kunnen we zien dat we aan het einde de langste waarde afdrukken. De leenchecker controleert of de kortste levensduur nog steeds geldig is.

Maar laten we zeggen dat ik iets anders wil doen, laten we zeggen zoals dit:

Hoewel string1 een langere levensduur heeft dan string2, wanneer we de langste waarde afdrukken, is de levensduur van string2 nog steeds geldig en kan ons programma zonder problemen compileren en uitvoeren.

Als we echter het volgende zouden doen:

De compiler zal een fout gooien om aan te geven dat de levensduur van de geretourneerde waarde niet lang genoeg is bij het aanroepen van de println! macro.

Vechten tegen de leenchecker

Zoals je misschien hebt gemerkt, kan het werken met lifetimes en borrowing een uitdaging zijn in Rust. Als je deze concepten echter goed begrijpt, kun je veilige en efficiënte code schrijven. Zelfs ervaren ontwikkelaars vinden het soms frustrerend om met de borrow checker te werken, wat heeft geleid tot het ontstaan van de meme "Fighting the borrow checker".

Conclusie

Concluderend is de borrow checker van Rust een krachtig hulpmiddel om geheugenveiligheid in Rust-programma's te garanderen, maar het kan ook een uitdaging zijn om ermee te werken. Met oefening en een goed begrip van lifetimes kunnen ontwikkelaars echter bedreven raken in het schrijven van Rust-code die niet alleen veilig is, maar ook performant.