Rust vs C++: Compare Safety, Control and Developer Trade-offs

Compare how Rust and modern C++ express ownership, catch lifetime mistakes, measure iterator code and define safe foreign-function boundaries.

KnowledgeGate Team

Exam prep & CS education

Updated 19 Sep 20266 min read

“Rust is safe” and “C++ gives control” are too blunt to guide a real choice. Safe Rust rejects some lifetime failures during compilation, while modern C++ makes ownership safety depend more heavily on the types and tools a codebase adopts. A ByteBuffer containing [16, 32, 48, 64] exposes the practical differences in moves, borrows, dangling views, abstraction-cost benchmarks and C ABI ownership.

Compare guarantees before syntax

An owner releases a resource. A borrow or non-owning view accesses it without taking that responsibility. A lifetime is the interval in which the object remains valid. RAII ties release to destruction. Undefined behaviour means the language places no requirements on an invalid execution. These concepts address memory safety, but they do not by themselves prove logical correctness, API design, deadlock freedom or performance. Safe Rust's type system also rules out data races in safe code; unsafe code, FFI and C++ need additional contracts and checks.

Question

Rust

Modern C++

Default ownership

Moves for non-Copy owners

Value types; std::vector and std::unique_ptr

Non-owning view

References and slices

References, pointers and std::span

Destruction

Drop

Destructors and RAII

Escape hatch

unsafe

Raw operations

Lifetime enforcement

Borrow checking for safe references

Discipline, partly aided by compilers and tools

Rust moves more lifetime and aliasing checks into compilation for safe code. C++ offers more ownership styles and direct codebase continuity, but its types determine how much safety is expressed. The C++ Tutorial learning path supplies broader C++ context, and Coding & Skill Development Courses collects adjacent language and systems material. If your alternative is Go rather than C++, Go vs Rust for Backend Systems compares runtime concurrency, tail latency and deployment. Rust versus C++ instead turns on moves, RAII, dangling views and C ABI ownership.

Build the same resource owner in both languages

The Rust owner stores a Vec<u8>. Its checksum borrows self and sums bytes as u32:

rust
struct ByteBuffer { data: Vec<u8> }

impl ByteBuffer {
    fn new(data: Vec<u8>) -> Self { Self { data } }
    fn checksum(&self) -> u32 {
        self.data.iter().copied().map(u32::from).sum()
    }
}

fn main() {
    let a = ByteBuffer::new(vec![16, 32, 48, 64]);
    let b = a;
    assert_eq!(b.checksum(), 160);
}

Using a after the move is rejected. C++20 expresses the same owner explicitly:

cpp
#include <cassert>
#include <cstdint>
#include <numeric>
#include <utility>
#include <vector>

struct ByteBuffer {
    std::vector<std::uint8_t> data;
    explicit ByteBuffer(std::vector<std::uint8_t> v) : data(std::move(v)) {}
    std::uint32_t checksum() const {
        return std::accumulate(data.begin(), data.end(), 0u);
    }
};

int main() {
    ByteBuffer a(std::vector<std::uint8_t>{16, 32, 48, 64});
    ByteBuffer b = std::move(a);
    assert(b.checksum() == 160);
}

In both versions, the component owns vector destruction and checksum only borrows it. Construction transfers the vector; the checksum borrows storage without transferring it. Rust assignment moves the non-Copy owner; C++ requests move construction with std::move. The moved-from C++ a remains valid, but its contents are unspecified.

The calculation is 16 + 32 + 48 + 64 = 160, with length 4 and one conceptually owned payload block. Vector capacity and metadata are outside those four payload bytes. In both versions, b releases the vector when destroyed.

Work through one lifetime failure

Suppose a function returns the middle two bytes as a view. Safe Rust rejects a reference to local b, which is destroyed at return:

rust
fn bad_view<'a>() -> &'a [u8] {
    let b = ByteBuffer::new(vec![16, 32, 48, 64]);
    &b.data[1..3]
}

Indices 1 and 2 select [32, 48] while b is alive, but the view cannot cross the return boundary. C++20 permits the corresponding span:

cpp
std::span<const std::uint8_t> bad_view() {
    ByteBuffer b(std::vector<std::uint8_t>{16, 32, 48, 64});
    return std::span<const std::uint8_t>(b.data.data() + 1, 2);
}

The returned span keeps a pointer and length 2, but the vector storage is released at return. Reading view[0] later has undefined behaviour, so no value is reliable.

Return ownership instead. Rust can use b.data[1..3].to_vec(); C++ can use std::vector<std::uint8_t>(b.data.begin() + 1, b.data.begin() + 3). The result is [32, 48]: 3 - 1 = 2 bytes and checksum 32 + 48 = 80. This copies two bytes. A borrowed view is valid only while its caller keeps the source buffer alive. See Sketch the owner, allocation and two-byte view to make that address relationship explicit.

ByteBuffer [16, 32, 48, 64] lifetimes: Rust blocks the returned slice, the C++ span dangles, and the repair returns owned [32, 48].

Treat safety and control as layered choices

Safety and control are not opposites. Both languages choose representations, avoid a garbage-collected runtime, destroy these owners deterministically and call native APIs. The difference is whether types, compilation or programmer contracts enforce an invariant. Lower-level access is not itself a failure; it shifts the proof burden.

Layer

Rust

Modern C++

Review question

Ordinary owner

Vec ownership rules

std::vector, std::unique_ptr, RAII

Who frees it?

Borrowed view

Slices carry lifetime rules

References, pointers and spans need contracts

Can it outlive its owner?

Unchecked operation

Raw dereference in unsafe

Raw operations

Where is it allowed?

External boundary

FFI needs programmer proof

Foreign calls need contracts

Which side owns data?

Rust makes selected invariants compiler obligations in safe code. C++ permits modern owners beside legacy representations. Neither prevents a wrong checksum, deadlock, bad API contract or faulty unchecked boundary.

Define a fair abstraction-cost benchmark

For doubled_sum, Rust can use b.data.iter().map(|&x| u32::from(x) * 2).sum::<u32>(); C++ can use std::transform_reduce(b.data.begin(), b.data.end(), 0u, std::plus<>{}, [](std::uint8_t x) { return 2u * x; }). Both return 2 x (16 + 32 + 48 + 64) = 320. They express traversal, not proven speed.

Repeat [16, 32, 48, 64] exactly 262,144 times for 1,048,576 payload bytes. One call returns 320 x 262,144 = 83,886,080. Independently, 160 x 262,144 = 41,943,040, then doubling gives 83,886,080.

Build optimised binaries, retain the result, use one machine and equivalent settings, warm up consistently, and report compiler versions. Measure generated code, allocations and runtime before claiming either pipeline is zero-cost, faster, allocation-free or vectorised. Debug and optimised modes answer different questions.

Make interoperability an ownership contract

Wrap opaque kg_buffer with three C ABI calls: kg_buffer_new(const uint8_t* src, size_t len), kg_buffer_sum(const kg_buffer*) and kg_buffer_free(kg_buffer*). For {16, 32, 48, 64} and len = 4, one creation yields 160 and one matching free. new returns null on failure; sum requires a live, non-null handle; free(null) is a no-op only when documented and implemented.

A C++ adapter can keep ByteBuffer behind the handle and expose extern "C" functions. Rust can keep it in Box, reserve #[repr(C)] for exposed layouts, and confine pointer work to an audited unsafe extern "C" boundary. Neither compiler can verify that foreign src is readable for four bytes, prevent double-free or guarantee a live handle.

Keep callers on this interface, replace one isolated implementation, and run the same [16, 32, 48, 64] -> 160 test against both libraries. Switch after behaviour and tooling are proven. Rust need not mean a full rewrite, and a C++ ABI is not automatically stable.

kg_buffer FFI over [16, 32, 48, 64]: a C or C++ caller runs new, then sum returning 160, then free, each exactly once.

Use tooling to expose different failure classes

For Rust, run cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test, adding cargo miri test when suitable. For C++, build with CMake, run ctest, add supported AddressSanitizer and UndefinedBehaviorSanitizer builds, then use clang-tidy with a pinned configuration. Borrow checking acts during compilation; sanitizers exercise runtime paths. Both languages still need analysis and review.

Watch for five slogan-shaped traps:

  • “Rust means no unsafe code” ignores unsafe and foreign code.

  • “Modern C++ is automatically safe” ignores raw and non-owning lifetime contracts.

  • “A moved-from C++ vector is always empty” assumes an unspecified state.

  • “Iterator style is automatically faster” skips measurement.

  • “A C ABI preserves Rust's safe guarantees” ignores foreign-caller obligations.

Check your understanding: explain Rust's rejection of bad_view; identify the dangling C++ span; predict owned [32, 48] and checksum 80; write a one-handle, one-free FFI contract returning 160.

Choose a first stack or migration pilot

For isolated code prioritising compile-time lifetime checks, pilot Rust if its toolchain fits. For mature C++ templates, libraries and builds, strengthen modern ownership and tooling first. In mixed estates, wrap one owner in a small C ABI and compare implementations with identical tests. Measure before switching the production implementation.

The short version

Rust front-loads ownership and borrowing checks for safe code into compilation; C++ offers ecosystem continuity and developer-chosen styles; both require boundary contracts, tests and measurement.

Implement ByteBuffer, both failed views, the owned [32, 48] repair and the 1,048,576-byte benchmark. Continue with C++ Programming or Coding for Placements.