Rust vs C

What Does Rust Offer Over C?

Rust provides memory safety, concurrency guarantees, and modern tooling that C lacks, preventing common bugs at compile time.

You're debugging a segmentation fault at 2 AM

The crash happens in a function you wrote three weeks ago, but the stack trace points to a buffer overflow in a completely different module. You've been staring at malloc and free calls for hours, trying to match every allocation with its deallocation. In C, this is a Tuesday. In Rust, the compiler would have stopped you from writing that code in the first place.

C gives you raw access to memory. You point to an address, you read what's there. If you point to the wrong address, the computer crashes. If you forget to free memory, the system slows down. Rust keeps that raw power but adds a layer of rules that the compiler enforces. You still write systems code. You still control performance. But you trade the freedom to shoot yourself in the foot for a compiler that acts as a relentless co-pilot.

Memory safety without the garbage collector

C relies on the programmer to manage memory manually. You call malloc to get space. You call free to give it back. If you forget free, you leak memory. If you call free twice, you corrupt the heap. If you use the pointer after free, you get a use-after-free bug. These bugs are hard to find. They might work in testing and crash in production.

Rust automates memory management without a garbage collector. The compiler tracks ownership. Every value has exactly one owner. When the owner goes out of scope, the value is dropped. References let you look at the value without owning it, but the compiler ensures the owner outlives the references. This happens at compile time. There is no runtime overhead for tracking ownership. The safety checks disappear in the final binary.

Think of C as a house where you build the walls yourself. You decide where the beams go. If you skip a beam, the roof collapses. Rust is like a construction site with a foreman who checks every beam before you nail it down. The foreman doesn't build the house for you, but he won't let you use a rotten beam or skip a support. You still make the design choices, but the structural failures get caught before you move in.

Trust the borrow checker. It usually has a point.

How ownership works in practice

The ownership system prevents entire classes of bugs. In C, you can have multiple pointers to the same data. You can modify the data through one pointer while another pointer is reading it. This leads to data races and corruption. Rust forbids this. You can have many immutable references or one mutable reference, never both at the same time.

/// Demonstrates safe borrowing versus raw pointer risks
fn main() {
    // String allocates on the heap; `data` owns the memory
    let data = String::from("important data");

    // `&data` creates a reference. The compiler tracks this borrow.
    // It ensures `data` stays alive while `reference` exists.
    let reference = &data;

    println!("{}", reference);

    // `data` goes out of scope here.
    // The compiler guarantees `reference` is not used after this point.
    // In C, using a pointer after free is undefined behavior.
}

When you compile this, the compiler checks that reference is never used after data is dropped. If you tried to use reference later, you'd get a lifetime error. The compiler proves that the reference is valid for its entire usage. In C, the compiler assumes you know what you're doing. It doesn't check lifetimes. You get a crash at runtime instead of an error at compile time.

Convention aside: Rust developers prefer Rc::clone(&data) over data.clone() when cloning reference counts. Both compile, but the explicit form signals that you are cloning the reference, not the underlying data. It prevents confusion for readers who might expect a deep copy.

Realistic example: Preventing dangling pointers

Dangling pointers are a classic C problem. You allocate a struct, return a pointer to a field inside it, then free the struct. The pointer now points to freed memory. Rust makes this impossible with lifetimes. Lifetimes tie the validity of a reference to the scope of the data it points to.

/// A safe wrapper around a resource that prevents dangling pointers
struct Document {
    content: String,
}

impl Document {
    /// Returns a reference to the content.
    /// The lifetime of the reference is tied to `self`.
    fn get_content(&self) -> &str {
        // Returning `&self.content` ties the output lifetime to `self`.
        // This prevents returning a reference to data that might be dropped.
        &self.content
    }
}

fn main() {
    let doc = Document {
        content: String::from("Rust is safe"),
    };

    // `text` borrows from `doc`.
    // The compiler ensures `doc` lives as long as `text`.
    let text = doc.get_content();
    println!("{}", text);

    // If we tried to drop `doc` here and use `text` later,
    // the compiler would reject the code with a lifetime error.
}

If you tried to return a reference to a local variable, the compiler would stop you. In C, you can return a pointer to a stack variable. The function returns, the stack frame is destroyed, and the pointer dangles. Rust catches this with E0515 (cannot return reference to local variable). The error tells you exactly what's wrong. You fix the design, not the symptoms.

Write code that can't be wrong, not code that you hope is right.

Fearless concurrency

C has threads. You can pass a pointer to a thread. If the main thread frees the data while the worker thread is reading it, you get a data race. Data races are non-deterministic. They might not show up in testing. They might corrupt memory silently. Rust prevents data races at compile time.

use std::thread;

/// Shows how Rust prevents data races at compile time
fn main() {
    let counter = 0;

    // This would fail to compile in Rust.
    // The compiler sees `counter` is moved into the thread.
    // It prevents the main thread from accessing `counter` simultaneously.
    // let handle = thread::spawn(|| {
    //     println!("Counter: {}", counter);
    // });
    // println!("Counter: {}", counter); // E0382: use of moved value

    // To share data, you need explicit synchronization.
    // This forces you to think about safety.
}

Rust uses traits to enforce thread safety. Types implement Send if they can be moved to another thread. Types implement Sync if they can be shared between threads. The compiler checks these traits automatically. If a type contains a raw pointer or a reference count that isn't thread-safe, it won't implement Send or Sync. You can't accidentally pass it to a thread.

Convention aside: When you must use unsafe, keep the block as small as possible. The community calls this the "minimum unsafe surface" rule. Wrap the unsafe operation in a safe function. This limits the blast radius of potential bugs and makes the code easier to audit.

Tooling and abstractions

C has no standard package manager. Projects use Makefiles, Autotools, or CMake. Dependencies are a manual process. You download libraries, compile them, and link them. Rust has cargo. It handles building, testing, dependencies, and formatting. You run cargo run and everything just works.

C macros are text substitution. They are dangerous. #define MAX(a,b) ((a) > (b) ? (a) : (b)) evaluates arguments multiple times. If you pass MAX(x++, y++), you get double increment. Rust generics are type-safe. fn max<T: PartialOrd>(a: T, b: T) -> T checks types at compile time. No double evaluation. No macro pitfalls.

/// A generic function that works with any comparable type
fn max<T: PartialOrd>(a: T, b: T) -> T {
    // The compiler checks that T implements PartialOrd.
    // This ensures the comparison is valid.
    if a >= b {
        a
    } else {
        b
    }
}

fn main() {
    // Works with integers
    println!("{}", max(10, 20));

    // Works with floats
    println!("{}", max(3.14, 2.71));

    // Fails to compile if types don't match
    // println!("{}", max(10, "hello")); // E0277: trait bound not satisfied
}

Rust also has built-in testing. You write tests in the same file as your code. You run cargo test and the compiler compiles and runs all tests. C requires external frameworks or manual assert statements. Rust documentation is generated from comments. You write /// comments and run cargo doc. The documentation stays in sync with the code.

Convention aside: cargo fmt formats every file the same way. Don't argue style; argue logic. The community convention is to run cargo fmt before committing. It removes style debates from code reviews.

Pitfalls and compiler errors

Rust's compiler errors can be intimidating at first. They are detailed and precise. They tell you exactly what's wrong and often suggest a fix. In C, the compiler might warn about a suspicious cast. In Rust, the compiler rejects the code.

If you try to use a value after moving it, you get E0382 (use of moved value). The compiler tracks ownership. Once a value is moved, the original variable is invalid. If you try to borrow a value as mutable while an immutable borrow exists, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable). This prevents data races. If you try to return a reference to a local variable, you get E0515 (cannot return reference to local variable). This prevents dangling pointers.

Buffer overflows are another common C bug. In C, arrays have no bounds. Accessing arr[10] when the array has 5 elements reads garbage or crashes. Rust slices have bounds. Accessing slice[10] panics safely. The program crashes, but it doesn't corrupt memory. You can also use slice.get(10) to check bounds explicitly.

The compiler error is a feature, not a bug. Fix the design, don't fight the error.

When to use Rust versus C

Use C when you need absolute control over memory layout and performance, and you are willing to audit every pointer manually. Use C when you are writing a bootloader or a kernel module where the overhead of Rust's abstractions is unacceptable. Use C when you are maintaining a legacy codebase that has decades of C dependencies and rewriting it is not feasible. Use C when you are interfacing with hardware that requires specific register access patterns that Rust's abstractions cannot express.

Use Rust when you want memory safety without garbage collection. Use Rust when you are building concurrent systems where data races are a risk. Use Rust when you want a modern toolchain with a package manager and cross-compilation support. Use Rust when you are writing libraries that will be used by other languages, as Rust can provide a safe C-compatible API. Use Rust when you want zero-cost abstractions that compile to efficient machine code.

Pick the tool that matches your risk tolerance. C is for the brave. Rust is for the pragmatic.

Where to go next