Sökresultat för

Rust syntax jämfört med C++

47 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Rust syntax jämfört med C++

Föregående artikel handlade om buggar Rust-kompilatorn stoppar. Den här handlar om något mer grundläggande: hur koden ser ut innan man ens hunnit skriva en bugg. Två språk som båda kompilerar till maskinkod och båda kallar sig systemspråk kan se förvånansvärt olika ut rad för rad — och skillnaderna är sällan godtyckliga. De flesta går att spåra till ett designbeslut om vad som ska vara standard och vad som ska krävas explicit.

Jag går igenom åtta sådana skillnader, grupperade i tre delar:

Defaults som skiljer sig

  1. Immutable vs. mutable
  2. Move-semantics vs. copy-semantics

Blockens och satsernas form

  1. Parenteser som försvinner, klamrar som blir obligatoriska
  2. Ingen C-loop kvar
  3. Blocket som är ett uttryck
  4. Pilen är obligatorisk, inte kosmetisk

Deklarationer och typer

  1. Bitbredden står i typnamnet
  2. Ingen overloading, inga default-argument

1. Muterbar som standard — eller inte

Börjar vi med den mest grundläggande skillnaden av alla, för den sätter tonen för resten av artikeln. I C++ är en variabel muterbar (mutable) om du inte säger något annat. I Rust är den oföränderlig (immutable) om du inte säger något annat. Samma rad kod, total = 1200, betyder alltså två olika löften beroende på språk.

int total = 1200;                        // the list price, or so the name suggests

total = apply_discount(total, 20);        // "just applying the campaign discount"
total = apply_discount(total, 10);        // ...six months later, loyalty stacks on top
$ g++ -std=c++23 -Wall -Wextra mutability.cxx -o mutability-c++ && ./mutability-c++
list price:     1200
after campaign:  960
after loyalty:   864

Inget av detta är fel — total ska uppenbarligen räknas om. Men språket gör ingen skillnad mellan en variabel du avsett ska ändras och en du råkat skriva till en gång för mycket. Verktyget för att uttrycka avsikten finns, const, men det är opt-in, en extra sak att komma ihåg per variabel: (const int total = 1200;)

$ g++ -std=c++23 -Wall -Wextra mutability-const.cxx -o mutability-const-c++
mutability-const.cxx:12:11: error: assignment of read-only variable ‘total’

Samma program i Rust, fast utan mut:

let total = 1200;

total = apply_discount(total, 20);
total = apply_discount(total, 10);
$ rustc --edition 2024 mutability.rs -o mutability-rust
error[E0384]: cannot assign twice to immutable variable `total`
  --> mutability.rs:10:5
   |
 6 |     let total = 1200;
   |         ----- first assignment to `total`
...
10 |     total = apply_discount(total, 20);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cannot assign twice to immutable variable
   |
help: consider making this binding mutable
   |
 6 |     let mut total = 1200;
   |         +++

Ingen const att komma ihåg — let är const tills du säger något annat. Vill du faktiskt att total ska räknas om, vilket är fallet här, skriver du let mut total och menar det: (let mut total = 1200;)

$ rustc --edition 2024 mutability-fixed.rs -o mutability-fixed-rust && ./mutability-fixed-rust
list price:     1200
after campaign:  960
after loyalty:   864

Samma utskrift som C++-versionen. Skillnaden är inte vad programmet gör, utan att en läsare som bara ser deklarationsraden redan vet om variabeln kommer att ändras längre ner — utan att behöva läsa resten av funktionen för att kolla.

2. Kopiera som standard — eller flytta

Samma rad, auto b = a; respektive let b = a;, betyder olika saker i de två språken. Föregående artikels första avsnitt visade vad som händer när den skillnaden biter en i efterhand; här handlar det bara om själva defaulten.

std::vector<int> a = {1, 2, 3, 4, 5};
auto b = a;                     // copies the whole vector

b.push_back(6);
$ g++ -std=c++23 -Wall -Wextra assignment.cxx -o assignment-c++ && ./assignment-c++
a has 5 elements
b has 6 elements

b blir en egen kopia. Att lägga till ett element i b påverkar inte a, vilket är precis vad man väntar sig — men det innebär också att hela vektorn kopierades i tysthet, oavsett om avsikten var det eller inte.

let a = vec![1, 2, 3, 4, 5];
let mut b = a;                  // moves ownership of the vector, does not copy it

b.push(6);
$ rustc --edition 2024 assignment.rs -o assignment-rust
error[E0382]: borrow of moved value: `a`
 --> assignment.rs:7:35
  |
2 |     let a = vec![1, 2, 3, 4, 5];
  |         - move occurs because `a` has type `Vec`, which does not implement the `Copy` trait
3 |     let mut b = a;                  // moves ownership of the vector, does not copy it
  |                 - value moved here
...
7 |     println!("a has {} elements", a.len());
  |                                   ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let mut b = a.clone();                  // moves ownership of the vector, does not copy it
  |                  ++++++++

let b = a; flyttar äganderätten, den kopierar inte. Efter den raden finns det ingen a kvar att läsa, och kompilatorn säger det innan något ens körts.

Kompilatorns eget förslag pekar rakt på lösningen — .clone():

$ rustc --edition 2024 assignment-clone-fixed.rs -o assignment-fixed-rust && ./assignment-fixed-rust
a has 5 elements
b has 6 elements

Samma utskrift som C++, och nu ser man den omvända bilden av avsnitt 1. I C++ ber du om att slippa en kopiering, med std::move. I Rust ber du om att en, med .clone(). Vilket som är standard är den egentliga skillnaden — inte att någotdera alternativet skulle saknas.

std::vector<int> a = {1, 2, 3, 4, 5};
auto b = std::move(a);                     // moves the content of the vector
b.push_back(6);

3. Parenteser som försvinner, klamrar som blir obligatoriska

C++ kräver parenteser runt villkoret i if/while men gör klamrarna runt kroppen valfria — en enda sats behöver inga. Rust gör precis tvärtom: inga parenteser, men klamrarna är obligatoriska. Det här avsnittet handlar om vad den bytesaffären köper dig.

void log_access(bool is_admin) {
    if (is_admin)
        std::cout << "admin check passed\n";
        std::cout << "access granted\n";   // indentation lies: this always runs
}
$ g++ -std=c++23 -Wall -Wextra access.cxx -o access-c++ && ./access-c++
access.cxx: In function ‘void log_access(bool)’:
access.cxx:4:5: warning: this ‘if’ clause does not guard... [-Wmisleading-indentation]
    4 |     if (is_admin)
      |     ^~
access.cxx:6:9: note: ...this statement, but the latter is misleadingly indented as if it were guarded by the ‘if’
    6 |         std::cout << "access granted\n";   // indentation lies: this always runs
      |         ^~~
-- as admin --
admin check passed
access granted
-- as guest --
access granted

access granted skrivs ut även för gästen — raden låg aldrig inne i if:et, trots indenteringen. Till g++:s heder: -Wmisleading-indentation (del av -Wall sedan GCC 6) fångar faktiskt den här. Men det är en varning, inte ett fel, så programmet kompilerar och kör med buggen kvar ändå.

Det finns ingen C++-formad variant av det här programmet att ens försöka i Rust — att utelämna klamrarna är ett parsningsfel, inte en stilfråga:

fn log_access(is_admin: bool) {
    if is_admin
        println!("admin check passed");
}
$ rustc --edition 2024 access.rs -o access-rust
error: expected `{`, found `println`
 --> access.rs:3:9
  |
3 |         println!("admin check passed");
  |         ^^^^^^^ expected `{`
  |
note: the `if` expression is missing a block after this condition
 --> access.rs:2:8
  |
2 |     if is_admin
  |        ^^^^^^^^
help: you might have meant to write this as part of a block

Med klamrarna utskrivna, precis som Rust kräver:

fn log_access(is_admin: bool) {
    if is_admin {
        println!("admin check passed");
    }
    println!("access granted");
}

Så går kompileringen genom

$ rustc --edition 2024 access-fixed.rs -o access-fixed-rust && ./access-fixed-rust
-- as admin --
admin check passed
access granted
-- as guest --
access granted

Samma beteende som C++-versionen — att access granted skrivs ut ovillkorligen var aldrig själva buggen. Men eftersom scopet måste stavas ut i klamrar istället för att läsas ur indenteringen finns det inget kvar för en -Wmisleading-indentation-liknande varning att hitta: koden och indenteringen kan inte säga emot varandra.

4. Ingen C-loop kvar

Rust saknar den klassiska tredelade for (init; villkor; steg) helt — det finns bara for pattern in iterator, while och loop. Så här ser det ut om man av vana sträcker sig efter C++-formen, och vad den idiomatiska ersättningen blir istället.

int sum = 0;
for (int i = 0; i < 10; i += 2) {
    sum += i;
}
$ g++ -std=c++23 -Wall -Wextra stepping.cxx -o stepping-c++ && ./stepping-c++
sum = 20

Samma sak, försökt skrivet i Rust:

let mut sum = 0;
for (let mut i = 0; i < 10; i += 2) {
    sum += i;
}
$ rustc --edition 2024 stepping.rs -o stepping-rust
error: expected pattern, found `let`
 --> stepping.rs:3:10
  |
3 |     for (let mut i = 0; i < 10; i += 2) {
  |          ^^^
  |
help: remove the unnecessary `let` keyword
  |
3 -     for (let mut i = 0; i < 10; i += 2) {
3 +     for (mut i = 0; i < 10; i += 2) {
  |

error: expected one of `)`, `,`, `@`, `if`, or `|`, found `=`
 --> stepping.rs:3:20
  |
3 |     for (let mut i = 0; i < 10; i += 2) {
  |                    ^ expected one of `)`, `,`, `@`, `if`, or `|`

Parsern försöker läsa for (...) som ett mönster att packa upp, för det är det enda som får stå mellan for och in. Det finns ingen tredelad header att tolka in i — den har aldrig funnits i grammatiken.

let mut sum = 0;
for i in (0..10).step_by(2) {
    sum += i;
}
$ rustc --edition 2024 stepping-fixed.rs -o stepping-fixed-rust && ./stepping-fixed-rust
sum = 20

(0..10).step_by(2) ersätter init/villkor/steg med ett intervall och en adapter. Ingen räknare att deklarera för hand, inget off-by-one att göra fel på vid gränsen — och inget att glömma initiera, vilket för oss tillbaka till avsnitt 1.

5. Blocket som är ett uttryck

I Rust kan if/else (och vilket { }-block som helst) vara ett värde — sista uttrycket i blocket, utan semikolon på slutet, är det som blocket blir. I C++ är if/else en sats; den kan inte lämna tillbaka något värde, så man tar antingen till villkorsoperatorn eller deklarerar en muterbar variabel i förväg och tilldelar den i varje gren.

auto classify(int celsius) -> std::string {
    std::string label;               // has to exist before we know the answer
    if (celsius < 0) {
        label = "freezing";
    } else if (celsius < 15) {
        label = "cold";
    } else if (celsius < 25) {
        label = "mild";
    } else {
        label = "hot";
    }
    return label;
}
$ g++ -std=c++23 -Wall -Wextra classify.cxx -o classify-c++ && ./classify-c++
-5 C -> freezing
10 C -> cold
20 C -> mild
30 C -> hot

Fyra grenar är en för många för en läsbar kedja av villkorsoperatorer, så label måste finnas innan någon gren körts, tilldelas i var och en, och returneras efteråt. Tre separata saker att få rätt — och avsnitt 1 visade redan vart "finns innan vi vet svaret" kan leda.

let label = if celsius < 0 {
    "freezing";                  // stray semicolon: this arm now yields ()
} else if celsius < 15 {
    "cold"
} else if celsius < 25 {
    "mild"
} else {
    "hot"
};
$ rustc --edition 2024 classify.rs -o classify-rust
error[E0308]: `if` and `else` have incompatible types
  --> classify.rs:4:12
   |
 2 |       let label = if celsius < 0 {
   |                   -------------- `if` and `else` have incompatible types
 3 |           "freezing";                  // stray semicolon: this arm now yields ()
   |           -----------
   |           |         |
   |           |         help: consider removing this semicolon
   |           expected because of this
 4 |       } else if celsius < 15 {
   |  ____________^
 5 | |         "cold"
 6 | |     } else if celsius < 25 {
 7 | |         "mild"
 8 | |     } else {
 9 | |         "hot"
10 | |     };
   | |_____^ expected `()`, found `&str`

Ett enda extra ; efter "freezing" gör att den grenens värde blir () — enhetstypen, Rusts "ingenting", inte C++:s void, och den deltar i typkontrollen precis som vilken annan typ som helst — istället för &str. De andra tre grenarna ger fortfarande &str, så grenarna är oense och hela if-uttrycket faller på typkontrollen. Semikolonet är alltså inte kosmetiskt här; det är skillnaden mellan ett uttryck och en sats.

Så här ser det fungerande programmet ut i Rust:

fn classify(celsius: i32) -> &'static str {
    if celsius < 0 { "freezing" } 
    else if celsius < 15 { "cold" } 
    else if celsius < 25 { "mild" } 
    else { "hot" }
}

fn main() {
    for t in [-5, 10, 20, 30] {
        println!("{t} C -> {}", classify(t));
    }
}
$ rustc --edition 2024 classify-fixed.rs -o classify-fixed-rust && ./classify-fixed-rust
-5 C -> freezing
10 C -> cold
20 C -> mild
30 C -> hot

Inga semikolon på grenarnas värden, och ingen label-variabel alls — kedjan av if/else if/else är sista uttrycket i funktionskroppen, så den är returvärdet. Inget förhandsdeklarerat, inget omtilldelat, inget att lämna oinitierat på en gren någon glömmer.

6. Pilen är obligatorisk, inte kosmetisk

C++ har tre likvärdiga sätt att skriva samma funktion: den traditionella returtypen först, pil-formen -> T (C++11), och — sedan C++14 — ingen utskriven returtyp alls, härledd ur kroppen. Rust har bara ett sätt: -> T finns och stämmer, eller så returnerar funktionen () och inget typkontrolleras.

int add_traditional(int a, int b) {
    return a + b;
}

auto add_trailing(int a, int b) -> int {
    return a + b;
}

auto add_inferred(int a, int b) {   // C++14: return type deduced, no arrow needed
    return a + b;
}
$ g++ -std=c++23 -Wall -Wextra add.cxx -o add-c++ && ./add-c++
5
5
5

Alla tre gör samma sak och kompilerar likadant. Pilen i auto add_trailing(int a, int b) -> int är ett stilval — add_inferred bevisar att man inte ens behöver skriva ut någon returtyp, C++ räknar ut den från return a + b;.

fn add(a: i32, b: i32) {    // no "-> i32": does this get inferred, like C++'s auto?
    a + b
}
$ rustc --edition 2024 add.rs -o add-rust
error[E0308]: mismatched types
 --> add.rs:2:5
  |
1 | fn add(a: i32, b: i32) {    // no "-> i32": does this get inferred, like C++'s auto?
  |                       - help: try adding a return type: `-> i32`
2 |     a + b
  |     ^^^^^ expected `()`, found `i32`

error[E0277]: `()` doesn't implement `std::fmt::Display`
 --> add.rs:6:20
  |
6 |     println!("{}", add(2, 3));
  |               --   ^^^^^^^^^ `()` cannot be formatted with the default formatter

Att utelämna -> i32 utlöser inte samma sorts inferens som C++14:s auto. En fn utan pil betyder att den returnerar (), punkt slut — kroppens sista uttryck a + b blir då ett typfel, eftersom det är en i32 som hamnar där () förväntades.

$ rustc --edition 2024 add-fixed.rs -o add-fixed-rust && ./add-fixed-rust
5

Det finns inga andra sätt att skriva den här funktionen på. Varje fn som returnerar ett värde stavar ut -> T, så signaturen ensam berättar alltid svaret — man behöver aldrig läsa in i kroppen (eller lita på att en kommentar stämmer) för att ta reda på vad en funktion returnerar.

7. Bitbredden står i typnamnet

C++:s heltalsnamn (int, char, size_t) säger inget om maskinstorleken (bytes och-eller bits) för respektive type — det överlåts åt plattformen, och språket konverterar mellan dem utan att bli tillfrågat. Rusts typnamn (i32, u8, usize) anger maskinstorleken utan krusiduller, och det finns ingen implicit konvertering mellan två heltalstyper, någonsin.

int big = 1000;
unsigned char narrowed = big;     // implicit narrowing, no cast asked for
$ g++ -std=c++23 -Wall -Wextra narrow.cxx -o narrow-c++ && ./narrow-c++
big      = 1000
narrowed = 232

unsigned char narrowed = big; kompilerar helt utan diagnostik under -Wall -Wextra — det krävs -Wconversion specifikt för att bli varnad. 1000 ryms inte i 8 bitar, så det wrappar (modulo-aritmetik): 1000 % 256 = 232.

let big: i32 = 1000;
let narrowed: u8 = big;           // no implicit conversion between integer types, ever
$ rustc --edition 2024 narrow.rs -o narrow-rust
error[E0308]: mismatched types
 --> narrow.rs:3:24
  |
3 |     let narrowed: u8 = big;           // no implicit conversion between integer types, ever
  |                   --   ^^^ expected `u8`, found `i32`
  |                   |
  |                   expected due to this
  |
help: you can convert an `i32` to a `u8` and panic if the converted value doesn't fit
  |
3 |     let narrowed: u8 = big.try_into().unwrap();           // ...

i32 och u8 är olika typer, punkt slut — det spelar ingen roll att just den här konverteringen råkar vara krympande; Rust avvisar all implicit heltalskonvertering, även breddande sådan. Lägg märke till vad rustc föreslår: try_into().unwrap(), som panikar istället för att tyst wrappa om värdet inte ryms. Det är det "säkra" alternativet — och medvetet inte det den fixade versionen nedan använder.

$ rustc --edition 2024 narrow-fixed.rs -o narrow-fixed-rust && ./narrow-fixed-rust
big      = 1000
narrowed = 232

as u8 är den explicita, krympande casten — samma wraparound-aritmetik som C++-versionen, samma 232. Utskriften är identisk med C++-programmet; skillnaden är att as fick skrivas ut för hand, exakt på det stället där krympningen sker, istället för att ske implicit varhelst en bredare typ möter en smalare.

Och för att visa att "säkert" ska tas bokstavligt: samma big, samma try_into() som rustc föreslog ovan — men den panikar, den wrappar inte:

let big: i32 = 1000;
let narrowed: u8 = big.try_into().unwrap();
$ rustc --edition 2024 narrow-panic.rs -o narrow-panic-rust && ./narrow-panic-rust
thread 'main' (353814) panicked at narrow-panic.rs:3:39:
called `Result::unwrap()` on an `Err` value: TryFromIntError(PosOverflow)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Samma indata som fick as u8 att tyst bli 232 får try_into().unwrap() att krascha programmet. Ingen av dem är "rätt" i något absolut mening — as är rätt när wrappningen är avsedd (kryptografi, hashning, bitmanipulation), try_into() är rätt när ett värde utanför intervallet är ett programmeringsfel som ska stoppa exekveringen direkt istället för att tyst ge fel svar.

8. Ingen overloading, inga default-argument

C++ löser greet(name) och greet(name, greeting) som två olika funktioner beroende på anropsstället. Rust har ingen overloading och inga default-värden på parametrar — ett namn betyder en signatur.

void greet(const std::string& name) {
    std::cout << "Hello, " << name << "!\n";
}

void greet(const std::string& name, const std::string& greeting) {
    std::cout << greeting << ", " << name << "!\n";
}
$ g++ -std=c++23 -Wall -Wextra greet.cxx -o greet-c++ && ./greet-c++
Hello, Ada!
Hej, Ada!

Två funktioner som heter greet, upplösta efter antal argument vid varje anropsställe. Inget ovanligt med det i C++ — rutin.

fn greet(name: &str) {
    println!("Hello, {name}!");
}

fn greet(name: &str, greeting: &str) {
    println!("{greeting}, {name}!");
}
$ rustc --edition 2024 greet.rs -o greet-rust
error[E0428]: the name `greet` is defined multiple times
 --> greet.rs:5:1
  |
1 | fn greet(name: &str) {
  | -------------------- previous definition of the value `greet` here
...
5 | fn greet(name: &str, greeting: &str) {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `greet` redefined here
  |
  = note: `greet` must be defined only once in the value namespace of this module

error[E0061]: this function takes 1 argument but 2 arguments were supplied
  --> greet.rs:11:5
   |
11 |     greet("Ada", "Hej");
   |     ^^^^^        ----- unexpected argument #2 of type `&'static str`

Två fn med samma namn är ett hårt fel, inte overload-upplösning — greet upptar en enda plats i modulens namnrymd oavsett parameterlista. Det andra felet är en följdeffekt: med bara enparametersvarianten kvar matchar inte längre anropet med två argument något alls.

$ rustc --edition 2024 greet-fixed.rs -o greet-fixed-rust && ./greet-fixed-rust
Hello, Ada!
Hej, Ada!

Två separata namn, greet och greet_with, där greet implementeras i termer av greet_with. Samma utskrift som C++-versionen. Anropsstället måste nu säga vilken av dem det menar — det finns ingen aritetsbaserad dispatch att luta sig mot, så de två beteendena får två namn istället för att dela på ett.

fn greet(name: &str) {
    greet_with("Hello", name);
}

fn greet_with(greeting: &str, name: &str) {
    println!("{greeting}, {name}!");
}

fn main() {
    greet("Ada");
    greet_with("Hej", "Ada");
}

Och vad blir kvar av det?

Åtta punkter, men egentligen bara en handfull grundfrågor som upprepas i olika skepnad: vad är standard och vad måste man be om, och vad räknas som ett värde och vad räknas bara som en instruktion. Muterbarhet (1), kopiering (2) och heltalskonvertering (7) är samma fråga — "vad händer om jag inte säger något" — ställd om variabler, äganderätt respektive typer. Parenteser (3), C-loopen (4) och uttrycksblock (5) är en annan variant av samma sak: hur mycket av satsens form är förhandlingsbar och hur mycket är fast. Pilen (6) och overloading (8) handlar båda om att en signatur ska gå att lita på utan att läsa kroppen.

Inget av det här är buggar i den mening föregående artikel handlade om — all exempelkoden i C++-spalten är fullt korrekt, avsiktligt skriven kod. Skillnaden är att C++ oftast ger dig ett val och en rimlig default, medan Rust oftare bara ger dig ett sätt. Det är bekvämt fram tills dagen du vill ha det andra sättet, och det är precis den avvägningen språkdesigners gör hela tiden — bara åt olika håll.

Om något av det här kändes som en detalj: det är poängen. Ingen enskild punkt är avgörande på egen hand. Tillsammans är de anledningen till att portering av kod, eller att bara växla mellan språken samma dag, ger den där låga bakgrundsfriktionen — fingrarna vet ett mönster, kompilatorn vill ha ett annat.


Saker jag hoppat över i denna artikel

Du har säkert noterat att det finns en del överhoppade begrepp i texten, såsom panic, .unwrap(), &str med flera. Under hösten kommer jag att beskriva dessa och flera andra i kommande artiklar.


Hur pass intresserad av Rust är du? Svara på en kort enkät!