Rust for C Programmers: What I Wish I Knew from Day One
If you’re coming from C like I did, Rust can feel simultaneously familiar and alien. You understand pointers, you know about stack vs heap, you’ve debugged memory leaks at 3 AM. But then Rust hits you with ownership, borrowing, and lifetimes, and suddenly you’re fighting the compiler more than you’re writing code.
Here’s what I wish someone had told me on day one.
The Mental Shift: From “Trust the Programmer” to “Trust the Compiler”
In C, you’re trusted. You can cast anything to anything, dereference null pointers, and create dangling pointers. The compiler assumes you know what you’re doing.
In Rust, the compiler is your paranoid copilot. It assumes you’ll make mistakes, and it won’t let you compile until it’s convinced your code is safe.
This was my biggest hurdle: I kept thinking “I know this is safe, just let me do it!” But here’s the thing - the compiler was usually right to be suspicious.
Ownership: It’s Just RAII on Steroids
If you’ve used RAII in C++ (or manually managed resources in C), ownership will click faster than you think.
C Version:
void process_data() {
char *buffer = malloc(1024);
// ... do stuff ...
free(buffer); // You must remember this
}
Rust Version:
fn process_data() {
let buffer = Vec::with_capacity(1024);
// ... do stuff ...
// Automatically freed here
}
The key insight: In C, cleanup is your responsibility. In Rust, cleanup happens automatically when the owner goes out of scope. It’s deterministic (like C), but automatic (like garbage collection).
Borrowing: Pointers With Rules
Coming from C, you’re used to passing pointers everywhere. Rust has references, which are basically safe pointers.
The Rules (that will save your sanity):
Rule 1: You can have either:
- One mutable reference (
&mut T
), OR - Multiple immutable references (
&T
)
Never both at the same time.
Why This Matters (C vs Rust):
C - The Problem:
void modify_data(int *data) {
*data = 42;
}
void read_data(int *data) {
printf("%d\n", *data);
}
int main() {
int x = 10;
int *ptr1 = &x;
int *ptr2 = &x;
modify_data(ptr1); // Modifies x
read_data(ptr2); // Reads x - but when?
// Data race possible in multithreaded code
}
Rust - The Solution:
fn modify_data(data: &mut i32) {
*data = 42;
}
fn read_data(data: &i32) {
println!("{}", data);
}
fn main() {
let mut x = 10;
modify_data(&mut x);
read_data(&x);
// This won't compile - can't borrow as immutable while mutable borrow exists:
// let r1 = &mut x;
// let r2 = &x; // ERROR!
}
The insight: Rust’s borrowing rules eliminate entire classes of bugs at compile time - no more data races, no more use-after-free.
Lifetimes: The Compiler Tracking Your Pointers
In C, this is a classic bug:
char* get_string() {
char buffer[100] = "Hello";
return buffer; // BOOM - returning pointer to stack memory
}
Rust prevents this with lifetimes:
// This won't compile
fn get_string() -> &str {
let s = String::from("Hello");
&s // ERROR: s is dropped here
}
// This works - ownership transferred
fn get_string() -> String {
String::from("Hello")
}
// This also works - reference to data that outlives the function
fn get_slice(data: &str) -> &str {
&data[0..5] // Reference has same lifetime as input
}
The pattern: If you’re returning a reference, it must point to either:
- Data passed in as a parameter
- Static data
- (Or just return owned data instead)
Memory Layout: Familiar Territory
Here’s where your C knowledge shines. Rust’s memory model is basically identical to C’s:
Concept | C | Rust |
---|---|---|
Stack allocation | int x = 5; |
let x: i32 = 5; |
Heap allocation | malloc() |
Box::new() , Vec , String
|
Pointers | int *ptr |
*const i32 , *mut i32 (raw pointers) |
References | N/A (just pointers) |
&i32 , &mut i32 (safe references) |
Size of types | sizeof(T) |
std::mem::size_of::<T>() |
The difference: Rust adds zero-cost abstractions on top. A Vec<T>
is just a pointer, length, and capacity - same as you’d implement in C.
The unsafe
Keyword: Your Escape Hatch
Rust trusts you to use unsafe
correctly. Inside an unsafe block, you can:
- Dereference raw pointers
- Call unsafe functions
- Access mutable statics
- Implement unsafe traits
fn manual_pointer_stuff() {
let x = 42;
let ptr = &x as *const i32;
unsafe {
println!("{}", *ptr); // Just like C
}
}
When to use it:
- FFI (calling C libraries)
- Building safe abstractions that need unsafe internals
- Performance-critical code where you’ve proven safety yourself
My rule: Use unsafe
only when you must, and keep it isolated in small, well-tested functions.
Practical Migration Strategy
When I rewrote my first C project in Rust, here’s what worked:
Step 1: Start with the data structures
// C struct
struct Point {
int x;
int y;
};
// Rust equivalent
struct Point {
x: i32,
y: i32,
}
Step 2: Replace malloc/free with Box or Vec
// C
int *data = malloc(sizeof(int) * 100);
free(data);
// Rust
let data = vec![0; 100]; // Auto-freed
Step 3: Turn function pointers into closures
// C
void apply(int *data, int (*func)(int)) {
*data = func(*data);
}
// Rust
fn apply<F>(data: &mut i32, func: F)
where F: Fn(i32) -> i32
{
*data = func(*data);
}
Step 4: Fight the borrow checker, learn, adapt
This is where you’ll spend 80% of your time initially. But each error teaches you something about writing safer code.
Common “WTF” Moments (and solutions)
1. “Can’t move out of borrowed content”
// Problem
fn process(data: &Vec<i32>) {
let item = data[0]; // Works for Copy types
let vec = data; // ERROR: can't move
}
// Solution
fn process(data: &Vec<i32>) {
let item = data[0];
let vec = data.clone(); // Explicit clone
}
2. “Borrowed value does not live long enough”
// Problem
let r;
{
let x = 5;
r = &x; // ERROR: x dropped here
}
// Solution
let x = 5;
let r = &x; // r's lifetime contained in x's lifetime
3. “Cannot borrow as mutable more than once”
// Problem
let mut v = vec![1, 2, 3];
let first = &mut v[0];
let second = &mut v[1]; // ERROR!
// Solution - split_at_mut
let mut v = vec![1, 2, 3];
let (left, right) = v.split_at_mut(1);
// Now you have two mutable slices
Performance: Still in Control
One fear I had: “Is Rust slower than C?”
The truth: Rust gives you the same control as C. You can:
- Control memory layout with
#[repr(C)]
- Inline functions with
#[inline]
- Use SIMD with unsafe intrinsics
- Avoid allocations entirely (embedded Rust)
Zero-cost abstractions means iterators, closures, and other high-level features compile to the same assembly as manual loops.
Tools That Make Life Better
Coming from C, these tools will feel like superpowers:
- cargo - build system that actually works (no Makefiles!)
- clippy - linter that teaches you Rust idioms
- rustfmt - consistent formatting (no more style debates)
- rust-analyzer - LSP that makes IDEs amazing
The Payoff
After fighting the compiler for a few weeks, something clicks. You start thinking in terms of ownership. You catch bugs before running code. You refactor fearlessly.
My C projects: “Let me carefully trace this pointer… is it still valid here?”
My Rust projects: “If it compiles, it probably works.”
Is it perfect? No. But it’s caught so many bugs before they reached production that I can’t imagine going back for new systems projects.
Your Turn
What’s been your biggest challenge moving from C to Rust? Or what’s holding you back from trying it?
Drop your questions in the comments - I learned most of this the hard way, so maybe I can save you some time!
Top comments (0)