Skip to content
rusthacks.dev
Rust

Rust vs C Plus Plus for New Systems Code and When Each Wins

Both give you control without a garbage collector. Rust makes memory safety the default; C++ has the larger ecosystem and decades of code. Compare safety, tooling, interop and when to stay in C++.

8 min read
Summarize in AI
Flat illustration for the post “Rust vs C Plus Plus for New Systems Code and When Each Wins”, composed around embedded.

Both languages exist for the same reasons: control over memory layout, no mandatory runtime and performance that does not depend on a garbage collector. The difference is who enforces correctness. C++ trusts the programmer and supplies tools to find mistakes after the fact. Rust moves a large class of mistakes into the compiler. This post puts both through the same four tests (a memory bug, a concurrency bug, a speed comparison and an interop call) and ends with a recommendation. Rust 1.99 on edition 2024 and Apple clang 21 with -std=c++23 were used, on an Apple M-series laptop.

A memory bug that compiles

Take the oldest trap in C++: hold a reference into a vector, then grow the vector.

uaf.cppcpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    int& first = v[0];
    v.push_back(4);          // may reallocate: `first` now dangles
    std::cout << first << "\n";
}

push_back may need to allocate a larger buffer and move the elements, which frees the old one. first still points at the old buffer. Built with -O2 and run:

Shell
clang++ -std=c++23 -O2 uaf.cpp -o uaf_plain && ./uaf_plain

It exits with status 0 and prints 2. The real answer should be 1. Nothing stopped, nothing warned, and the program printed a value read from memory that no longer belonged to it. On another run, another compiler or another day the output could be anything, or a crash. That is undefined behaviour in practice.

C++ does have a tool that finds this, and it is good. Rebuild with AddressSanitizer:

Shell
clang++ -std=c++23 -O0 -g -fsanitize=address uaf.cpp -o uaf_asan && ./uaf_asan
Text
==PID==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000b0 at pc 0x00010076cb14 bp 0x00016f6920d0 sp 0x00016f6920c8
READ of size 4 at 0x6020000000b0 thread T0
    #0 0x00010076cb10 in main uaf.cpp:8
    #1 0x0001814d04e0 in start+0x1b4c (dyld:arm64e+0x204e0)

0x6020000000b0 is located 0 bytes inside of 12-byte region [0x6020000000b0,0x6020000000bc)
freed by thread T0 here:
    #0 0x00010114c358 in _ZdlPv+0x74 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x50358)

The report names the bug (heap-use-after-free), the line that read it and, in the lines that follow, the stack where the memory was freed. It is exact, and it only exists for the executions you ran with the sanitizer on. Code paths your tests do not reach stay silent.

The Rust version of the same program:

src/main.rsrust
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{first}");
}
Text
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &v[0];
  |                  - immutable borrow occurs here
4 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |                ----- immutable borrow later used here

The program never builds. The compiler explains the three facts that make it wrong: first borrows v, push needs to change v, and first is used afterwards. No tests, no sanitizer run and no reviewer are needed to find it, and it is found for every path through the code, not the ones you executed. That is the central argument for Rust in two outputs. The cost is that you must learn what the rule is, which takes time; the mental model is in ownership.

A concurrency bug that compiles

Four threads incrementing a shared counter:

threads.cppcpp
#include <iostream>
#include <thread>
#include <vector>

int main() {
    long counter = 0;
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i)
        ts.emplace_back([&] { for (int j = 0; j < 100000; ++j) ++counter; });
    for (auto& t : ts) t.join();
    std::cout << counter << "\n";
}

Three runs of the plain build printed these totals:

Text
expected 400000
run 1: 108904
run 2: 117111
run 3: 107466

The correct answer is 400,000. The program produced about a quarter of it, a different wrong number each time, with no crash and no message. ThreadSanitizer (-fsanitize=thread) does report it, starting with a line like this:

Text
WARNING: ThreadSanitizer: data race 
  Write of size 8 at 0x00016fcaa220 by thread T4:

Again the tool is good and again it needs a run. In Rust, the closure that mutates counter from four threads is rejected at compile time (a borrowed value that may outlive the function, E0373, or a missing Sync, depending on how you write it), and the fix is an Arc<Mutex<_>> or an atomic, which cannot lose updates. The mechanism is the pair of traits Send and Sync, explained in Send and Sync.

Speed

Performance is the usual reason people start with C++, and it is the least decisive. Both compile to native code through LLVM. The same word-count program, written the straightforward way in each language, over a 72 MB log with 1,178,392 distinct tokens, best of five runs:

wf2.cppcpp
#include <algorithm>
#include <cctype>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>

int main(int, char** argv) {
    std::ifstream in(argv[1], std::ios::binary);
    std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());

    std::unordered_map<std::string_view, unsigned> counts;
    const char* p = text.data();
    const char* end = p + text.size();
    while (p < end) {
        while (p < end && std::isspace((unsigned char)*p)) ++p;
        const char* start = p;
        while (p < end && !std::isspace((unsigned char)*p)) ++p;
        if (p > start) ++counts[std::string_view(start, p - start)];
    }
    std::vector<std::pair<std::string_view, unsigned>> v(counts.begin(), counts.end());
    std::sort(v.begin(), v.end(), [](auto& a, auto& b) {
        return a.second != b.second ? a.second > b.second : a.first < b.first;
    });
    for (int i = 0; i < 3 && i < (int)v.size(); ++i) std::cout << v[i].first << " " << v[i].second << "\n";
    std::cout << counts.size() << " distinct\n";
}
src/main.rsrust
use std::collections::HashMap;
use std::fs;

fn main() -> std::io::Result<()> {
    let path = std::env::args().nth(1).expect("usage: wf FILE");
    let text = fs::read_to_string(path)?;
    let mut counts: HashMap<&str, u32> = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    let mut v: Vec<(&str, u32)> = counts.iter().map(|(w, c)| (*w, *c)).collect();
    v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    for (w, c) in v.iter().take(3) {
        println!("{w} {c}");
    }
    println!("{} distinct", counts.len());
    Ok(())
}
Text
C++  iostream version (wf.cpp)       : 1501 ms
C++  string_view version (wf2.cpp)   : 1436 ms
Rust HashMap<&str> (default SipHash) : 614 ms

All three printed the same top tokens and the same count of distinct words. The iostream C++ version (in >> word, 1,501 ms) is the one most programmers write first, and a second version that reads the file into memory and uses string_view keys took 1,436 ms. Rust took 614 ms. Memory:

Text
C++  string_view version   peak memory  224.0 MB
Rust HashMap<&str>         peak memory  197.8 MB

Do not read that as "Rust is 2.3 times faster than C++". Read it as: with the default containers and the obvious code, this workload is faster in Rust, and the reason is the data structure. Rust's HashMap is the open-addressed hashbrown table and stores entries in one array, while libc++'s std::unordered_map allocates a node for every entry, which with 1.18 million entries means 1.18 million allocations and a cache miss for each lookup. A C++ programmer who swaps in absl::flat_hash_map or boost::unordered_flat_map would close most of that gap; I did not run those, so I cannot give the number. The lesson is that performance in both languages comes from the design, and the language rarely decides.

Rust's default hasher, SipHash, is slower than it could be, and replacing it, as shown in faster hashers, widens the lead on a workload like this one.

Calling across the boundary

You do not have to choose for a whole product. Rust calls C through the C ABI and C++ through a bridge. In edition 2024 a foreign declaration is unsafe extern, and the call needs a safety comment:

src/main.rsrust
use std::ffi::{CString, c_char};

unsafe extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

fn main() {
    let s = CString::new("hello").unwrap();
    // SAFETY: `s` is a valid NUL-terminated string that outlives the call.
    let n = unsafe { strlen(s.as_ptr()) };
    println!("{n}");
}
Text
5

The older spelling, extern "C" { ... } without unsafe, is now an error:

Text
error: extern blocks must be unsafe
 --> src/main.rs:1:1
  |
1 | / extern "C" {

For real C headers, bindgen generates these declarations, and for C++ the cxx crate bridges types safely; calling C from Rust with bindgen walks through it, and cbindgen goes the other direction. The place where unsafe lives is the boundary, so keep it small and wrap it in a safe function.

Where C++ is stronger

  • Ecosystem breadth. Game engines, CAD kernels, DSP, GPU frameworks, Qt and decades of scientific code are C++ first.
  • Existing code. A million lines of working C++ is an asset, and rewriting it has a real price and a risk.
  • Platform support. Some embedded vendors and certified toolchains ship only a C or C++ compiler.
  • Compile-time metaprogramming. Templates and constexpr are very flexible, though Rust's const fn and macros cover a growing share.
  • Build time on small programs. clang compiled the word count in 0.70 seconds.

Where Rust is stronger

  • Defaults: bounds checks, no null, no uninitialised reads and moves by default.
  • Concurrency: data races are compile errors in safe code.
  • Tooling: cargo build, cargo test, cargo doc, Clippy and rustfmt come with the toolchain, where C++ needs CMake or Bazel plus a package manager plus a formatter.
  • Error handling: Result and Option are values the compiler makes you handle.
  • Versioning: crates and semver replace header files and include paths.

Which to pick

For a new component with no legacy, pick Rust. The memory and concurrency classes of bug that C++ leaves to sanitizers and review, which together account for most serious vulnerabilities in large C and C++ projects, are not expressible in safe Rust, and the performance is the same. The extra month of learning is paid back the first time a refactor goes through with the compiler checking it. For an existing C++ codebase, do not rewrite; add new modules in Rust behind a C ABI, port the pieces that handle untrusted input first, and keep C++ where it is stable. For anything that depends on a C++ library with no equivalent, use C++ for the boundary and Rust around it.

When this does not apply

If your team has deep C++ experience and a strong test and sanitizer culture, the safety argument is weaker for you than for the average team, and the learning cost is real. If the platform has no Rust target, the decision is made for you. And if the code is a thin layer over a large C++ framework, such as a Qt application, Rust adds more bridging than it removes bugs.

Also inSystems
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.