Back to lessons
Scrollytelling

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.

memory.cpp -- The evolution of C++ memory management
// 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.

ownership.rs -- Compiler-enforced safety
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.

Comparison: the same pattern in C++ vs Rust
// 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.

data_race.cpp -- C++ data race (compiles fine)
#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.
}
no_data_race.rs -- Rust prevents this at compile time
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.

errors.cpp -- C++ exceptions
#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.
}
errors.rs -- Rust's Result type
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.