flowchart subgraph Stack C["bar(a = 20) c = 21"] B["foo(x = 10) y = 20"] A["main() num: 10"] end
Veel van de fouten die voorkomen in computerprogramma’s hebben te maken met het beheren van geheugen. Tot dusver was er geen gemakkelijke tool die een groot deel daarvan kon verhelpen, maar nu wel! Een van de belangrijkste eigenschappen van Rust is de Borrow Checker. In deze post neem ik je mee in hoe de Borrow Checker werkt en hoe die zich verhoudt tot het beheren van geheugen.
Waarde van de Borrow Checker
De Borrow Checker controleert tijdens het compileren van je programma, bijvoorbeeld dat een stukje geheugen niet meer gebruikt wordt nadat het is opgeruimd. Hierdoor voorkom je onder andere segfaults, omdat de Borrow Checker het compileren blokkeert en een foutmelding geeft als je iets probeert te doen wat niet veilig is. Hoewel deze meldingen van de Borrow Checker in het begin vervelend kunnen zijn om telkens tegen te komen, helpt het je op de lange termijn om betrouwbare en veilige code te schrijven zonder onverwachte geheugenfouten.
Meer en meer bedrijven nemen daarom Rust op in hun codebases. En ook zeker de grote tech bedrijven zoals: Google, Microsoft en Mozilla (waar Rust ook begonnen is). Wat ik dan extra interessant vind is dat er initieel wordt overgestapt naar Rust voor de extra veiligheid die de Borrow Checker geeft, maar dan ook nog blijkt dat het zorgt voor meer productiviteitten ten opzichte van C++ en dat men meer vertrouwen heeft in de code kwaliteit van Rust code.
Voordat we verder ingaan op de werking van de Borrow Checker, wil ik jullie meenemen naar hoe geheugenbeheer (stack en heap) van verschillende types werkt in Rust. Dit maakt namelijk ook duidelijker waarom de Borrow Checker bepaalde controles uitvoert.
De stack en de heap
De stack en de heap zijn abstracties die worden gebruikt om te beschrijven hoe geheugen wordt beheerd binnen een computerprogramma. De stack wordt vaak vergeleken met een stapel borden: nieuwe borden leg je op de stapel. Als je een bord nodig hebt, pak je die van de stapel. Dus het laatste bord dat je op de stapel legt, pak je als eerste er af. Dit heet Last-In, First-Out (LIFO).
Wanneer een functie wordt aangeroepen in je code, wordt er een nieuw stack frame op de stack geplaatst. Dat frame bevat onder andere de parameters, lokale variabelen en de plek waar het programma straks verder moet gaan zodra de functie klaar is. Elke nieuwe functieaanroep “stapelt” dus boven op het vorige frame, en zodra de functie klaar is, wordt het bovenste frame weer van de stack verwijderd (zoals met de stapels borden hierboven, dus LIFO). Hieronder is een schematische weergave, met bijbehorende code, van de stack.
fn bar(a: i32) {
let c = a + 1;
println!("bar: c = {}", c);
}
fn foo(x: i32) {
let y = x * 2;
bar(y);
}
fn main() {
let num = 10;
foo(num);
}
De heap is rommeliger dan de stack. Wanneer je iets op de heap wilt plaatsen, moet je eerst zoeken naar een plek met voldoende ruimte. Vervolgens plaats je de data op die plek. Om die data later weer terug te halen moet je weten waar je het hebt geplaatst, dus moet je bijhouden waar je iets geplaatst hebt en daarom heb je een soort van adres nodig. Deze twee extra stappen, zoeken naar plek en de data op die plek weer opvragen, kosten meer tijd dan het plaatsen van data op de stack. Hieronder is een schematische weergave, met bijbehorende code, van de heap.
fn main() {
let a = 5; // Primitive, staat op de stack
let b = Box::new(10); // Heap-allocated integer
let c = vec![1, 2, 3]; // Heap-allocated vector
}
graph LR subgraph Stack direction TB subgraph "main()" A[a: 5] B["b: Box -> 📦"] C["c: Vec -> 📦📦📦"] end end subgraph Heap direction TB I[Data] D[10] K[DATA] L[DATA] E[1] F[2] G[3] J[DATA] end B --> D C --> E C --> F C --> G
Rust-types op de stack en de heap
Alle primitive types in Rust gaan op de stack (integers, floating points, chars, arrays en tuples), maar ook Structs gaan op de stack. Mocht je deze waarden wel op de heap willen, dan kun je de Box<T>
gebruiken. Niet-primitieve types zoals Vectors, HashMaps en Strings worden op de heap opgeslagen. De belangrijkste reden hiervoor is dat deze types in grootte kunnen variëren: ze kunnen tijdens de uitvoering van een programma groeien of krimpen.
Maar hoe weet een computerprogramma waar die typen op de heap staan? Dat wordt gedaan door ernaar toe te wijzen met een pointer. En de pointer, die leeft weer op de stack. Hieronder is een schematische weergave van een simpel voorbeeld waar een integer (i32) en een pointer (&String) op de stack staan. En de String “Hello, world!” op de heap staat.
graph LR subgraph Stack direction TB A[Primitive: i32 = 42] B[Pointer: &String] end subgraph Heap direction TB C["String: "Hello, world!""] end B --> C
Werking van de Borrow Checker
Als we kijken naar Rust, is bovenstaande schematische weergave niet helemaal correct. In Rust gebruik je namelijk geen pointers, maar references.
graph LR subgraph Stack direction TB A[Primitive: i32 = 42] B[Reference: &String] end subgraph Heap direction TB C["String: "Hello, world!""] end B --> C
Een reference is een soort “slimme” pointer die onder toezicht staat van de Borrow Checker. De slimmigheid is dat een referentie zich aan bepaalde regels moet houden. Deze regels zijn de magie van de Borrow Checker en dus ook van Rust, hierdoor garandeer je dat je op de juiste manier omgaat met data in het computergeheugen.
De Borrow Checker doet dit door tijdens het compileren van je programma, bij te houden wie de Owner van de data is, of er Borrowers van de data zijn (dit zijn referenties) en of de Lifetime van de referenties klopt.
Ownership, Move en References
Ownership in Rust betekent dat er maar een variabele tegelijk eigenaar mag zijn van een stuk data. Wanneer je een andere variabele bindt aan die data, dan verplaatst de ownership zich, dit heet een move. Als je vervolgens de oorspronkelijke variabele probeert te gebruiken, zal de Borrow Checker tijdens het compileren een foutmelding geven. Omdat je de ownership hebt overgedragen, mag de oorspronkelijke variabele de data niet meer gebruiken.
Onderstaand code voorbeeld illustreert dat proces:
Dat levert deze compiler error:
error[E0382]: borrow of moved value: `x`
--> src/main.rs:7:22
|
2 | let x = vec![1, 2, 3];
| - move occurs because `x` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |
4 | let y = x;
| - value moved here
...
7 | println!("{:?}", x);
| ^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
4 | let y = x.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
Zoals je in de compiler error ziet, geeft het heel duidelijk aan dat bij let y = x;
de waarde movet, waardoor je x
daarna niet meer kunt gebruiken in println!("{:?}", x);
. Het geeft zelfs aan dat je het kan clonen en zo kan oplossen.
Een andere oplossing is door y
geen eigenaar te laten worden van de waarde maar er een referentie naar te laten zijn: let y = &x;
Met een referentie, doe je geen move maar een borrow. En dat is wel toegestaan.
In dit volgende voorbeeld doet de code iets soortgelijks, maar dan met Rust-primitive types.
Het enige wat verandert is het type van x
, maar dit werkt wel! Dat komt doordat primitive-types de Copy-trait implementeren. In plaats van dat er een Move plaatsvindt, wordt de waarde gekopieerd.
Let op: afhankelijk van de data types in een Tuple of Array wordt de Copy-trait wel geïmplementeerd maar wanneer een van de types niet de Copy-trait implementeert, dan zal bovenstaande niet werken want de waarde kan niet worden gekopieerd.
Deze verschillende werkingen van primitieve- en niet-primitieve-types zitten hem erin dat de primitieve-types op de Stack leven. Het is snel en efficiënt (in termen van tijd) om een waarde op de Stack te kopiëren. Daarentegen kost het kopiëren van niet-primitieve-types meer tijd, omdat ze op de Heap worden gekopieerd. Daarom kiest Rust er standaard voor om zulke waarden te moven in plaats van te kopiëren.
Als laatste, wat gebeurt er als je de data wilt veranderen? Als je meerdere referenties hebt naar data dan wil je niet dat de onderliggende data van die referenties wordt gewijzigd. Dat is nog iets wat de Borrow Checker controleert. Zie het voorbeeld hieronder:
fn main() {
let mut x = vec![1, 2, 3];
let y = &x;
let z = &mut x;
let z = z.push(4);
println!("{:?}", y);
println!("{:?}", z);
}
Hier is de compiler weer erg behulpzaam:
error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
--> src/main.rs:5:13
|
3 | let y = &x;
| -- immutable borrow occurs here
4 |
5 | let z = &mut x;
| ^^^^^^ mutable borrow occurs here
...
8 | println!("{:?}", y);
| - immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
Dit is een andere regel van de Borrow Checker: je kunt of één mutable reference hebben of meerdere immutable references, maar nooit allebei tegelijk. Dit zorgt ervoor dat onderliggende data van een referentie niet onverwacht kan wijzigen.
Samenvattend: de Borrow Checker controleert het Ownership van data, volgt de Moves van het Ownership en bewaakt het gebruik van mutable en immutable Referenties (Borrowing).
Kleine notitie: een ander belangrijke term in de Borrow Checker is Lifetimes. Die laat ik voor nu buiten beschouwing, ik licht ze toe zodra we ze daadwerkelijk tegenkomen
Nu we de Borrow Checker beter begrijpen, gaan we in de volgende post aan de slag met het bouwen van de HTTP server.
Mees