Flera programspråk har utmanat C++ som systemutvecklingsspråk utan att riktigt lyckas, förutom Rust. Rust har de senaste åren seglat upp som en seriös utmanare till C++, så till den milda grad att man numera kan implementera moduler för Linux-kärnan i Rust.
Detta har uppmärksammats av standardiseringskommittén för C++ och man försöker nu bemöta detta under den nya parollen Safety. Arbetet har startat i samband med standarden C++26 och till att börja med arbetar man med att kartlägga och identifiera alla s.k. Undefined Behaviour (UB) i språket. UB innebär att kompilatorn är "fri" att göra vad den vill, vilket för det mesta innebär att inte göra något alls. Poängen är att olika kompilatorer kan göra olika val, inklusive olika versioner av samma kompilator.
Safety utgör den mest framträdande USP:en (Unique Selling Point) för Rust, så man förstår att det är allvar denna gång.
Begreppet Safety syftar på minnessäkerhet, vilket innebär att ett program aldrig kan läsa eller skriva utanför det minne det faktiskt äger och som fortfarande är giltigt. Det utesluter en hel familj av klassiska C++-buggar: hängande pekare (dangling pointers), användning efter frigöring (use-after-free), dubbel frigöring (double-free), buffertöverskridningar (buffer overflow) och läsning av oinitierat minne. Till det kommer trådsäkerhet, dvs. att två trådar inte kan skriva till samma data samtidigt utan synkronisering (data race). Fem ur den familjen tar jag upp här.
Skillnaden mot C++ är inte vilka regler som gäller — de är i praktiken desamma — utan vem som ser till att de följs. I C++ vilar ansvaret på programmeraren, kodgranskningen och i bästa fall en sanitizer som råkar trigga vid rätt tillfälle. I Rust är reglerna inbyggda i typsystemet och kontrolleras av kompilatorn vid varje bygge.
TL;DR De buggar som titt som tätt dyker upp i C++-program stoppas av Rust-kompilatorn, när man skrivit om motsvarande program från C++ till Rust. Poängen är determinism, inte att "Rust är säkert" och "C++ är det inte".
Jag kommer i denna artikel gå igenom följande problem:
- Objektet du redan gett bort (use-after-move)
- Referensen som överlevde sin ägare (dangling references)
- Vektorn som flyttade sig under fötterna (iterator-invalidation)
- Variabeln som aldrig fick ett värde (uninitialized read)
- Pekaren som pekade på ingenting (null pointer)
Varje problem beskriver jag enligt följande indelning:
- Kort beskrivning
- C++ koden
- Kompilering och utskrifter från exekveringen
- Varför buggen dyker upp
- Motsvarande kod i Rust
- Rust kompilering med utskrifter
- Hur man kan förebygga problemet i C++, via extra kompileringsflaggor eller andra verktyg
1. Objektet du redan gett bort
C++ kopierar som standard och flyttar när du ber om det. Rust gör tvärtom. Det låter som en detalj i språkdesignen, men det är den som avgör vem som håller reda på när ett objekt inte längre är ditt.
Ett std::move lämnar källobjektet i ett tillstånd som standarden kallar valid but
unspecified. Värt att läsa en gång till: inte ogiltigt och inte förstört — giltigt,
men du får inte anta något om innehållet. Att läsa det är alltså inte UB. Det är fullt
lagligt, det kompilerar, och det säger ingenting om att du precis läst ett tomt skal.
struct User {
std::string name;
int id;
};
auto register_user(User u) -> void {
std::cout << "registering " << u.name << " (id " << u.id << ")\n";
}
auto main() -> int {
auto user = User{"Ada Lovelace", 1815};
register_user(std::move(user));
// ...plenty of code later, or after an innocent refactoring...
std::cout << "user is now: \"" << user.name << "\" (id " << user.id << ")\n";
std::cout << "name.size() = " << user.name.size() << "\n";
}
Kompilering och körning:
$ g++ -std=c++23 -Wall -Wextra use-after-move.cxx -o uam-c++ && ./uam-c++
registering Ada Lovelace (id 1815)
user is now: "" (id 1815)
name.size() = 0
Inte en enda varning, och programmet kraschar inte. Men se efter vad som faktiskt står
där: objektet är inte tomt, det är halvtomt. name flyttades och lämnade en tom sträng
efter sig. id är en int som inte har någon flyttkonstruktor att flytta med, så den
kopierades och originalet ligger kvar orört på 1815.
Det är just den halvheten som gör buggen svår att se. Ett objekt som är helt tomt ser trasigt ut och man börjar leta direkt. Ett objekt där id:t stämmer men namnet är tomt ser istället ut som ett datafel någonstans längre upp i kedjan — och då letar man på fel ställe.
Den kompilatorgenererade flyttkonstruktorn flyttar medlem för medlem. Vilka medlemmar som töms beror alltså helt på vilka typer de råkar ha, inte på något beslut du fattat. Byt
int idmotstd::string idoch utskriften blir en annan.
Ingen skriver den här buggen med flit. Den uppstår när någon lägger till ett std::move i
ett optimeringssvep, eller när en funktion byter signatur från const User& till User
och alla anropsställen "rättas" utan att någon läser vad som händer efter anropet. Koden
runtomkring ändrades inte — den bara betyder något annat nu.
Samma program i Rust:
struct User {
name: String,
id: u32,
}
fn register_user(u: User) {
println!("registering {} (id {})", u.name, u.id);
}
fn main() {
let user = User { name: String::from("Ada Lovelace"), id: 1815 };
register_user(user);
// ...plenty of code later, or after an innocent refactoring...
println!("user is now: \"{}\" (id {})", user.name, user.id);
}
Notera att det inte finns något std::move här. register_user tar sin parameter by
value, och i Rust betyder det flytt — inget behöver sägas ut. Det blir heller ingen körning:
$ rustc --edition 2024 use-after-move.rs -o uam-rust
error[E0382]: borrow of moved value: `user`
--> use-after-move.rs:21:57
|
16 | let user = User { name: String::from("Ada Lovelace"), id: 1815 };
| ---- move occurs because `user` has type `User`, which does not implement the `Copy` trait
17 |
18 | register_user(user);
| ---- value moved here
...
21 | println!("user is now: \"{}\" (id {})", user.name, user.id);
| ^^^^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `register_user` to borrow instead
if owning the value isn't necessary
Två detaljer värda att stanna vid. Den första: markören står under user.id — exakt det
fält som C++ lämnade kvar med rätt värde och som därför aldrig hade fått dig att misstänka
något.
Den andra: note:-raden är inte en gissning utan hela lösningen. Funktionen behöver
ju bara läsa användar-objektet, så den kan låna istället för att flytta.
Att låna i Rust gör man med liknande syntax som referenser/pekare i C++:
- Parameter-deklaration:
register_user(u: &User) - Argument-överföring:
register_user(&user)
Notera att argumentet är explicit (2), vilket är en genomgående egenskap för Rust, inga implicita transformeringar.
fn register_user(u: &User) { // <--- (1)
println!("registering {} (id {})", u.name, u.id);
}
fn main() {
let user = User { name: String::from("Ada Lovelace"), id: 1815 };
register_user(&user); // <--- (2)
println!("user is still: \"{}\" (id {})", user.name, user.id);
}
$ rustc --edition 2024 use-after-move-fixed.rs -o uam-fixed-rust && ./uam-fixed-rust
registering Ada Lovelace (id 1815)
user is still: "Ada Lovelace" (id 1815)
Kan C++ hitta den åt oss?
Här är det ärliga svaret att g++ inte gör det, oavsett hur mycket man skruvar på reglagen. Följande gav noll diagnostik på programmet ovan:
| Försök | Resultat |
|---|---|
-Wall -Wextra |
inget |
-Wall -Wextra -Wpedantic -Wshadow -Wconversion |
inget |
-fanalyzer |
inget |
-fsanitize=address,undefined |
kör rent |
-O2 |
samma utskrift |
Och sanitizers gör inte fel som håller tyst. De letar efter odefinierat beteende, och use-after-move är inte odefinierat beteende — det är ett fullt lagligt program som råkar läsa ett värde du inte längre borde bry dig om. Det finns helt enkelt inget att rapportera.
Att
-fanalyzertiger betyder inte att den är avstängd. Planterar man en dubbelfree()i samma fil hittar den den direkt, med CWE-nummer och allt. Den kan bara inte se den här sortens bugg.
Men det finns ett verktyg som hittar den, och rättvist nog gör det ett bra jobb: clang-tidy
har en check vid namn bugprone-use-after-move som modellerar flyttsemantik.
$ clang-tidy --checks='-*,bugprone-use-after-move' use-after-move.cxx -- -std=c++23
warning: 'user' used after it was moved [bugprone-use-after-move]
26 | std::cout << "user is now: \"" << user.name << "\" (id " << user.id << ")\n";
| ^
note: move occurred here
23 | register_user(std::move(user));
| ^
Prick rätt, och med samma två punkter som rustc pekade ut — var värdet flyttades och var det lästes. Så påståendet "C++ kan inte hitta det här" vore helt enkelt falskt.
Två saker skiljer ändå. Den första är att jag var tvungen att be om checken. Kör man bara
clang-tidy rakt av på filen sägs ingenting alls, för bugprone-use-after-move ingår inte
i standarduppsättningen — den består av 134 checkar som allihop är clang-analyzer-*.
Den andra är mer intressant. Flytta bara std::move ett steg ner, bakom en
referensparameter:
auto hand_over(User& u) -> void {
register_user(std::move(u));
}
auto main() -> int {
auto user = User{"Ada Lovelace", 1815};
hand_over(user);
std::cout << "user is now: \"" << user.name << "\" (id " << user.id << ")\n";
}
$ clang-tidy --checks='-*,bugprone-use-after-move' use-after-move-cross-fn.cxx -- -std=c++23
$ g++ -std=c++23 -Wall -Wextra use-after-move-cross-fn.cxx -o uam-crossfn-c++ && ./uam-crossfn-c++
registering Ada Lovelace (id 1815)
user is now: "" (id 1815)
Ingen diagnostik alls, och exakt samma felaktiga utskrift. Checken arbetar inom en funktion i taget, så en enda funktionsgräns räcker för att den ska tappa spåret.
Och det, snarare än "Rust hittar buggar som C++ missar", är den verkliga skillnaden. Analysen i clang-tidy är ett separat verktyg med egen konfiguration, ett eget körsteg i bygget och en räckvidd som tar slut vid närmaste funktionsanrop. Rusts motsvarighet är inte en analys utan en regel i typsystemet: en flyttad variabel är flyttad, oavsett hur många funktionsgränser flytten gömde sig bakom, och kompilatorn tillämpar det på varje bygge utan att bli tillfrågad.
2. Referensen som överlevde sin ägare
Det här är den stora klassen. När man går igenom säkerhetshål i C- och C++-kod är use-after-free den enskilt vanligaste posten, och det är den bugg som Rusts lånekontroll (the borrow checker) i praktiken byggdes för.
Problemet är alltid detsamma: någon behåller en pekare eller referens till data som en annan part äger, och ägaren dör först. Pekaren vet ingenting om det. Den pekar fortfarande på en adress, adressen är fortfarande läsbar, och minnet innehåller ofta fortfarande det gamla värdet — ända tills det inte gör det längre.
struct Reading {
int celsius;
};
auto main() -> int {
const Reading* hottest = nullptr;
{
auto readings = std::vector<Reading>{
{18}, {21}, {19}, {23}, {22}, {25}, {24}, {31}, {29}, {26}, {20}, {17},
};
hottest = &*std::ranges::max_element(readings, {}, &Reading::celsius);
std::cout << "inside scope: " << hottest->celsius << " C\n";
}
// readings är borta. hottest är det inte.
std::cout << "after scope: " << hottest->celsius << " C\n";
auto other = std::vector<Reading>(12, Reading{0});
std::cout << "after realloc: " << hottest->celsius << " C\n";
}
Kompilering och körning:
$ g++ -std=c++23 -Wall -Wextra dangling-reference.cxx -o dr-c++ && ./dr-c++
inside scope: 31 C
after scope: 31 C
after realloc: 0 C
Rad två är den intressanta. Där läser vi minne som redan lämnats tillbaka till
allokatorn — och får rätt svar. Ingen krasch, ingen varning, inget som antyder att något
är fel. Blocket ligger kvar orört tills någon annan begär minne, och först på rad tre, när
other fått samma block, syns det att något är trasigt.
Och nu det som gör den här buggen till just en heisenbug. Samma program, samma dator, samma
kompilator, enda skillnaden är ett -O2:
$ g++ -std=c++23 -Wall -Wextra -O2 dangling-reference.cxx -o dr-c++ && ./dr-c++
inside scope: 31 C
after scope: 31 C
after realloc: 31 C
Nu är felet helt osynligt. Programmet skriver ut rätt värde hela vägen, i alla tre fallen. Ett program som beter sig korrekt i release och avvikande i debug — eller tvärtom — är precis den sortens bugg som överlever kodgranskning, testsvit och en veckas felsökning.
Att värdet överlevde frigöringen är ingen slump utan en glibc-implementationsdetalj: allokatorn skriver sin egen bokföring i blockets första bytes. Vår
Readinglåg en bit in i vektorn och klarade sig. Hade toppvärdet legat först i listan hade rad två skrivit ut skräp direkt. Buggen beror alltså på var i en datastruktur värdet råkar ligga.
Ingen skriver den här koden i ett enda svep heller. Den uppstår när en variabel lyfts ut ur en loop för att "återanvändas", eller när någon sparar undan en pekare till det bästa resultatet och en refaktorering senare flyttar in datat i en snävare scope.
Vad är en heisenbug?
Ordet är en ordlek på efternamnet till fysikern Werner Heisenberg, som utvecklade teorin för osäkerhetsprincipen inom partikelfysiken, vilket kort sammanfattat innebär att det
för ett objekt inte går att samtidigt känna till både position och rörelsemängd med en godtyckligt hög grad av noggrannhet utan att det finns en bestämbar undre gräns för osäkerheten. Detta innebär att en mätning av en partikels position nödvändigtvis orsakar en påverkan (störning) av partikelns rörelsemängd.
Överfört till programmering innebär det att en bugg kan manifestera sig, men försvinna när man börjar leta efter den via t.ex. spårutskrifter — eller tvärtom!
Tillbaka till Rust
Samma program skrivet i Rust, ser ut så här:
struct Reading {
celsius: i32,
}
fn main() {
let hottest;
{
let readings = vec![
Reading { celsius: 18 }, Reading { celsius: 21 }, /* ... */
Reading { celsius: 31 }, /* ... */ Reading { celsius: 17 },
];
hottest = readings.iter().max_by_key(|r| r.celsius).unwrap();
println!("inside scope: {} C", hottest.celsius);
}
// readings är borta. hottest är det inte.
println!("after scope: {} C", hottest.celsius);
}
Några snabba kommentarer till syntaxen ovan. vec![...] är ett makro, precis som println!,
vilket innebär att kompilatorn genererar kod. |r| r.celsius utgör ett lambda-uttryck, där
r utgör parameter och får sin typ från kontexten. max_by_key() returnerar en Option,
eftersom en tom lista saknar största element, och unwrap() på slutet plockar ut värdet ur
den — eller får programmet att panika om det inte finns något. Mer om den saken i avsnitt 5.
$ rustc --edition 2024 dangling-reference.rs -o dr-rust
error[E0597]: `readings` does not live long enough
--> dangling-reference.rs:20:19
|
14 | let readings = vec![
| -------- binding `readings` declared here
...
20 | hottest = readings.iter().max_by_key(|r| r.celsius).unwrap();
| ^^^^^^^^ borrowed value does not live long enough
21 | println!("inside scope: {} C", hottest.celsius);
22 | }
| - `readings` dropped here while still borrowed
...
25 | println!("after scope: {} C", hottest.celsius);
| --------------- borrow later used here
Läs felmeddelandet som en tidslinje, för det är vad det är. Här skapades ägaren, här lånades den ut, här dog ägaren medan lånet fortfarande gällde, och här användes lånet. Fyra positioner i källkoden som tillsammans beskriver exakt varför det inte går ihop. Det är, kan man tycka, betydligt trevligare än tre rader utskrift där två är rätt.
Rättningen är i det här fallet trivial — deklarera readings i yttre scope, så lever ägaren
längre än lånet:
$ rustc --edition 2024 dangling-reference-fixed.rs -o dr-fixed && ./dr-fixed
inside scope: 31 C
after scope: 31 C
Kan C++ hitta den åt oss?
Här är svaret ett helt annat än i förra avsnittet, och betydligt bättre för C++. Till skillnad från use-after-move är det här odefinierat beteende (UB), och då har sanitizers något att arbeta med. AddressSanitizer sätter fingret exakt rätt:
$ g++ -std=c++23 -g -fsanitize=address dangling-reference.cxx -o dr-asan && ./dr-asan
ERROR: AddressSanitizer: heap-use-after-free on address 0x7881021e002c
READ of size 4 at 0x7881021e002c thread T0
#0 ... in main dangling-reference.cxx:29
freed by thread T0 here:
... std::vector::~vector() ...
#7 ... in main dangling-reference.cxx:26
Både läsningen och frigöringen, med radnummer, plus destruktorn som utförde den. Det är i praktiken samma information som rustc gav, och den som påstår att C++ saknar verktyg för det här har inte provat.
Statisk analys hittade den däremot inte, hur jag än bad om det:
| Försök | Resultat |
|---|---|
-Wall -Wextra -Wpedantic |
inget |
-Wdangling-pointer=2 -Wuse-after-free=3 |
inget |
-fanalyzer |
inget |
clang-tidy med bugprone-* och clang-analyzer-* |
inget |
-fsanitize=address |
hittar den, exakt |
Så ASan är svaret — med ett förbehåll, och det är hela skillnaden. En sanitizer rapporterar vad programmet gjorde, inte vad det kunde ha gjort. Lägg samma läsning bakom en gren:
if (argc > 1) {
std::cout << "peak: " << hottest->celsius << " C\n";
} else {
std::cout << "summary mode, nothing to report\n";
}
$ ./dr-path
summary mode, nothing to report
$ ./dr-path --verbose
ERROR: AddressSanitizer: heap-use-after-free ...
Samma binär, samma sanitizer, två utfall. Grönt bygge, grön testsvit, och buggen följer med ut i den gren ingen råkade köra. Lånekontrollen i Rust bryr sig inte om vilken gren som körs, eftersom den aldrig kör någonting — den jämför livstider i källkoden och täcker därför även de vägar din testsvit aldrig besöker.
3. Vektorn som flyttade sig under fötterna
Om man skulle rösta fram den mest klassiska C++-buggen av alla vore det här en stark kandidat: att lägga till element i en behållare medan man itererar över den. Alla har gjort det, och de flesta har gjort det utan att märka något — för den fungerar. Ända tills den inte gör det.
En std::vector lagrar sina element i ett sammanhängande minnesblock. Så länge det finns
plats kvar lägger push_back bara till på slutet och ingenting rör sig. När det tar slut
allokeras ett nytt, större block, allt innehåll flyttas dit och det gamla lämnas tillbaka.
I samma ögonblick pekar loopens iterator på minne som inte längre tillhör vektorn.
Det avgörande är att huruvida det händer beror på hur mycket kapacitet som råkade finnas — alltså på hur stort indatat är.
auto clamp_and_flag(std::vector<int> readings) -> std::vector<int> {
readings.reserve(8); // headroom for the alerts
for (auto& r : readings) {
if (r > 30) {
readings.push_back(-r); // may reallocate
r = 30; // clamp to the maximum
}
}
return readings;
}
auto main() -> int { // print() är en lambda som skriver ut vektorn, se repot
print("3 readings: ", clamp_and_flag({18, 31, 22}));
print("9 readings: ", clamp_and_flag({18, 31, 22, 25, 33, 19, 28, 21, 35}));
}
Funktionen ska göra två saker med varje mätvärde över 30: lägga till en varningspost i
listan och kapa värdet till 30. Den som skrev den la dit ett reserve(8) för att slippa
omallokeringar, vilket var precis rätt tanke.
$ g++ -std=c++23 -Wall -Wextra iterator-invalidation.cxx -o ii-c++ && ./ii-c++
3 readings: 18 30 22 -31
9 readings: 18 31 22 25 33 19 28 21 35 -31 -549833798 -33 -35
Första raden är korrekt: 31 har blivit 30 och varningen -31 ligger sist. Tre värden
plus en varning ryms i de åtta reserverade platserna, ingenting flyttar sig, allt stämmer.
Andra raden är samma funktion med sex värden till. Nu räcker inte reserveringen, vektorn
omallokerar vid första varningen — och därefter pekar r in i det frigjorda blocket. Alla
kapningar skrivs dit. 31, 33 och 35 ligger kvar oförändrade i resultatet.
Notera att programmet inte kraschar och inte varnar. Det svarar. Det svarar bara fel, med en lista som ser fullt rimlig ut för den som inte råkar veta att inget värde skulle vara över 30.
Skräpelementet
-549833798i mitten kommer inte att se likadant ut hos dig. Jag byggde om programmet tolv gånger: de okapade värdena kom varje gång, medan skräpposten dök upp i åtta av tolv, med nytt värde varje gång och ibland två stycken. Den delen är UB i sin renaste form. De okapade värdena är däremot lika stabila som en riktig bugg ska vara.
Och där är hela problemet med den här buggen. Den kom inte in i koden i det skick vi ser
den. Den skrevs när reserve(8) faktiskt räckte, testades mot en handfull mätvärden, och
fungerade felfritt i åratal. Sedan växte indatat.
Samma sak i Rust:
fn clamp_and_flag(mut readings: Vec<i32>) -> Vec<i32> {
readings.reserve(8);
for r in &mut readings {
if *r > 30 {
readings.push(-*r);
*r = 30;
}
}
readings
}
$ rustc --edition 2024 iterator-invalidation.rs -o ii-rust
error[E0499]: cannot borrow `readings` as mutable more than once at a time
--> iterator-invalidation.rs:11:13
|
9 | for r in &mut readings {
| -------------
| |
| first mutable borrow occurs here
| first borrow later used here
10 | if *r > 30 {
11 | readings.push(-*r);
| ^^^^^^^^ second mutable borrow occurs here
Lägg märke till hur lite kompilatorn behöver bry sig om för att komma fram till det.
Den resonerar inte om kapacitet, omallokering eller hur stort indatat är. Den konstaterar
att loopen lånat ut vektorn föränderligt för hela sin varaktighet, och att push vill låna
den en gång till. Två samtidiga föränderliga lån, alltså nej.
Det är samma regel som gör buggen omöjlig: den som itererar äger vektorn så länge loopen
varar, och ingen annan får röra den under tiden. reserve(8) hade inte hjälpt, och en
miljon element hade inte stjälpt.
Här kommer vi till de primära budorden i Rust:
- Du får ha flera immutable borrows samtidigt.
- Du får ha högst en mutable borrow i taget.
- Du får aldrig blanda borrow-typer — det är antingen eller.
Rättningen är att dela upp arbetet i två faser — kapa och samla varningarna, lägg sedan till dem — vilket också är vad man borde ha skrivit i C++:
fn clamp_and_flag(mut readings: Vec<i32>) -> Vec<i32> {
let mut alerts = Vec::new();
for r in &mut readings {
if *r > 30 {
alerts.push(-*r);
*r = 30;
}
}
readings.extend(alerts);
readings
}
$ rustc --edition 2024 iterator-invalidation-fixed.rs -o ii-fixed && ./ii-fixed
3 readings: [18, 30, 22, -31]
9 readings: [18, 30, 22, 25, 30, 19, 28, 21, 30, -31, -33, -35]
Andra raden är vad C++-programmet skulle ha skrivit ut. Tre kapade värden, tre varningar, inget skräp.
Kan C++ hitta den åt oss?
Ja, och den här gången med två olika verktyg. -D_GLIBCXX_DEBUG byter ut libstdc++:s
behållare mot kontrollerade varianter och avbryter direkt:
Error: attempt to increment a singular iterator.
AddressSanitizer pekar istället ut den förlorade skrivningen, med funktion och radnummer:
ERROR: AddressSanitizer: heap-use-after-free
WRITE of size 4 at 0x744aa6fe0014 thread T0
#0 ... in clamp_and_flag(std::vector) iterator-invalidation.cxx:19
Statisk analys hittade som vanligt ingenting — -fanalyzer är tyst, och clang-tidy med
bugprone-*, clang-analyzer-* och misc-* har bara åsikter om intern länkning.
Men nu kommer det som gör det här avsnittet värt att läsa två gånger. Båda verktygen är kapacitetsberoende, precis som buggen. Kompilera bara anropet med tre mätvärden och kör det under vilket som helst av dem:
$ g++ -std=c++23 -D_GLIBCXX_DEBUG small_only.cxx -o small_dbg && ./small_dbg
18 30 22 -31
$ g++ -std=c++23 -g -fsanitize=address small_only.cxx -o small_asan && ./small_asan
18 30 22 -31
Rent i båda fallen, avslut 0. Koden är exakt lika trasig som förut — det råkade bara inte ske någon omallokering som avslöjade den. En testsvit byggd på små testdata går alltså igenom med full instrumentering påslagen och släpper ändå igenom buggen.
Det är därför "vi kör med sanitizers i CI" inte är samma sak som "vi har inte den här buggen". Sanitizern granskar körningen, och körningen bestäms av testdatat. rustc granskar koden, och avvisar den innan något körts — då spelar det ingen roll hur stort indatat blev i produktion.
4. Variabeln som aldrig fick ett värde
Den här är den äldsta av dem alla, och den enda som finns kvar oförändrad sedan C. En variabel deklareras utan värde, någon läser den innan den skrivits, och det man får ut är vad som råkade ligga i minnet.
Låt oss börja med skolboksexemplet, för det gömmer en överraskning:
auto main() -> int {
int sum;
for (auto i = 0; i < 5; ++i) {
sum += i;
}
std::cout << sum << "\n";
}
Nio rader kod och en uppenbar bugg. Frågan är vad kompilatorn säger om den:
| Bygge | Diagnostik | Utskrift |
|---|---|---|
-O0 |
ingen | 29339 |
-O1 |
warning: 'sum' is used uninitialized [-Wuninitialized] |
10 |
-O2 |
warning: 'sum' is used uninitialized [-Wuninitialized] |
10 |
Läs tabellen en gång till, för den är bakvänd på ett sätt som är lätt att missa.
Med optimering påslagen varnar kompilatorn — och programmet skriver ut 10, alltså rätt
svar. Utan optimering är kompilatorn tyst, och det är då programmet skriver ut skräp.
Varningen och felet uppträder alltså i olika byggen.
Förklaringen är att -Wuninitialized bygger på optimerarens dataflödesanalys. Vid -O0
körs den inte, så det finns inget underlag att varna utifrån. Och -O0 är, för de flesta
av oss, det bygge vi felsöker i.
Siffran
29339är inte reproducerbar — jag fick 29339, 30084, 31487, 31538 och 32489 i fem körningar. Att-O1och-O2ger10varje gång är däremot stabilt: optimeraren behandlar det oinitierade värdet som noll och räknar ihop 0+1+2+3+4.
Så långt exemplet ur läroboken. I riktig kod ser buggen sällan ut så. Den ser ut så här:
struct Stats {
int count;
double mean;
double peak; // added later
};
auto summarise(const std::vector<double>& xs) -> Stats {
Stats s;
s.count = static_cast<int>(xs.size());
auto total = 0.0;
for (auto x : xs) total += x;
s.mean = total / s.count;
// s.peak is never assigned
return s;
}
Stats s; är default-initialisering, vilket för de här medlemstyperna betyder "lämna
bytesen ifred". Två fält tilldelas, det tredje inte. Ingen skrev buggen — någon la till
peak i strukten ett halvår senare, uppdaterade de anropsställen hen hittade, och missade
det här.
$ g++ -std=c++23 -Wall -Wextra uninitialized-read.cxx -o ur-c++ && ./ur-c++
count = 4
mean = 24
peak = 6.35176e-310
Ingen varning, varken vid -O0, -O1 eller -O2. Och den här gången hjälper det inte att
slå på optimering.
I Rust finns ingen default-initialisering att göra fel med. En strukturliteral måste ange samtliga fält:
Stats {
count: xs.len(),
mean: total / xs.len() as f64,
// peak is never assigned
}
$ rustc --edition 2024 uninitialized-read.rs -o ur
error[E0063]: missing field `peak` in initializer of `Stats`
--> uninitialized-read.rs:15:5
|
15 | Stats {
| ^^^^^ missing `peak`
Det intressanta är inte att det här specifika programmet stoppas, utan vad regeln gör med
scenariot som skapade buggen. Den dagen någon lägger till ett fält i Stats slutar
varenda konstruktion av en Stats i hela kodbasen att kompilera, tills alla har tagit
ställning. Det finns inget anropsställe att missa.
Skalärfallet ger E0381 istället, och det är värt att citera för resonemangets skull:
error[E0381]: used binding `sum` is possibly-uninitialized
|
5 | let mut sum: i32;
| ------- binding declared here but left uninitialized
7 | for i in 0..5 {
| ---- if the `for` loop runs 0 times, `sum` is not initialized
11 | println!("{sum}");
| ^^^ `sum` used here but it is possibly-uninitialized
Notera mittenraden. Kompilatorn har inte bara konstaterat att variabeln saknar värde, den har identifierat under vilken förutsättning det blir ett problem: om loopen kör noll varv.
Och det leder till en poäng som ofta missförstås. Rust kräver inte att du initierar vid deklarationen. Rust kräver att varje väg fram till första läsningen har tilldelat — och kontrollerar det istället för att anta det:
fn main() {
let limit: i32;
if std::env::args().count() > 1 {
limit = 100;
} else {
limit = 10;
}
println!("limit = {limit}");
}
Det kompilerar. Ta bort endera grenen och det blir E0381.
Kan C++ hitta den åt oss?
Här gör C++ sin bästa insats i hela artikeln. -fanalyzer, som varit tyst i de tre
föregående avsnitten, sätter fingret exakt rätt:
$ g++ -std=c++23 -Wall -Wextra -fanalyzer uninitialized-read.cxx -o /dev/null
warning: use of uninitialized value 's.Stats::peak' [CWE-457] [-Wanalyzer-use-of-uninitialized-value]
36 | << "peak = " << s.peak << "\n";
| ~~^~~~
Rätt fält, rätt rad och ett CWE-nummer på köpet. Dessutom — och det är minst lika viktigt — är den tyst på kod där alla fält faktiskt tilldelas. Inga falsklarm.
| Försök | Resultat |
|---|---|
-Wall -Wextra, strukten |
inget, på någon -O-nivå |
-Wall -Wextra, skalären |
varnar, men först från -O1 |
-fanalyzer |
namnger fältet, CWE-457, inga falsklarm |
clang-tidy cppcoreguidelines-pro-type-member-init |
flaggar Stats s; — se nedan |
-ftrivial-auto-var-init=pattern |
rapporterar inget; gör värdet deterministiskt |
-fsanitize=memory |
stöds inte av g++ — MSan finns bara i clang |
-ftrivial-auto-var-init=pattern förtjänar en egen kommentar, för den gör något annat än
de övriga. Den letar inte efter buggen utan fyller variabeln med ett bestämt bitmönster,
vilket förvandlar ett slumpmässigt skräpvärde till ett förutsägbart: peak = -5.31401e+303
i varje bygge jag testade. Det är en påtaglig förbättring för den som felsöker. Det är
bara inte detektion.
Var går då gränsen? På samma ställe som i avsnitt 1: vid nästa nivå av indirektion. Låt resultaten samlas i en vektor innan något läser dem, så tystnar analysatorn:
$ g++ -std=c++23 -Wall -Wextra -fanalyzer analyzer-limit.cxx -o al
$ ./al
peak = 6.95263e-310
peak = 6.95263e-310
clang-tidys check klarar det där, eftersom den är syntaktisk — den tittar på Stats s; och
inte på vad som händer sedan. Men det är också dess svaghet: den flaggar Stats s; även i
kod där alla tre fält tilldelas på nästa rad. En check som larmar lika högt på korrekt kod
som på felaktig är en check folk stänger av.
Kvar står alltså en analysator som är precis men tappar spåret, en linter som är robust men larmar på allt, och en flagga som gör buggen reproducerbar utan att hitta den. Rusts svar är av ett annat slag: det finns ingen konstruktion i språket som producerar ett oinitierat värde, så det finns ingenting att leta efter.
5. Pekaren som pekade på ingenting
Den sista buggen skiljer sig från de fyra föregående, och det är värt att säga rakt ut med en gång: den här kraschar. Högljutt, omedelbart och varje gång uppslagningen missar.
Efter fyra avsnitt om tysta fel som överlever testsviten kan det låta som ett antiklimax. Men argumentet mot null handlar inte om hur felet visar sig — utan om vad typen berättar.
auto find_config(const std::vector<Config>& all, std::string_view host) -> const Config* {
for (const auto& c : all) {
if (c.name == host) return &c;
}
return nullptr;
}
auto main() -> int {
const auto all = std::vector<Config>{
{"web-01", 3},
{"web-02", 5},
};
std::cout << "web-01 retries: " << find_config(all, "web-01")->retries << "\n";
std::cout << "web-99 retries: " << find_config(all, "web-99")->retries << "\n";
}
$ g++ -std=c++23 -Wall -Wextra null-pointer.cxx -o np && ./np
web-01 retries: 3
web-99 retries: Segmentation fault (core dumped)
Titta på returtypen: const Config*. Vad säger den om huruvida nullptr kan komma ut?
Ingenting. Exakt samma typ hade en funktion haft som aldrig kan misslyckas. Det finns
alltså inget i signaturen, ingenting på anropsstället och ingenting i kompilatorns beteende
som påminner om att det finns ett fall till att hantera.
Och den träff som saknas är förstås just det fall som inte finns i testerna. Alla känner igen mönstret: uppslagningen fungerar för alla värden man testat med, för man testar med värden man vet finns.
Så här ser samma funktion ut i Rust:
fn find_config<'a>(all: &'a [Config], host: &str) -> Option<&'a Config> {
all.iter().find(|c| c.name == host)
}
Skillnaden ligger helt i returtypen. Option<&Config> är inte samma typ som &Config —
det är en annan typ, och den går inte att använda som om den vore en referens:
$ rustc --edition 2024 null-pointer.rs -o np-rust
error[E0609]: no field `retries` on type `Option<&Config>`
--> null-pointer.rs:19:64
|
19 | println!("web-01 retries: {}", find_config(&all, "web-01").retries);
| ^^^^^^^ unknown field
|
help: one of the expressions' fields has a field of the same name
|
19 | println!("web-01 retries: {}", find_config(&all, "web-01").unwrap().retries);
| +++++++++
Notera att båda anropsställena underkänns, även det som skulle ha fungerat. Det är poängen: det spelar ingen roll att den här uppslagningen råkar träffa. Möjligheten att den inte gör det finns i typen, och då måste den hanteras.
Men läs också kompilatorns förslag. .unwrap() kompilerar — och panikar på missen. Det
är onekligen bättre än en segfault: en namngiven panik, med radnummer, i varje bygge. Det
är fortfarande ett körtidsfel, och ett du själv fick skriva dit. Rust hindrar dig inte från
att gräva en fallgrop, det tvingar dig bara att stava den.
Det man egentligen vill är att hantera fallet:
fn report(all: &[Config], host: &str) {
match find_config(all, host) {
Some(c) => println!("{host} retries: {}", c.retries),
None => println!("{host} retries: no config, using default 1"),
}
}
$ rustc --edition 2024 null-pointer-fixed.rs -o npf && ./npf
web-01 retries: 3
web-99 retries: no config, using default 1
Vad kostar det?
Nu invänder någon att allt det där låter dyrt. Ett extra fält som håller reda på om värdet finns, för varje uppslagning. Låt oss mäta istället för att gissa:
$ ./os $ ./oz
&i32 8 bytes const Config* 8 bytes
Option<&i32> 8 bytes std::optional> 16 bytes
f64 8 bytes double 8 bytes
Option 16 bytes std::optional 16 bytes
| Fall | C++ | Rust |
|---|---|---|
| referens/pekare | 8 | 8 |
| valfri referens | 16 | 8 |
| vanlig double / f64 | 8 | 8 |
| valfri double / f64 | 16 | 16 |
Option<&i32> kostar alltså ingenting alls jämfört med &i32. Förklaringen är att en
referens i Rust aldrig kan vara noll, vilket innebär att bitmönstret "bara nollor" är
oanvänt — och då kan det användas som markör för None. Rust kallar det niche
optimisation.
Och för att vara tydlig med att det varken är magi eller universellt: Option<f64> kostar
16 byte, eftersom varje tänkbart bitmönster i en f64 redan betyder något. Där finns
ingen ledig plats, och då behövs en separat markör. Precis som i C++.
Kan C++ hitta den åt oss?
UBSan gör det, och gör det bra:
$ g++ -std=c++23 -g -fsanitize=undefined,null null-pointer.cxx -o np-ubsan && ./np-ubsan
web-01 retries: 3
web-99 retries: null-pointer.cxx:34:68: runtime error: member access within null pointer of type 'const struct Config'
Fil, rad, kolumn och vilken typ det gällde. Varken -Wall -Wextra, -fanalyzer eller
clang-tidy sa något — men det spelar mindre roll här, för programmet kraschade ju ändå.
Den viktigare invändningen mot det här avsnittet är en helt annan, och den ska jag ta
själv innan någon annan gör det: C++ har std::optional sedan C++17. Det uttrycker
samma sak, det är en fullgod typ, och det finns ingen egenskap hos Option som saknar
motsvarighet.
Det här är alltså ingen skillnad i vad språken kan, utan i vad de gör som standard. En
handskriven uppslagning i C++ returnerar en pekare, för det är vad man gör. Motsvarigheten
till Option<&Config> heter std::optional<std::reference_wrapper<const Config>>, väger
dubbelt så mycket, och är inte något man skriver av vana.
Det är i grunden samma slutsats som i de fyra föregående avsnitten, bara från andra hållet. Verktygen finns i C++. Det är vad du får när du inte tänker efter som skiljer.
Och här är vad Rust inte ger dig
Det finns ett engelskt ord för den där sortens språkkonstruktion som inte gör något fel men som gärna låter dig göra det: footgun. En funktion vars främsta användningsområde visar sig vara att skjuta sig själv i foten med.
C++ har ett rykte om sig att vara generöst utrustat i den avdelningen, och de fem avsnitten ovan gör knappast något för att motbevisa det. Men om du nu läst hit och känner dig övertygad, tänker jag be dig vara det lite mindre. För det finns en motsvarande bild, och den lovar för mycket:
Memory safe. Thread safe. Fearless coding. Låt oss gå igenom skylten rad för rad, för den första stämmer, den andra sparar jag till en senare artikel, och den tredje är marknadsföring.
unsafe finns, och standardbiblioteket är fullt av det
Rust har ett nyckelord som heter unsafe, och det är inte en nödutgång som ingen använder
— det är fundamentet. Varje indexering i en Vec landar till slut i osäker kod som någon
har granskat.
Värt att reda ut direkt, för det missförstås ofta: unsafe stänger inte av
lånekontrollen. Ägande och lån gäller precis som vanligt även inuti ett unsafe-block,
och skriver du ett dubbelt lån där får du samma E0502 som överallt annars. Vad
nyckelordet låser upp är fem specifika saker — att dereferera råpekare, anropa
unsafe-funktioner, komma åt muterbara globala variabler, implementera unsafe-traits
och läsa fält ur en union.
Påståendet är alltså inte att osäker kod inte finns, utan att den är inkapslad bakom kontrollerade gränssnitt. Det är en väsentligt blygsammare utfästelse än skylten antyder, och det är den som faktiskt håller.
Minnesläckor är helt säkra
Det här är den som brukar överraska. Minnessäkerhet i Rusts mening betyder att inget används efter att det frigjorts. Den betyder inte att det frigörs.
struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("closing {}", self.name);
}
}
fn main() {
{
let _c = Connection { name: String::from("db-01") };
} // dropped here, as expected
{
let c = Connection { name: String::from("db-02") };
std::mem::forget(c); // never dropped
}
{
let c = Box::leak(Box::new(Connection { name: String::from("db-03") }));
println!("leaked {} and kept a &'static to it", c.name);
} // also never dropped
println!("done");
}
$ rustc --edition 2024 leaks-are-safe.rs -o las && ./las
closing db-01
leaked db-03 and kept a &'static to it
done
Lite kort om syntaxen: Medlemsfunktioner till en struct skrivs i separata impl-block, vilka
ofta realiserar s.k. traits (avlägsen släkting till interface i Java). Trait Drop är
Rusts motsvarighet till en destructor och anropas normalt på liknande sätt som i C++, när ett
objekt lämnar sitt deklarerande scope. Emellertid kan man i Rust be om att detta inte görs via
std::mem::forget(c). Exemplet ovan är litet larvigt övertydligt, men demonstrerar att du
får skriva denna typ i kod även i Rust.
Anropet Box::new(...) innebär att skapa ett heap-allokerat objekt och hantera detta via en
smart pekare. Box motsvarar tämligen exakt std::unique_ptr i C++. Anropet Box::leak(...)
ligger nära std::unique_ptr<T>::release — båda släpper ägarskapet så att destruktorn aldrig
körs. Skillnaden är vad du får tillbaka: release() ger en råpekare som du fortfarande kan
delete:a, medan Box::leak ger en &'static-referens utan säker väg tillbaka.
Två av tre destruktorer kördes aldrig. Inget unsafe-block, ingen varning, inget knot
från kompilatorn. Och det är inte ett kryphål utan en konsekvens av vad garantin faktiskt
säger: modellen lovar att ett värde aldrig observeras efter att det städats bort, inte att
det någonsin städas bort.
unwrap() är en fallgrop du själv gräver
Den här kände vi redan igen från avsnitt 5, där rustc självt föreslog den. unwrap()
kompilerar, och när None dyker upp panikar programmet. Typsystemet tvingade dig att
skriva ut den. Det hindrade dig inte.
En panik är visserligen betydligt trevligare än en segfault — namngiven, med radnummer, i varje bygge — men den inträffar fortfarande i produktion, hos en kund, klockan tre på natten.
Rust har inte exceptions, som vi känner dem från C++, utan istället ett begrepp som kallas panic. Ordet är hämtat från Unix/Linux världen och innebär att systemet kraschar kontrollerat och eventuellt bootar om. I Rust innebär det att programmet avslutas abnormalt, med en utskrift om vad som gick fel. I samband med en panic, så rullas anropsstacken tillbaka (unwind) på ett liknande sätt som i C++. Detta har betydelse för flertrådade program, eftersom detta sker lokalt för den tråd/thread som drabbades.
Logikfel är fullständigt orörda
Inget av ovanstående gör felaktig kod korrekt. Ett program som räknar fel räknar lika fel i Rust, och kompilatorn har inga synpunkter.
Och C++ har inte stått stilla
Två saker till, som hör hemma här snarare än i något av de fem avsnitten.
Rusts ägandemodell är C++:s egen idé. RAII och smarta pekare kom först, och det Rust gjort är att göra dem obligatoriska och kontrollerade istället för rekommenderade. Det är värt att säga rakt ut, för det kostar ingenting och det är sant.
Och den gamla käpphästen om ofattbara templatefel bör dateras. std::optional,
std::variant och std::expected uttrycker sedan C++17 (och C++23 för den senare) samma saker
som Option och Result, och C++20:s koncept ger felmeddelanden vid anropsstället istället för
sjuttonhundra rader ur en biblioteksheader. Den som klagar på det idag klagar på C++ från före 2020.
Så vad blir kvar när allt är avräknat?
Ungefär det tabellerna i de fem avsnitten visade. C++ har verktyg för varenda en av de här buggarna, och för avsnitt 2, 3 och 4 är de riktigt bra. Men de är opt-in, de kräver ett eget körsteg, och de har alla en gräns: nästa funktionsanrop, nästa gren, nästa storlek på indata. Rusts motsvarigheter är regler i typsystemet som tillämpas på varje bygge, utan att bli tillfrågade och utan räckvidd att ta slut.
Det är inte samma sak som att skylten har rätt. Det är bara betydligt mer än ingenting.
Länkar
Senaste Artiklarna
-
Fem skäl att välja Rust över C++
10 augusti 2026 -
Apollo 11 och kvinnan bakom koden
12 april 2026 -
Hur gör man en Ralph Loop?
6 april 2026 -
Kan du alla nya AI termer?
22 mars 2026 -
OpenClaw - Det ska vara en hummer i år
15 mars 2026 -
Så här kopplar du SMHI till Claude AI
16 februari 2026 -
Asynkrona operationer med coroutines
21 januari 2026 -
Asynkron filläsning med C++20 coroutines
1 januari 2026