Sökresultat för

Referenser & pekare i Rust jämfört med C++

38 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Referenser & pekare i Rust jämfört med C++

Om du frågar en C++-programmerare vad en referens är blir svaret ofta "ett alias", och en pekare "en adress med en typ på". Rimlig gissning för Rust också — samma tecken, & och *, samma grundidé. Men gissningen håller bara i några rader kod: en Rust-referens går att peka om till ett helt annat värde, något som är omöjligt i C++, och kompilatorn håller reda på exakt hur länge varje lån får leva. Och unsafe visar sig inte vara någon genväg runt reglerna, utan en dörr rakt in i samma minnesbuggar som C++ alltid haft — use-after-free, oinitierat minne, allt fortfarande fullt möjligt, bara att man måste be om det uttryckligen.

Den här artikeln går igenom skillnaderna ett steg i taget: modifieringsbarhet, referenser och deras låneregler, råa pekare bakom unsafe, och till sist tre sätt att allokera på heapen i Rust, med C++ som jämförelsepunkt hela vägen.


Variabler i Rust

En variabel deklareras i Rust med satsen

let var: type = init

Typen kan vanligtvis utelämnas eftersom kompilatorn kan klura ut detta själv. Till skillnad mot de flesta andra programmeringsspråk är variabeln konstant, dvs icke modifieringsbar, eller på engelska immutable.

Om man däremot vill deklarera en variabel som kan modifieras, trycker man in mut efter let. Därmed blir den mutable.

let value1 = 42;
value1 += 1; //error[E0384]: cannot assign twice to immutable variable `value1`

let mut value2 = 42;
value2 += 1; 

I C++ är det ju tvärtom, man trycker in const för att få en variabel immutable.

int value1 = 42;
value1++;

int const value2 = 37; // "const int" works too
value2++; //error: increment of read-only variable ‘value2’

Referenser i Rust

Referenser finns i både Rust och C++, men skiljer sig åt på flera punkter, som vi kommer att se. Att deklarera en referens i Rust görs med satsen.

let var: &type = &target

Precis som för en vanlig variabel kan referens-typen utelämnas, eftersom kompilatorn kan från uttrycket &target lista ut att vi har en referens till ett värde av den typ target har.

Immutable refs

Notera att Rust använder och-tecknet (&) för att denotera en referens, vilket ju i C och C++ är adressoperatorn. I Rust är allting explicit; typer, operatorer med mera. Tja, i alla fall nästan - det finns förenklingar/genvägar. Man kommer åt värdet på en referens i Rust via stjärna (*) vilket ju är dereferens-operatorn också i C/C++.

let target: i32 = 42;
let refvar: &i32 = ⌖
println!("target={}, *ref={}", target, *refvar);
println!("target={}, ref={}", target, refvar); //auto-deref
target=42, *ref=42
target=42, ref=42

Mutable refs

Här ovan har vi enbart variabler som är immutable. Önskar man muterbarhet, behöver man "gödsla" med operatorn mut. I exemplet nedan har vi en mutable referens som ändrar värde på målvariabeln.

let mut value = 42;
print!("a) value={}", value);
let rf = &mut value;
println!(", ref={}", rf);

*rf = 37;
print!("b) ref={}", rf);
println!(", value={}", value);
a) value=42, ref=42
b) ref=37, value=37

En icke obetydlig semantisk skillnad mellan referenser i Rust respektive C++ är att i Rust är det en vanlig variabel med en plats och en storlek, medan i C++ är det ett alias som hanteras av kompilatorn. OK, bakom kulisserna i C++ så realiseras det som pekare, men det är ingenting vi kan använda oss av. I Rust fungerar referenser mer som pekare i C/C++, men med ganska tydliga semantiska skillnader.

Owning vs. Borrow

Äga och låna är två tydliga koncept i Rust. Varje värde har exakt en ägare, vilken ansvarar för att frigöra eventuella resurser i samband med att värdet faller ur sitt giltighetsområde (scope). Ägarskapet kan överföras till någon annan via tilldelning eller parameteröverföring. Detta sker implicit i Rust, medan det är explicit i C++ via std::move (tja, implicit i vissa fall med s.k. xvalue).

Det går också att låna ett värde (borrow) i Rust och det är för detta ändamål vi har referenser. Hur detta hanteras bestäms av följande regler:

  1. Ett värde kan lånas immutable ett godtyckligt antal gånger samtidigt.
  2. Ett värde kan lånas mutable exakt en gång i taget.
  3. Lånetyperna kan aldrig blandas - strikt antingen eller.
  4. Livslängden för ett lån får aldrig överstiga livslängden för värdet som lånas ut.

Ett lån varar tills referensvariabeln faller ur sitt giltighetsområde, eller, som vi ska se strax, tills lånet inte längre behövs. OK, här kommer ett belysande exempel:

let mut value: i32 = 42;
value += 1; //this is allowed

let rf: &i32 = &value;
println!("a) value={}, ref={}", value, *rf);

//rf += 2;
//error[E0368]: binary assignment operation `+=` cannot be applied to type `&i32`

//value += 3; //this is now forbidden
//error[E0506]: cannot assign to `value` because it is borrowed
println!("b) value={}, ref={}", value, *rf);
//                                     --- borrow later used here

print!("c) ref={}", *rf); //the borrow stops here
value += 4; //now, it's allowed again
println!(", value={}", value);
a) value=43, ref=43
b) value=43, ref=43
c) ref=43, value=47

Re-assignment

Precis som med pekare —rent allmänt— så kan referenser i Rust omtilldelas (re-assignment).

let mut first = 42;
print!("a) first={}", first);
let mut rf = &mut first;
println!(", ref={}", rf);

*rf = 37; //assign value to target
print!("b) ref={}", rf);
println!(", first={}", first);

let mut second = 17;
rf = &mut second; //assign new target
print!("c) ref={}", rf);
println!(", first={}", first);
a) first=42, ref=42
b) ref=37, first=37
c) ref=17, first=37

Detta är omöjligt i C++, utan en tilldelning propagerar till målvariabeln.

int first = 42;
int& ref = first;
std::println("a) first={}, ref={}", first, ref);

ref = 37; //assign value to target
std::println("b) first={}, ref={}", first, ref);

int second = 37;
ref = second; //also, assign value to target
std::println("c) ref={}", ref);
std::println("c) 1st={}, 2nd={}", first, second);
a) first=42, ref=42
b) first=37, ref=37
c) ref=37
c) 1st=37, 2nd=37

Composite Values

Hur fungerar det då med sammansatta värden, såsom en struct? Tja, på liknande sätt. Läs kodkommentarerna nedan noga, för att följa lånens livslängder.

#[derive(Debug)]
struct Point { x: f32, y: f32 }
//...
let mut pnt = Point { x: 5.0, y: 10.0 };
let rf: &Point = &pnt;
println!("a) value={:?}, ref={:?}", pnt, *rf);

//rf.x *= 2.5;
//^^^^^^^^^ `rf` is a `&` reference, so it cannot be written to
//error[E0594]: cannot assign to `rf.x`, which is behind a `&` reference

//pnt.x *= 2.71; //error[E0506]: cannot assign to `pnt.x` because it is borrowed
println!("b) value={:?}, ref={:?}", pnt, *rf);
//                                       -- borrow later used here

println!("c) ref={:?}", *rf); //the borrow stops here
pnt.x *= 2.71;
pnt.y *= 3.14;
println!("c) value={:?}", pnt);
a) value=Point { x: 5.0, y: 10.0 }, ref=Point { x: 5.0, y: 10.0 }
b) value=Point { x: 5.0, y: 10.0 }, ref=Point { x: 5.0, y: 10.0 }
c) ref=Point { x: 5.0, y: 10.0 }
c) value=Point { x: 13.55, y: 31.400002 }

Pekare i Rust

Även om Rust är designat på så vis att referenser är att föredra i det flesta fall, finns det också pekare i den mer klassiska bemärkelsen. Pekare i Rust fungerar på ett sätt som har en hel del likheter, men också skillnader, jämfört med C/C++.

Precis som för övriga typer av variabler i Rust måste man skilja mellan immutable respektive mutable. Syntaxen har vissa likheter med C/C++. Vill man ha den förstnämnda, används *const och för den sistnämnda *mut.

let value1 = 42;
let ptr1: *const i32 = &value1; //immutable ptr to immutable value

let mut value2 = 37;
let ptr2: *mut i32 = &mut value2; //mutable ptr to mutable value

Huvudargumentet för Rust är ju safety, vilket vi redan sett exempel på i min artikelserie. Men Rust är också ett pragmatiskt språk (precis som C++) så det finns ett sätt att temporärt stiga ur säkerhetsburen genom att utföra vissa operationer i ett unsafe block.

Så för pekare i Rust gäller att man kan fritt deklarera dessa, men vill man komma åt det de pekar på, måste detta göras inom ett unsafe-block.

Så här gör man med en immutable pekare.

let value = 42;
let ptr: *const i32 = &value;
unsafe {
    println!("val={}, ptr={}", value, *ptr);
}
val=42, ptr=42

Och så här med en mutable dito.

let mut value = 42;
let ptr: *mut i32 = &mut value;
unsafe {
    println!("a) val={}, ptr={}", value, *ptr);
    *ptr *= 10;
    println!("b) val={}, ptr={}", value, *ptr);
}
value += 12;
unsafe {
    println!("c) val={}, ptr={}", value, *ptr);
}
a) val=42, ptr=42
b) val=420, ptr=420
c) val=432, ptr=432

Heap-allokerade objekt

Pekare till stack-allokerade värden har ett begränsat användningsområde, men det är givetvis när man har heap-allokerade minnesblock/objekt som pekare är som mest användbara. Hur fungerar detta då i Rust, givet de begränsningar vi sett ovan?

Box

Om vi börjar med den rekommenderade metoden i Rust, är det att använda smarta pekare. Bibliotekstypen Box motsvarar std::unique_ptr i C++, men med en distinkt skillnad: Box::new allokerar alltid sitt minnesblock som en del av konstruktionen. unique_ptr kan även ta över ägarskapet av en redan allokerad rå-pekare (t.ex. unique_ptr<int> p(new int(42))) - något Box inte har en motsvarighet till. I övrigt fungerar de på samma sätt, när ett Box-objekt lämnar sitt giltighetsområde utförs dess drop-funktion (motsvarar destructor) vilken frigör minnesblocket. Med andra ord, RAII används också (flitigt) i Rust, precis som i C++. Skillnaden är syntaktisk: constructor vs. Type::new() respektive destructor vs. drop().

Så här kan det se ut:

fn scalar() {
    println!("--- scalar ---");
    let mut ptr: Box<i32> = Box::new(42);
    println!("a) ptr={}", *ptr);

    *ptr *= 10; //must deref explicit here
    println!("b) ptr={}", ptr); //auto-deref
} //ptr goes out of scope here, and it will drop the memory block


#[derive(Debug)] struct Point { x: f32, y: f32 }

fn composite() {
    println!("--- composite ---");
    let mut ptr = Box::new(Point {x: 5.5, y: 42.0});
    println!("a) ptr={:?}", *ptr);

    (*ptr).x *= 10.0; //explicit deref
    ptr.y *= 10.0;    //auto-deref
    println!("b) ptr={:?}", *ptr);
}
--- scalar ---
a) ptr=42
b) ptr=420
--- composite ---
a) ptr=Point { x: 5.5, y: 42.0 }
b) ptr=Point { x: 55.0, y: 420.0 }

Det finns ytterligare några smarta pekartyper i Rust, såsom Rc<T> och Arc<T>, men dessa får vänta till en annan artikel.

Så Box är en typ i standardbiblioteket, vilket innebär att Box är implementerad i Rust, vilket i sin tur innebär att på något sätt så har ju metoden new allokerat ett minnesblock och typens drop implementation innehåller Rust-kod för att frigöra densamma. Så helt klart går det att allokera minnesblock i Rust. Detta ska vi kika närmare på nu, via två olika vägar.

std::alloc

Först, via std::alloc vars funktioner är unsafe och därför måste hanteras inom ett unsafe-block. Här kommer ett exempel med en skalär.

use std::alloc::{Layout, alloc, dealloc};
use std::ptr::null_mut;
//. . .
let layout = Layout::new::<i32>();
unsafe {
    let mut ptr: *mut i32 = alloc(layout) as *mut i32;
    println!("a) ptr={} @ {}", *ptr, ptr as usize); //oops, UB both in Rust and C++

    *ptr = 42; //initialization
    println!("b) ptr={} @ {}", *ptr, ptr as usize);

    *ptr *= 10; //modification
    println!("c) ptr={} @ {}", *ptr, ptr as usize);

    dealloc(ptr as *mut u8, layout); //destruction
    println!("d) ptr={} @ {}", *ptr, ptr as usize); //oops, UB here as well

    ptr = null_mut(); //corresponds to nullptr in modern C++
    println!("e) ptr @ {}, null={}", ptr as usize, ptr.is_null());
}
a) ptr=0 @ 100359357848912
b) ptr=42 @ 100359357848912
c) ptr=420 @ 100359357848912
d) ptr=-1268007427 @ 100359357848912
e) ptr @ 0, null=true

Varning: Redan steg a) läser *ptr innan minnet skrivits till - alloc ger ett oinitierat minnesblock, så det är också UB, även om det för en enkel i32 sällan syns i praktiken. Värre är steg d), som läser *ptr efter att dealloc har frigjort minnet. Det är undefined behavior (UB) - precis lika illa som motsvarande use-after-free hade varit i C/C++. Att outputen ser ut som vanliga (om än skräpiga) heltal är ren tur; kompilatorn har i praktiken fria händer vid en UB-punkt, inklusive att optimera bort kod på sätt som gör resultatet oförutsägbart. Jag visar det ändå, för att det är exakt den typen av fotbojor unsafe tar bort - och som varken referenser eller säker Rust-kod någonsin hade tillåtit.

Notera: Raden *ptr = 42; fungerar här eftersom i32 saknar Drop-implementation och alla bitmönster är giltiga i32-värden. Det idiomatiska sättet att skriva till nyallokerat, oinitierat minne är annars std::ptr::write(ptr, 42) — en vanlig dereferens-tilldelning (*ptr = value) tolkas som "droppa det som redan finns på *ptr, skriv sedan value", och där finns inget giltigt att droppa än. För en typ med en riktig Drop-implementation hade *ptr = value alltså varit UB, av samma skäl som ovan.

Variabeln layout kommer lite otippat, men innehåller metadata om minnesblockets storlek i antalet bytes (size), plus information om blockets adressjustering (alignment).

pub struct Layout {
    size_: usize,
    align_: NonZeroUsize,
}

I C++17 kom nya varianter av operatorerna new och delete, vilka medgav att man kan skicka med förutom antalet bytes, också vilken adressjustering som ska användas. Så här kan det se ut:

#include <new>

//operator declaration:
operator new(size_t, std::align_val_t)
operator delete(void*, std::align_val_t) noexcept

//operator usage:
auto ptr = new (std::align_val_t{64}) int{12345};
//...usage of the memory block...
::operator delete(ptr, std::align_val_t{64});

(Anropet av delete görs som ett vanligt funktionsanrop eftersom det inte finns någon "placement"-variant av delete-uttrycket i C++ — grammatiken tillåter bara delete expr, inga extra argument. Denna operator delete-overload, en s.k. placement deallocation function, anropas annars enbart automatiskt av kompilatorn om konstruktorn i ett placement new-uttryck kastar ett undantag.)

Allokerar man ett minnesblock för en sammansatt datatyp, kan det se ut så här.

let layout = Layout::new::<Point>();
unsafe {
    let ptr: *mut Point = alloc(layout) as *mut Point;
    println!("a) ptr={:?} @ {}", *ptr, ptr as usize);

    (*ptr).x = 10.0;
    (*ptr).y = 5.0;
    println!("b) ptr={:?} @ {}", *ptr, ptr as usize);

    dealloc(ptr as *mut u8, layout);
    println!("c) ptr={:?} @ {}", *ptr, ptr as usize); //oops, UB
}
a) ptr=Point { x: -2.1955198e-7, y: 7e-45 } @ 100359357848912
b) ptr=Point { x: 10.0, y: 5.0 } @ 100359357848912
c) ptr=Point { x: -2.1955198e-7, y: 7e-45 } @ 100359357848912

Varning: Precis som i exemplet ovan är steg c) UB - dealloc har redan frigjort minnet när vi läser *ptr igen.

Notera: Samma sak här som i skalar-exemplet ovan - (*ptr).x = 10.0; och (*ptr).y = 5.0; skriver fält för fält till oinitierat minne. Fungerar eftersom f32 saknar Drop-implementation, men det idiomatiska sättet är std::ptr::write(ptr, Point { x: 10.0, y: 5.0 }) - skriv hela värdet på en gång istället för att gå via fältprojektion på oinitierat minne.

Notera: (*ptr).x ser ut som klassisk C, men det är faktiskt den enda syntaxen - Rust har ingen arrow-operator (->), och till skillnad från referenser gör punktoperatorn ingen auto-deref genom råa pekare. ptr.x (utan den explicita *) ger error[E0609]: no field x on type *mut Point, med kompilatorförslaget "try dereferencing it: (*ptr).x". Samma sak gäller metodanrop på råa pekare. Auto-deref via . är reserverat för referenser och smarta pekare (via Deref-traiten), inte för *const/*mut.

libc::malloc/free

Rust på Linux länkar dynamiskt libc (standardbiblioteket för C), vilket innebär att man har tillgång till "good old malloc/free". I gengäld måste lejonparten av koden avseende allokering och access ligga inom ett unsafe-block.

#[derive(Debug)]
struct Point { x: f32, y: f32 }

pub fn main() {
    unsafe {
        let mut ptr: *mut Point = libc::malloc( size_of::<Point>() ) as *mut Point;
        if ptr.is_null() {
            panic!("malloc failed");
        }
        (*ptr).x = 10.0;
        (*ptr).y = 5.0;
        println!("a) ptr={:?} @ {}", *ptr, ptr as usize);

        libc::free(ptr as *mut libc::c_void);
        println!("b) ptr={:?} @ {}", *ptr, ptr as usize); //oops, UB

        ptr = 0 as *mut Point;
        println!("c) ptr: null={} @ {}", ptr.is_null(), ptr as usize);
    }
}
a) ptr=Point { x: 10.0, y: 5.0 } @ 94361111567696
b) ptr=Point { x: 7.3198564e17, y: 7e-45 } @ 94361111567696
c) ptr: null=true @ 0

Varning: Samma sak här - steg b) läser *ptr efter libc::free, alltså UB av samma skäl som i std::alloc-exemplen ovan.


Sammanfattning

Rust och C++ delar vokabulär - referenser, pekare, mutability - men semantiken skiljer sig rejält under ytan. I Rust är immutable default, och en referens (&) är en fullvärdig variabel med egen plats i minnet, inte bara ett kompilatoralias som i C++. Det är också därför en Rust-referens går att peka om (re-assignment), medan en C++-referens är låst till sitt mål för alltid. Ownership- och borrow-reglerna - ett mutable-lån åt gången, aldrig blandat med immutable-lån, och lånets livslängd får aldrig överstiga värdets - är kompilatorkontrollerade och stoppar hela kategorier av buggar redan vid cargo build, något C++ helt enkelt inte gör åt dig.

Pekare i klassisk mening (*const/*mut) finns kvar i Rust, men är instängda bakom unsafe-block så fort man vill dereferencera dem. Samma story gäller heap-allokering: Box är den rekommenderade vägen och sköter frigörande automatiskt via drop, medan std::alloc och libc::malloc/free ger samma råa kontroll - och samma fallgropar - som i C. Use-after-free och läsning av oinitierat minne är fullt möjligt även i Rust; skillnaden är att språket tvingar en att skriva unsafe för att öppna dörren till dem.


Källkoden

All kod i artikeln finns körklar i ett eget GitHub-repo, länkat sist i länksamlingen nedan.

Rust-sidan byggs med Cargo (varje exempel är ett eget [[bin]]):

cargo build
cargo run --bin reference_demo

C++-sidan byggs med CMake:

cmake -B build-cxx -G Ninja
cmake --build build-cxx
./build-cxx/reference_demo