Rust vs C++

A Comprehensive Comparison

Rust ensures memory safety and concurrency at compile time, while C++ offers manual control and legacy support.

The fork in the road

You are building a high-throughput network proxy. It needs to handle thousands of concurrent connections, share configuration across worker threads, and never crash under load. In C++, you spend weeks writing custom allocators, auditing pointer arithmetic, and hoping your mutex strategy does not deadlock in production. In Rust, you spend those weeks fighting the borrow checker until the compiler finally accepts your design, then deploy knowing data races are mathematically impossible. Both languages give you control over memory and CPU. They just hand you the keys differently.

C++ treats memory like a blank canvas. You draw what you want, and you are responsible for cleaning up every brushstroke. Rust treats memory like a strict lease agreement. The compiler tracks who holds the lease, how long they hold it, and whether anyone else is trying to modify the same space. The analogy is a shared notebook. In C++, multiple people can write in it at once. You have to manually coordinate with sticky notes and hope nobody tears out a page while someone else is reading it. In Rust, the compiler hands out pens. Only one person gets a pen to edit. Everyone else gets a read-only copy. When the editor is done, the pen changes hands. The compiler enforces this at compile time, so you never accidentally overwrite someone else's work.

How they handle memory

C++ relies on RAII (Resource Acquisition Is Initialization) and smart pointers like std::unique_ptr and std::shared_ptr to automate cleanup. The language gives you the tools, but it does not force you to use them correctly. You can still leak memory, double-free, or dereference a dangling pointer. The compiler will happily generate machine code for all of it. The crash happens later, usually when a customer triggers a rare edge case.

Rust removes the option to be careless. Ownership is baked into the type system. Every value has exactly one owner. When the owner goes out of scope, the value is dropped. You cannot accidentally keep a pointer alive after the data it points to is freed. The compiler builds a borrow graph during compilation and rejects any code that violates the rules.

/// Demonstrates ownership transfer versus manual memory management
fn main() {
    // Rust: ownership moves to the function. The original variable is invalid.
    let data = String::from("hello");
    take_ownership(data);
    
    // The compiler blocks this line before the program ever runs.
    // println!("{}", data); // E0382: use of moved value
    
    // C++ equivalent would require explicit std::move or manual new/delete.
    // Rust handles the cleanup automatically when the scope ends.
}

/// Takes ownership of a String and prints it
fn take_ownership(s: String) {
    println!("Got: {}", s);
    // s is dropped here automatically. No manual delete required.
}

The tradeoff is upfront friction. You will write code that feels correct, only to have the compiler reject it with a lifetime error. You will restructure your data layout, extract helper functions, and change ownership boundaries. Once the code compiles, it works. The pain shifts from debugging segfaults in production to refactoring until the borrow checker is satisfied. Trust the borrow checker. It usually has a point.

The toolchain gap

C++ development relies on a fragmented ecosystem of build systems, package managers, and compiler flags. You spend time configuring include paths, linking libraries, and managing dependencies across platforms. CMake is the industry standard, but it requires writing domain-specific language scripts that often feel like configuration puzzles. Cross-compilation, static linking, and dependency resolution are manual processes that vary by project.

Rust ships with Cargo. One command fetches dependencies, builds the project, runs tests, and formats code. The convention is strict: cargo fmt formats every file identically. You stop arguing about brace placement and start arguing about architecture. The dependency graph is resolved deterministically. You never wonder which version of a crate your project actually uses. The Cargo.lock file pins exact versions, and cargo update handles upgrades safely.

/// A realistic task queue shared across threads
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::thread;

/// Shared state wrapped in atomic reference counting and a mutex
struct TaskQueue {
    // Arc allows multiple threads to own the same data.
    // Mutex ensures only one thread mutates the queue at a time.
    queue: Arc<Mutex<VecDeque<String>>>,
}

impl TaskQueue {
    fn new() -> Self {
        // Initialize with an empty queue wrapped in Arc<Mutex>
        Self {
            queue: Arc::new(Mutex::new(VecDeque::new())),
        }
    }

    fn push(&self, task: String) {
        // Lock the mutex before mutating. The lock is automatically released.
        let mut q = self.queue.lock().unwrap();
        q.push_back(task);
    }

    fn pop(&self) -> Option<String> {
        // Lock the mutex before reading. Returns None if empty.
        let mut q = self.queue.lock().unwrap();
        q.pop_front()
    }
}

fn main() {
    let queue = TaskQueue::new();
    
    // Spawn a worker thread that consumes tasks
    let worker_queue = Arc::clone(&queue.queue);
    let handle = thread::spawn(move || {
        // Clone the Arc to give the thread its own reference count
        while let Some(task) = worker_queue.lock().unwrap().pop_front() {
            println!("Processing: {}", task);
        }
    });

    // Push tasks from the main thread
    queue.push("task_1".to_string());
    queue.push("task_2".to_string());
    
    handle.join().unwrap();
}

In C++, you would use std::shared_ptr<std::mutex> and std::queue. The compiler will not stop you from accessing the queue without locking. You must manually remember to call lock() and unlock(), or use std::lock_guard. If you forget, you get a data race. Rust makes the lock mandatory at the type level. You cannot call pop_front() without acquiring the mutex first. The convention is to keep unsafe blocks tiny. If you need to bypass synchronization checks for performance, you isolate the unsafe code in a single function and document the invariants. Treat the // SAFETY: comment as a proof. If you cannot write it, you do not have one.

Concurrency and the borrow checker

C++ gives you raw threads and mutexes. You can accidentally share mutable state across threads and get a data race. The compiler will not stop you. You rely on sanitizers, code reviews, and testing to catch concurrency bugs. Rust extends the borrow checker to threads. The Send trait marks types that can be transferred across thread boundaries. The Sync trait marks types that can be safely shared. The compiler verifies that access is synchronized. You cannot accidentally create a data race.

The learning curve is steep but predictable. You will encounter lifetime errors when trying to share references across threads. You will learn to use Arc<T> for shared ownership and Mutex<T> or RwLock<T> for interior mutability. The compiler forces you to think about data flow explicitly. You cannot hide behind implicit copies or hidden pointers. Every ownership transfer is visible in the code.

When the compiler says no

You will hit compiler errors early and often. The errors are verbose because they explain exactly what rule was violated and why. You will see E0502 when you try to borrow a value as mutable while an immutable borrow is still active. You will see E0308 when you pass the wrong type to a function. You will see E0277 when a type does not implement a required trait like Send or Clone. The compiler does not guess your intent. It tells you what it sees and what it expects.

/// Demonstrates common borrow checker rejections
fn main() {
    let mut numbers = vec![1, 2, 3];
    
    // Create an immutable reference to the first element
    let first = &numbers[0];
    
    // Attempt to push a new element while first is still borrowed
    // numbers.push(4); // E0502: cannot borrow as mutable because it is also borrowed as immutable
    
    // The compiler blocks this because pushing might reallocate the vector.
    // If reallocation happens, the first reference would point to freed memory.
    println!("First: {}", first);
    
    // Once the immutable borrow ends, mutation is allowed again
    numbers.push(4);
}

C++ compilers are permissive by design. They assume you know what you are doing. Rust compilers are restrictive by design. They assume you might make a mistake. The restriction feels heavy until you realize it catches entire classes of bugs before they reach testing. You stop writing defensive null checks. You stop hunting use-after-free bugs. You start designing data structures that match the compiler's expectations. The convention is to write Rc::clone(&data) instead of data.clone() when working with reference counting. Both compile, but the explicit form signals to readers that you are cloning the pointer, not the underlying data. Small signals prevent big misunderstandings.

Picking your weapon

Use Rust when you are building new systems where memory safety and concurrency are priorities. Use Rust when your team wants to eliminate entire categories of runtime bugs without sacrificing performance. Use Rust when you prefer a unified toolchain that handles dependencies, formatting, and testing without external configuration.

Reach for C++ when you are maintaining a large existing codebase that cannot be rewritten. Reach for C++ when you need direct hardware manipulation and the flexibility to bypass language constraints for niche embedded targets. Reach for C++ when your team has decades of expertise in manual memory management and prefers runtime flexibility over compile-time guarantees.

Pick C++ when you are integrating with legacy libraries that lack safe bindings and you need to call them directly. Pick Rust when you are building a new library and want to guarantee that users cannot misuse your API. Pick Rust when you want the compiler to enforce thread safety rather than relying on testing and code reviews.

Stay with Rust when you are willing to spend extra time during development to save time during debugging. Stay with C++ when you need maximum control over every byte of memory and are comfortable managing that control manually. Stay with C++ when your project timeline demands rapid prototyping and you can accept the risk of runtime memory errors.

Counter-intuitive but true: the more you fight the borrow checker, the more your architecture improves. The compiler forces you to separate concerns, minimize shared state, and design clear ownership boundaries. You will write less code to handle edge cases. You will spend more time thinking about data flow. The language does not give you freedom from rules. It gives you freedom from consequences.

Where to go next