What Makes Rust Safer Than C
What Makes Rust Safer Than C (and What It Doesn’t Do)
“What makes Rust safer than C?”
Rust and C, while both powerful programming languages, approach memory management and safety in fundamentally different ways. This leads to distinct behaviors and common pitfalls in each language. Let’s explore some key differences, particularly focusing on memory safety.
What is Memory Safety?
Memory safety refers to the concept of a program accessing memory in a valid and predictable manner. In essence, it means that a program should only read and write to memory that it has been allocated and that it should do so within the bounds of that allocated memory. When a program violates memory safety, it can lead to various issues, including:
- Crashes: Attempting to access memory that is not allocated can cause the program to crash.
- Data Corruption: Writing to memory outside of allocated bounds can overwrite other data, leading to unpredictable program behavior.
- Security Vulnerabilities: Memory safety violations can be exploited by malicious actors to inject code or gain unauthorized access to system resources.
Common memory safety issues include:
- Buffer Overflows: Writing beyond the allocated size of a buffer.
- Use-After-Free: Accessing memory that has already been deallocated.
- Dangling Pointers: Pointers that point to memory that has been deallocated.
- Double Free: Freeing the same memory block twice.
- Memory Leaks: Failing to deallocate memory that is no longer needed.
Buffer Overflow
Buffer Overflow in C
C arrays don’t have length specifiers. C strings also don’t have length specifiers, they rely on null terminators. These 2 examples alone are enough to tell you what can go wrong.
Here’s an example with strings:
#include <stdio.h>
int main() {
char str[10] = {};
scanf("%s", str); // sus.
return 0;
}
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
*** stack smashing detected ***: terminated
Aborted (core dumped)
#include <stdio.h>
#include <stdlib.h>
int main() {
char* str = (char*)calloc(5, sizeof(char));
scanf("%s", str); // sus.
printf("%s\n", str);
return 0;
}
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
malloc(): corrupted top size
Aborted (core dumped)
Buffer Overflow in Rust
Rust won’t even let you update string literals (they’re immutable). Rust String
objects on the other hand are like C++‘s std::string
, they expand as needed (they’re stored in the heap).
use std::io;
fn main() {
let mut str = String::with_capacity(10);
println!("Enter a string:");
io::stdin()
.read_line(&mut str)
.expect("Failed to read line");
let trimmed = str.trim(); // remove trailing newline
println!("You entered: {}", trimmed);
}
I input a string thats obviously longer than 10 bytes:
Enter a string:
Hello aaaaaaaaaaaaaaaaaaaa
You entered: Hello aaaaaaaaaaaaaaaaaaaa
Use-After-Free
Use-After-Free in C
C’s manual memory management often leads to use-after-free errors. This occurs when a program tries to access memory that has already been deallocated.
#include <stdlib.h>
#include <stdio.h>
int main() {
int *ptr = (int *)malloc(sizeof(int));
*ptr = 10;
printf("%d\n", *ptr);
free(ptr);
// ptr is now a dangling pointer
*ptr = 20; // undefined behavior!
return 0;
}
In this example, ptr is freed, but the program still attempts to write to it. This results in undefined behavior, which could lead to crashes, data corruption, or security vulnerabilities.
Use-After-Free in Rust
Meanwhile in Rust, the code looks approximately like this.
fn main() {
let mut ptr = Box::<i32>::new(0);
*ptr = 10;
println!("{}", ptr);
drop(ptr);
*ptr = 20;
}
In safe rust this will not compile. Rust prevents you from compiling unsafe code in this case.
error[E0382]: use of moved value: `ptr`
--> src/main.rs:6:5
|
2 | let mut ptr = Box::<i32>::new(0);
| ------- move occurs because `ptr` has type `Box<i32>`, which does not implement the `Copy` trait
...
5 | drop(ptr);
| --- value moved here
6 | *ptr = 20;
| ^^^^^^^^^ value used here after move
|
help: consider cloning the value if the performance cost is acceptable
|
5 | drop(ptr.clone());
| ++++++++
What Rust Doesn’t Do/Solve
Out-of-Bound Access
In this case using an array/stack is never the correct solution. Unfortunately, this is a mistake first year Computer Science majors are taught. When you are dealing with variable input sizes it’s almost always safer to just use the heap, with resize/realloc mechanism. This is why Rust recommends you to use vectors for these cases.
fn main() {
let mut arr: [i32; 3] = [0; 3];
arr[2] = 1;
let mut user_input = Box::new(3); // let's pretend this is user input, varies on runtime
arr[*user_input] = 1;
}
thread 'main' panicked at src/main.rs:5:5:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[!NOTE] Rust vectors are just like C++ vectors (or C realloc…) when it expands in the sense: it allocates new memory, copies the values, deallocates old array.
It is advised that you define the initial capacity based on your requirements which will reduce reallocation frequency.
[!NOTE] It really depends on your use cases, it doesn’t have to be vectors, it can be hashmaps, binary trees, linked lists, or other data structures.
Yes, this already existed in C++
Memory Leaks
As frustrating as it sounds, I have actually read and heard many people say “Rust prevents memory leaks.” Unfortunately, this is simply not true. While it’s true that not having garbage collectors gets rid one potential point of failure, ultimately the most common causes of memory leaks are logic errors. This one is 99.99% human factor. While there are many design practices that helps programmers avoid making such mistakes, shit will still happen.
Here is proof on why you can still cause memory leak in Rust:
use std::rc::Rc;
fn main() {
let a = Rc::new(5);
let b = Rc::clone(&a);
let c = Rc::clone(&b);
// a, b, and c are now in a reference cycle.
// they will never be deallocated, even when they go out of scope.
// this is a memory leak.
}
This compiles.
Better Performance
Rust DOES NOT provide better performance (when compared against C). If you’ve seen “rust rewrites” perform better this is not because of the rust compiler having significantly superior optimizations. This is mostly due to 2 things:
- Whatever they’re rewriting are old and didn’t consider modern CPUs with lots of cores. (Multithreading and friends are OP)
- The new code takes advantage of that.
- The old code simply had more baggage, it probably had to be backwards compatible with its previous versions and such.
- It’s a rewrite, you get to do and learn more from the existing reference.
However there are still some debatable factors. “You can focus more on optimizations than dealing with safety issues.” “Rust’s language limitations makes it difficult to do expensive abstractions.” Take OOP in Rust as an example, it isn’t even a fully featured OOP. You can’t even do a pure OOP with it.
By the way rust uses the LLVM toolkit. So you can expect it to not be that different to other compilers that also use LLVM.
At this point we should rewrite everything in assembly 🗿
Conclusion
Rust’s borrow checker provides a powerful safety mechanism against common errors like use-after-free. However, it’s essential to remember that Rust does not magically solve all memory-related problems. Memory leaks can still occur, particularly with reference cycles. Understanding these distinctions is crucial for writing safe and efficient code in both C and Rust.