Rust vs C/C++
Comparing memory safety approaches
Same Performance, Different Safety
Rust and C++ both compile to native machine code with no garbage collector. Both give you control over memory layout, stack vs heap allocation, and hardware-level operations. In benchmarks, they consistently perform within a few percent of each other.
But there is a fundamental difference in their approach to safety. C++ trusts the programmer and provides tools to help. Rust verifies the programmer and makes entire classes of bugs structurally impossible.
Rust vs C++ at a Glance ──────────────────────────────────────────── Feature C++ Rust ──────────────────────────────────────────── Compilation Native Native Garbage collector No No Memory control Full Full Memory safety Convention Enforced Data race safety Convention Enforced Null pointers Yes (nullptr) No (Option<T>) Exceptions Yes (throw) No (Result<T,E>) Undefined behavior Common Impossible* Package manager None (CMake...) Cargo (built-in) ──────────────────────────────────────────── * in safe Rust. Unsafe blocks can opt out.
This is not about one language being "better" in absolute terms. C++ has 40+ years of libraries, compilers, and expertise. Rust offers a different tradeoff: a steeper initial learning curve in exchange for the compiler catching bugs that C++ would let through.
Manual Memory in C++
C++ offers multiple layers of memory management, from raw pointers to smart pointers to RAII. But each layer is opt-in, and nothing prevents you from bypassing them.
// 1. Raw pointers (C-style) -- dangerous
int* raw = new int(42);
// ... 200 lines later, do you remember to:
delete raw; // Easy to forget, double-delete, or use-after-delete
// 2. RAII with smart pointers (modern C++)
#include <memory>
auto unique = std::make_unique<int>(42);
// Freed automatically when unique goes out of scope.
// Better! But...
// 3. You can STILL do dangerous things:
int* danger = unique.get(); // raw pointer extracted
unique.reset(); // memory freed
*danger = 99; // USE-AFTER-FREE! Compiles fine.
// 4. Shared ownership adds complexity:
auto shared1 = std::make_shared<int>(42);
auto shared2 = shared1; // reference count = 2
// Works, but: cyclic references cause leaks,
// reference counting has runtime overhead,
// and thread safety is only for the count, not the data.Modern C++ best practices (the C++ Core Guidelines, RAII, smart pointers) go a long way toward safety. But they are conventions, not compiler requirements. In a large codebase with many contributors, someone will inevitably use raw pointers, forget a delete, or extract a dangling reference. The compiler cannot help because it does not track ownership.
C++ Memory Safety: Layers of Defense ───────────────────────────────────── Layer Enforced? ───────────────────────────────────── Raw new/delete Programmer discipline RAII By convention unique_ptr If you use it shared_ptr If you use it Core Guidelines If you follow them Static analyzers If you run them Sanitizers (ASan) If you test with them ───────────────────────────────────── Every layer is optional. None are enforced by the compiler.
Rust's Ownership Model
Rust takes the best idea from C++ -- RAII -- and makes it mandatory, verified, and extended with borrowing rules. The result is that the compiler can prove your code is memory-safe before it ever runs.
fn main() {
// Ownership is always clear and enforced:
let s1 = String::from("hello");
let s2 = s1; // ownership MOVES to s2
// println!("{}", s1); // COMPILE ERROR: s1 is no longer valid
// Borrowing is controlled:
let s3 = String::from("world");
let len = calculate_length(&s3); // borrow s3 immutably
println!("{} has length {}", s3, len); // s3 is still valid
// Mutable borrows are exclusive:
let mut s4 = String::from("hello");
let r1 = &mut s4;
// let r2 = &mut s4; // COMPILE ERROR: second mutable borrow
// let r3 = &s4; // COMPILE ERROR: immutable borrow while mutably borrowed
r1.push_str(", world!");
println!("{}", s4);
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s goes out of scope, but it was just a reference,
// so nothing happens to the owned String.Compare this to C++: in Rust, you cannot extract a raw pointer from a unique ownership and create a dangling reference. The compiler tracks every borrow and ensures it does not outlive the data it points to.
// C++ -- compiles, crashes at runtime
std::string make_greeting() {
std::string s = "Hello";
std::string_view sv = s; // sv references s's buffer
return std::string(sv);
// Wait -- what if we returned sv instead?
// std::string_view is a non-owning reference.
// If s is destroyed first, sv dangles. Compiles fine.
}
// Rust -- compile-time error
fn make_greeting() -> &str { // ERROR: missing lifetime
let s = String::from("Hello");
&s // cannot return reference to local variable
}
// The compiler refuses to compile this.
// You must return the owned String instead.Concurrency
Data races are one of the hardest bugs to find and fix. They occur when two threads access the same memory simultaneously and at least one is writing. In C++, preventing data races is entirely the programmer's responsibility. In Rust, the type system makes them impossible.
#include <thread>
#include <vector>
int main() {
std::vector<int> data;
// Two threads writing to the same vector
// with no synchronization:
std::thread t1([&data]() {
for (int i = 0; i < 1000; i++)
data.push_back(i); // DATA RACE!
});
std::thread t2([&data]() {
for (int i = 0; i < 1000; i++)
data.push_back(i); // DATA RACE!
});
t1.join();
t2.join();
// Undefined behavior: crashes, corruption, or
// appears to work until production.
}use std::thread;
fn main() {
let mut data = vec![];
// Rust won't let you share a mutable reference
// across threads:
thread::spawn(|| {
data.push(1); // COMPILE ERROR:
// closure may outlive the current function,
// but it borrows 'data', which is owned by
// the current function.
});
// The fix: use Arc<Mutex<T>> for shared mutable state
use std::sync::{Arc, Mutex};
let data = Arc::new(Mutex::new(vec![]));
let data1 = Arc::clone(&data);
let t1 = thread::spawn(move || {
for i in 0..1000 {
data1.lock().unwrap().push(i); // safe!
}
});
let data2 = Arc::clone(&data);
let t2 = thread::spawn(move || {
for i in 0..1000 {
data2.lock().unwrap().push(i); // safe!
}
});
t1.join().unwrap();
t2.join().unwrap();
}Send and Sync: fearless concurrency
Rust uses two marker traits to enforce thread safety at compile time:
Send-- a type can be safely transferred to another thread.Sync-- a type can be safely shared (referenced) across threads.
Types that are not thread-safe (like Rc<T>) simply do not implement these traits, so the compiler refuses to let you use them across thread boundaries. This is all checked at compile time with zero runtime cost.
Error Handling
C++ uses exceptions for error handling. While powerful, exceptions have significant problems: invisible control flow, performance overhead for unwinding, and no way to know from a function signature whether it can throw. Rust takes a fundamentally different approach.
#include <fstream>
#include <string>
#include <stdexcept>
// Can this function throw? You can't tell from the signature.
std::string read_config(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
throw std::runtime_error("Cannot open " + path);
}
std::string content;
// getline can also throw...
std::getline(file, content);
return content;
}
// Caller has no indication that this might throw:
void initialize() {
auto config = read_config("/etc/app.conf");
// If read_config throws and we don't catch,
// the exception propagates invisibly up the stack.
}use std::fs;
use std::io;
// The return type TELLS you this can fail:
fn read_config(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn initialize() -> Result<(), io::Error> {
// The ? operator propagates errors explicitly:
let config = read_config("/etc/app.conf")?;
// ^
// If read_config returns Err, this function
// returns that error immediately. If Ok, the
// value is unwrapped into 'config'.
println!("Config: {}", config);
Ok(())
}
// You MUST handle the Result. Ignoring it is a compiler warning.
fn main() {
match initialize() {
Ok(()) => println!("Started successfully"),
Err(e) => eprintln!("Failed to start: {}", e),
}
}Error Handling Comparison ────────────────────────────────────────────── Aspect C++ Exceptions Rust Result ────────────────────────────────────────────── Visible in type? No Yes Can be ignored? Yes Warning Performance cost Stack unwinding Zero (no throw) Control flow Hidden jumps Explicit with ? Null safety nullptr Option<T> Pattern matching No Yes (match) ──────────────────────────────────────────────
Rust also replaces null pointers with Option<T>: a value is either Some(value) or None. You cannot use the value without checking, which eliminates null pointer dereferences entirely.
Summary
Rust and C++ are both powerful systems languages that compile to efficient native code. The key difference is where the safety checking happens.
C++: safety by convention
C++ provides smart pointers, RAII, static analyzers, and sanitizers. These are excellent tools, but all are optional. In practice, memory bugs, data races, and undefined behavior remain common in large C++ codebases.
Rust: safety by construction
Rust makes safety the default. The ownership system, borrow checker, Send/Sync traits, and Result/Option types form a comprehensive safety net enforced by the compiler. You cannot accidentally opt out.
When to choose Rust over C++
Consider Rust for new projects where safety is critical: network services, OS components, embedded systems, WebAssembly, and CLI tools. C++ remains a strong choice when you need deep ecosystem compatibility, existing library access, or when your team has decades of C++ expertise.
The trend is clear: the Linux kernel, Android, Windows, Chromium, and Firefox are all adopting Rust for new safety-critical components alongside existing C++ code. It is not about replacing C++ everywhere -- it is about having the right tool for the right job.