TL;DR
Most everyday C++ has a direct Rust counterpart, but the translation is rarely one keyword for another. What changes is the defaults. C++ allows almost everything and makes safety opt-in: const, explicit, -Wall, sanitizers. Rust forbids almost everything and makes permission opt-in: mut, as, pub, unsafe. So code that looks the same often behaves differently, and the biggest takeaway is to translate the intent, not the syntax.
- TL;DR
- What this series is, and what it is not
- The one big shift: the defaults flip
- Toolchain and project layout
- Primitive types and conversions
- Variables: immutable, inferred, always initialized
- Integer overflow: from undefined to defined
- Everything is an expression
- Functions: no overloading, no default arguments
- Strings: two types and a byte count
- Containers
- Structs and classes
- Enums: the enum class C++ wanted
- Null: Option instead of nullptr
- References: a first look
- Printing and formatting
- Cheat sheet
- Traps for C++ developers
- What comes next
- References
What this series is, and what it is not
This is not a “rewrite it in Rust” sermon, and it is not a language reference. It is a phrasebook, ordered as a learning path, for people who already write C++ for a living:
- Part 1 (this post): the everyday vocabulary. Types, variables, control flow, functions, strings, containers, structs, enums, and null.
- Part 2: ownership and abstraction. Moves, RAII, borrowing, smart pointers, sum types, templates, traits, operators, errors, closures, and iterators.
- Part 3: the advanced material. Threads and locks, lifetimes, compile-time programming, macros, self-referential data,
unsafe, FFI, and how teams migrate real C++ code bases.
Each section shows a C++ snippet, the idiomatic Rust, and the semantic gap between them. Syntax is the easy part. The gap is where the bugs and the surprises live.
Every snippet in the series was compiled with rustc 1.99 (edition 2024) and Apple clang 21 with -std=c++23. Each snippet carries the #include lines or use imports it needs. A Rust snippet whose first line is // does not compile is there to fail, and it fails with the error code it names.
Two free resources cover similar ground and make good companions: Brown University’s C++ to Rust Phrasebook, a lookup table organized by C++ concept, and Google’s Comprehensive Rust, a multi-day course.
This series differs from both in three ways:
- It is ordered as a learning path.
- Every snippet is compile-checked on a named toolchain.
- It ends with how to migrate a real code base.
The one big shift: the defaults flip
Rust and C++ share more than either community likes to admit:
- zero-cost abstractions
- no garbage collector
- RAII
- generics compiled by monomorphization (one copy of the code per concrete type, as with templates)
- the same memory model for atomics
The real difference is not what you can do. It is what happens when you say nothing.
| When you write nothing… | C++ | Rust |
|---|---|---|
| Mutability | mutable | immutable (mut opts in) |
| Visibility | struct public, class private |
private to the module (pub opts in) |
| Numeric conversions | implicit, narrowing included | none (as, From, TryFrom) |
| Assigning a non-trivial type | copies | moves |
| Reading an uninitialized local | undefined behavior | compile error |
| Pointers | can be null, and the type does not say so | references are never null; Option<&T> opts in |
| Signed integer overflow | undefined behavior | panics in debug builds |
| Out-of-bounds index | undefined behavior | panics |
switch / match |
falls through, may be incomplete | no fallthrough, must be exhaustive |
| Dangerous operations | allowed anywhere | only inside unsafe |
C++ hands you a workshop with every power tool plugged in, plus a thick safety manual. Rust hands you the same workshop with the tools locked in a cabinet, and each key you take out goes on a sign-out sheet. You can still do dangerous things in Rust. You just have to say so in writing, in a place a reviewer can grep for.
C++26, technically complete since March 2026, softens two rows of the C++ column:
- Reading an uninitialized local becomes erroneous behavior (a defined but wrong result that tools may diagnose) instead of undefined behavior.
- A hardened standard library can trap on an out-of-bounds
operator[].
Compiler support is still arriving. Both changes turn silent corruption into defined behavior that implementations are encouraged to diagnose or trap, and neither one is a compile error. The rest of the table stands.
Almost every section below is one row of this table.
Toolchain and project layout
Cargo is the build system, package manager, test runner, documentation generator, and lint driver in one tool. The “which build system, which package manager” decision that starts every C++ project does not exist.
cargo new inventory # Cargo.toml + src/main.rs
cargo build --release # optimized build in target/release
cargo test # runs every #[test] function, no framework needed
cargo clippy # lints, the clang-tidy of Rust
cargo fmt # formatting, the clang-format of Rust
| C++ | Rust |
|---|---|
Translation unit (.cpp + headers) |
Crate: the whole library or binary is one compilation unit |
#include "parser.h" |
mod parser; declares a module, use parser::Token; imports a name |
namespace net { ... } |
mod net { ... }, or a file net.rs |
| CMake, Meson, Bazel | Cargo.toml |
| vcpkg, Conan | crates.io, through Cargo.toml [dependencies] |
-O3 / -O0 -g |
--release / the default dev profile |
| GoogleTest, Catch2 | built-in #[test] + cargo test |
| clang-tidy, clang-format | cargo clippy, cargo fmt |
The gap: there are no headers, so there are no include-order bugs, no forward declarations, and no One Definition Rule violations. Items in a module can refer to each other in any order.
The trade-off is that the crate, not the file, is the unit of compilation, so a big crate behaves like a unity build. Code generation inside one crate already runs in parallel, but parsing and type-checking a crate do not. When builds get slow, split the code into several crates in a workspace: they type-check in parallel and rebuild independently.
Primitive types and conversions
| C++ | Rust | Note |
|---|---|---|
int8_t … int64_t |
i8 … i64 |
exact match |
uint8_t … uint64_t |
u8 … u64 |
exact match |
int |
i32 |
on every mainstream platform; the standard only promises at least 16 bits |
long |
i64 or i32 |
64-bit on 64-bit Linux and macOS, 32-bit on Windows and 32-bit targets: choose the width you mean |
size_t, ptrdiff_t |
usize, isize |
lengths and indices are always usize |
__int128 (extension) |
i128, u128 |
standard in Rust |
float, double |
f32, f64 |
|
bool |
bool |
no implicit conversion to or from integers |
char (a byte) |
u8 |
|
char32_t |
char |
a 4-byte Unicode scalar value, not a byte |
C++ converts between arithmetic types silently, narrowing included. The compiler may warn, but it will not stop you, unless you use brace initialization, where narrowing is an error.
#include <cstdint>
int big = 300;
std::uint8_t small = big; // 44, silently
unsigned u = -1; // 4294967295, silently
double d = 3.9;
int i = d; // 3, silently
long total = i + small; // integer promotions do the rest
Rust has no implicit numeric conversions at all, not even widening ones. You pick the conversion, and the name of the conversion tells the reader what can go wrong.
fn main() {
let big: i32 = 300;
// let small: u8 = big; // error[E0308]: mismatched types
let small = big as u8; // 44: `as` truncates, and now it says so
let checked = u8::try_from(big); // Err(TryFromIntError(PosOverflow))
let wide = i64::from(big); // From exists only when no value can be lost
let i = 3.9_f64 as i32; // 3
let huge = 1e20_f64 as i32; // 2147483647: float-to-int `as` saturates
// let total = i + small; // error[E0308]: mismatched types, then E0277
let total = i + i32::from(small);
println!("{small} {checked:?} {wide} {i} {huge} {total}");
}
The gap: pick the conversion by what can go wrong:
- Use
Fromwhen the conversion is lossless. - Use
TryFromwhen it can fail. - Use
asonly when you mean “truncate”.
as is the one remaining sharp edge, so Clippy has opt-in pedantic lints (cast_possible_truncation and friends) to flag it.
One more difference hides in the last cast: a float-to-int conversion that is out of range is undefined behavior in C++, while Rust’s as saturates to the nearest bound and turns NaN into 0.
Variables: immutable, inferred, always initialized
| C++ | Rust |
|---|---|
const int x = 5; |
let x = 5; |
int x = 5; |
let mut x = 5; |
auto x = f(); |
let x = f(); |
constexpr std::size_t N = 64; |
const N: usize = 64; |
int x; (no initializer) |
let x: i32; is allowed only if every path assigns it before use |
global static int counter; |
static COUNTER: AtomicUsize (globals must be thread-safe; Part 3) |
Reading an uninitialized local is a classic C++ bug. This compiles, and it reads garbage when verbose is false:
int log_level(bool verbose) {
int level; // indeterminate value
if (verbose) level = 2;
return level; // undefined behavior when verbose == false
}
Rust tracks definite initialization on every path:
// does not compile: E0381
fn log_level(verbose: bool) -> i32 {
let level: i32;
if verbose {
level = 2;
}
level // error[E0381]: used binding `level` is possibly-uninitialized
}
The idiomatic fix is to stop declaring first and assigning later. In Rust, if is an expression, so the value is computed where the variable is born:
fn log_level(verbose: bool) -> i32 {
let level = if verbose { 2 } else { 0 };
level
}
Two more differences are worth knowing on day one.
Shadowing. Rust lets you declare a new variable with the same name in the same scope. That sounds like a bug factory to C++ ears, but it is the idiomatic way to give a transformed value its natural name, instead of inventing port_str, port_trimmed, and port_num:
fn main() {
let port = " 8080\n";
let port = port.trim(); // &str
let port: u16 = port.parse().unwrap(); // u16
println!("{port}");
}
Mutability is deep. In C++, const is part of the type, and it is shallow: a const object can hold a pointer to mutable data. That gives you the four-way puzzle of const T*, T* const, and friends.
In Rust, mutability belongs to the binding and is inherited by everything the binding owns. A let v = vec![1, 2] cannot be pushed to, its elements cannot be changed, and there is no const_cast to get around it. (Part 2 covers the controlled escape hatch, interior mutability.)
Integer overflow: from undefined to defined
In C++, signed overflow is undefined behavior, so the optimizer is allowed to assume it never happens. Here is a real overflow check that clang 21 at -O2 compiles to return false:
bool will_overflow(int x) {
return x + 1 < x; // "cannot happen", so the compiler deletes the check
}
Unsigned arithmetic is the opposite: it is defined to wrap, and a lot of C++ code (hashes, checksums, ring-buffer indices) depends on it.
Rust treats every overflow, signed or unsigned, as a bug, but never as undefined behavior. The Rust Reference splits the rule by build:
- When debug assertions are on, “implementations must insert dynamic checks that panic on overflow”.
- Other builds “may result in panics or silently wrapped values on overflow, at the implementation’s discretion”, always with two’s complement results.
Cargo’s defaults are overflow-checks = true in the dev profile and false in release.
When you want a specific overflow behavior, you name it:
use std::hint::black_box;
fn main() {
let a = i32::MAX;
let b = black_box(1); // hide the value from the compiler's constant checks
println!("{:?}", a.checked_add(b)); // None
println!("{}", a.wrapping_add(b)); // -2147483648
println!("{}", a.saturating_add(b)); // 2147483647
println!("{:?}", a.overflowing_add(b)); // (-2147483648, true)
let c = a + b; // panics: debug build, "attempt to add with overflow"
println!("{c}");
}
The gap: port a C++ hash function to Rust with plain + and *, and your debug build panics on the first input. That is not a Rust bug. That is Rust asking you to say “yes, I want wrapping here”. Use wrapping_* methods or the std::num::Wrapping<T> type.
The opposite trap also exists: release builds wrap silently by default. If overflow would be a real bug in production, set overflow-checks = true in [profile.release] and pay the small cost.
Everything is an expression
In Rust, if, match, and plain blocks all produce values. The ternary operator does not exist, because if already does its job, and the last expression of a function body is its return value.
The switch statement becomes match, and match must be exhaustive. Here is the C++ version. It compiles, with a -Wswitch warning (on by default in clang, part of -Wall in GCC):
enum class Shape { Circle, Square, Triangle };
const char* name(Shape s) {
switch (s) {
case Shape::Circle: return "circle";
case Shape::Square: return "square";
// Triangle forgotten: a warning at best
}
return "unknown";
}
The same mistake in Rust is a hard error, not a warning:
// does not compile: E0004
enum Shape { Circle, Square, Triangle }
fn name(s: Shape) -> &'static str {
match s {
Shape::Circle => "circle",
Shape::Square => "square",
} // error[E0004]: non-exhaustive patterns: `Shape::Triangle` not covered
}
(&'static str means “a string slice that lives for the whole program”, which is what a string literal is.)
match also has no fallthrough, and its patterns do much more than compare constants: alternatives, ranges, guards, and bindings.
fn classify(status: u16) -> &'static str {
match status {
200 | 204 => "ok",
300..=399 => "redirect",
s if s >= 500 => "server error",
_ => "other",
}
}
fn main() {
println!("{} {} {}", classify(204), classify(302), classify(503));
}
Loops translate almost one to one, with two upgrades: loop can return a value through break, and labeled breaks replace the goto you use to escape nested loops.
| C++ | Rust |
|---|---|
for (const auto& x : v) |
for x in &v |
for (auto& x : v) |
for x in &mut v |
for (int i = 0; i < n; ++i) |
for i in 0..n |
| index + element | for (i, x) in v.iter().enumerate() |
while (cond) |
while cond |
for (;;) / while (true) |
loop |
goto found; out of nested loops |
break 'label; with a value only from loop or a labeled block |
fn main() {
let grid = [[1, 2, 3], [4, 5, 6]];
let found = 'search: {
for (r, row) in grid.iter().enumerate() {
for (c, &value) in row.iter().enumerate() {
if value == 5 {
break 'search Some((r, c));
}
}
}
None
};
println!("{found:?}"); // Some((1, 1))
}
Functions: no overloading, no default arguments
Rust has neither function overloading nor default arguments. Both are deliberate. Overload resolution combined with implicit conversions is one of the most complicated parts of the C++ standard, and in Rust a function name always means exactly one function.
#include <string>
#include <utility>
struct Conn { std::string host; int port; int timeout_ms; bool tls; };
Conn connect(std::string host, int port = 443, int timeout_ms = 5000, bool tls = true) {
return {std::move(host), port, timeout_ms, tls};
}
void log(int value);
void log(const std::string& value); // overload by type
The Rust replacements, from simplest to most flexible:
- Different names for different things:
log_int,log_str. Or one generic function over a trait (fn log(value: impl Display)), which Part 2 covers. - An options struct with
Default, plus struct update syntax (..Default::default()) to override only some fields. - A builder when construction has rules or many optional parts.
#[derive(Debug)]
struct ConnectOptions {
port: u16,
timeout_ms: u32,
tls: bool,
}
impl Default for ConnectOptions {
fn default() -> Self {
Self { port: 443, timeout_ms: 5000, tls: true }
}
}
fn connect(host: &str, opts: ConnectOptions) -> String {
format!("{host}:{} tls={} timeout={}ms", opts.port, opts.tls, opts.timeout_ms)
}
fn main() {
println!("{}", connect("example.com", ConnectOptions::default()));
println!(
"{}",
connect("localhost", ConnectOptions { port: 8080, tls: false, ..Default::default() })
);
}
The options struct is more typing than = 443, but it ages better: the call site names every value it changes, and adding a new option does not break any caller.
To return several values, return a tuple, fn min_max(v: &[i32]) -> (i32, i32), and destructure it with let (lo, hi) = min_max(&v);, like C++17 structured bindings.
Strings: two types and a byte count
| C++ | Rust |
|---|---|
std::string |
String: owned, growable, always valid UTF-8 |
std::string_view, const std::string& |
&str: a borrowed view into UTF-8 text |
const char* to or from C |
&CStr / CString, only at FFI boundaries |
std::to_string(42) |
42.to_string(), format!("{}", 42) |
std::stoi(s) (throws) |
s.parse::<i32>() (returns a Result) |
s.size() |
s.len(), in bytes |
s[i] |
no direct equivalent; see below |
s.substr(pos, n) |
&s[pos..pos + n], a byte range that must fall on character boundaries |
a + b |
a + &b (consumes a), or format!("{a}{b}") |
Function parameters that only read text should take &str, the same advice as “take std::string_view” in modern C++. A &String converts to &str automatically.
A Rust String is guaranteed to hold valid UTF-8, where a character can take one to four bytes. So “the i-th character” cannot be both O(1) and correct, and Rust refuses to pretend otherwise:
// does not compile: E0277
fn main() {
let s = String::from("naïve");
let c = s[0]; // error[E0277]: the type `str` cannot be indexed by `{integer}`
}
You say what you mean instead: bytes, characters, or a byte range.
fn main() {
let s = String::from("naïve");
println!("{} bytes, {} chars", s.len(), s.chars().count()); // 6 bytes, 5 chars
println!("{:?}", s.chars().nth(2)); // Some('ï')
println!("{}", &s[0..2]); // "na"
// &s[0..3] would panic: byte 3 is in the middle of 'ï'
let n: i32 = "42".parse().unwrap();
println!("{}", n + 1);
}
The second difference is lifetime. A std::string_view that outlives its string is a dangling pointer, and the compiler will not tell you:
#include <print>
#include <string>
#include <string_view>
std::string_view first_word(std::string_view s) {
return s.substr(0, s.find(' '));
}
int main() {
auto w = first_word(std::string("hello world")); // the temporary dies at the ';'
std::println("{}", w); // dangling view: undefined behavior
}
The same code in Rust does not compile, because the borrow checker knows that w points into a temporary:
// does not compile: E0716
fn first_word(s: &str) -> &str {
s.split(' ').next().unwrap_or("")
}
fn main() {
let w = first_word(&String::from("hello world")); // temporary dropped here...
println!("{w}"); // ...while `w` still borrows it
}
This is your first look at the borrow checker. Part 2 is mostly about it.
Containers
| C++ | Rust | Note |
|---|---|---|
T[N], std::array<T, N> |
[T; N] |
a value type, like std::array |
std::vector<T> |
Vec<T> |
|
std::span<T>, pointer + length |
&[T], &mut [T] |
a slice always carries its length |
std::deque<T> |
VecDeque<T> |
a ring buffer |
std::list<T> |
LinkedList<T> |
rarely the right choice, in either language |
std::map<K, V> |
BTreeMap<K, V> |
ordered; a B-tree, where std::map is usually a red-black tree |
std::unordered_map<K, V> |
HashMap<K, V> |
SipHash 1-3 by default: resists HashDoS, slower for small keys |
std::set, std::unordered_set |
BTreeSet, HashSet |
|
std::priority_queue<T> |
BinaryHeap<T> |
both are max-heaps by default |
v.push_back(x), v.emplace_back(args) |
v.push(x) |
no emplace: see below |
v[i] / v.at(i) |
v[i] panics if out of range / v.get(i) returns Option |
get_unchecked is the unsafe opt-out |
The most important container lesson is that in Rust, reading never writes. In C++, operator[] on a map inserts a default value when the key is missing, even when all you wanted was to look:
#include <map>
#include <print>
#include <string>
std::map<std::string, int> stock{{"apples", 3}};
if (stock["pears"] > 0) { /* ... */ } // inserts "pears" -> 0 as a side effect
std::println("{}", stock.size()); // 2
In Rust, indexing a map with a missing key panics, and get returns an Option. When you want insert-or-update, the entry API says so out loud:
use std::collections::BTreeMap;
fn main() {
let mut stock = BTreeMap::from([("apples", 3)]);
// stock["pears"] would panic: indexing never inserts
let pears = stock.get("pears").copied().unwrap_or(0);
println!("{pears} {}", stock.len()); // 0 1
*stock.entry("pears").or_insert(0) += 5;
println!("{stock:?}"); // {"apples": 3, "pears": 5}
}
The gap: out-of-bounds access is a panic, not undefined behavior. The bounds checks are cheap and are often optimized out, especially when you iterate instead of indexing, which is the idiomatic style anyway (Part 2).
There is also no emplace_back: a Rust move is a plain copy of the bytes that never runs user code, so there is no constructor to forward arguments to. You build the value and push it.
Structs and classes
A C++ class splits into two Rust pieces: a struct for the data and an impl block for the functions.
#include <cstdint>
#include <string>
#include <utility>
class Account {
public:
explicit Account(std::string owner) : owner_(std::move(owner)), balance_(0) {}
void deposit(std::int64_t amount) { balance_ += amount; }
std::int64_t balance() const { return balance_; }
private:
std::string owner_;
std::int64_t balance_;
};
pub struct Account {
owner: String, // private: visible in this module and its child modules
balance: i64,
}
impl Account {
pub fn new(owner: String) -> Self {
Self { owner, balance: 0 }
}
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
pub fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut acc = Account::new("ada".to_string());
acc.deposit(100);
println!("{} has {}", acc.owner, acc.balance()); // fine: same module
}
| C++ | Rust |
|---|---|
| constructor | an ordinary associated function, by convention called new |
| overloaded constructors | several named functions: new, with_capacity, from_parts |
this->field |
self.field, always explicit |
const member function |
&self |
non-const member function |
&mut self |
&&-qualified member function |
self: the method takes ownership and consumes the object |
static member function |
an associated function with no self |
private: |
the default, but private to the module, not to the type |
friend |
not needed: everything in the same module can see private fields |
| destructor | impl Drop (Part 2) |
| inheritance | traits and composition (Part 2) |
The gap: a Rust constructor is not special. A struct literal must set every field, so there is no half-built object, no member initialization order puzzle, and no exception escaping from the middle of a constructor.
The price is that “constructor” is now a naming convention, and nothing forces callers to go through new except privacy. That is why privacy matters: a type with a private field can only be created inside its own module (or a child module), which is how you protect an invariant.
Enums: the enum class C++ wanted
A C++ enum class maps to a Rust enum, with explicit values when you need them:
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
enum Level {
Debug = 10,
Info = 20,
Warn = 30,
}
fn main() {
let lvl = Level::Info;
println!("{:?} = {}", lvl, lvl as u8); // Info = 20
// let bad = 7 as Level; // error[E0605]: non-primitive cast
}
Casting an enum to an integer is fine. The other way does not compile. In C++, static_cast<Level>(7) gives you a value that matches no enumerator, and every switch downstream has to deal with it. A Rust enum value is always one of its variants, so integer-to-enum conversions go through a TryFrom impl you write, and that impl can fail.
Rust enums can also carry data, one payload per variant. That makes them tagged unions, the job std::variant does in C++, but built into the language and checked by match:
enum Command {
Quit,
Move { x: i32, y: i32 },
Write(String),
}
fn run(cmd: Command) -> String {
match cmd {
Command::Quit => "bye".to_string(),
Command::Move { x, y } => format!("move to {x},{y}"),
Command::Write(text) => text,
}
}
fn main() {
println!("{}", run(Command::Move { x: 1, y: 2 }));
}
Data-carrying enums are among the most used features in Rust. Part 2 compares them with std::variant and std::visit in detail.
Null: Option instead of nullptr
A C++ pointer can always be null, and nothing in the type tells the caller whether it might be:
#include <string>
#include <vector>
struct User { int id; std::string name; };
const User* find_user(const std::vector<User>& users, int id) {
for (const auto& u : users)
if (u.id == id) return &u;
return nullptr; // the caller must remember to check
}
Rust references are never null. “Maybe a value” is a separate type, Option<T>, and you cannot reach the value inside without handling the None case:
struct User {
id: u32,
name: String,
}
fn find_user(users: &[User], id: u32) -> Option<&User> {
users.iter().find(|u| u.id == id)
}
fn main() {
let users = vec![User { id: 1, name: "ada".into() }];
// find_user(&users, 1).name // error[E0609]: no field `name` on type `Option<&User>`
match find_user(&users, 1) {
Some(u) => println!("found {}", u.name),
None => println!("no such user"),
}
let guest = find_user(&users, 2).map(|u| u.name.as_str()).unwrap_or("guest");
println!("{guest}");
let Some(u) = find_user(&users, 1) else {
return; // let-else: unwrap or leave early
};
println!("{}", u.name);
}
|u| u.id == id is a closure, Rust’s lambda, and .iter().find(...) is the iterator form of std::find_if. Part 2 covers both.
This safety costs zero bytes. The standard library guarantees that Option<&T>, Option<Box<T>>, and a few others have the same size as the bare pointer, because the compiler uses the forbidden null value to encode None.
On this machine, size_of::<Option<&u64>>() is 8, the same as a pointer, while sizeof(std::optional<int*>) is 16. The type Rust uses to remove null pointers compiles back down to a null pointer.
References: a first look
C++ references and Rust references look alike. const T& becomes &T, and T& becomes &mut T. One difference is visible right away, at the call site:
#include <vector>
void scale(std::vector<double>& v, double k) {
for (auto& x : v) x *= k;
}
// scale(data, 2.0); -- nothing here says that `data` is modified
fn scale(v: &mut [f64], k: f64) {
for x in v.iter_mut() {
*x *= k;
}
}
fn sum(v: &[f64]) -> f64 {
v.iter().sum()
}
fn main() {
let mut data = vec![1.0, 2.0, 3.0];
scale(&mut data, 2.0); // the call says it: `data` may change here
println!("{}", sum(&data));
}
The deeper difference is one rule, which the compiler checks everywhere: at any moment, a value has either any number of &T readers or exactly one &mut T writer, never both. Add “a reference can never outlive what it points to”, and you have the core of the borrow checker.
Part 2 shows how this one rule removes iterator invalidation, dangling references, and a whole family of aliasing bugs, and what to do when it gets in your way.
Printing and formatting
C++20 std::format and C++23 std::print use almost the same format-spec mini-language as Rust’s format! and println!, so this section is mostly good news. The ! marks a macro. Macros are how Rust checks the format string against the arguments at compile time, the job that the consteval format string does for std::format in C++20.
| C++ | Rust |
|---|---|
std::println("{} {}", a, b) |
println!("{} {}", a, b) or println!("{a} {b}") |
std::format("{:>8}", s) |
format!("{:>8}", s) |
std::cerr << ... |
eprintln!(...) |
operator<< or a std::formatter specialization |
impl Display (Part 2) |
| a debug dump of a struct | #[derive(Debug)] plus {:?}, or {:#?} for pretty output |
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p = Point { x: 3.14159, y: -2.0 };
let label = "offset";
println!("{label}: {p:?}"); // offset: Point { x: 3.14159, y: -2.0 }
println!("[{:>8.2}]", p.x); // [ 3.14]
eprintln!("{p:#?}"); // pretty, multi-line, to stderr
}
The gap: one trap hides in the “almost”. In Rust, {:.2} on a float means two digits after the decimal point. In C++, {:.2} without a type letter uses the general format, where the precision counts significant digits: std::format("{:.2}", 3.14159) gives 3.1, and you need {:.2f} to get 3.14. Port a format string without the f and your numbers change quietly.
Cheat sheet
| C++ | Rust |
|---|---|
int, long, size_t |
i32, i64/i32 (choose), usize |
| implicit conversion | From (lossless), TryFrom (fallible), as (truncating) |
const T x = v; / T x = v; |
let x = v; / let mut x = v; |
constexpr value |
const |
auto |
type inference with let |
| uninitialized variable | compile error until every path assigns it |
| signed overflow (undefined behavior) | panic in debug, wrap in release; checked_, wrapping_, saturating_ |
| unsigned wraparound | wrapping_* or Wrapping<T> (plain + panics in debug) |
c ? a : b |
if c { a } else { b } |
switch |
match: exhaustive, no fallthrough, patterns and guards |
| range-for | for x in &v / for x in &mut v / for x in v |
goto out of loops |
labeled break 'outer |
| overloads | distinct names, or generics with traits |
| default arguments | Default + ..Default::default(), or a builder |
std::pair, structured bindings |
tuples, let (a, b) = ... |
std::string / std::string_view |
String / &str |
const char* |
&CStr, CString (FFI only) |
std::stoi |
s.parse::<i32>() |
std::array / std::vector / std::span |
[T; N] / Vec<T> / &[T] |
std::map / std::unordered_map |
BTreeMap / HashMap |
m[k] (inserts) |
m.get(&k) (reads), m.entry(k).or_insert(v) (writes) |
v.at(i) |
v[i] (panics) or v.get(i) (Option) |
class with constructor |
struct + impl + fn new |
const / non-const method |
&self / &mut self |
private: + friend |
module privacy, pub |
enum class |
enum (#[repr(u8)] for a fixed layout) |
std::variant |
enum with data |
T* that can be null, std::optional<T> |
Option<&T>, Option<T> |
const T& / T& |
&T / &mut T (explicit at the call site) |
std::println, std::format |
println!, format! |
operator<< for printing |
impl Display, #[derive(Debug)] |
Traps for C++ developers
- Unsigned arithmetic panics in debug builds. Hashes and checksums need
wrapping_*. - Release builds wrap on overflow by default. Turn on
overflow-checksin release if overflow would be a real bug. astruncates silently. PreferFromandTryFrom.s.len()counts bytes, and string slices panic if a range cuts a character in half.- Map indexing never inserts. It panics on a missing key. Use
getto read andentryto write. {:.2}means decimals in Rust and significant digits in C++ (withoutf).- Privacy is per module. Code in the same module, and in its child modules, can see private fields.
- Immutability is deep. There is no
const_cast; Part 2 shows the legal escape hatch.
What comes next
The syntax swap takes an afternoon. The defaults take longer, because each one removes something your C++ habits relied on: the implicit conversion, the uninitialized variable, the overflow that “never happens”, the null that is “always checked”.
In your first weeks, read every Rust compile error as a C++ assumption that someone finally wrote down. Most of the time the assumption was fine. Sometimes it was a bug that had been waiting in your C++ code all along, and in Rust it stops the build instead of reaching production.
Part 2 takes on the biggest default flip of all: ownership. It covers moves without moved-from objects, RAII with Drop, the borrow checker in depth, smart pointers, errors without exceptions, and how templates and virtual functions become generics and traits.
References
- The Rust Programming Language, the official book
- The Rust Reference: Integer overflow, overflow rules quoted above
- Cargo Book: Profiles, default
overflow-checksper profile - std::option: Representation, the null pointer optimization guarantee
- std::collections::HashMap, default hasher (SipHash 1-3) and HashDoS resistance
- std::fmt, Rust format syntax
- cppreference: Arithmetic operators, overflows, signed overflow is undefined behavior
- cppreference: std::map::operator[], inserts when the key is missing
- cppreference: Standard format specification, precision with and without a type letter
- C++ to Rust Phrasebook, Brown University, a good companion to this series
- Comprehensive Rust, Google’s free Rust course
- Clippy lints, including
cast_possible_truncation - Herb Sutter: C++26 is done! Trip report, March 2026, C++26 status, erroneous behavior, and library hardening
- cppreference: C++26 compiler support, which compilers ship which C++26 features