The contract changes
You have spent years wrangling pointers in C++. You know the rhythm of new and delete. You know the pain of a use-after-free that only crashes on the third run in production. You know the joy of a perfectly tuned std::vector reserve. Now you are looking at Rust, and the compiler is screaming at you for writing code that compiles fine in C++. The shift is not just syntax. It is a fundamental renegotiation of the contract between you and the machine.
In C++, you promise the compiler you will manage memory correctly. You promise you won't dereference a null pointer. You promise you won't have data races. The compiler trusts you. If you lie, you get undefined behavior. The program might crash, it might corrupt data silently, or it might appear to work until the heat death of the universe. Rust flips the script. The compiler does not trust you. It proves you are right before the code ever runs. If you cannot prove safety, the code does not compile.
This feels restrictive at first. You will fight the borrow checker. You will wonder why you cannot pass a mutable reference and an immutable reference to the same function. Then you will ship code that runs in production without a single memory safety bug, and you will realize the compiler was not blocking you. It was teaching you to write better abstractions.
Ownership: RAII on autopilot
C++ developers love RAII. Resource Acquisition Is Initialization. You wrap a resource in a class, the constructor acquires it, the destructor releases it. std::unique_ptr and std::shared_ptr make this ergonomic. Rust takes RAII and makes it the default for every value. You do not need to wrap values in smart pointers to get automatic cleanup. The value itself is the owner.
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped. The compiler inserts the cleanup code for you. There is no delete. There is no free. You cannot accidentally leak memory by forgetting to call a destructor, because the destructor is called automatically and deterministically.
In C++, assignment copies data or moves it depending on the type and the operator. In Rust, assignment moves data by default for types that do not implement Copy. This prevents double-free errors at compile time. If you assign a value to a new variable, the old variable becomes invalid. The compiler tracks this flow.
fn main() {
let s1 = String::from("hello"); // Allocates heap memory for the string.
let s2 = s1; // Moves ownership to s2. s1 is now invalid.
// println!("{}", s1); // Compiler error E0382: value borrowed here after move.
println!("{}", s2); // s2 owns the data and prints it.
}
The String type owns heap memory. Copying the heap memory on every assignment would be expensive. Rust moves the pointer instead. The compiler marks s1 as unusable after the move. If you try to use s1, you get E0382 (use of moved value). This guarantees that only one variable owns the heap allocation at a time. When s2 goes out of scope, the memory is freed. No double-free. No leak.
Some types implement the Copy trait. i32, f64, bool, and references are Copy. These types live entirely on the stack or are just pointers without ownership. Copying them is cheap. The compiler treats assignment as a bitwise copy for these types. You do not need to think about moves for integers.
Convention aside: Use #[derive(Copy, Clone)] on your own types when they are small, stack-only structs with no pointers. This makes them behave like primitives and reduces boilerplate.
References are not pointers
C++ has pointers, references, and smart pointers. Rust has references and raw pointers. Safe Rust code uses references. Raw pointers exist only inside unsafe blocks. This distinction matters.
A C++ reference is essentially a pointer that cannot be null and cannot be reseated. It is still just a memory address. You can have multiple references to the same object. You can mutate the object through one reference while reading through another. This leads to data races in multithreaded code and iterator invalidation in single-threaded code.
Rust references come with lifetimes and mutability guarantees. An immutable reference &T guarantees that the data will not change while the reference exists. A mutable reference &mut T guarantees exclusive access. No other references can exist while a mutable reference is active. The compiler enforces this.
fn main() {
let mut data = vec![1, 2, 3];
let ref1 = &data; // Immutable borrow.
let ref2 = &data; // Another immutable borrow. This is allowed.
// let ref3 = &mut data; // Error E0502: cannot borrow as mutable because it is also borrowed as immutable.
println!("{}, {}", ref1[0], ref2[1]); // Read through both references.
// Mutable borrow requires exclusive access.
let ref_mut = &mut data;
ref_mut.push(4); // Modify through the mutable reference.
}
This rule eliminates data races. If you cannot have two mutable references to the same data, you cannot have two threads writing to the same data. The compiler proves this. You get thread safety without locks in many cases.
The lifetime system tracks how long references are valid. In C++, you manage lifetimes with scope and smart pointers. In Rust, the compiler infers lifetimes automatically in most cases. You only need to write lifetime annotations when the compiler cannot figure out the relationship between input and output references. Lifetime annotations do not change how long references live. They tell the compiler how to check that references live long enough.
Ah-ha reveal: In C++, const is a promise. You promise not to modify the data, but you can cast away const and modify it anyway. In Rust, immutability is a guarantee. If a variable is immutable, the compiler ensures no code can modify the data through that variable. You cannot cast away immutability in safe Rust.
Error handling: No exceptions, no nulls
C++ uses exceptions for error handling. Exceptions are powerful but come with costs. They can hide control flow. They require unwinding the stack. They can cause performance spikes. Rust does not have exceptions. Errors are values.
Rust uses Result<T, E> for operations that can fail. Result is an enum with two variants: Ok(T) for success and Err(E) for failure. Functions return Result to indicate failure. Callers must handle the result. You cannot ignore an error. The compiler forces you to deal with it.
use std::fs;
fn read_config() -> Result<String, std::io::Error> {
// fs::read_to_string returns a Result.
let content = fs::read_to_string("config.txt")?; // The ? operator propagates errors.
Ok(content)
}
fn main() {
match read_config() {
Ok(content) => println!("Config: {}", content),
Err(e) => eprintln!("Failed to read config: {}", e),
}
}
The ? operator makes error handling ergonomic. It checks the Result. If it is Ok, it extracts the value. If it is Err, it returns the error from the current function. This chains errors up the call stack without boilerplate. You get the safety of explicit error handling with the conciseness of exceptions.
Rust also uses Option<T> for values that might be missing. Option is an enum with Some(T) and None. There is no null pointer. You cannot dereference a null pointer because null does not exist. You must handle the None case. This eliminates null pointer dereferences, a source of countless bugs in C++.
Convention aside: Use expect("message") instead of unwrap() in production code. unwrap() panics with a generic message. expect() panics with a message you provide, making debugging easier. Reserve unwrap() for tests and quick scripts where panicking is acceptable.
Concurrency: The borrow checker saves you from yourself
C++ multithreading is dangerous. std::thread and std::mutex give you the tools, but nothing stops you from using them wrong. You can forget to lock a mutex. You can lock a mutex and then pass a reference to the protected data to another thread. You can have a data race where one thread writes while another reads. The compiler does not catch these errors. You find them with sanitizers or in production.
Rust prevents data races at compile time. The borrow checker applies to threads as well. To send data to another thread, the type must implement the Send trait. To share data between threads, the type must implement the Sync trait. These traits are auto-implemented for most types, but the compiler checks the rules.
If you have a &mut T, the type is not Sync. You cannot share a mutable reference across threads. You must use a synchronization primitive like Mutex<T> or RwLock<T>. The compiler forces you to lock the mutex before accessing the data. You cannot forget.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0)); // Arc provides thread-safe shared ownership.
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter); // Clone the Arc, not the Mutex.
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap(); // Lock the mutex.
*num += 1; // Modify the protected data.
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", *counter.lock().unwrap());
}
Arc<T> is the atomic reference-counted pointer. It allows multiple threads to own the same data. Mutex<T> provides interior mutability with synchronization. You must lock the mutex to get a mutable reference. The lock guard goes out of scope when the block ends, releasing the lock. This pattern is enforced by the type system. You cannot access the data without the lock.
Trust the borrow checker. It usually has a point. If the compiler rejects your concurrent code, there is a race condition waiting to happen.
Pitfalls and compiler errors
Coming from C++, you will hit specific walls. The compiler errors are verbose but helpful. Learn to read them.
E0502 is the classic borrow conflict. You try to borrow data as mutable while an immutable borrow exists. The compiler tells you exactly where the borrows start and end. Fix this by restructuring your code to separate reads and writes. Sometimes you need to clone data. Sometimes you need to use RefCell<T> for interior mutability in single-threaded code.
E0382 is use after move. You try to use a value after it has been moved. The compiler shows you where the move happened. Fix this by cloning the value if you need to keep the original, or by borrowing instead of moving.
E0277 is a trait bound not satisfied. You try to use a type in a context that requires a trait, but the type does not implement the trait. For example, you try to send a type to a thread, but it does not implement Send. The compiler lists the missing trait. Fix this by implementing the trait or using a type that implements it.
E0716 is a temporary value dropped while borrowed. You try to return a reference to a local variable. The variable is dropped when the function returns, leaving a dangling reference. The compiler prevents this. Fix this by returning the owned value instead of a reference, or by extending the lifetime of the data.
Convention aside: Run cargo clippy regularly. Clippy is a linter that catches idiomatic issues. It suggests improvements like using ? instead of match, or using iter() instead of indexing. It helps you write idiomatic Rust.
Decision: Mapping C++ patterns to Rust
You cannot translate C++ code to Rust line by line. You must translate patterns. Use the right tool for the job.
Use Box<T> when you need heap allocation and single ownership, replacing std::unique_ptr<T>. Box is a smart pointer that owns heap data. It drops the data when it goes out of scope.
Use Rc<T> when you need shared ownership in a single-threaded context, replacing std::shared_ptr<T>. Rc uses reference counting. It is not thread-safe. Use it for graphs, caches, or UI state where multiple parts of the code need to read the same data.
Use Arc<T> when you need shared ownership across threads, replacing std::shared_ptr<T> with atomic reference counting. Arc is thread-safe. Combine it with Mutex<T> or RwLock<T> for shared mutable state.
Use unsafe when you must interface with C libraries or implement low-level abstractions where the compiler cannot verify safety. Keep unsafe blocks small. Isolate the unsafe code in a function with a safe interface. Write a // SAFETY: comment explaining the invariants. If you cannot write the safety proof, you do not have one.
Reach for Result and Option for error handling, replacing exceptions and null pointers. Make error handling explicit. Use the ? operator to propagate errors. Handle errors at the boundaries of your application.
Reach for Vec<T> and String for dynamic collections. They are the standard library equivalents of std::vector and std::string. They grow automatically. They manage their own memory. You do not need to manage capacity manually unless you are optimizing a hot path.
Reach for traits when you need polymorphism, replacing virtual functions and templates. Traits are like interfaces with implementation. They are monomorphized at compile time, giving you performance similar to templates. Use trait objects &dyn Trait when you need dynamic dispatch, but prefer static dispatch for performance.