Hello World & the Build
Hello, World
The first thing to notice is what is absent from the Rust version: no
#include, no namespace qualification, and no return 0.#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}fn main() {
println!("Hello, World!");
}Rust has no preprocessor and no headers, so there is nothing to include —
println! comes from the prelude, which is injected into every module automatically. The trailing ! marks it as a macro rather than a function, which is how it can typecheck its format string at compile time; a mismatched placeholder is a compile error, not a runtime surprise. Returning from main is implicit, and the process exit code is 0 unless main returns an Err.CMake vs Cargo
This is the single biggest day-one difference, and it has nothing to do with the language. Choosing and wiring a C++ build is a project in itself; in Rust the answer was decided once, for everyone.
// A C++ project needs a build system chosen and configured.
// CMakeLists.txt, typically:
//
// cmake_minimum_required(VERSION 3.28)
// project(inventory CXX)
// set(CMAKE_CXX_STANDARD 23)
// add_executable(inventory src/main.cpp src/report.cpp)
// target_link_libraries(inventory PRIVATE fmt::fmt)
//
// Dependencies come from the system, vcpkg, Conan, or a git submodule —
// there is no single answer, and every source file must be listed.
//
// cmake -B build && cmake --build build
// ./build/inventory
#include <iostream>
int main() {
std::cout << "built by whichever build system this project chose" << std::endl;
return 0;
}// Cargo ships with the compiler, so every Rust project has the same shape.
// Cargo.toml, in full:
//
// [package]
// name = "inventory"
// version = "0.1.0"
// edition = "2024"
//
// [dependencies]
// serde = "1"
//
// Source files are discovered, not listed. Dependencies are resolved,
// downloaded, version-locked in Cargo.lock and built for you.
//
// cargo run — build and run
// cargo test — build and run every #[test]
// cargo build --release
fn main() {
println!("built by Cargo, like every other Rust project");
}There is no Rust equivalent of the CMake-versus-Meson-versus-Bazel question, and no equivalent of hunting a header down through
find_package. The cost is the flip side of the same coin: Cargo assumes it owns the build, so slotting Rust into an existing CMake tree means driving Cargo from CMake rather than the reverse. Note also that cargo test needs no separate framework — the test runner is part of the toolchain.Headers and namespaces vs modules
A Rust
mod looks like a C++ namespace, but it is doing a second job as well: it is also the privacy boundary that C++ spells with class access specifiers or an anonymous namespace.#include <iostream>
#include <string>
namespace geometry {
struct Point {
double x;
double y;
};
double distance_from_origin(const Point& point) {
return std::sqrt(point.x * point.x + point.y * point.y);
}
}
#include <cmath>
int main() {
geometry::Point corner{3.0, 4.0};
std::cout << geometry::distance_from_origin(corner) << std::endl;
return 0;
}mod geometry {
pub struct Point {
pub x: f64,
pub y: f64,
}
pub fn distance_from_origin(point: &Point) -> f64 {
(point.x * point.x + point.y * point.y).sqrt()
}
}
fn main() {
let corner = geometry::Point { x: 3.0, y: 4.0 };
println!("{}", geometry::distance_from_origin(&corner));
}Everything in a Rust module is private to that module unless marked
pub — including struct fields, which is why x and y need it here. A module also compiles as part of one crate rather than being textually included, so there is no header/implementation split, no include guards, no one-definition rule to violate, and no possibility of two translation units disagreeing about a type because they saw different #defines.Variables & Types
const by default
The defaults are inverted. C++ is mutable unless you write
const; Rust is immutable unless you write mut.#include <iostream>
int main() {
const int attempts = 3;
int remaining = 3;
remaining -= 1;
// attempts -= 1; // error: assignment of read-only variable
std::cout << attempts << " " << remaining << std::endl;
return 0;
}fn main() {
let attempts = 3;
let mut remaining = 3;
remaining -= 1;
// attempts -= 1; // error: cannot assign twice to immutable variable
println!("{} {}", attempts, remaining);
}This sounds like a cosmetic swap and is not. Because the annotated case is the rarer one,
mut actually gets written and actually gets noticed, whereas const-correctness in C++ is a discipline that erodes the moment one function in the call chain forgets it. A Rust reviewer can find every place a value changes by grepping for mut.Fixed-width integers
C++ inherited a family of integer types whose sizes are minimums rather than guarantees. Rust has no such family.
#include <iostream>
#include <cstdint>
int main() {
int platform_dependent = 42; // 16, 32 or 64 bits — the standard does not say
std::int32_t exactly_32 = 42;
std::size_t index = 42;
std::cout << sizeof(platform_dependent) << " "
<< sizeof(exactly_32) << " "
<< sizeof(index) << std::endl;
return 0;
}fn main() {
let platform_dependent: i32 = 42; // i32 is exactly 32 bits, always
let exactly_32: i32 = 42;
let index: usize = 42;
println!("{} {} {}",
size_of_val(&platform_dependent),
size_of_val(&exactly_32),
size_of_val(&index));
}There is no
int in Rust and no <cstdint>-style second set of names, because the primary names already carry the width: i8, i16, i32, i64, i128, and the u family for unsigned. Only usize/isize vary by platform, and they exist for exactly the reason std::size_t does. The default when inference has nothing else to go on is i32.No implicit numeric conversion
This C++ program prints
-1 is NOT less than 1. That is not a compiler bug — it is the usual arithmetic conversions doing exactly what the standard says.#include <iostream>
int main() {
int signed_count = -1;
unsigned int unsigned_count = 1;
// Both operands convert to unsigned. The comparison is not the one written.
if (signed_count < unsigned_count) {
std::cout << "-1 < 1" << std::endl;
} else {
std::cout << "-1 is NOT less than 1" << std::endl;
}
return 0;
}fn main() {
let signed_count: i32 = -1;
let unsigned_count: u32 = 1;
// `signed_count < unsigned_count` does not compile: mismatched types.
// The conversion has to be written, and then it is visible.
if (signed_count as i64) < (unsigned_count as i64) {
println!("-1 < 1");
} else {
println!("-1 is NOT less than 1");
}
}Rust performs no implicit numeric conversions at all: not signed to unsigned, not narrowing, not even
i32 to i64. Every conversion is an explicit as, which means the sign-comparison trap above cannot be written by accident. The cost is real — arithmetic that mixes widths gets noisier — but the class of bug that -Wsign-compare exists to warn about simply does not compile.Integer overflow is defined
Signed overflow is the most consequential undefined behavior in C++, because it licenses the optimizer to delete the very bounds check you wrote to guard against it.
#include <iostream>
#include <limits>
int main() {
int largest = std::numeric_limits<int>::max();
// Signed overflow is undefined behavior. The optimizer is entitled to
// assume it never happens, so testing AFTER the fact is worthless —
// the check has to happen before, and you write it yourself:
if (largest > std::numeric_limits<int>::max() - 1) {
std::cout << "would overflow" << std::endl;
}
unsigned int unsigned_largest = std::numeric_limits<unsigned int>::max();
std::cout << "unsigned wraps to " << unsigned_largest + 1 << std::endl;
return 0;
}fn main() {
let largest = i32::MAX;
// Overflow panics in debug and wraps in release, so neither is silent
// undefined behavior. Ask explicitly and both builds agree:
if largest.checked_add(1).is_none() {
println!("would overflow");
}
let unsigned_largest = u32::MAX;
println!("unsigned wraps to {}", unsigned_largest.wrapping_add(1));
}Rust has no undefined overflow. The default
+ panics in a debug build and wraps in a release build, and when you want a specific behavior you name it: checked_add returns Option, wrapping_add wraps, saturating_add clamps, and overflowing_add returns both the result and a flag. The important part is that no choice among these is ever undefined, so the optimizer cannot reason backwards from "this cannot happen."No uninitialized reads
Rust also lets you declare a variable before you have a value for it. The difference is what happens if you then read it.
#include <iostream>
int main() {
int total; // legal, and its value is indeterminate
// Reading `total` here is undefined behavior, and compiles with a warning
// at most. Initialize it to make the program well-defined:
total = 0;
for (int value = 1; value <= 4; ++value) {
total += value;
}
std::cout << total << std::endl;
return 0;
}fn main() {
let mut total; // legal — declaration without a value
// Reading `total` here is a COMPILE error, not undefined behavior.
// The compiler tracks initialization along every path.
total = 0;
for value in 1..=4 {
total += value;
}
println!("{}", total);
}The compiler performs a definite-initialization analysis over the whole control-flow graph, so a variable assigned in an
if branch but not the else is rejected at the point of use. This is one of the borrow checker's quieter jobs and it removes an entire category of C++ bug without any runtime cost — the check is purely static.Strings
std::string and string_view
The pairing you already know maps across almost exactly:
std::string owns a heap buffer, std::string_view borrows one. Rust spells them String and &str.#include <iostream>
#include <string>
#include <string_view>
void announce(std::string_view text) {
std::cout << "[" << text << "]" << std::endl;
}
int main() {
std::string owned = "hello";
owned += ", world";
announce(owned); // implicit conversion to string_view
announce("a literal");
return 0;
}fn announce(text: &str) {
println!("[{}]", text);
}
fn main() {
let mut owned = String::from("hello");
owned += ", world";
announce(&owned); // deref coercion from &String to &str
announce("a literal");
}The one difference is the one that matters. A
std::string_view that outlives its std::string is a dangling view and the compiler will not stop you — this is one of the easiest C++ footguns to fire. A &str carries a lifetime, so the same mistake does not compile. Note also that taking &owned where &str is wanted works through deref coercion, the closest thing Rust has to a user-visible implicit conversion.Strings are guaranteed UTF-8
Both languages count bytes, and both are right to. The difference is whether the type knows what those bytes mean.
#include <iostream>
#include <string>
int main() {
std::string greeting = "naïve";
// .size() counts BYTES, and indexing yields one byte, which may be
// half of a character.
std::cout << "bytes: " << greeting.size() << std::endl;
// Counting characters means decoding it yourself.
std::size_t characters = 0;
for (unsigned char byte : greeting) {
if ((byte & 0xC0) != 0x80) { ++characters; }
}
std::cout << "characters: " << characters << std::endl;
return 0;
}fn main() {
let greeting = String::from("naïve");
// .len() also counts BYTES — Rust is honest about that.
println!("bytes: {}", greeting.len());
// But decoding is built in, because the UTF-8 invariant is guaranteed.
println!("characters: {}", greeting.chars().count());
}A
std::string is a bag of char with no encoding attached, so counting characters means writing the UTF-8 decoder above. A Rust String is guaranteed valid UTF-8 — the constructors that could break that invariant return a Result — so .chars() can exist and be correct. The same guarantee is why greeting[0] does not compile in Rust: it would have to return half a character, so the language declines to offer it.Formatting
C++20 finally adopted the Python-style brace syntax that Rust has used since 1.0, so this row is mostly a pleasant surprise: you already know the syntax.
#include <iostream>
#include <format>
#include <string>
int main() {
std::string product = "widget";
int quantity = 7;
double price = 3.5;
std::string line = std::format("{} x{} at {:.2f}", product, quantity, price);
std::cout << line << std::endl;
return 0;
}fn main() {
let product = "widget";
let quantity = 7;
let price = 3.5;
let line = format!("{} x{} at {:.2}", product, quantity, price);
println!("{}", line);
}The two grammars are deliberately close cousins — both descend from Python's. Two differences to note: Rust's float precision spec is
{:.2} with no trailing type letter, and because format! is a macro rather than a function it validates the string against the argument list at compile time. A wrong placeholder count is a compile error in Rust; in C++ it is a compile error only when the format string is a constant expression, which is the common case but not the only one.Collections
std::vector vs Vec
Vec<T> is std::vector<T>: contiguous, heap-allocated, growable, amortized O(1) push.#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{12, 7, 30};
readings.push_back(4);
for (int reading : readings) {
std::cout << reading << " ";
}
std::cout << std::endl;
std::cout << "size " << readings.size() << std::endl;
return 0;
}fn main() {
let mut readings = vec![12, 7, 30];
readings.push(4);
for reading in &readings {
print!("{} ", reading);
}
println!();
println!("size {}", readings.len());
}The layouts are identical, so this really is the same data structure with different spelling. Two things to watch: iterating
&readings rather than readings borrows the vector instead of consuming it (writing for reading in readings would move it, and the vector would be unusable afterwards), and push requires mut where C++ needs only a non-const reference.Iterator invalidation
This is the canonical example of what the borrow checker buys you, and it is worth seeing in the shape you already recognize.
#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{12, 7, 30};
// Pushing during iteration may reallocate, leaving the iterator dangling.
// This compiles cleanly and is undefined behavior. Collect first instead:
std::vector<int> doubled;
for (int reading : readings) {
doubled.push_back(reading * 2);
}
for (int value : doubled) {
readings.push_back(value);
}
std::cout << readings.size() << std::endl;
return 0;
}fn main() {
let mut readings = vec![12, 7, 30];
// `for reading in &readings { readings.push(...) }` does not COMPILE:
// the loop holds a shared borrow, push needs a mutable one.
// The same two-phase fix, but the compiler insisted on it:
let doubled: Vec<i32> = readings.iter().map(|reading| reading * 2).collect();
for value in doubled {
readings.push(value);
}
println!("{}", readings.len());
}Mutating a container while iterating it is undefined behavior in C++ and a compile error in Rust, and the rule that produces the error is simple enough to hold in your head: any number of shared borrows, or exactly one mutable borrow, never both. That single rule also rules out the aliasing that makes C++ optimizers so conservative, which is why Rust can pass
noalias to LLVM on ordinary references.std::unordered_map vs HashMap
The trap here is
operator[]. In C++ a lookup on a missing key silently inserts one; that behavior has no counterpart in Rust.#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> stock;
stock["widget"] = 7;
stock["gadget"] = 3;
// operator[] INSERTS a default-constructed value if the key is absent.
if (auto found = stock.find("sprocket"); found != stock.end()) {
std::cout << found->second << std::endl;
} else {
std::cout << "sprocket: absent" << std::endl;
}
std::cout << "widget: " << stock.at("widget") << std::endl;
return 0;
}use std::collections::HashMap;
fn main() {
let mut stock = HashMap::new();
stock.insert("widget", 7);
stock.insert("gadget", 3);
// There is no operator[] that inserts. Lookup returns Option.
match stock.get("sprocket") {
Some(count) => println!("{}", count),
None => println!("sprocket: absent"),
}
println!("widget: {}", stock["widget"]);
}Rust's
get returns Option<&V>, so the absent case is a value you must handle rather than a default-constructed entry you did not ask for. Indexing with [] does exist and panics on a missing key, matching at() rather than operator[]. When you do want insert-if-absent, the entry API is explicit about it: *stock.entry("sprocket").or_insert(0) += 1.Pointer-plus-length vs slices
C++20's
std::span is the feature that finally retired the (pointer, length) parameter pair. Rust has had the same thing since before 1.0 and calls it a slice.#include <iostream>
#include <span>
#include <vector>
int sum(std::span<const int> values) {
int total = 0;
for (int value : values) { total += value; }
return total;
}
int main() {
std::vector<int> readings{12, 7, 30, 4};
int raw_array[3] = {1, 2, 3};
std::cout << sum(readings) << " " << sum(raw_array) << std::endl;
return 0;
}fn sum(values: &[i32]) -> i32 {
let mut total = 0;
for value in values { total += value; }
total
}
fn main() {
let readings = vec![12, 7, 30, 4];
let raw_array = [1, 2, 3];
println!("{} {}", sum(&readings), sum(&raw_array));
}A
&[i32] is a fat pointer — address plus length — exactly like a std::span, and it accepts a Vec, a fixed-size array, or part of either via &readings[1..3]. The difference is again lifetimes: a span outliving its vector is a dangling read that compiles, while a slice outliving its Vec does not compile. Note that sum ends in an expression with no semicolon and no return, which is the ordinary Rust style.Control Flow
if is an expression
Rust has no
?: operator, and does not need one.#include <iostream>
int main() {
int temperature = 31;
// The ternary is the only conditional that produces a value, so
// anything more than one line has to go through a mutable variable.
const char* advice = temperature > 30 ? "stay inside" : "go for a walk";
std::cout << advice << std::endl;
return 0;
}fn main() {
let temperature = 31;
// `if` produces a value, so there is no separate ternary operator
// and no need for a mutable variable.
let advice = if temperature > 30 {
"stay inside"
} else {
"go for a walk"
};
println!("{}", advice);
}Almost everything in Rust is an expression:
if, match, loop, and a bare block all produce values. The practical payoff is that initialization stays a single let even when the logic needs several lines, so a variable that is conceptually constant does not have to be declared mutable just to be filled in afterwards. Both arms must have the same type, which is what makes it type-check.Loops and labeled break
Rust deleted the C-style
for (init; test; step) loop outright. Every loop is over an iterator.#include <iostream>
int main() {
// The three-clause for loop, and goto for a labeled break.
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
if (row * column > 2) { goto done; }
std::cout << row * column << " ";
}
}
done:
std::cout << std::endl;
for (int countdown = 3; countdown >= 1; --countdown) {
std::cout << countdown << " ";
}
std::cout << std::endl;
return 0;
}fn main() {
// No three-clause for loop at all. Labeled break instead of goto.
'outer: for row in 0..3 {
for column in 0..3 {
if row * column > 2 { break 'outer; }
print!("{} ", row * column);
}
}
println!();
for countdown in (1..=3).rev() {
print!("{} ", countdown);
}
println!();
}The replacement for a counted loop is a range:
0..3 is half-open, 1..=3 is inclusive, and .rev() counts down. Breaking out of nested loops uses a lifetime-style label ('outer) on both the loop and the break, which is what C++ needs goto or a flag variable for. There is no goto in Rust, and labeled break/continue covers the cases people actually used it for.switch fallthrough vs match
The missing
break is such a reliable source of bugs that C++17 added [[fallthrough]] so the intentional case could be told from the accidental one.#include <iostream>
int main() {
int code = 2;
switch (code) {
case 1:
std::cout << "one" << std::endl;
break; // forget this and control falls into case 2
case 2:
std::cout << "two" << std::endl;
break;
default:
std::cout << "other" << std::endl;
break;
}
return 0;
}fn main() {
let code = 2;
match code {
1 => println!("one"),
2 => println!("two"),
_ => println!("other"),
}
}Rust's
match does not fall through, so there is no break to forget. It is also exhaustive: leaving out the _ arm here is a compile error because the compiler cannot prove every i32 is covered. That exhaustiveness is what makes match genuinely more powerful than switch rather than merely safer — see the enum rows below, where adding a variant turns every incomplete match in the codebase into a compile error.Functions & Closures
Function declarations
If you have used C++11's trailing return type, Rust's function syntax will already look familiar — the arrow is the same idea.
#include <iostream>
// Trailing return type, C++11 onward — the syntax Rust adopted wholesale.
auto larger(int first, int second) -> int {
if (first > second) { return first; }
return second;
}
int main() {
std::cout << larger(3, 9) << std::endl;
return 0;
}// Rust has only the trailing form.
fn larger(first: i32, second: i32) -> i32 {
if first > second { return first; }
second
}
fn main() {
println!("{}", larger(3, 9));
}Two things differ from C++ habit. There is no forward declaration and no ordering requirement, because a Rust module is parsed as a whole rather than top to bottom, so a function may call one defined below it. And the final expression is the return value when it has no semicolon —
second here — so explicit return is normally reserved for early exits, as above. Parameters cannot have default values; overloading by arity is done with an Option parameter or a builder.Lambda capture vs closure capture
C++ makes you write the capture list; Rust infers it from the body. That is convenient, but the more important difference is what happens when the closure outlives what it captured.
#include <iostream>
#include <string>
int main() {
std::string prefix = "total: ";
int running = 0;
// The capture list is explicit: by value or by reference, per variable.
auto add = [&running, prefix](int value) {
running += value;
std::cout << prefix << running << std::endl;
};
add(3);
add(4);
return 0;
}fn main() {
let prefix = String::from("total: ");
let mut running = 0;
// No capture list. What the body does determines how each variable
// is captured — `running` mutably, `prefix` by shared reference.
let mut add = |value| {
running += value;
println!("{}{}", prefix, running);
};
add(3);
add(4);
}A C++ lambda capturing by reference and then stored past the enclosing scope is a dangling reference, and
[&] makes that mistake a single character wide. Rust infers the weakest capture the body needs, and the borrow checker then refuses to let the closure outlive the borrow. When you want C++'s [=] semantics — take ownership of everything — write move |value| { … }, which is required for closures sent to another thread.Passing functions around
Taking a callable as a parameter has the same two options in both languages — a template parameter, or a type-erased wrapper — but the defaults people reach for differ.
#include <iostream>
#include <functional>
#include <vector>
// std::function type-erases and usually heap-allocates.
int apply_to_all(const std::vector<int>& values,
const std::function<int(int)>& transform) {
int total = 0;
for (int value : values) { total += transform(value); }
return total;
}
int main() {
std::vector<int> readings{1, 2, 3};
std::cout << apply_to_all(readings, [](int value) { return value * 10; })
<< std::endl;
return 0;
}// `impl Fn` is a generic parameter: monomorphized, inlined, no allocation.
fn apply_to_all(values: &[i32], transform: impl Fn(i32) -> i32) -> i32 {
let mut total = 0;
for value in values { total += transform(*value); }
total
}
fn main() {
let readings = vec![1, 2, 3];
println!("{}", apply_to_all(&readings, |value| value * 10));
}The C++ habit is
std::function, which is convenient and costs an indirect call plus, often, an allocation. The Rust habit is impl Fn, which is a generic parameter in disguise: it monomorphizes, so the closure inlines and nothing is allocated. Rust's equivalent of std::function is Box<dyn Fn(i32) -> i32>, and you reach for it only when you genuinely need to store differently-typed callables together. The three traits Fn, FnMut and FnOnce distinguish closures that read, mutate, or consume their captures.Structs & Methods
Classes vs structs plus impl
Rust separates the data from the behavior: the
struct declares fields, and one or more impl blocks declare methods. There is no class keyword.#include <iostream>
class Rectangle {
public:
Rectangle(double width, double height)
: width_(width), height_(height) {}
double area() const { return width_ * height_; }
void scale(double factor) { width_ *= factor; height_ *= factor; }
private:
double width_;
double height_;
};
int main() {
Rectangle card(3.0, 4.0);
card.scale(2.0);
std::cout << card.area() << std::endl;
return 0;
}struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
fn area(&self) -> f64 { self.width * self.height }
fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; }
}
fn main() {
let mut card = Rectangle::new(3.0, 4.0);
card.scale(2.0);
println!("{}", card.area());
}The
const qualifier moves to the front and becomes part of the receiver: &self is a const method, &mut self is a non-const one, and self by value consumes the object — a receiver C++ can only approximate with an rvalue-ref-qualified overload. new carries no special meaning; it is a plain associated function, and a type is free to have several constructors with descriptive names instead of a pile of overloads.The rule of five vs derive
The rule of three, then five, then zero is C++ folklore precisely because the compiler-generated special members interact in ways that are hard to keep straight. Rust replaced the whole area with an opt-in list.
#include <iostream>
// To be copyable, comparable and printable, a C++ type writes it all out.
struct Point {
int x;
int y;
// Copy/move constructors and assignment are implicit here, but any
// user-declared destructor or move operation suppresses some of them —
// hence the rule of five.
bool operator==(const Point& other) const = default;
};
std::ostream& operator<<(std::ostream& stream, const Point& point) {
return stream << "Point(" << point.x << ", " << point.y << ")";
}
int main() {
Point origin{0, 0};
Point copy = origin;
std::cout << copy << " equal: " << std::boolalpha << (copy == origin) << std::endl;
return 0;
}// One line asks the compiler to generate all four.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let origin = Point { x: 0, y: 0 };
let copy = origin;
println!("{:?} equal: {}", copy, copy == origin);
}A Rust type has no copy, no comparison and no printing until you ask, and asking is a
derive attribute naming exactly what you want. Clone is an explicit .clone() call; Copy additionally makes assignment duplicate rather than move, and is only allowed for types where a bitwise copy is correct. That last restriction is the point: a type owning a heap buffer cannot be Copy, so the accidental deep-copy-on-assignment that a C++ copy constructor performs silently cannot happen.Destructors and Drop
This is the piece of C++ that Rust adopted most directly. RAII is not an alternative to Rust's model — it is Rust's model.
#include <iostream>
#include <string>
class ScopedLogger {
public:
explicit ScopedLogger(std::string name) : name_(std::move(name)) {
std::cout << "enter " << name_ << std::endl;
}
~ScopedLogger() { std::cout << "leave " << name_ << std::endl; }
private:
std::string name_;
};
int main() {
ScopedLogger outer("outer");
{
ScopedLogger inner("inner");
}
std::cout << "done" << std::endl;
return 0;
}struct ScopedLogger {
name: String,
}
impl ScopedLogger {
fn new(name: &str) -> ScopedLogger {
println!("enter {}", name);
ScopedLogger { name: name.to_string() }
}
}
impl Drop for ScopedLogger {
fn drop(&mut self) { println!("leave {}", self.name); }
}
fn main() {
let _outer = ScopedLogger::new("outer");
{
let _inner = ScopedLogger::new("inner");
}
println!("done");
}Destruction is deterministic, runs at end of scope in reverse declaration order, and composes through struct fields exactly as in C++. The differences are small: the trait is named
Drop rather than a tilde method, you cannot call it directly (use std::mem::drop(value) to destroy early), and a type with a Drop implementation cannot be Copy. The leading underscore on _outer only silences the unused-variable warning; binding to bare _ would drop it immediately, which is a genuine gotcha.No implementation inheritance
Rust has no class inheritance at all — no base classes, no
protected, no virtual bases, and therefore no diamond problem and no slicing.#include <format>
#include <iostream>
#include <memory>
#include <string>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
// Inherited state and inherited behavior come together.
std::string label() const { return std::format("a shape of area {}", area()); }
};
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
private:
double side_;
};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Square>(3.0);
std::cout << shape->label() << std::endl;
return 0;
}trait Shape {
fn area(&self) -> f64;
// A default method: shared behavior WITHOUT shared state.
fn label(&self) -> String {
format!("a shape of area {}", self.area())
}
}
struct Square { side: f64 }
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let shape: Box<dyn Shape> = Box::new(Square { side: 3.0 });
println!("{}", shape.label());
}What survives is the half that people actually wanted: an interface with default method bodies, which is a trait. What does not survive is inherited state; a Rust type that wants a base class's fields holds it as a member and forwards, which is the composition-over-inheritance advice C++ style guides give anyway.
Box<dyn Shape> is the std::unique_ptr<Shape> of this example: a fat pointer carrying data and vtable, dispatched dynamically.Enums & Pattern Matching
enum class vs enum
A plain Rust enum behaves like a C++
enum class: scoped, not implicitly convertible to an integer.#include <iostream>
enum class Status { Pending, Active, Closed };
int main() {
Status current = Status::Active;
switch (current) {
case Status::Pending: std::cout << "pending" << std::endl; break;
case Status::Active: std::cout << "active" << std::endl; break;
case Status::Closed: std::cout << "closed" << std::endl; break;
}
return 0;
}enum Status { Pending, Active, Closed }
fn main() {
let current = Status::Active;
match current {
Status::Pending => println!("pending"),
Status::Active => println!("active"),
Status::Closed => println!("closed"),
}
}The important difference is invisible here and shows up on the next commit. Add a
Status::Archived variant and the Rust match stops compiling, at every site in the codebase that failed to handle it. The C++ switch compiles and silently does nothing — -Wswitch catches this case, but only when there is no default: arm, and a default: is exactly what most real switches have.std::variant vs enums with data
This is the row where Rust's enums stop resembling C++ enums. A Rust enum variant can carry data, which makes it a tagged union with a name for each case.
#include <iostream>
#include <string>
#include <variant>
using Message = std::variant<int, std::string>;
int main() {
Message message = std::string("hello");
std::visit([](const auto& value) {
using Held = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Held, int>) {
std::cout << "number " << value << std::endl;
} else {
std::cout << "text " << value << std::endl;
}
}, message);
return 0;
}enum Message {
Number(i32),
Text(String),
}
fn main() {
let message = Message::Text(String::from("hello"));
match message {
Message::Number(value) => println!("number {}", value),
Message::Text(value) => println!("text {}", value),
}
}Everything
std::variant does, a Rust enum does with first-class syntax: no std::visit, no if constexpr ladder, no std::get that throws, and no valueless-by-exception state. The variants are also named — Number and Text rather than int and std::string — so a variant holding two integers with different meanings is expressible, which std::variant<int, int> is not. This one feature is why Rust needs no null and no exceptions; the next two rows are both just enums.std::optional vs Option
Option<T> is std::optional<T> — and it is an ordinary enum with two variants, defined in the standard library rather than the language.#include <iostream>
#include <optional>
#include <string>
std::optional<int> parse_port(const std::string& text) {
try {
return std::stoi(text);
} catch (const std::exception&) {
return std::nullopt;
}
}
int main() {
auto port = parse_port("8080");
// Nothing forces the check. *port on an empty optional is
// undefined behavior, not an exception.
if (port.has_value()) {
std::cout << "port " << *port << std::endl;
}
std::cout << "fallback " << parse_port("xyz").value_or(80) << std::endl;
return 0;
}fn parse_port(text: &str) -> Option<i32> {
text.parse().ok()
}
fn main() {
let port = parse_port("8080");
// The value is INSIDE the Option. Reaching it requires handling None.
if let Some(number) = port {
println!("port {}", number);
}
println!("fallback {}", parse_port("xyz").unwrap_or(80));
}The API surface is nearly a rename:
value_or is unwrap_or, and_then is and_then, has_value is is_some. The behavioral difference is that dereferencing an empty std::optional is undefined behavior while unwrap() on a None panics with a message and a backtrace. And because Rust has no null pointer for safe references, Option is the only way to express absence — there is no second mechanism to also check for. Option<Box<T>> is optimized to a single nullable pointer, so the abstraction is free.Destructuring and guards
C++17 structured bindings destructure a tuple, but destructuring and branching stay separate steps. In Rust they are one construct.
#include <iostream>
#include <tuple>
int main() {
std::tuple<int, int> position{3, 4};
// Structured bindings destructure, but cannot MATCH — the
// conditions have to be written as a separate if/else ladder.
auto [row, column] = position;
if (row == 0 && column == 0) { std::cout << "origin" << std::endl; }
else if (row == column) { std::cout << "diagonal" << std::endl; }
else if (row > 0 && column > 0) { std::cout << "first quadrant" << std::endl; }
else { std::cout << "elsewhere" << std::endl; }
return 0;
}fn main() {
let position = (3, 4);
// The shape and the conditions live in the same construct.
match position {
(0, 0) => println!("origin"),
(row, column) if row == column => println!("diagonal"),
(row, column) if row > 0 && column > 0 => println!("first quadrant"),
_ => println!("elsewhere"),
}
}A
match arm is a pattern, so it can test literal values, bind names, and nest — Some((row, 0)) is a valid arm. The if after a pattern is a guard, evaluated only when the shape matches. Patterns also appear in let, in if let, in while let, and in function parameters, so the same small syntax covers what C++ spreads across structured bindings, std::visit, and if constexpr.Templates vs Generics & Traits
Templates vs generics
Both compile to specialized machine code per type — Rust monomorphizes exactly as C++ instantiates. What differs is when the body is type-checked.
#include <iostream>
#include <vector>
// The body is only checked when instantiated, so an error in an unused
// branch of a template is invisible until someone calls it that way.
template <typename Element>
Element largest(const std::vector<Element>& values) {
Element best = values[0];
for (const Element& value : values) {
if (value > best) { best = value; }
}
return best;
}
int main() {
std::vector<int> numbers{3, 9, 2};
std::cout << largest(numbers) << std::endl;
return 0;
}// The bound is part of the signature, so the body is checked ONCE,
// against the bound, before anyone instantiates it.
fn largest<Element: PartialOrd + Copy>(values: &[Element]) -> Element {
let mut best = values[0];
for &value in values {
if value > best { best = value; }
}
best
}
fn main() {
let numbers = vec![3, 9, 2];
println!("{}", largest(&numbers));
}A C++ template body is checked at instantiation, which is why a mistake surfaces as a wall of errors pointing into the standard library rather than at your code. Rust checks the generic body once against its bounds, so
value > best is only allowed because PartialOrd was declared. The practical effect is that the error arrives at the definition and names the missing bound. C++20 concepts close much of this gap by constraining the interface, but the body is still checked late.Concepts vs trait bounds
C++20 concepts and Rust trait bounds solve the same problem, and reading one after the other shows how differently the two languages got there.
#include <iostream>
#include <concepts>
template <typename Value>
concept Summable = requires(Value left, Value right) {
{ left + right } -> std::convertible_to<Value>;
};
template <Summable Value>
Value add_twice(Value value) {
return value + value;
}
int main() {
std::cout << add_twice(21) << std::endl;
std::cout << add_twice(1.5) << std::endl;
return 0;
}use std::ops::Add;
// No separate concept declaration: the operator IS a trait, so the
// bound names it directly.
fn add_twice<Value: Add<Output = Value> + Copy>(value: Value) -> Value {
value + value
}
fn main() {
println!("{}", add_twice(21));
println!("{}", add_twice(1.5));
}A C++ concept describes a shape structurally — "whatever type supports
left + right". A Rust bound names an explicit trait the type must have declared it implements. Structural is more flexible; nominal means a type cannot satisfy a bound by accident, and it means the operator itself is a trait method (+ is Add::add), so no separate requires clause is needed to talk about it. The Output = Value is an associated type constraint, which is roughly a concept's return-type requirement.Virtual dispatch vs trait objects
Rust does have dynamic dispatch; it just makes you say so, because static dispatch is the default.
#include <iostream>
#include <memory>
#include <vector>
class Greeter {
public:
virtual ~Greeter() = default;
virtual void greet() const = 0;
};
class English : public Greeter {
public:
void greet() const override { std::cout << "Hello" << std::endl; }
};
class Dutch : public Greeter {
public:
void greet() const override { std::cout << "Hallo" << std::endl; }
};
int main() {
std::vector<std::unique_ptr<Greeter>> greeters;
greeters.push_back(std::make_unique<English>());
greeters.push_back(std::make_unique<Dutch>());
for (const auto& greeter : greeters) { greeter->greet(); }
return 0;
}trait Greeter {
fn greet(&self);
}
struct English;
struct Dutch;
impl Greeter for English {
fn greet(&self) { println!("Hello"); }
}
impl Greeter for Dutch {
fn greet(&self) { println!("Hallo"); }
}
fn main() {
let greeters: Vec<Box<dyn Greeter>> = vec![
Box::new(English),
Box::new(Dutch),
];
for greeter in &greeters { greeter.greet(); }
}The vtable is stored differently, and it is worth knowing which. A C++ polymorphic object embeds a vtable pointer in the object itself, so
sizeof grows and the object is never trivially copyable. A Rust dyn Trait reference is a fat pointer: the vtable pointer lives beside the data pointer, not inside the object, so the concrete English struct is zero bytes and carries no hidden field. The trade is that dyn Trait is unsized, which is why it always appears behind & or Box.Adding methods to a foreign type
C++ has no way to add a member function to a type you do not own. Rust does, and it is used constantly.
#include <iostream>
#include <string>
// You cannot add a member function to std::string. A free function is
// the C++ answer, and it does not participate in method call syntax.
bool is_shouting(const std::string& text) {
for (char character : text) {
if (std::islower(static_cast<unsigned char>(character))) { return false; }
}
return !text.empty();
}
int main() {
std::string message = "HELLO";
std::cout << std::boolalpha << is_shouting(message) << std::endl;
return 0;
}// A trait you define can be implemented FOR a type you did not.
trait Shouting {
fn is_shouting(&self) -> bool;
}
impl Shouting for String {
fn is_shouting(&self) -> bool {
!self.is_empty() && !self.chars().any(|character| character.is_lowercase())
}
}
fn main() {
let message = String::from("HELLO");
println!("{}", message.is_shouting());
}This is the extension-trait pattern, and it is why so much of Rust's ecosystem composes without wrappers:
rayon adds .par_iter() to every standard collection, and itertools adds a dozen adapters to every iterator, all without touching the original types. The guard rail is the orphan rule — an impl is only allowed if either the trait or the type is local to your crate — which is what prevents two libraries from defining conflicting implementations for a pair they both merely import.Ownership, Moves & Borrowing
Moved-from state
This is the row to read twice. Both languages move; only one of them stops you from touching the corpse.
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string source = "hello";
std::string destination = std::move(source);
// `source` is valid but UNSPECIFIED. Reading it is legal and
// the result is not guaranteed — this compiles without a warning.
std::cout << "destination: " << destination << std::endl;
std::cout << "source size after move: " << source.size() << std::endl;
return 0;
}fn main() {
let source = String::from("hello");
let destination = source; // a move; no std::move needed
// `source` is now INVALID. Using it is a compile error:
// error[E0382]: borrow of moved value: `source`
println!("destination: {}", destination);
// println!("source: {}", source);
}C++ leaves a moved-from object in a "valid but unspecified" state, which means reading it is well-defined but the value is whatever the implementation chose — usually empty, not always. Rust moves by default for any non-
Copy type, and then statically marks the source as moved-out, so use-after-move is a compile error rather than a convention. That also means no std::move and no rvalue references: there is nothing to opt into, because ordinary assignment already moves.References vs borrows
&T is const T& and &mut T is T&. So far this is a rename.#include <iostream>
#include <string>
void observe(const std::string& text) {
std::cout << "read " << text.size() << std::endl;
}
void modify(std::string& text) {
text += "!";
}
int main() {
std::string message = "hello";
observe(message);
modify(message);
std::cout << message << std::endl;
return 0;
}fn observe(text: &String) {
println!("read {}", text.len());
}
fn modify(text: &mut String) {
text.push('!');
}
fn main() {
let mut message = String::from("hello");
observe(&message);
modify(&mut message);
println!("{}", message);
}Two things are new. First, the borrow is visible at the call site —
&mut message tells a reader this call may change the argument, which the C++ call modify(message) does not. Second, borrows carry lifetimes the compiler tracks: a reference cannot outlive what it points at, so returning a reference to a local, or holding one across a reallocation, does not compile. That check is what makes the aliasing rule in the next row enforceable.The aliasing rule
The borrow rules are usually sold as a safety feature. They are also the reason Rust can hand LLVM aliasing information that C++ cannot.
#include <iostream>
#include <vector>
// Two references to the same vector, one of them mutable. The compiler
// cannot assume they do not alias, so it must reload after every write.
int sum_and_clear(std::vector<int>& values, const std::vector<int>& other) {
int total = 0;
for (std::size_t index = 0; index < other.size(); ++index) {
total += other[index];
values.push_back(0); // may reallocate `other` too, if aliased
}
return total;
}
int main() {
std::vector<int> first{1, 2, 3};
std::vector<int> second{10, 20};
std::cout << sum_and_clear(first, second) << std::endl;
return 0;
}// &mut and & to the same value cannot coexist, so the compiler KNOWS
// these two do not alias and optimizes accordingly.
fn sum_and_clear(values: &mut Vec<i32>, other: &[i32]) -> i32 {
let mut total = 0;
for value in other {
total += value;
values.push(0); // cannot possibly touch `other`
}
total
}
fn main() {
let mut first = vec![1, 2, 3];
let second = vec![10, 20];
println!("{}", sum_and_clear(&mut first, &second));
}C++ has
restrict only as a compiler extension for pointers, and using it is a promise the compiler cannot check — break it and you get miscompilation. In Rust the guarantee is structural: &mut T is unique by construction, so every one of them is emitted with noalias, unconditionally and safely. Calling this function with the same vector twice is not a bug you have to remember to avoid; it does not compile.Returning a reference to a local
Every C++ programmer has been bitten by a reference or pointer that outlived its referent. This is the bug class the borrow checker was built to eliminate.
#include <iostream>
#include <string>
// Returning a reference to a local is a dangling reference. GCC and
// Clang warn, but it compiles. Return by value instead:
std::string build_greeting(const std::string& name) {
std::string greeting = "hello, " + name;
return greeting; // copy elided; safe because it is BY VALUE
}
int main() {
std::cout << build_greeting("world") << std::endl;
return 0;
}// The dangling version is not a warning, it is an error:
// error[E0106]: missing lifetime specifier
// and no lifetime can be supplied, because the data dies here.
fn build_greeting(name: &str) -> String {
let greeting = format!("hello, {}", name);
greeting // moved out, so the caller owns it
}
fn main() {
println!("{}", build_greeting("world"));
}The fix is the same in both languages — return by value — but the enforcement differs. C++ emits
-Wreturn-local-addr, which is a warning you can ignore and which does not fire through even one layer of indirection. Rust cannot express the broken version: the return type would need a lifetime, and there is no input lifetime to tie it to. Returning the String moves the heap buffer to the caller with no copy, exactly as C++ does under return-value optimization.Smart Pointers & Shared State
unique_ptr vs Box
Box<T> is std::unique_ptr<T>: one owner, heap allocation, freed when the owner is dropped, no reference count.#include <iostream>
#include <memory>
struct Node {
int value;
std::unique_ptr<Node> next;
};
int main() {
auto tail = std::make_unique<Node>(2, nullptr);
auto head = std::make_unique<Node>(1, std::move(tail));
for (const Node* current = head.get(); current != nullptr; current = current->next.get()) {
std::cout << current->value << " ";
}
std::cout << std::endl;
return 0;
}struct Node {
value: i32,
next: Option<Box<Node>>,
}
fn main() {
let tail = Node { value: 2, next: None };
let head = Node { value: 1, next: Some(Box::new(tail)) };
let mut current = Some(&head);
while let Some(node) = current {
print!("{} ", node.value);
current = node.next.as_deref();
}
println!();
}The difference worth noticing is nullability. A
std::unique_ptr can be null, so every dereference is a potential crash; a Box<T> never can, which is why the optional link is spelled Option<Box<Node>>. That is not extra cost — the null-pointer optimization makes it the same one word — it just moves the "might be absent" fact into the type where the compiler can enforce the check. Note also that no std::move appears: putting tail into head moves it because that is the default.shared_ptr vs Rc and Arc
C++ has one shared pointer. Rust has two, and choosing between them is a real decision.
#include <iostream>
#include <memory>
#include <string>
int main() {
auto document = std::make_shared<std::string>("shared text");
auto second_handle = document;
std::cout << *second_handle << std::endl;
std::cout << "count " << document.use_count() << std::endl;
// The refcount is ATOMIC whether or not threads are involved.
return 0;
}use std::rc::Rc;
fn main() {
let document = Rc::new(String::from("shared text"));
let second_handle = Rc::clone(&document);
println!("{}", second_handle);
println!("count {}", Rc::strong_count(&document));
// Rc is NON-atomic. Arc is the atomic one, and costs more.
}std::shared_ptr always uses an atomic refcount, because it cannot know whether you will share it across threads — so single-threaded code pays for thread safety it does not use. Rust splits the type: Rc is non-atomic and cheap, Arc is atomic and thread-safe, and the compiler enforces the distinction by refusing to send an Rc across a thread boundary. Note also that the clone is explicit: Rc::clone(&document) makes the refcount bump visible, where C++ copy-assignment hides it.mutable vs RefCell
The memoized-getter problem is the same in both languages: a logically-const method needs to write a cache field.
#include <iostream>
#include <string>
class Report {
public:
explicit Report(std::string body) : body_(std::move(body)) {}
// `mutable` lets a const method write to this field. Nothing checks
// that concurrent const calls are safe.
std::size_t length() const {
if (cached_length_ == 0) { cached_length_ = body_.size(); }
return cached_length_;
}
private:
std::string body_;
mutable std::size_t cached_length_ = 0;
};
int main() {
Report report("some text");
std::cout << report.length() << std::endl;
return 0;
}use std::cell::Cell;
struct Report {
body: String,
cached_length: Cell<usize>,
}
impl Report {
// &self, yet the cache is writable — through Cell, not around it.
fn length(&self) -> usize {
if self.cached_length.get() == 0 {
self.cached_length.set(self.body.len());
}
self.cached_length.get()
}
}
fn main() {
let report = Report { body: String::from("some text"), cached_length: Cell::new(0) };
println!("{}", report.length());
}C++ answers with
mutable, which simply exempts the field from const with no further conditions. Rust answers with a type: Cell<T> for copyable values, and RefCell<T> when you need a reference, which moves the borrow check from compile time to runtime and panics on violation. The distinction that matters is that neither is Sync, so a struct containing one cannot be shared across threads — the escape hatch is bounded, whereas mutable silently creates a data race the moment two threads call the getter.Lifetimes
Lifetime annotations
This is the one construct in Rust with no C++ counterpart at all, so it is worth being precise about what it does — and does not — do.
#include <iostream>
#include <string_view>
// Which argument does the result point into? The signature does not say,
// so the caller has to read the body to know what must stay alive.
std::string_view longer(std::string_view first, std::string_view second) {
return first.size() >= second.size() ? first : second;
}
int main() {
std::string_view winner = longer("apple", "fig");
std::cout << winner << std::endl;
return 0;
}// 'a says: the result borrows from BOTH arguments, and is valid only
// as long as both are. The caller can check this without reading the body.
fn longer<'a>(first: &'a str, second: &'a str) -> &'a str {
if first.len() >= second.len() { first } else { second }
}
fn main() {
let winner = longer("apple", "fig");
println!("{}", winner);
}A lifetime parameter changes no code and costs nothing at runtime; it is purely a claim in the signature about which input the returned reference borrows from, which the compiler then checks on both sides. C++ has the same relationship in this function — the returned
string_view really does point into one of the arguments — it just has no way to write it down, so the invariant lives in a comment or in nobody's head. Most functions need no annotation at all: the elision rules infer the common cases, and a signature with one reference in and one out is inferred automatically.A struct that holds a reference
Any C++ struct holding a
string_view, a raw pointer, or a reference member has an unwritten rule attached: do not let it outlive its source.#include <iostream>
#include <string>
#include <string_view>
struct Excerpt {
std::string_view text; // borrows; nothing tracks the owner
};
int main() {
std::string article = "the first sentence. the second.";
Excerpt first{std::string_view(article).substr(0, 19)};
// If `article` were destroyed or reallocated here, `first.text`
// would dangle — and this would still compile.
std::cout << first.text << std::endl;
return 0;
}struct Excerpt<'a> {
text: &'a str, // the struct is tied to what it borrows
}
fn main() {
let article = String::from("the first sentence. the second.");
let first = Excerpt { text: &article[0..19] };
// Dropping `article` here would be a compile error, because
// `first` still borrows it.
println!("{}", first.text);
}The
<'a> on the struct is that unwritten rule, written down and checked. It propagates: anything holding an Excerpt also gets a lifetime parameter, which is the compiler tracking the same obligation you were tracking by hand. The practical advice is the same as in C++ — reach for an owned String field unless the borrow is measurably worth it — but where C++ makes borrowing look free and defers the cost to a crash, Rust makes the cost visible in the type up front.Error Handling
Exceptions vs Result
Rust has no exceptions and no
throw. Failure is a return value, and Result<T, E> is — again — an ordinary enum with two variants.#include <iostream>
#include <stdexcept>
#include <string>
int parse_age(const std::string& text) {
int value = std::stoi(text); // throws on failure
if (value < 0) { throw std::out_of_range("negative age"); }
return value;
}
int main() {
try {
std::cout << parse_age("42") << std::endl;
std::cout << parse_age("-1") << std::endl;
} catch (const std::exception& problem) {
std::cout << "failed: " << problem.what() << std::endl;
}
return 0;
}fn parse_age(text: &str) -> Result<i32, String> {
let value: i32 = text.parse().map_err(|_| String::from("not a number"))?;
if value < 0 { return Err(String::from("negative age")); }
Ok(value)
}
fn main() {
match parse_age("42") {
Ok(age) => println!("{}", age),
Err(problem) => println!("failed: {}", problem),
}
match parse_age("-1") {
Ok(age) => println!("{}", age),
Err(problem) => println!("failed: {}", problem),
}
}The consequence a C++ programmer will feel first is that the signature tells the truth.
noexcept aside, any C++ function may throw, so every call is a potential early exit and exception safety is a property you reason about globally; a Rust function returning i32 cannot fail, full stop. The cost is visible plumbing, which is what the ? operator in the next row exists to reduce. There is no unwinding through the happy path either, so there is no "zero-cost until it throws" cliff.Propagating failures
The objection to returning errors is that propagating them by hand is tedious.
? is the answer, and it is worth understanding as sugar rather than magic.#include <iostream>
#include <string>
// Propagation is implicit: this function does not mention failure at all,
// which is convenient and also why the exception path is easy to miss.
int doubled_age(const std::string& text) {
return std::stoi(text) * 2;
}
int main() {
try {
std::cout << doubled_age("21") << std::endl;
std::cout << doubled_age("nope") << std::endl;
} catch (const std::exception& problem) {
std::cout << "failed: " << problem.what() << std::endl;
}
return 0;
}use std::num::ParseIntError;
// `?` returns early on Err and unwraps on Ok. Propagation is one
// character, but it IS a character — the exit point is visible.
fn doubled_age(text: &str) -> Result<i32, ParseIntError> {
let value: i32 = text.parse()?;
Ok(value * 2)
}
fn main() {
match doubled_age("21") {
Ok(age) => println!("{}", age),
Err(problem) => println!("failed: {}", problem),
}
match doubled_age("nope") {
Ok(age) => println!("{}", age),
Err(problem) => println!("failed: {}", problem),
}
}value? expands to "if this is Err, convert the error type and return it; otherwise evaluate to the Ok payload." That conversion step is what lets a function returning a general error type absorb several specific ones. Compared with exceptions you trade invisibility for auditability: every place a function can bail out is marked with a ?, so reading for error paths is a search rather than an analysis. ? works on Option too, propagating None.Unrecoverable failure
Rust distinguishes recoverable failure (
Result) from a bug (panic!). Out-of-bounds indexing is firmly the second.#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3};
// operator[] out of range is UNDEFINED BEHAVIOR — no check at all.
// .at() throws instead:
try {
std::cout << readings.at(10) << std::endl;
} catch (const std::out_of_range&) {
std::cout << "caught: index out of range" << std::endl;
}
std::cout << "still running" << std::endl;
return 0;
}fn main() {
let readings = vec![1, 2, 3];
// Indexing is ALWAYS bounds-checked and panics. The checked form
// returns Option instead of unwinding:
match readings.get(10) {
Some(value) => println!("{}", value),
None => println!("caught: index out of range"),
}
println!("still running");
}The C++ default,
operator[], does not check; you opt into checking with .at(). Rust inverts this: readings[10] is always checked and panics, and you opt out of the check with get_unchecked, which requires unsafe. A panic unwinds by default and can be caught with catch_unwind, but that exists for thread and FFI boundaries rather than for control flow — the intended response to a panic is to fix the bug.Ranges vs Iterators
Ranges vs iterator adapters
C++20 ranges brought lazy pipelines to the standard library. Rust's iterators have always worked this way, and reading them side by side shows how close the designs are.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3, 4, 5, 6};
auto pipeline = readings
| std::views::filter([](int value) { return value % 2 == 0; })
| std::views::transform([](int value) { return value * value; });
int total = 0;
for (int value : pipeline) { total += value; }
std::cout << total << std::endl;
return 0;
}fn main() {
let readings = vec![1, 2, 3, 4, 5, 6];
let total: i32 = readings
.iter()
.filter(|value| *value % 2 == 0)
.map(|value| value * value)
.sum();
println!("{}", total);
}Both are lazy — nothing runs until the pipeline is consumed — and both compile away entirely, so the loop is as fast as one written by hand. The syntax differs in that Rust uses method chaining rather than the pipe operator, which means the adapters read in call order and need no
views:: qualification. The consuming step is explicit in both: a range-for in C++, and a terminal method like sum, collect or for_each in Rust. Forgetting the terminal step in Rust produces an unused-value warning, since an iterator that is never consumed does nothing.Materializing a pipeline
Turning a lazy pipeline back into a container is the step where the two libraries differ most in ergonomics.
#include <iostream>
#include <ranges>
#include <string>
#include <vector>
int main() {
std::vector<std::string> words{"alpha", "beta", "gamma"};
// C++23 has ranges::to; before that, an explicit loop or
// std::ranges::copy into a back_inserter.
std::vector<std::size_t> lengths;
for (const std::string& word : words) {
lengths.push_back(word.size());
}
for (std::size_t length : lengths) { std::cout << length << " "; }
std::cout << std::endl;
return 0;
}fn main() {
let words = vec!["alpha", "beta", "gamma"];
// collect() builds whichever container the annotation asks for.
let lengths: Vec<usize> = words.iter().map(|word| word.len()).collect();
for length in &lengths { print!("{} ", length); }
println!();
}collect() is generic over its return type, which is unusual and worth internalizing: the same call produces a Vec, a HashMap, a String, or a HashSet depending only on the annotation, and it is often the only place a Rust program needs an explicit type. There is one especially useful case: collecting an iterator of Result into Result<Vec<_>, _> stops at the first error and returns it, which is a whole error-handling pattern in one method call.Index and paired iteration
Iterating two containers together, with an index, is the case where the C-style loop still tempts most C++ programmers.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> names{"ada", "grace", "alan"};
std::vector<int> scores{90, 95, 88};
for (std::size_t index = 0; index < names.size(); ++index) {
std::cout << index << ": " << names[index] << " " << scores[index] << std::endl;
}
return 0;
}fn main() {
let names = vec!["ada", "grace", "alan"];
let scores = vec![90, 95, 88];
for (index, (name, score)) in names.iter().zip(scores.iter()).enumerate() {
println!("{}: {} {}", index, name, score);
}
}The index-based loop has two failure modes the Rust version cannot have: it reads
scores[index] without checking that scores is as long as names, and the </<= boundary is written by hand every time. zip stops at the shorter of the two, and enumerate supplies the counter, so neither mistake is expressible. C++23 has std::views::zip and std::views::enumerate, which close this gap when you can use them.Concurrency
Spawning threads
Spawning and joining line up closely. What differs is how a worker gets its answer back to you, and that difference follows directly from what the compiler allows the closure to capture.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<std::thread> workers;
std::vector<int> squares(3);
for (int worker_number = 0; worker_number < 3; ++worker_number) {
// Captured by reference; nothing checks that the slots do not overlap.
workers.emplace_back([worker_number, &squares]() {
squares[worker_number] = worker_number * worker_number;
});
}
for (std::thread& worker : workers) { worker.join(); }
for (int square : squares) { std::cout << square << " "; }
std::cout << "all joined" << std::endl;
return 0;
}use std::thread;
fn main() {
let mut workers = Vec::new();
for worker_number in 0..3 {
// `move` is required: the closure owns what it captures.
workers.push(thread::spawn(move || worker_number * worker_number));
}
// join() hands back the closure's return value, so no shared slots.
let squares: Vec<i32> = workers.into_iter().map(|worker| worker.join().unwrap()).collect();
for square in &squares { print!("{} ", square); }
println!("all joined");
}The C++ workers write into a shared vector captured by reference — correct here only because the indices happen not to overlap, which nothing checks. Rust's
spawn requires move, so captures are owned, and its 'static bound makes borrowing a local into a thread a compile error; the way out is that join() returns the closure's value, so no shared slot is needed at all. Two smaller differences: forgetting to join a std::thread calls std::terminate, while dropping a Rust JoinHandle simply detaches. And when you genuinely do want to borrow a local, thread::scope proves the threads finish first and permits it.Mutex holds the data
This is the design difference that best explains what "fearless concurrency" means in practice.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
int main() {
int total = 0;
std::mutex guard; // the mutex and the data are separate
std::vector<std::thread> workers;
for (int worker_number = 0; worker_number < 4; ++worker_number) {
workers.emplace_back([&total, &guard]() {
std::lock_guard<std::mutex> held(guard);
total += 10; // nothing forces the lock to be taken
});
}
for (std::thread& worker : workers) { worker.join(); }
std::cout << total << std::endl;
return 0;
}use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// The data lives INSIDE the mutex; there is no way to reach it unlocked.
let total = Arc::new(Mutex::new(0));
let mut workers = Vec::new();
for _worker_number in 0..4 {
let handle = Arc::clone(&total);
workers.push(thread::spawn(move || {
*handle.lock().unwrap() += 10;
}));
}
for worker in workers { worker.join().unwrap(); }
println!("{}", *total.lock().unwrap());
}A
std::mutex guards data only by convention — the association between guard and total exists in the programmer's head, and touching total without locking compiles fine. A Rust Mutex<T> contains the T, so the only way to obtain a reference is to lock, and the guard returned by lock() releases on drop exactly like std::lock_guard. The Arc wrapper is what lets several threads own the mutex; the compiler rejects Rc here, because Rc is not Send.Data races rejected at compile time
Neither of these programs has a data race. The difference is whether that was checked or merely intended.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3};
// Sharing a non-thread-safe container mutably across threads
// COMPILES. It is simply a data race at runtime.
// Serialize it yourself, or do the work on one thread:
int total = 0;
std::thread worker([&readings, &total]() {
for (int value : readings) { total += value; }
});
worker.join();
std::cout << total << std::endl;
return 0;
}use std::thread;
fn main() {
let readings = vec![1, 2, 3];
// Sharing this mutably across threads does not COMPILE — Vec is not
// Sync. Scoped threads let the borrow be proven safe instead:
let mut total = 0;
thread::scope(|scope| {
scope.spawn(|| {
for value in &readings { total += value; }
});
});
println!("{}", total);
}Rust encodes thread-safety in two marker traits the compiler applies automatically:
Send means a value can move to another thread, Sync means &T can be shared with one. Rc is neither, RefCell is Send but not Sync, Mutex<T> is both when T is Send. Because these propagate structurally, a struct built from race-unsafe parts is itself rejected at the thread boundary. This is the check C++ has no equivalent of — std::thread will hand a lambda anything you capture.Macros, unsafe & C Interop
The preprocessor vs macro_rules!
Rust has no preprocessor — no
#include, no #define, no #ifdef. Macros exist, but they are a different mechanism entirely.#include <iostream>
// Textual substitution. No scoping, no type awareness; the parentheses
// are defensive and the double evaluation of the arguments is real.
#define MAXIMUM(first, second) ((first) > (second) ? (first) : (second))
int bump(int& counter) { return ++counter; }
int main() {
int counter = 0;
std::cout << MAXIMUM(3, 9) << std::endl;
// bump() runs TWICE — once in the condition, once in the branch taken.
std::cout << MAXIMUM(bump(counter), 0) << " counter=" << counter << std::endl;
return 0;
}// Operates on syntax trees, is hygienic, and binds each argument once.
macro_rules! maximum {
($first:expr, $second:expr) => {{
let left = $first;
let right = $second;
if left > right { left } else { right }
}};
}
fn bump(counter: &mut i32) -> i32 { *counter += 1; *counter }
fn main() {
let mut counter = 0;
println!("{}", maximum!(3, 9));
// bump() runs ONCE, because the macro binds it to a local first.
let largest = maximum!(bump(&mut counter), 0);
println!("{} counter={}", largest, counter);
}A
macro_rules! macro matches on parsed syntax, not tokens of text, so its arguments are expressions and it can bind each one to a local exactly once. It is also hygienic: the left and right above cannot collide with variables at the call site, which is what the C++ convention of __ugly_names inside macros is trying and failing to achieve. Conditional compilation moves to the #[cfg(...)] attribute, which is evaluated by the compiler rather than a separate text-substitution pass.unsafe is a smaller word than it looks
The common misreading is that
unsafe turns the borrow checker off. It does not; it enables five specific operations that the compiler cannot verify.#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3};
// Every raw pointer dereference in C++ is unchecked. There is no
// marker distinguishing the audited ones from the rest.
int* data = readings.data();
std::cout << *(data + 1) << std::endl;
std::cout << "checked: " << readings.at(1) << std::endl;
return 0;
}fn main() {
let readings = vec![1, 2, 3];
// The unchecked read must be marked, so it is greppable and auditable.
let data = readings.as_ptr();
unsafe {
println!("{}", *data.add(1));
}
println!("checked: {}", readings[1]);
}Those five are: dereferencing a raw pointer, calling an
unsafe function, accessing a mutable static, implementing an unsafe trait, and reading a union field. Everything else — borrow checking, type checking, lifetimes — still applies inside the block. The value is not that the operations are rare but that they are marked: a memory-safety bug in a Rust program is, by construction, inside an unsafe block or in a library's, which turns an audit of the whole program into an audit of a few hundred lines. In C++ every pointer dereference is in that category and none of them are labeled.Calling C from both sides
Both languages speak the C ABI natively, and the ceremony is comparable — with one asymmetry worth knowing about.
#include <iostream>
#include <cstdlib>
// C++ calls C directly; the header is included and that is that.
// The other direction needs extern "C" to suppress name mangling:
extern "C" int add_in_cpp(int first, int second) {
return first + second;
}
int main() {
std::cout << add_in_cpp(2, 3) << std::endl;
std::cout << std::abs(-7) << std::endl;
return 0;
}// Rust declares the C signature itself; there is no header parser.
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
// The other direction: no mangling, C calling convention, always exported.
#[unsafe(no_mangle)]
pub extern "C" fn add_in_rust(first: i32, second: i32) -> i32 {
first + second
}
fn main() {
println!("{}", add_in_rust(2, 3));
println!("{}", unsafe { abs(-7) });
}C++ gets C declarations for free by including the header; Rust has no header parser, so the signature is re-declared (in practice
bindgen generates this from the header for you). Calling into C is unsafe because the compiler cannot verify what happens on the other side. Going the other way is symmetric: extern "C" plus #[unsafe(no_mangle)] is C++'s extern "C". What Rust has no equivalent of is direct C++ interop — templates, overloads and exceptions have no stable ABI to bind against — so the two normally meet across a C-shaped boundary, which is exactly what cxx and similar crates automate.