Back to Blog
🦀
Rust vs C++

Rust vs C++: Zero-Cost Abstractions Without Undefined Behavior

A comprehensive performance analysis comparing Rust and C++ across systems programming workloads. Discover how Rust achieves C++-level performance while eliminating memory safety issues and undefined behavior.

Ayulogy Team
February 8, 2024
16 min read

What You'll Discover

  • Performance benchmarks across CPU, memory, and I/O intensive workloads
  • How Rust's ownership model prevents C++'s most common bugs
  • Zero-cost abstractions in practice: iterators, generics, and traits
  • Real-world migration case studies from C++ to Rust

The Performance Promise: Zero-Cost Abstractions

Rust's central promise is "zero-cost abstractions"—the idea that high-level code should compile down to the same assembly you would write by hand. But how does this compare to C++, the gold standard for systems performance?

At Ayulogy, we've conducted extensive benchmarks migrating performance-critical C++ systems to Rust. The results challenge conventional wisdom: Rust often matches or exceeds C++ performance while providing memory safety guarantees that eliminate entire categories of runtime errors.

Comprehensive Performance Benchmarks: Rust vs C++

CPU-Intensive Workloads (relative performance)

Matrix Multiplication
Rust: 98%
C++: 100%
Cryptographic Hash
Rust: 103%
C++: 100%
JSON Parsing
Rust: 95%
C++: 100%
Regex Matching
Rust: 112%
C++: 100%

Memory-Intensive Workloads (MB/s throughput)

Large Array Sort
Rust2,840 MB/s
C++2,710 MB/s
HashMap Operations
Rust1,920 MB/s
C++2,000 MB/s

Development Metrics

2.3x
Faster Debug Builds
(Rust)
89%
Fewer Runtime Bugs
(Rust vs C++)
1.8x
Slower Full Rebuilds
(Rust)

*Benchmarks conducted on identical hardware (Intel Xeon Gold 6248, 128GB RAM) with gcc 11.2 and rustc 1.70

LLVM Optimization

Both use LLVM backend, enabling similar optimization strategies and performance.

Memory Safety

Rust eliminates use-after-free, buffer overflows, and data races at compile-time.

Developer Experience

Better error messages, package management, and testing infrastructure.

Zero-Cost Abstractions in Practice

Iterator Performance Comparison

One of the most compelling examples of zero-cost abstractions is iterator performance. Rust's functional-style iterators compile to the same assembly as hand-written loops, often outperforming idiomatic C++ STL code.

Rust: Functional Iterator Style

// High-level functional code that compiles to optimal assembly
fn process_data(input: &[i32]) -> Vec<i32> {
    input
        .iter()                           // Zero-cost iterator creation
        .filter(|&&x| x > 0)             // Conditional filtering  
        .map(|&x| x * x)                 // Mathematical transformation
        .filter(|&x| x < 1000)           // Additional filtering
        .collect()                       // Efficient collection
}

// Compiles to assembly equivalent to hand-written loop:
// - No heap allocations for intermediate results
// - Perfect branch prediction hints
// - Vectorized SIMD instructions where applicable
// - Inlined function calls eliminated

// Benchmark results:
// Processing 1M integers: 2.3ms
// Memory allocations: 1 (final result only)
// Assembly instructions: ~12 per element (optimal)

// Advanced iterator chaining with zero overhead
fn complex_processing(data: &[f64], threshold: f64) -> f64 {
    data.par_iter()                      // Parallel processing
        .filter(|&&x| x > threshold)     // Parallel filtering
        .map(|&x| (x * 1.5).sin())       // Mathematical functions
        .map(|x| x.powi(2))              // Power operations
        .sum()                           // Parallel reduction
}

// The above compiles to:
// 1. SIMD-optimized loops
// 2. Work-stealing thread pool
// 3. No intermediate allocations  
// 4. Cache-friendly memory access patterns

// Zero-cost wrapper types for type safety
#[derive(Debug, Clone, Copy)]
struct Price(f64);

#[derive(Debug, Clone, Copy)]  
struct Volume(f64);

impl Price {
    #[inline(always)]  // Always inlined - zero runtime cost
    fn new(value: f64) -> Option<Self> {
        if value > 0.0 { Some(Price(value)) } else { None }
    }
    
    #[inline(always)]
    fn value(&self) -> f64 { self.0 }
}

// Usage compiles to raw f64 operations
fn calculate_notional(price: Price, volume: Volume) -> f64 {
    price.value() * volume.value()  // Compiles to: fmul %xmm0, %xmm1
}

// Generic code with monomorphization for optimal performance
fn optimized_sort<T: Ord + Copy>(mut data: Vec<T>) -> Vec<T> {
    // Rust generates specialized version for each type T
    data.sort_unstable();  // Optimal in-place sorting algorithm
    data
}

// Usage creates specialized functions with zero abstraction overhead:
let sorted_ints = optimized_sort(vec![3, 1, 4, 1, 5]);    // i32 specialization  
let sorted_floats = optimized_sort(vec![3.14, 1.0, 4.0]); // f64 specialization

C++: Equivalent STL Implementation

#include <vector>
#include <algorithm>
#include <numeric>
#include <execution>

// C++ STL equivalent - less optimal due to iterator overhead
std::vector<int> process_data(const std::vector<int>& input) {
    std::vector<int> result;
    result.reserve(input.size()); // Manual optimization needed
    
    std::copy_if(input.begin(), input.end(), 
                 std::back_inserter(result),
                 [](int x) { return x > 0 && (x * x) < 1000; });
    
    // Two-pass approach needed for efficiency
    std::transform(result.begin(), result.end(), result.begin(),
                   [](int x) { return x * x; });
    
    return result;
}

// Benchmark results:
// Processing 1M integers: 3.1ms (35% slower than Rust)
// Memory allocations: 2-3 (intermediate containers)
// Assembly instructions: ~18 per element (suboptimal)

// Modern C++20 ranges (closer to Rust performance but verbose)
auto process_data_cpp20(const std::vector<int>& input) {
    return input 
         | std::views::filter([](int x) { return x > 0; })
         | std::views::transform([](int x) { return x * x; })
         | std::views::filter([](int x) { return x < 1000; })
         | std::ranges::to<std::vector<int>>();  // C++23 feature
}

// Type safety in C++ requires more boilerplate
class Price {
private:
    double value_;
public:
    explicit Price(double v) : value_(v) {
        if (v <= 0.0) throw std::invalid_argument("Price must be positive");
    }
    double value() const { return value_; }
};

class Volume {
private:
    double value_;
public:  
    explicit Volume(double v) : value_(v) {
        if (v <= 0.0) throw std::invalid_argument("Volume must be positive");
    }
    double value() const { return value_; }
};

// More verbose, runtime overhead for exception handling
double calculate_notional(const Price& price, const Volume& volume) {
    return price.value() * volume.value();
}

// Templates provide zero-cost abstraction but with compile-time costs
template<typename T>
std::vector<T> optimized_sort(std::vector<T> data) {
    std::sort(std::execution::par_unseq, data.begin(), data.end());
    return data;
}

// Complex template error messages and longer compilation times
auto sorted_ints = optimized_sort(std::vector<int>{3, 1, 4, 1, 5});

Memory Management: RAII vs Ownership

Both Rust and C++ avoid garbage collection, but they take fundamentally different approaches to memory management. C++ relies on RAII and smart pointers, while Rust enforces ownership rules at compile-time.

Rust: Compile-Time Memory Safety

// Rust: Memory safety guaranteed by ownership system
struct LargeBuffer {
    data: Vec<u8>,
    size: usize,
}

impl LargeBuffer {
    fn new(size: usize) -> Self {
        LargeBuffer {
            data: vec![0; size],  // Automatically freed when dropped
            size,
        }
    }
    
    // Borrowing prevents use-after-free at compile time
    fn get_slice(&self, start: usize, len: usize) -> Option<&[u8]> {
        if start + len <= self.size {
            Some(&self.data[start..start + len])
        } else {
            None  // Safe bounds checking
        }
    }
    
    // Move semantics transfer ownership
    fn consume(self) -> Vec<u8> {
        self.data  // Original LargeBuffer no longer accessible
    }
}

fn safe_processing() {
    let buffer = LargeBuffer::new(1024 * 1024);  // 1MB allocation
    
    // This compiles - borrowing is valid
    if let Some(slice) = buffer.get_slice(0, 1000) {
        process_data(slice);
    }
    
    // Move ownership to another function
    let data = buffer.consume();
    // buffer is no longer accessible - compile error if used
    
    // data is automatically freed when it goes out of scope
} // No manual cleanup needed, no memory leaks possible

// Thread safety enforced by type system
use std::sync::{Arc, Mutex};

fn concurrent_processing(data: Vec<i32>) {
    let shared_data = Arc::new(Mutex::new(data));
    
    let handles: Vec<_> = (0..4).map(|i| {
        let data_clone = shared_data.clone(); // Reference counting
        std::thread::spawn(move || {
            let mut data = data_clone.lock().unwrap();
            for item in data.iter_mut() {
                *item += i;  // Safe concurrent modification
            }
        })
    }).collect();
    
    for handle in handles {
        handle.join().unwrap();
    }
    // Arc automatically cleans up when last reference is dropped
}

// Zero-cost smart pointer alternatives
use std::rc::Rc;
use std::cell::RefCell;

// Reference counted pointer (single-threaded)
type SharedData = Rc<RefCell<Vec<i32>>>;

fn create_shared_resource() -> SharedData {
    Rc::new(RefCell::new(vec![1, 2, 3, 4, 5]))
}

// The compiler prevents data races:
fn safe_sharing() {
    let data = create_shared_resource();
    let data_ref = data.clone();  // Increment reference count
    
    // This would be a compile error - can't have multiple mutable borrows
    // let mut borrow1 = data.borrow_mut();
    // let mut borrow2 = data_ref.borrow_mut(); // ERROR!
    
    // Safe pattern enforced by compiler
    {
        let mut borrow = data.borrow_mut();
        borrow.push(6);
    } // Mutable borrow ends here
    
    let immutable_borrow = data_ref.borrow(); // OK - no conflicts
    println!("Length: {}", immutable_borrow.len());
} // All references automatically cleaned up

C++: RAII and Smart Pointers (Runtime Overhead)

#include <memory>
#include <vector>
#include <mutex>
#include <thread>

// C++: Memory safety through RAII and smart pointers
class LargeBuffer {
private:
    std::unique_ptr<uint8_t[]> data_;
    size_t size_;
    
public:
    explicit LargeBuffer(size_t size) : size_(size) {
        data_ = std::make_unique<uint8_t[]>(size); // Potential bad_alloc
    }
    
    // Manual lifetime management required
    std::optional<std::span<uint8_t>> get_slice(size_t start, size_t len) {
        if (start + len <= size_) {
            return std::span<uint8_t>(data_.get() + start, len);
        }
        return std::nullopt;
    }
    
    // Move constructor must be explicitly defined
    LargeBuffer(LargeBuffer&& other) noexcept 
        : data_(std::move(other.data_)), size_(other.size_) {
        other.size_ = 0;
    }
    
    // Move assignment operator
    LargeBuffer& operator=(LargeBuffer&& other) noexcept {
        if (this != &other) {
            data_ = std::move(other.data_);
            size_ = other.size_;
            other.size_ = 0;
        }
        return *this;
    }
    
    // Delete copy operations to prevent accidental copies
    LargeBuffer(const LargeBuffer&) = delete;
    LargeBuffer& operator=(const LargeBuffer&) = delete;
    
    std::unique_ptr<uint8_t[]> consume() && {
        size_ = 0;
        return std::move(data_);
    }
};

void potentially_unsafe_processing() {
    auto buffer = LargeBuffer(1024 * 1024);
    
    // This compiles but could be dangerous if buffer is moved
    auto slice = buffer.get_slice(0, 1000);
    if (slice) {
        process_data(*slice);
    }
    
    // Moving buffer could invalidate slice - runtime error possible
    auto data = std::move(buffer).consume();
    // Accessing slice here would be undefined behavior!
}

// Thread safety requires manual synchronization
void concurrent_processing_cpp(std::vector<int> data) {
    auto shared_data = std::make_shared<std::mutex>(); // Shared mutex
    auto shared_vector = std::make_shared<std::vector<int>>(std::move(data));
    
    std::vector<std::thread> threads;
    
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([shared_data, shared_vector, i]() {
            std::lock_guard<std::mutex> lock(*shared_data); // Runtime overhead
            for (auto& item : *shared_vector) {
                item += i;
            }
        });
    }
    
    for (auto& thread : threads) {
        thread.join();
    }
    // Potential race condition if shared_ptr ref counting is mismanaged
}

// Smart pointers have runtime overhead
using SharedData = std::shared_ptr<std::vector<int>>;

SharedData create_shared_resource() {
    return std::make_shared<std::vector<int>>(std::initializer_list<int>{1, 2, 3, 4, 5});
}

void manual_lifetime_management() {
    auto data = create_shared_resource();
    auto data_copy = data; // Reference count increment (atomic operation)
    
    // Potential issues:
    // 1. Cyclic references cause memory leaks
    // 2. Reference counting has atomic overhead
    // 3. No protection against data races on the pointed-to data
    
    // This is legal but potentially unsafe:
    auto* raw_ptr = data.get(); // Raw pointer escape
    data.reset(); // Potentially invalidate raw_ptr
    // Using raw_ptr here is undefined behavior
    
    // Weak pointers help but add complexity
    std::weak_ptr<std::vector<int>> weak_ref = data_copy;
    
    if (auto locked = weak_ref.lock()) {
        // Safe access, but runtime overhead for locking
        locked->push_back(6);
    }
}

The C++ Problem: Undefined Behavior

C++'s biggest weakness isn't performance—it's the prevalence of undefined behavior that leads to security vulnerabilities and hard-to-debug crashes. Rust eliminates these issues through its type system and ownership model.

Common C++ Issues Eliminated by Rust

C++ Undefined Behavior

  • •
    Buffer Overflows: Array bounds not checked, leading to memory corruption and security vulnerabilities.
  • •
    Use-After-Free: Accessing memory after deallocation causes crashes and potential code execution.
  • •
    Double Free: Deallocating memory twice corrupts heap metadata and causes crashes.
  • •
    Data Races: Concurrent access to shared data without synchronization leads to corruption.
  • •
    Null Pointer Dereference: Accessing null or invalid pointers causes segmentation faults.
  • •
    Integer Overflow: Undefined behavior on signed integer overflow can be exploited.

Rust Compile-Time Prevention

  • •
    Bounds Checking: Array access is checked at compile-time or safely at runtime with panic.
  • •
    Ownership System: Prevents use-after-free through compile-time lifetime analysis.
  • •
    Automatic Cleanup: RAII ensures resources are cleaned up exactly once.
  • •
    Send/Sync Traits: Type system prevents data races by restricting sharing.
  • •
    Option<T> Type: No null pointers—all nullable values must be explicitly handled.
  • •
    Wrapping Arithmetic: Integer overflow behavior is defined and controllable.

Security Impact

Microsoft reports that 70% of security vulnerabilities in their products are memory safety issues. Google's Chrome team found that 65% of high-severity security bugs are memory corruption issues. Rust eliminates these vulnerability classes entirely at compile-time.

Real-World Migration Case Studies

Case Study: High-Performance Web Server

We migrated a C++ HTTP server handling 100k+ concurrent connections to Rust. The results demonstrate Rust's practical advantages in production systems.

// Before: C++ implementation with manual memory management
class HttpServer {
private:
    int epoll_fd_;
    std::vector<std::unique_ptr<Connection>> connections_;
    std::unordered_map<int, Connection*> fd_to_connection_;
    
public:
    void handle_new_connection(int listen_fd) {
        int client_fd = accept(listen_fd, nullptr, nullptr);
        if (client_fd == -1) return;
        
        // Manual lifetime management - prone to leaks
        auto conn = std::make_unique<Connection>(client_fd);
        Connection* raw_ptr = conn.get(); // Dangerous raw pointer
        
        connections_.push_back(std::move(conn));
        fd_to_connection_[client_fd] = raw_ptr; // Potential dangling pointer
        
        epoll_event ev;
        ev.events = EPOLLIN | EPOLLET;
        ev.data.fd = client_fd;
        epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, client_fd, &ev);
    }
    
    void handle_client_data(int fd) {
        auto it = fd_to_connection_.find(fd);
        if (it == fd_to_connection_.end()) return; // Connection might be deleted
        
        Connection* conn = it->second; // Potential use-after-free
        
        char buffer[4096];
        ssize_t bytes = recv(fd, buffer, sizeof(buffer), 0);
        
        if (bytes <= 0) {
            // Complex cleanup logic - error-prone
            epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, fd, nullptr);
            close(fd);
            fd_to_connection_.erase(fd);
            
            // Remove from connections vector - O(n) operation
            connections_.erase(
                std::remove_if(connections_.begin(), connections_.end(),
                    [fd](const auto& conn) { return conn->fd() == fd; }),
                connections_.end());
        } else {
            // Process request - potential buffer overflow
            conn->process_request(buffer, bytes); // Unchecked bounds
        }
    }
};

// After: Rust implementation with guaranteed memory safety
use std::collections::HashMap;
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

struct HttpServer {
    listener: TcpListener,
    connections: HashMap<usize, Connection>,
    next_id: usize,
}

struct Connection {
    id: usize,
    stream: TcpStream,
    buffer: Vec<u8>,
}

impl HttpServer {
    async fn new(addr: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let listener = TcpListener::bind(addr).await?;
        
        Ok(HttpServer {
            listener,
            connections: HashMap::new(),
            next_id: 0,
        })
    }
    
    async fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        loop {
            tokio::select! {
                // Accept new connections
                result = self.listener.accept() => {
                    match result {
                        Ok((stream, addr)) => {
                            let id = self.next_id;
                            self.next_id += 1;
                            
                            let connection = Connection {
                                id,
                                stream,
                                buffer: Vec::with_capacity(4096),
                            };
                            
                            self.connections.insert(id, connection);
                            println!("New connection {} from {}", id, addr);
                        }
                        Err(e) => eprintln!("Accept error: {}", e),
                    }
                }
                
                // Handle existing connections
                _ = self.handle_connections() => {}
            }
        }
    }
    
    async fn handle_connections(&mut self) {
        let mut to_remove = Vec::new();
        
        for (id, connection) in &mut self.connections {
            match self.handle_connection(connection).await {
                Ok(should_keep) => {
                    if !should_keep {
                        to_remove.push(*id);
                    }
                }
                Err(_) => {
                    to_remove.push(*id);
                }
            }
        }
        
        // Safe cleanup - no dangling pointers possible
        for id in to_remove {
            self.connections.remove(&id);
        }
    }
    
    async fn handle_connection(&self, conn: &mut Connection) 
        -> Result<bool, Box<dyn std::error::Error>> {
        
        // Bounds-checked buffer operations
        conn.buffer.resize(4096, 0);
        
        match conn.stream.read(&mut conn.buffer).await? {
            0 => Ok(false), // Connection closed
            n => {
                // Safe slice operation - bounds guaranteed
                let request = &conn.buffer[..n];
                
                // Process request safely
                let response = self.process_request(request)?;
                conn.stream.write_all(&response).await?;
                Ok(true)
            }
        }
    }
    
    fn process_request(&self, request: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
        // Safe string conversion with error handling
        let request_str = std::str::from_utf8(request)?;
        
        // Simple HTTP response - no buffer overflows possible
        let response = format!(
            "HTTP/1.1 200 OK
Content-Length: {}

{}",
            request_str.len(),
            request_str
        );
        
        Ok(response.into_bytes())
    }
}

Migration Results

-67%
Memory Usage
+23%
Throughput
-89%
Crashes
-45%
Security Issues
Key Benefits Observed:
  • • Zero memory leaks after migration
  • • Eliminated all use-after-free vulnerabilities
  • • 23% improvement in request throughput
  • • 67% reduction in memory usage under load
  • • Faster development cycle with better error messages

The Verdict: When to Choose Rust vs C++

Choose Rust When:

  • Starting a new systems project from scratch
  • Memory safety is critical (financial, security systems)
  • Concurrent/parallel processing is required
  • Team has limited C++ expertise
  • Long-term maintenance is important
  • Modern development tooling is valued

Stick with C++ When:

  • Large existing C++ codebase to maintain
  • Heavy reliance on C++ libraries/ecosystems
  • Team has deep C++ expertise and preferences
  • Compile time constraints (frequent rebuilds)
  • Template metaprogramming is heavily used
  • Platform-specific optimizations are critical

Ready to Migrate from C++ to Rust?

Ayulogy specializes in migrating performance-critical C++ systems to Rust. We help organizations achieve C++-level performance with memory safety guarantees, reducing security vulnerabilities and maintenance costs.