Skip to content
rusthacks.dev
Rust

Rust for C Plus Plus Developers a Practical Guide Explained

C++ developers know manual lifetimes. Rust turns the rules you follow by habit into compiler checks. This guide maps RAII, templates, move semantics and build tools across.

20 min read
Summarize in AI
Flat illustration for the post “Rust for C Plus Plus Developers a Practical Guide Explained”, composed around ownership.

If you write modern C++, you already follow a list of rules that no compiler checks. Do not touch an object after you move from it. Do not keep a reference past the lifetime of its owner. Do not push into a vector while you iterate it. Do not share a mutable object between threads without a lock. Every one of those rules has caused an outage somewhere, and every one of them is an error message in Rust. This post maps the C++ you know onto the Rust that replaces it, one failing program at a time, and it is explicit about where the mapping breaks.

Everything here was built with rustc 1.99.0 and edition 2024, on an Apple M1 Max, and the C++ half uses clang++ with -std=c++20. No crates are involved except where a section says so, so each program is a single file you can paste into src/main.rs of a cargo new --vcs none project. The compiler messages are pasted as they were printed, with the file name normalized to src/main.rs.

RAII is the default, not a discipline

In C++ you opt into resource safety by wrapping a resource in a class with a destructor and then remembering to use the wrapper. In Rust every value has exactly one owner, and when that owner goes out of scope the compiler inserts the drop. The Drop trait is the destructor. This program prints on construction and destruction so you can watch the order.

src/main.rsrust
struct Connection {
    id: u32,
}

impl Drop for Connection {
    fn drop(&mut self) {
        println!("closing {}", self.id);
    }
}

fn open(id: u32) -> Connection {
    println!("opening {id}");
    Connection { id }
}

fn main() {
    let _a = open(1);
    {
        let _b = open(2);
        let c = open(3);
        drop(c);
        println!("inner scope ends");
    }
    let moved = open(4);
    let holder = vec![moved];
    println!("holder has {}", holder.len());
    println!("end of main");
}
Text
opening 1
opening 2
opening 3
closing 3
inner scope ends
closing 2
opening 4
holder has 1
end of main
closing 4
closing 1

Read the output against the source. _a is declared first and dropped last, which is the same reverse-order rule C++ uses. _b dies at the closing brace of the inner block. c is dropped early because drop(c) is an ordinary function that takes its argument by value: it moves the connection in and the function body ends, so the destructor runs right there. You cannot call it twice and you cannot use c afterwards, so there is no double free to guard against. Finally, moved goes into the vector, and its destructor runs when holder is dropped at the end of main, not when the variable moved goes out of scope. After the move, moved owns nothing, and the compiler does not drop it a second time.

The leading underscore on _a and _b matters more than it looks. A binding named just _ drops the value immediately, because nothing is holding it. _a is a real binding that happens to suppress the unused-variable warning, so the value lives to the end of the scope. C++ programmers who write a guard object and forget to name it know the equivalent bug, and Rust has the same trap in this one spot.

One difference has no C++ analogue: forgetting is safe. std::mem::forget and a reference-counted cycle can both skip a destructor without any unsafe, so Rust does not promise that Drop runs. It promises that nothing is used after it runs. Code that depends on a destructor for memory safety, which is what a scoped-thread API once did, has to be designed around that. For ordinary resources such as sockets and files, the guarantee you get is the one you were relying on in C++.

Moves are destructive, and the compiler holds you to it

A C++ move constructor leaves the source in a valid but unspecified state, and the language lets you read it. Static analyzers flag use-after-move, but only some of them and only when they can see it. Rust moves are a plain bitwise copy of the value followed by a compile-time decision that the source is no longer there. There is no move constructor to write, no moved-from state to document, and no way to read the old name. Here is the C++ habit, translated directly.

src/main.rsrust
fn consume(v: Vec<u8>) -> usize {
    v.len()
}

fn main() {
    let data = vec![1, 2, 3];
    let n = consume(data);
    println!("{n} {:?}", data);
}

This is the first error every C++ developer meets, and it is worth reading all of it:

Text
error[E0382]: borrow of moved value: `data`
 --> src/main.rs:8:26
  |
6 |     let data = vec![1, 2, 3];
  |         ---- move occurs because `data` has type `Vec<u8>`, which does not implement the `Copy` trait
7 |     let n = consume(data);
  |                     ---- value moved here
8 |     println!("{n} {:?}", data);
  |                          ^^^^ value borrowed here after move
  |
note: consider changing this parameter type in function `consume` to borrow instead if owning the value isn't necessary
 --> src/main.rs:1:15
  |
1 | fn consume(v: Vec<u8>) -> usize {
  |    -------    ^^^^^^^ this parameter takes ownership of the value
  |    |
  |    in this function
help: consider cloning the value if the performance cost is acceptable
  |
7 |     let n = consume(data.clone());
  |                         ++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

The message does the debugging for you. It tells you where the value moved, why it moved (Vec<u8> is not Copy), and it points at the parameter type as the thing to reconsider. There is also an offer to .clone(), which is the Rust equivalent of copying a std::vector by value. Taking that offer works, but it copies the buffer, and in C++ you would not write void consume(std::vector<uint8_t> v) for a function that only reads. You would write a const reference. The Rust spelling is a borrowed slice.

src/main.rsrust
fn count(v: &[u8]) -> usize {
    v.len()
}

fn main() {
    let data = vec![1, 2, 3];
    let n = count(&data);
    println!("{n} {:?}", data);
}
Text
3 [1, 2, 3]

Two details are worth stating. The parameter is &[u8], a slice, rather than &Vec<u8>, because a slice accepts a vector, an array or a part of either, which is the role std::span plays in C++20. And count(&data) borrows for the duration of the call only. After the call returns, data is usable again. If you want the long version of the borrowing rules, ownership in Rust covers them, and Copy versus Clone explains why a small plain type such as an i32 does not move at all.

The cost of this model shows up in data structures with back pointers. A type that stores a pointer into itself cannot be moved by memcpy, because the pointer would dangle. C++ solves this by deleting the move constructor or writing a careful one. Rust has no move constructor to customize, so safe Rust simply refuses to build a self-referential struct, and Pin exists for the async machinery and the rare hand-written case that needs it. If your C++ design has an intrusive linked list or an observer that stores this, expect to redesign it with indices into a Vec or with an arena, not to port it line by line.

Smart pointers are mostly types you stop writing

The table below is the map most people want first. The right-hand column is shorter because ownership already expresses a lot of what C++ uses pointers for.

C++RustWhat changes
a value membera field of the structNothing. Layout is similar, though field order is not guaranteed unless you ask for #[repr(C)].
std::unique_ptr<T>an owned value, or Box<T> for heap allocationYou box far less often. A Vec<T> already owns its heap buffer.
std::shared_ptr<T>Rc<T> on one thread, Arc<T> across threadsThe thread-safety choice is in the type, not in a runtime flag.
std::weak_ptr<T>Weak<T>Same purpose: break cycles.
a raw T* or T&&T, &mut T, or *const T inside unsafeReferences are never null and never dangle in safe code.
std::optional<T>Option<T>Option<&T> and Option<Box<T>> are the same size as the bare pointer.

The reason you reach for shared_ptr less is that a tree or an owned graph of values has one obvious owner for each node, and the borrow checker lets you pass out references into it. Rust code that does use Rc<RefCell<T>> is usually modeling a real graph, and the runtime borrow check that RefCell performs is the price. The page on Box, Rc and Arc shows each of them in a small program.

Templates become generics, and the error moves to the definition

A C++ template is checked when it is instantiated. If you call largest with a type that lacks operator>, you get the error at the call site, inside the template body, behind a stack of instantiation notes. C++20 concepts improve this, but a concept is optional and a template written before concepts existed is still unconstrained. A Rust generic function is checked once, against the bounds it declares. The body cannot use anything the bounds do not promise. Here is the unconstrained version, which is what an experienced C++ programmer writes first.

src/main.rsrust
fn largest<T>(items: &[T]) -> &T {
    let mut best = &items[0];
    for x in items {
        if x > best {
            best = x;
        }
    }
    best
}

fn main() {
    println!("{}", largest(&[3, 9, 4]));
}
Text
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:4:14
  |
4 |         if x > best {
  |            - ^ ---- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(items: &[T]) -> &T {
  |             ++++++++++++++++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0369`.

The error is reported at the definition, with no mention of the i32 the caller passed, because the function would be wrong for any T. The suggestion at the bottom is the fix. Adding PartialOrd is the Rust equivalent of a requires std::totally_ordered<T> clause, except that it is mandatory. The fixed version below also returns an Option, because the first draft indexed items[0], which panics on an empty slice, where C++ would have read past the end.

src/main.rsrust
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> {
    let mut iter = items.iter();
    let mut best = iter.next()?;
    for x in iter {
        if x > best {
            best = x;
        }
    }
    Some(best)
}

fn main() {
    println!("{:?}", largest(&[3, 9, 4]));
    println!("{:?}", largest(&["pear", "apple"]));
    println!("{:?}", largest::<f64>(&[]));
}
Text
Some(9)
Some("pear")
None

The function takes a slice of references, so it works on i32, &str and f64 without requiring Copy. It returns a reference into the caller's slice. The third call needs the turbofish, ::<f64>, because an empty array literal gives the compiler nothing to infer T from. Both languages monomorphize, so each call site gets its own machine code and there is no run-time dispatch cost. The same bloat concern applies, and generics and monomorphization shows how to measure it. If you want the dynamic side, a trait object is the Rust spelling of a virtual call; static versus dynamic dispatch compares the two.

Know what Rust generics cannot do. There is no specialization on stable, so you cannot provide a faster implementation of one trait for one concrete type behind the same generic function. There is no variadic template, so code that C++ writes with parameter packs becomes tuples, macros or repeated impls. And there is no SFINAE: a trait bound is an all-or-nothing gate, which is the reason the errors are readable. The trait bounds and where clauses post goes through the syntax.

Dangling references stop at the function boundary

The classic C++ bug is returning a view of something that dies at the end of the function. std::string_view over a temporary is the modern version of it, and it compiles without a warning on most settings. Here is the same shape in Rust.

src/main.rsrust
fn longest_line(text: &str) -> &str {
    let owned = text.to_uppercase();
    owned.lines().max_by_key(|l| l.len()).unwrap_or("")
}

fn main() {
    println!("{}", longest_line("a\nbbb\ncc"));
}
Text
error[E0515]: cannot return value referencing local variable `owned`
 --> src/main.rs:3:5
  |
3 |     owned.lines().max_by_key(|l| l.len()).unwrap_or("")
  |     -----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |     |
  |     returns a value referencing data owned by the current function
  |     `owned` is borrowed here

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0515`.

The returned &str borrows from owned, and owned is destroyed when the function returns. The compiler traces the borrow through lines(), max_by_key and unwrap_or, and it names the local that would be left dangling. The fix is to decide who owns the result. Returning a String means the caller owns a copy and nothing can dangle.

src/main.rsrust
fn longest_line(text: &str) -> String {
    text.to_uppercase()
        .lines()
        .max_by_key(|l| l.len())
        .unwrap_or("")
        .to_string()
}

fn main() {
    println!("{}", longest_line("a\nbbb\ncc"));
}
Text
BBB

A subtlety here may surprise you: the temporary produced by to_uppercase() lives until the end of the full expression, which is why the chain compiles. It is the same rule C++ uses for temporaries, and in Rust it is checked rather than assumed. When the allocation matters, the alternative is to return an index or a range into the original input, and let the caller slice. That is a design choice, not a workaround, and the E0597 post covers the other common shape of this error.

Iterator invalidation is a borrow error

Pushing into a std::vector while a range-for loop is walking it is undefined behavior: the push may reallocate and leave the iterator pointing at freed memory. Many code reviews have caught this by eye and many have not. Rust encodes the same fact in the borrow rules, because the loop holds a shared borrow of the vector and push needs an exclusive one.

src/main.rsrust
fn main() {
    let mut names = vec![String::from("a"), String::from("b")];
    for n in &names {
        if n == "a" {
            names.push(String::from("c"));
        }
    }
    println!("{names:?}");
}
Text
error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:13
  |
3 |       for n in &names {
  |       -        ------
  |       |        |
  |       |        immutable borrow occurs here
  |  _____|        immutable borrow later used here
  | |
4 | |         if n == "a" {
5 | |             names.push(String::from("c"));
  | |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
6 | |         }
7 | |     }
  | |_____- this for loop borrows `names` immutably, preventing mutation within its body
  |
  = help: consider using an index-based loop instead, or collecting modifications into a separate collection

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0502`.

The compiler even draws the loop as the borrow's scope. The fix mirrors what you would do in a careful C++ codebase: collect the changes first, apply them after the walk. The versions of this error with different shapes are in the E0502 post.

src/main.rsrust
fn main() {
    let mut names = vec![String::from("a"), String::from("b")];
    let extra: Vec<String> = names
        .iter()
        .filter(|n| *n == "a")
        .map(|_| String::from("c"))
        .collect();
    names.extend(extra);
    println!("{names:?}");
}
Text
["a", "b", "c"]

If you need to grow the vector as you go, loop over indices (for i in 0..names.len()) or use a work-list pattern with while let Some(x) = queue.pop(). Both are explicit about the thing the C++ code left implicit: the collection can change under you.

Data races fail at compile time

C++ has a memory model and tools such as ThreadSanitizer, and still a data race is undefined behavior that surfaces under load. Rust turns the thread-safety of a type into two marker traits, Send and Sync, and checks them where a thread is spawned. Move a non-atomic reference-counted pointer into a thread and see what happens.

src/main.rsrust
use std::rc::Rc;
use std::thread;

fn main() {
    let shared = Rc::new(5);
    let h = thread::spawn(move || println!("{shared}"));
    h.join().unwrap();
}
Text
error[E0277]: `Rc<i32>` cannot be sent between threads safely
 --> src/main.rs:6:27
  |
6 |     let h = thread::spawn(move || println!("{shared}"));
  |             ------------- -------^^^^^^^^^^^^^^^^^^^^^
  |             |             |
  |             |             `Rc<i32>` cannot be sent between threads safely
  |             |             within this `{closure@src/main.rs:6:27: 6:34}`
  |             required by a bound introduced by this call
  |
  = help: within `{closure@src/main.rs:6:27: 6:34}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
 --> src/main.rs:6:27
  |
6 |     let h = thread::spawn(move || println!("{shared}"));
  |                           ^^^^^^^
note: required by a bound in `spawn`
 --> /rustc/b940084d7eb6.../library/std/src/thread/functions.rs:125:0

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0277`.

This is shared_ptr reasoning with a type-level answer. Rc updates its count without atomics, so sharing it across threads would corrupt the count, and the compiler knows because Rc does not implement Send. The error is long, but the third line of the help text is the whole story. Replace Rc with Arc, which uses atomic increments, and the program compiles.

src/main.rsrust
use std::sync::Arc;
use std::thread;

fn main() {
    let shared = Arc::new(5);
    let h = thread::spawn({
        let shared = Arc::clone(&shared);
        move || println!("{shared}")
    });
    h.join().unwrap();
    println!("strong count after join: {}", Arc::strong_count(&shared));
}
Text
5
strong count after join: 1

The clone is made in a block before the closure so that the closure captures the clone by move, while shared stays in main. After join returns, the thread has finished and its clone was dropped, so the strong count is back to 1. Notice that this is a shared immutable value. To mutate shared state you wrap it in Mutex, and unlike std::mutex, the data lives inside the lock, so you cannot reach it without taking the lock. A bare read of a field that another thread writes is not expressible in safe code. That is the property the C++ standard can only recommend. The mechanics are in the Arc and Mutex pattern, and the marker traits are explained in Send and Sync.

What the safety costs at run time

The honest question from a C++ developer is whether any of this is slower. In most code the answer is no, because the ownership rules are compile-time facts and the generated code is what you would have written by hand. The one cost that survives to run time is bounds checking. Indexing a slice with v[i] compares i to the length and panics if it is out of range, where C++ operator[] does nothing and trusts you. We measured the worst realistic case: 8.4 million random reads from a 1M-element Vec<u64>, which defeats the prefetcher and keeps every access independent. The checked loop uses v[i], the other uses get_unchecked.

src/main.rsrust
use std::hint::black_box;
use std::time::Instant;

fn indexed(v: &[u64], idx: &[usize]) -> u64 {
    let mut s = 0u64;
    for &i in idx {
        s = s.wrapping_add(v[i]);
    }
    s
}

fn unchecked(v: &[u64], idx: &[usize]) -> u64 {
    let mut s = 0u64;
    for &i in idx {
        // SAFETY: every index was generated below v.len().
        s = s.wrapping_add(unsafe { *v.get_unchecked(i) });
    }
    s
}

fn main() {
    let n = 1 << 20;
    let v: Vec<u64> = (0..n as u64).collect();
    let mut x = 12345u64;
    let idx: Vec<usize> = (0..n * 8)
        .map(|_| {
            x ^= x << 13;
            x ^= x >> 7;
            x ^= x << 17;
            (x as usize) % n
        })
        .collect();
    for (name, f) in [("indexed", indexed as fn(&[u64], &[usize]) -> u64), ("unchecked", unchecked)] {
        let mut best = f64::MAX;
        for _ in 0..15 {
            let t = Instant::now();
            black_box(f(black_box(&v), black_box(&idx)));
            best = best.min(t.elapsed().as_secs_f64());
        }
        println!("{name:10} {:.2} ms", best * 1000.0);
    }
}
Text
indexed    9.21 ms
unchecked  7.25 ms

Compiled with rustc -O on the M1 Max, the best of 15 runs was 9.2 ms for the checked loop and 7.3 ms for the unchecked one, about a quarter more time for the check on this gather-heavy workload. Two runs agreed within 0.4 ms. The same measurement on a sequential sum is a different story, because the optimizer proves the index in range and removes the check, so the iterator form (for x in v.iter()) costs nothing at all. Use iterators for sequential work and keep the indexed form for genuinely random access. The unchecked version needs unsafe, and the 1.9 ms it recovers is only worth that when a profile says this loop is the program. Most code should leave the checks on. This benchmark is one machine and one access pattern, so treat the figure as an order of magnitude and rerun it on the hardware you ship to. The bounds checks post goes further into when the optimizer removes them.

Undefined behavior and where unsafe fits

Safe Rust has no use-after-free, no double free, no data race, no uninitialized read and no out-of-bounds access, since indexing panics. A panic is a defined, catchable failure, not undefined behavior. unsafe marks the regions where you take over from the compiler, and the discipline is the one you already use for the dangerous parts of a C++ codebase: keep those regions small, document the invariants in a // SAFETY: comment, and expose a safe function around them. The tool that exercises them is Miri, which interprets the program and reports undefined behavior; it plays the part that sanitizers play for C++. The page on writing a safe wrapper walks through the pattern.

Build, dependencies and the toolchain

CMake or Bazel, a package manager such as Conan or vcpkg, and header search paths collapse into Cargo.toml and cargo build. Modules are files. There are no headers, no forward declarations and no include order. Generic code is compiled with the crate that defines it, and that is the source of the compile-time cost: a Rust crate with many generics and a large dependency tree builds more slowly than the equivalent C++ translation unit, and the debug build is much slower to run. Workspaces, incremental compilation and a faster linker bring this down, covered in speeding up Cargo builds. Rust also fixes no stable ABI between compiler versions, so a C++-style binary plugin interface is not on offer. Cross-language boundaries use the C ABI, which is the next section.

Calling between the two, with one working example

Most teams adopt Rust one component at a time: a parser, a codec, a network handler. The reliable seam is the C ABI, and it works in both directions. The following crate builds as a static library and exports one function. It counts words in a NUL-terminated string and returns -1 for a null pointer or for bytes that are not valid UTF-8, because panicking across the boundary is not an option.

Cargo.tomlTOML
[package]
name = "textstat"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["staticlib"]
src/lib.rsrust
use std::ffi::{CStr, c_char};

/// Counts the words in a NUL-terminated UTF-8 string.
/// Returns -1 when the pointer is null or the bytes are not UTF-8.
///
/// # Safety
/// `text` must be null or point to a NUL-terminated string that stays valid
/// for the duration of the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn textstat_words(text: *const c_char) -> i64 {
    if text.is_null() {
        return -1;
    }
    // SAFETY: the caller promised a valid NUL-terminated string.
    let bytes = unsafe { CStr::from_ptr(text) };
    match bytes.to_str() {
        Ok(s) => s.split_whitespace().count() as i64,
        Err(_) => -1,
    }
}

Edition 2024 requires #[unsafe(no_mangle)] because exporting an unmangled symbol can collide with another symbol in the link, which is a safety property you are now asserting. The function is declared unsafe extern "C" because its caller must pass a valid pointer, and the # Safety doc section states the contract the way the standard library does. Inside, CStr::from_ptr reads up to the terminator, to_str validates UTF-8, and the match turns both failures into the sentinel. The C++ side declares the symbol and links the archive.

main.cppcpp
#include <cstdint>
#include <iostream>

extern "C" int64_t textstat_words(const char* text);

int main() {
    std::cout << textstat_words("one two  three") << "\n";
    std::cout << textstat_words(nullptr) << "\n";
    std::cout << textstat_words("bad \xff byte") << "\n";
}

Build with cargo build --release, then clang++ -std=c++20 main.cpp target/release/libtextstat.a -o demo, and run it:

Text
3
-1
-1

The three results are the word count, the null guard and the invalid-UTF-8 guard. The \xff byte is not valid UTF-8, so Rust refused it. Hand-written declarations like the one in main.cpp drift from the Rust side over time, which is why larger projects generate the header with cbindgen (Rust from C with cbindgen) and generate Rust bindings to C headers with bindgen (C from Rust with bindgen). Panics need care: since Rust 1.81 a panic that reaches an extern "C" boundary aborts the process instead of unwinding into C++ frames, which is defined behavior but still a crash, so catch failures and return error codes as the example does. For richer types such as std::string and std::vector, the cxx crate generates a checked bridge, at the price of a build step on both sides.

When this advice does not apply

The mapping above assumes you are writing new code, or can restructure old code. It is weaker in four cases. If your program is a thin layer over a large C++ framework with its own object model, such as a game engine or Qt, you will spend more time on bindings than on logic, and a rewrite is rarely the right first step. If you depend on template metaprogramming, specialization or compile-time computation at the scale of Boost.Hana, Rust's const fn and macros reach part of it but not all of it. If your platform only has a C++ compiler, the decision is already made. And if the code is performance-critical on a single known CPU, where the last 5 percent comes from hand-tuned intrinsics and custom allocators, Rust can do all of it, but it is not easier than in C++, only safer.

The strongest case for the move is code that parses untrusted input, handles concurrent requests or runs for months without a restart. That is where memory errors and races are most expensive and where the compiler's checks pay back the learning curve. Rust versus C++ for systems work compares the two more broadly, and the language tour is the quickest way to see the rest of the syntax.

Taggedrustcpp
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.